Compare commits

..
138 Commits
Author SHA1 Message Date
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 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
Charles GTEandGitHub 0e7ebb79db Merge pull request #42 from Portabase/fix/ntfy
fix: added support for Basic Auth ntfy instance
2026-01-24 19:05:46 +01:00
charlesgauthereau 727d9eb57c fix: added support for Basic Auth ntfy instance 2026-01-24 19:04:49 +01:00
charlesgauthereau 228ff75468 chore(release): 1.2.3 2026-01-24 13:18:01 +01:00
Charles GTEandGitHub 80018cac08 Merge pull request #40 from Portabase/feat/google-drive-storage
feat/google-drive-storage
2026-01-24 13:17:06 +01:00
charlesgauthereau b2c19e7ed0 fix: refactoring of storage method 2026-01-24 13:14:38 +01:00
charlesgauthereau a64a80910d feat: Working version with google drive storage. 2026-01-24 12:13:55 +01:00
charlesgauthereau 4eeeef3427 feat: start working on google drive storage 2026-01-24 00:26:09 +01:00
charlesgauthereau 657586ddff feat: start working on google drive storage 2026-01-24 00:26:05 +01:00
Charles GTEandGitHub 797b287899 chore: README.md 2026-01-22 10:40:09 +01:00
Charles GTEandGitHub db2d95f2c5 Create FUNDING.yml
create and added buy me a coffee sponsor
2026-01-22 10:31:22 +01:00
charlesgauthereau 16a8f259ab chore(release): 1.2.2 2026-01-21 21:29:33 +01:00
Charles GTEandGitHub ee1cd8d08d Merge pull request #36 from Portabase/feat/arm-architecture
feat/arm-architecture
2026-01-21 21:22:45 +01:00
charlesgauthereau e53046324c fix: docker-compose.prod.yml 2026-01-21 21:21:51 +01:00
charlesgauthereau dd71977317 chore(release): 1.2.2-rc.2 2026-01-21 21:00:58 +01:00
charlesgauthereau bee0da1454 chore(release): 1.2.2-rc.2 2026-01-21 20:56:58 +01:00
charlesgauthereau b60feaa447 feat: added support for arm and amd docker images. 2026-01-21 20:56:23 +01:00
charlesgauthereau 743e54614a chore(release): 1.2.2-rc.1 2026-01-21 11:41:17 +01:00
charlesgauthereau bd96713af4 fix: some refactoring 2026-01-21 11:41:00 +01:00
Charles GTEandGitHub 8f7f9b457a Merge pull request #33 from Portabase/feat/avatar-upload
fix: Upload avatar image error for s3 storage
2026-01-21 11:38:47 +01:00
charlesgauthereau 4c0d68eab8 fix: Upload avatar image error for s3 storage 2026-01-21 11:36:50 +01:00
Charles GTEandGitHub 8211407cf2 chore: README.md 2026-01-20 22:59:47 +01:00
charlesgauthereau 5fb6c7d36a chore(release): 1.2.1 2026-01-20 22:53:08 +01:00
Charles GTEandGitHub 30b1cf4493 Merge pull request #31 from Portabase/feat/add-mongodb
feat/add-mongodb
2026-01-20 22:51:36 +01:00
charlesgauthereau f4789ac5fe fix: attribute default storage at startup in init. Adding support for mongodb. 2026-01-20 22:47:23 +01:00
charlesgauthereau 5fd826ae71 fix: attribute default storage at startup in init. 2026-01-20 20:31:42 +01:00
charlesgauthereau 22ce08f06c Merge branch 'feature' 2026-01-20 19:50:40 +01:00
charlesgauthereau 696c88f03f chore(release): 1.2.1-rc.3 2026-01-20 19:43:59 +01:00
charlesgauthereau 9d5d87e47b fix: smtp error for SMTP_SECURE false. Added in .env 2026-01-20 19:41:49 +01:00
charlesgauthereau e32539e02c chore(release): 1.2.1-rc.2 2026-01-19 13:58:35 +01:00
charlesgauthereau 46ba931a6c fix: Retention cron error. 2026-01-19 13:58:04 +01:00
charlesgauthereau 4bac5143d6 feat: working on mongodb 2026-01-18 21:19:19 +01:00
charlesgauthereau bcb1674491 feat: adding support for mongodb 2026-01-18 20:20:10 +01:00
Killian LarcherandGitHub 3cbd9677c2 Merge pull request #28 from Portabase/feat/coming-soon-storage-preview
feat(ui): add preview for upcoming storage provider
2026-01-18 16:08:41 +01:00
killianlarcher b216ee1a80 feat(ui): add preview for upcoming storage provider 2026-01-18 16:00:13 +01:00
charlesgauthereau 32ec482449 Merge branch 'feature' 2026-01-18 15:30:30 +01:00
charlesgauthereau 5074748055 chore(release): 1.2.1-rc.1 2026-01-18 15:30:14 +01:00
charlesgauthereau 6fa11f834b fix: retention delete backup cron action 2026-01-18 15:29:29 +01:00
charlesgauthereau 25c7702127 chore(release): 1.2.0 2026-01-18 15:21:37 +01:00
charlesgauthereau b546556d08 chore(release): 1.2.0 2026-01-18 15:04:11 +01:00
charlesgauthereau 379a85f6f6 chore: .env.example 2026-01-18 15:03:54 +01:00
Charles GTEandGitHub 92d2c14d6a Merge pull request #27 from Portabase/feat/storage
Feat/storage
2026-01-18 15:01:52 +01:00
charlesgauthereau a6c1f7d3f2 chore: Merging errors due to merge with main. 2026-01-18 15:00:53 +01:00
charlesgauthereau 6fa6187cc4 Merge remote-tracking branch 'origin/main' into feat/storage
# Conflicts:
#	src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx
#	src/components/wrappers/dashboard/admin/notifications/helpers.tsx
#	src/db/migrations/meta/0020_snapshot.json
#	src/db/migrations/meta/_journal.json
2026-01-18 14:40:26 +01:00
charlesgauthereau 848e7a1609 chore: .gitignore 2026-01-18 14:35:39 +01:00
Charles GTEandGitHub 3e8c0a2896 Delete .vscode directory 2026-01-18 14:20:12 +01:00
Charles GTEandGitHub 11900b1500 Delete .idea directory 2026-01-18 14:19:16 +01:00
charlesgauthereau 85044fe3e0 chore(release): 1.2.0-rc.1 2026-01-18 14:16:40 +01:00
charlesgauthereau c0499d40ea fix: restoration columns.tsx is not backup storage linked, legacy 2026-01-18 14:14:49 +01:00
charlesgauthereau 8ee44ffea1 fix: migration errors by testing on a production instance. 2026-01-18 14:10:35 +01:00
Killian LarcherandGitHub 5668a1411f Merge pull request #26 from Portabase/feat/coming-soon-preview
feat(ui): add preview for upcoming notification provider
2026-01-18 12:37:29 +01:00
killianlarcher 6db60f111d feat(ui): add preview for upcoming notification provider 2026-01-18 12:36:12 +01:00
charlesgauthereau 3d695709d5 fix: migration storage backend and also user avatar upload. 2026-01-18 11:45:36 +01:00
Killian LarcherandGitHub 5c682addc9 Merge pull request #25 from Portabase/fix/profile
fix(ui) : improve profile modal
2026-01-18 10:29:38 +01:00
Killian LarcherandGitHub 24810cc818 Merge pull request #24 from Portabase/feat/last-connected
feat : add last login date
2026-01-18 10:29:19 +01:00
Killian LarcherandGitHub 8caeb11da5 Merge pull request #21 from Portabase/feat/connection-indicator
feat(ui): add pulse effect to connection indicator
2026-01-18 10:28:52 +01:00
Killian LarcherandGitHub 5e07b446fc Merge pull request #23 from Portabase/fix/development-conf
fix: update configuration for local development
2026-01-18 10:28:37 +01:00
charlesgauthereau f29444a219 fix: deleteBackupCronAction with new backend storage system 2026-01-18 09:48:14 +01:00
charlesgauthereau bce1c5d9b8 feat: adding storage system ping method in order to test. 2026-01-17 22:41:39 +01:00
charlesgauthereau 5b980fb382 feat: adding system settings page. And did some refactoring. 2026-01-17 22:26:44 +01:00
charlesgauthereau e63e62d3e0 fix: Sidebar focus on some items 2026-01-17 21:13:54 +01:00
charlesgauthereau 53f82792d8 fix: Upload with new storage backend system. 2026-01-17 20:56:57 +01:00
killianlarcher 760388307c fix(ui): reduce opacity on avatar hover and add bottom padding to profile modal 2026-01-17 20:40:57 +01:00
killianlarcher eebd8500ee feat: add lastConnectedAt to user schema and display it in user settings 2026-01-17 20:27:06 +01:00
killianlarcher 12112afa37 feat(ui):resize connection indicator and add pulse effect for all use cases. 2026-01-17 20:06:01 +01:00
charlesgauthereau 424fa581b9 feat: backup delete/restore actions. 2026-01-17 19:55:17 +01:00
killianlarcher cddb0a7e05 feat(ui): add pulse effect to connection indicator 2026-01-17 19:36:28 +01:00
charlesgauthereau 83ef0ee74e feat: backup delete/restore actions. 2026-01-17 19:23:15 +01:00
killianlarcher 21b317e4f1 fix: Update .env.example and docker-compose.yml for local development configuration 2026-01-17 17:44:31 +01:00
charlesgauthereau 7d818206b0 refactor: API route for backup/restore 2026-01-17 17:27:33 +01:00
charlesgauthereau 0216c40bfe feat: Working on UI/UX for multiple storage backends. 2026-01-17 15:57:37 +01:00
charlesgauthereau ecf80280f1 Merge branch 'main' into feat/storage 2026-01-16 23:21:04 +01:00
charlesgauthereau 6e9568b4fb Merge branch 'main' into feat/storage 2026-01-16 22:53:33 +01:00
charlesgauthereau 8d968e7d63 Merge branch 'main' into feature 2026-01-16 22:53:13 +01:00
charlesgauthereau bb264f0b9b chore(release): 1.1.13 2026-01-16 22:48:40 +01:00
Soluce TechnologiesandGitHub be4f770c35 Merge pull request #19 from Portabase/feat/no-email-config-error
Do not crash if no email config is provided in .env file.
2026-01-16 21:44:22 +00:00
charlesgauthereau 17d955dd69 chore(release): 1.1.13-rc.2 2026-01-16 22:24:08 +01:00
charlesgauthereau 3f31e85c06 fix: Do not crash if no email config is provided in .env file. 2026-01-16 22:22:38 +01:00
charlesgauthereau 5a0c230ead chore: working on backup api for new storage system. 2026-01-16 21:54:16 +01:00
Théo LAGACHEandGitHub e10350268d Merge pull request #17 from Portabase/feat/guard-cookie
fix: bad cookie name with https
2026-01-16 11:34:11 +00:00
Théo LAGACHE 969a48bef2 chore(release): 1.1.13-rc.1 2026-01-16 12:21:50 +01:00
Théo LAGACHE 8158934cb9 fix: bad cookie name with https 2026-01-16 12:15:18 +01:00
charlesgauthereau 670f6a1974 chore: working on backup api for new storage system. 2026-01-15 21:27:36 +01:00
charlesgauthereau dd1f791b3e chore: migration for the storage-backend feature 2026-01-14 23:01:06 +01:00
charlesgauthereau 91f25bc25d feat: Working on storage modal in database page in order to add multiple backend storages. Adding a system to manage independently channels from organizations and from system admin. 2026-01-14 21:54:13 +01:00
charlesgauthereau d8e9e2dac3 feat: Working on backend storage providers. 2026-01-13 22:22:01 +01:00
charlesgauthereau 67a89a12ac feat: backend storage 2026-01-12 21:16:57 +01:00
charlesgauthereau faaa4033bd feat: backend storage 2026-01-12 21:16:52 +01:00
charlesgauthereau f3eedf6f30 fix(ci): docker.yml 2026-01-11 14:30:52 +01:00
charlesgauthereau 29718d2a0c chore(release): 1.1.12 2026-01-11 14:26:10 +01:00
charlesgauthereau c7e6bd8b72 Merge branch 'feature'
# Conflicts:
#	CITATION.cff
#	package.json
2026-01-11 14:26:01 +01:00
charlesgauthereau a67ff12786 fix(ci): docker.yml 2026-01-11 14:24:13 +01:00
charlesgauthereau c92e671622 chore(release): 1.1.12-rc.4 2026-01-11 14:19:49 +01:00
charlesgauthereau 2bd25353da chore(release): 1.1.12-rc.3 2026-01-11 14:13:58 +01:00
charlesgauthereau e1bee19c18 fix(ci): docker.yml 2026-01-11 14:13:51 +01:00
charlesgauthereau 0ec49af72a chore(release): 1.1.12-rc.2 2026-01-11 14:12:43 +01:00
charlesgauthereau 6d4eb836ab fix(ci): working on github actions to add new docker repository 2026-01-11 14:12:40 +01:00
charlesgauthereau 0c3cfecb1d chore(release): 1.1.12-rc.1 2026-01-11 13:54:35 +01:00
charlesgauthereau f9186e7354 fix: overflow in agent-card.tsx 2026-01-11 13:54:22 +01:00
charlesgauthereau bc603a08cf fix: Remove card in some forms. 2026-01-11 13:52:02 +01:00
charlesgauthereau da5c65d5a6 chore(release): 1.1.11 2026-01-11 13:41:24 +01:00
charlesgauthereau 077aada056 chore(release): 1.1.11-rc.2 2026-01-11 13:36:57 +01:00
charlesgauthereau 4ed5f9872d fix: delete backup crash if no file. 2026-01-11 13:36:11 +01:00
charlesgauthereau 2d6be20b30 fix: improving organization-form.tsx UX/UI and removed the card. 2026-01-11 12:41:39 +01:00
charlesgauthereau be8f3bd98c fix: organization settings page responsive on mobile. 2026-01-11 12:37:57 +01:00
charlesgauthereau a3a20bee4a fix: agent-card.tsx overflow on mobile. 2026-01-11 12:23:32 +01:00
charlesgauthereau 09a1e75acf fix: notifier-card.tsx overflow on mobile. 2026-01-11 12:12:14 +01:00
charlesgauthereau ce6bdd01ef fix: overflow mobile view on the notification provider card in detabase details page. 2026-01-11 11:57:18 +01:00
charlesgauthereau a03a1d635f fix: responsive in project details. 2026-01-11 10:59:42 +01:00
charlesgauthereau 419b6d4891 chore(release): 1.1.11-rc.1 2026-01-09 19:16:17 +01:00
charlesgauthereau b4840318d5 fix: database page Backup button and route api. 2026-01-09 19:15:27 +01:00
Théo LAGACHE bf81f437d1 chore(release): 1.1.10 2026-01-07 15:29:53 +01:00
Théo LAGACHEandGitHub 81a4d33df1 Merge pull request #7 from Portabase/feat/webhooks
Feat/webhooks
2026-01-07 15:29:30 +01:00
Théo LAGACHE baab364b56 chore(release): 1.1.10-rc.2 2026-01-07 15:00:21 +01:00
Théo LAGACHE 585ee041ec ui fixes 2026-01-07 15:00:11 +01:00
Théo LAGACHE da3d4350cc chore(release): 1.1.10-rc.1 2026-01-07 13:33:42 +01:00
Théo LAGACHE df9390847f add: new notifiers and ui fixes 2026-01-07 13:33:23 +01:00
Théo LAGACHE 2a1acbfde8 chore(release): 1.1.9 2026-01-07 09:50:42 +01:00
Théo LAGACHEandGitHub 16ef8507b0 Merge pull request #6 from Portabase/readme
Readme
2026-01-07 09:48:56 +01:00
Théo LAGACHE df5d4e336c update: release 2026-01-07 09:46:30 +01:00
Théo LAGACHE ad2015329b chore(release): 1.1.9-rc6 2026-01-06 16:35:47 +01:00
Théo LAGACHE 4cc4bed785 chore(release): 1.1.9-rc5 2026-01-06 16:35:44 +01:00
Théo LAGACHE 6c506e6907 chore(release): 1.1.9-rc5 2026-01-06 16:25:12 +01:00
Théo LAGACHE 977f9344c7 chore(release): 1.1.9-rc4 2026-01-06 16:23:09 +01:00
Théo LAGACHE 2adb2f3b68 chore(release): 1.1.9-rc3 2026-01-06 16:21:06 +01:00
Théo LAGACHE 40a6e9e69f fix 2026-01-06 16:12:04 +01:00
Théo LAGACHE e1d1a3fb9a fix(ci): grant necessary permissions for docker publish workflow 2026-01-06 15:37:19 +01:00
Théo LAGACHE 75120c0a56 fix 2026-01-06 15:33:06 +01:00
Théo LAGACHE 33f1ef3bb3 ci: restore release candidate workflow and use reusable actions 2026-01-06 15:10:28 +01:00
Théo LAGACHE b6e742333c update: README.md 2026-01-06 13:26:52 +01:00
Théo LAGACHE 9f8c66fc20 update readme.md 2026-01-06 12:11:45 +01:00
charlesgauthereau 3b93257890 [Release] - 1.1.8 2026-01-05 19:41:53 +01:00
charlesgauthereau 1104e2a074 [FIX] (profile) - error in local upload (#4) 2026-01-05 19:39:59 +01:00
Théo LAGACHE 3e950652e8 update readme.md 2026-01-05 15:04:28 +01:00
259 changed files with 58218 additions and 18632 deletions
+12 -16
View File
@@ -1,37 +1,33 @@
# Environnement
NODE_ENV=production
NODE_ENV=development
# Base de données
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
# Projet
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io
PROJECT_URL=http://localhost:8887
PROJECT_SECRET=
# SMTP (email)
SMTP_HOST=
SMTP_PORT=587
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
#SMTP_SECURE="true"
#Port 587 -> secure: false
#Port 465 -> secure: true
#By default : true
# Google
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3
S3_ENDPOINT=http://app.s3.portabase.io
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET_NAME=portabase
S3_PORT=9000
S3_USE_SSL=true
# Storage Type (s3, local)
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
RETENTION_CRON="* * * * *"
# Updates
NEXT_PUBLIC_UPDATE_CHANNEL=stable
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: portabase
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+97
View File
@@ -0,0 +1,97 @@
name: Docker Publish
on:
workflow_call:
inputs:
image_name:
required: false
type: string
default: 'solucetechnologies/portabase'
image_name2:
required: false
type: string
default: 'portabase/portabase'
add_latest:
required: false
type: boolean
default: false
target:
required: false
type: string
default: 'prod'
dockerfile:
required: false
type: string
default: './docker/dockerfile/Dockerfile'
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
DOCKER_USERNAME_2:
required: true
DOCKER_PASSWORD_2:
required: true
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set tags
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
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push to solucetechnologies/portabase
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.set-tags.outputs.tags }}
target: ${{ inputs.target }}
- name: Log in to Docker Hub account 2
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME_2 }}
password: ${{ secrets.DOCKER_PASSWORD_2 }}
- 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 }}
+128
View File
@@ -0,0 +1,128 @@
name: GitHub Release
on:
workflow_call:
inputs:
prerelease:
required: false
type: boolean
default: false
make_latest:
required: false
type: boolean
default: false
discord_title:
required: true
type: string
discord_color:
required: true
type: number
discord_footer:
required: true
type: string
secrets:
DISCORD_WEBHOOK:
required: true
GH_TOKEN:
required: true
jobs:
create-release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v5
with:
mode: "COMMIT"
configurationJson: |
{
"template": "#{{CHANGELOG}}",
"categories": [
{
"title": "## Feature",
"labels": ["feat", "feature"]
},
{
"title": "## Fix",
"labels": ["fix", "bug"]
},
{
"title": "## Other",
"labels": []
}
],
"label_extractor": [
{
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
"on_property": "title",
"target": "$1"
}
]
}
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: false
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: ${{ inputs.prerelease }}
make_latest: ${{ inputs.make_latest }}
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Send Discord Notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
AUTHOR_NAME="Portabase"
AUTHOR_ICON="https://github.com/Portabase.png"
PAYLOAD=$(jq -n \
--arg title "$RELEASE_TITLE" \
--arg description "$RELEASE_BODY" \
--arg url "$RELEASE_URL" \
--arg author "$AUTHOR_NAME" \
--arg icon "$AUTHOR_ICON" \
--arg discord_title "${{ inputs.discord_title }}" \
--arg discord_footer "${{ inputs.discord_footer }}" \
--argjson discord_color ${{ inputs.discord_color }} \
'{
content: $discord_title,
embeds: [{
title: $title,
url: $url,
description: $description,
color: $discord_color,
author: {
name: $author,
icon_url: $icon
},
footer: {
text: $discord_footer
}
}]
}'
)
curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK"
+34
View File
@@ -0,0 +1,34 @@
name: Publish Docker image for release candidate
on:
push:
tags:
- '*-rc*'
permissions:
contents: write
packages: write
jobs:
docker_publish:
uses: ./.github/workflows/docker.yml
with:
add_latest: false
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release:
needs: docker_publish
uses: ./.github/workflows/github.yml
with:
prerelease: true
make_latest: false
discord_title: "||@release-dashboard|| New release candidate published"
discord_color: 16776960
discord_footer: "Portabase"
secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+29 -44
View File
@@ -1,50 +1,35 @@
name: Publish Docker image for release
on:
release:
types: [published]
push:
tags:
- '*.*.*'
- '!*-*'
permissions:
contents: write
packages: write
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
docker_publish:
uses: ./.github/workflows/docker.yml
with:
add_latest: true
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: solucetechnologies/portabase
- name: Set tags
id: set-tags
run: |
echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
TAGS="solucetechnologies/portabase:${GITHUB_REF#refs/tags/}"
if [ "${{ github.event.release.target_commitish }}" = "main" ]; then
TAGS="$TAGS,solucetechnologies/portabase:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dockerfile/Dockerfile
push: true
tags: ${{ steps.set-tags.outputs.tags }}
target: prod
github_release:
needs: docker_publish
uses: ./.github/workflows/github.yml
with:
prerelease: false
make_latest: true
discord_title: "||@release-dashboard|| New release published"
discord_color: 5814783
discord_footer: "Portabase"
secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3
View File
@@ -1,5 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea
.vscode
# dependencies
/node_modules
/.pnp
@@ -41,3 +42,5 @@ public/uploads/*
private/uploads/*
/.env
certificates
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/portabase.iml" filepath="$PROJECT_DIR$/.idea/portabase.iml" />
</modules>
</component>
</project>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-3
View File
@@ -1,3 +0,0 @@
{
"WillLuke.nextjs.hasPrompted": true
}
Binary file not shown.
-2
View File
@@ -1,2 +0,0 @@
nodeLinker: node-modules
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.1.6
date-released: "2026-01-01"
version: 1.2.4-rc.8
date-released: "2026-01-28"
+25 -249
View File
@@ -1,37 +1,41 @@
<br />
<div align="center">
<a href="https://portabase.io">
<img src="/public/images/logo.png" alt="Logo" width="80" height="80">
<img src="/.github/assets/logo.png" alt="Logo" width="80" height="80">
</a>
<h3 align="center">Portabase</h3>
<p>
Take full control of your databases with Portabase — the self-hosted, open-source platform
for automated backup, restoration, and operational management. Powered by the <strong>Portabase Agent</strong>,
every database in your infrastructure can be monitored, backed up, and managed in real time,
with zero reliance on third-party services.
</p>
<p>
Secure, lightweight, and deployable anywhere — on Docker, Kubernetes, or directly on your servers.
Designed for teams, DevOps, and enterprises who demand control, reliability, and automation at scale.
<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>
</p>
[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase)
[![Support Portabase](https://img.shields.io/badge/Support-Portabase-orange)](https://www.buymeacoffee.com/portabase)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
[![NextJS][NextJS]][NextJS-url]
[![BetterAuth][BetterAuth]][BetterAuth-url]
[![Drizzle][Drizzle]][Drizzle-url]
[![ShadcnUI][ShadcnUI]][ShadcnUI-url]
[![Docker][Docker]][Docker-url]
<p>
<strong>
<a href="https://portabase.io">Documentation</a> •
<a href="https://portabase.io">Website</a> •
<a href="https://portabase.io/docs">Documentation</a> •
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
<a href="#installation">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=enhancement&template=feature-request---.md">Request Feature</a>
</strong>
@@ -42,257 +46,29 @@
</div>
---
## Installation
## ✨ About The Project
You have 4 ways to install Portabase:
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
integrates seamlessly with [Portabase agents](https://github.com/Portabase/agent-portabase) for managing operations
securely and efficiently.
### 🔧 Built With
- [![NextJS][NextJS]][NextJS-url]
- [![Drizzle][Drizzle]][Drizzle-url]
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
- [![BetterAuth][BetterAuth]][BetterAuth-url]
- [![Docker][Docker]][Docker-url]
---
## 📦 Features
### 🗄️ Supported databases
- PostgreSQL
- MySQL
- MariaDB
### ⏱️ Scheduled backups
- Cron-based scheduling for full control
- Manual trigger support for on-demand backups
### 💾 Storage backends
- On-premise storage: Backups are stored directly on your VPS or server
- Cloud storage: S3-compatible backends
supported ([documentation](https://portabase.io/docs/dashboard/advanced/storage/s3))
- Full data ownership: No third-party access — your data stays under your control
### 🔔 Smart notifications
- Multi-channel delivery: Email, Slack, Discord, webhooks
- Real-time alerts: Immediate feedback on success and failure
- Custom alert policies: Database-level notification rules
- Team-ready: Designed for DevOps, on-call, and incident workflows
### 👥 Built for team environments
- Workspaces: Organize databases, notification channels, and storage backends by organization and project
- Access control: Fine-grained, role-based permissions on all resources
- Role management: Member, admin, and owner roles at both system and organization levels
### 🐳 Self-hosted & secure
- Containerized deployment: Docker-based setup for predictable installation and operations
- Privacy by design: All data remains within your own infrastructure
- Open source: Apache 2.0 licensed — fully auditable codebase
### 🤖 Portabase Agent ([details](https://github.com/Portabase/agent-portabase))
- Headless architecture: Runs locally on your infrastructure to manage backups and database operations
- Multi-target support: Single agent can connect to multiple databases across different servers
- Lightweight & efficient: Minimal resource footprint while providing full operational control
- Secure communication: Encrypted channels between agent and central dashboard
---
## 🚀 Getting Started
### Installation
You have 3 ways to install **Portabase**:
- Automated CLI (recommended) - [details](https://portabase.io/docs/cli)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup)
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
- Kubernetes with Helm (soon)
- 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.**
### Docker Compose Setup
Create a `docker-compose.yml` file with the following configuration:
```yaml
name: portabase
services:
portabase:
image: solucetechnologies/portabase:latest
env_file:
- .env
ports:
- '8887:80'
environment:
- TZ="Europe/Paris"
volumes:
- portabase-private:/app/private
depends_on:
db:
condition: service_healthy
container_name: portabase-app
db:
image: postgres:17-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=<your_database>
- POSTGRES_USER=<database_user>
- POSTGRES_PASSWORD=<database_password>
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U <database_user> -d <your_database>" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
portabase-private:
```
Then run:
```bash
docker compose up -d
```
If you use reverse proxy like
Traefik : [Check this link](https://portabase.io/docs/dashboard/advanced/reverse-proxy)
#### Environment Variables
```yml
# Environment
NODE_ENV=production
# Database
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
# Project Info
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io
PROJECT_SECRET=
# SMTP (Email)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Google OAuth
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3/MinIO Configuration
S3_ENDPOINT=http://app.s3.portabase.io
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET_NAME=portabase
S3_PORT=9000
S3_USE_SSL=true
# Storage Backend: 'local' or 's3'
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
```
To get more information about env variables, check
that [link](https://portabase.io/docs/dashboard/advanced/environment)
### Locally (Development)
1. Clone the repository:
```bash
git clone https://github.com/Portabase/portabase
cd portabase
```
2. Start the development database for Portabase service:
```bash
docker compose up
```
3. Start the Next.js app:
```bash
make up
```
---
## 🛠️ Usage
Once the installation process is done, follow the steps to configure your instance.
### Dashboard configuration process
1. **Access the dashboard** Open `http://localhost:8887` in your browser.
2. **Sign up** Register the first user, who will automatically have the **Admin** role in the default workspace.
3. **Add your first agent** Follow [this guide](https://github.com/Portabase/agent-portabase) for setup
instructions.
4. **Create organizations and projects** Link your databases to projects to enable backups and restores.
5. **Configure backup policies** Define schedules (hourly, daily, weekly, or monthly) and retention rules.
6. **Choose a storage provider** Select where backups will be stored (local, S3, etc.).
7. **Save and start** Portabase validates your configuration and starts automated backups based on your defined
policies.
---
## 🤝 Contributing
Contributions are welcome and appreciated! Here's how to get started:
1. Fork the repository
2. Create a new branch:
```bash
git checkout -b features/your-feature
```
3. Commit your changes:
```bash
git commit -m "Add YourFeature"
```
4. Push to the branch:
```bash
git push origin features/your-feature
```
5. Open a pull request
### Top Contributors
## Contributors
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors)
---
[!["Support Portabase"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/portabase)
## 📄 License
## License
Distributed under the Apache License. See `LICENSE.txt` for more details.
---
## 🙏 Acknowledgments
Thanks to all contributors and the open-source community!
Give the project a ⭐ if you like it!
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
+2 -2
View File
@@ -8,8 +8,8 @@ import {CardAuth} from "@/features/layout/card-auth";
export default async function GuardPage() {
const cookieStore = await cookies();
const token = cookieStore.get("better-auth.two_factor")?.value;
const token = cookieStore.get("better-auth.two_factor")?.value || cookieStore.get("__Secure-better-auth.two_factor")?.value;
if (!token) {
redirect("/login");
}
@@ -0,0 +1,40 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
const storageChannels = await db.query.storageChannel.findMany({
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
}) as StorageChannelWith[]
if (!settings || !storageChannels ) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System settings</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} settings={settings} />
</PageContent>
</Page>
);
}
@@ -33,16 +33,22 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{capitalizeFirstLetter(agent.name)}
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(agent.name)}
</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
</PageTitle>
<PageActions className="justify-between">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</PageActions>
</div>
{agent.description && (
@@ -22,11 +22,9 @@ export default async function RoutePage(props: PageParams<{}>) {
with: {
databases: true
},
orderBy: (fields) => desc(fields.createdAt),
orderBy: (fields) => desc(fields.lastContact),
});
console.log(agents);
if (!agents) {
notFound();
@@ -1,13 +1,12 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Metadata} from "next";
import {
NotificationChannelsSection
} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section";
import {db} from "@/db";
import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import * as drizzleDb from "@/db";
export const metadata: Metadata = {
title: "Notification Channels",
@@ -19,6 +18,7 @@ export default async function RoutePage(props: PageParams<{}>) {
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
orderBy: desc(notificationChannel.createdAt)
}) as NotificationChannelWith[]
@@ -35,11 +35,14 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader>
<PageTitle>Notification channels</PageTitle>
<PageActions>
<NotifierAddEditModal adminView={false}/>
{/*<NotifierAddEditModal adminView={false}/>*/}
<ChannelAddEditModal kind="notification" adminView={false}/>
</PageActions>
</PageHeader>
<PageContent>
<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>
{/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
<ChannelsSection kind="notification" organizations={organizations}
channels={notificationChannels}/>
</PageContent>
</Page>
);
@@ -0,0 +1,50 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Metadata} from "next";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
export const metadata: Metadata = {
title: "Storage Channels",
};
export default async function RoutePage(props: PageParams<{}>) {
const storageChannels = await db.query.storageChannel.findMany({
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
}) as StorageChannelWith[]
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
});
return (
<Page>
<PageHeader>
<PageTitle>Storage channels</PageTitle>
<PageActions>
<ChannelAddEditModal kind={"storage"} adminView={false}/>
</PageActions>
</PageHeader>
<PageContent>
<ChannelsSection defaultStorageChannelId={settings?.defaultStorageChannelId} kind={"storage"} organizations={organizations} channels={storageChannels}/>
</PageContent>
</Page>
);
}
@@ -2,7 +2,6 @@ import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db";
@@ -12,9 +11,13 @@ import {getOrganizationProjectDatabases} from "@/lib/services";
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 {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
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 {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
export default async function RoutePage(props: PageParams<{
projectId: string;
@@ -39,7 +42,8 @@ export default async function RoutePage(props: PageParams<{
with: {
project: true,
retentionPolicy: true,
alertPolicies: true
alertPolicies: true,
storagePolicies: true
}
});
@@ -51,6 +55,11 @@ export default async function RoutePage(props: PageParams<{
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
}
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
@@ -60,7 +69,7 @@ export default async function RoutePage(props: PageParams<{
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
@@ -87,6 +96,10 @@ export default async function RoutePage(props: PageParams<{
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 isMember = activeMember?.role === "member";
@@ -102,12 +115,22 @@ export default async function RoutePage(props: PageParams<{
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{/* Do not delete*/}
{/*<EditButton/>*/}
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/>
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
organizationId={organization.id}/>
<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">
@@ -126,10 +149,16 @@ export default async function RoutePage(props: PageParams<{
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/>
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
/>
</BackupModalProvider>
</PageContent>
</Page>
);
@@ -16,6 +16,7 @@ import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
export default async function RoutePage(props: PageParams<{
projectId: string
}>) {
@@ -55,22 +56,25 @@ export default async function RoutePage(props: PageParams<{
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{capitalizeFirstLetter(proj.name)}
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</div>
</div>
)}
</PageTitle>
{!isMember && (
<PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions>
)}
</div>
<PageDescription>The list of associated databases</PageDescription>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
@@ -78,8 +82,8 @@ export default async function RoutePage(props: PageParams<{
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={4}
numberOfColumns={1}
cardsPerPage={6}
numberOfColumns={3}
extendedProps={proj}
/>
) : (
@@ -56,8 +56,8 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug}
data={projects}
cardItem={ProjectCard}
cardsPerPage={4}
numberOfColumns={1}
cardsPerPage={6}
numberOfColumns={3}
/>
) : isMember ? (
<EmptyStatePlaceholder text="No project available"/>
@@ -3,14 +3,15 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation";
import {
DeleteOrganizationButton
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
import {Metadata} from "next";
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
import {
EditButtonSettings
} from "@/components/wrappers/dashboard/organization/settings/edit-button-settings/edit-button-settings";
export const metadata: Metadata = {
title: "Settings",
@@ -26,31 +27,37 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
}
const notificationChannels = await getOrganizationChannels(organization.id)
const storageChannels = await getOrganizationStorageChannels(organization.id)
const permissions = computeOrganizationPermissions(activeMember);
return (
<Page>
<PageHeader>
<PageTitle className="flex items-center">
Organization settings
{permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/>
)}
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
Organization settings
</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</div>
</div>
</PageTitle>
<PageActions>
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</PageActions>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
storageChannels={storageChannels}
/>
</PageContent>
</Page>
+1
View File
@@ -6,6 +6,7 @@ import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sid
import {Header} from "@/features/layout/Header";
import {currentUser} from "@/lib/auth/current-user";
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export default async function Layout({children}: { children: ReactNode }) {
const user = await currentUser();
@@ -1,8 +1,5 @@
import fs from "node:fs";
import forge from "node-forge";
import {EventPayload} from "@/features/notifications/types";
import {dispatchNotification} from "@/features/notifications/dispatch";
import {Database, DatabaseWith} from "@/db/schema/07_database";
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
+10 -29
View File
@@ -1,16 +1,15 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {Backup} from "@/db/schema/07_database";
import {and, eq} from "drizzle-orm";
import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils";
import {decryptedDump, getFileExtension} from "./helpers";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {storeBackupFiles} from "@/features/storages/helpers";
export async function POST(
request: Request,
@@ -25,6 +24,7 @@ export async function POST(
{status: 400}
);
}
eventEmitter.emit('modification', {update: true});
const agentId = (await params).agentId;
@@ -34,6 +34,9 @@ export async function POST(
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"},
@@ -56,7 +59,8 @@ export async function POST(
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: {
project: true,
alertPolicies: true
alertPolicies: true,
storagePolicies: true
}
});
@@ -112,6 +116,7 @@ export async function POST(
}
if (!file) {
return NextResponse.json(
{error: "File is required for successful backup"},
@@ -125,36 +130,12 @@ export async function POST(
const uuid = uuidv4();
const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
throw new Error("System settings not found.");
}
let success: boolean, message: string, filePath: string;
const result =
settings.storage === "local"
? await uploadLocalPrivate(fileName, buffer)
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
console.log(extension)
({success, message, filePath} = result);
if (!success) {
return NextResponse.json(
{error: message},
{status: 500}
);
}
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
file: fileName,
fileSize: fileSizeBytes,
status: 'success',
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
const storageResults = await storeBackupFiles(backup, database, buffer, fileName)
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "success_backup");
-1
View File
@@ -24,7 +24,6 @@ export async function POST(
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json();
console.log(body)
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
+79 -45
View File
@@ -1,17 +1,15 @@
import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
import {Agent} from "@/db/schema/08_agent";
import {Database} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db";
import {and, eq} from "drizzle-orm";
import {EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod";
import {and, eq, inArray} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
const databasesResponse = [];
@@ -48,6 +46,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
{status: 500}
);
}
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
console.log(`Database type not available: ${db.dbms}`);
continue;
}
const [databaseCreated] = await dbClient
.insert(drizzleDb.schemas.database)
.values({
@@ -75,76 +79,67 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.returning();
const backup = await dbClient.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
const activeBackup = await dbClient.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
)
})
const restoration = await dbClient.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting"))
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
with: {
backupStorage: true
}
})
if (backup) {
if (activeBackup && activeBackup.status == "waiting") {
backupAction = true
await dbClient
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
.where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
}
if (restoration) {
restoreAction = true
const backupToRestore = await dbClient.query.backup.findFirst({
where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
with: {
database: {
with: {
project: true
}
}
}
})
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json(
{error: "Unable to find settings"},
{status: 500}
);
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
restoreAction = false
continue;
}
const fileName = backupToRestore?.file
const input: StorageInput = {
action: "get",
data: {
path: restoration.backupStorage.path,
signedUrl: true,
},
metadata: {
storageId: restoration.backupStorage.storageChannelId,
fileKind: "backups"
}
};
let data: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string>, object> | undefined
try {
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
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 ?? "";
if (result.success) {
urlBackup = result.url ?? "";
} 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";
const errorMessage = "Failed to get backup URL";
console.error("Restoration failed: ", errorMessage);
continue;
}
} catch (err) {
@@ -153,9 +148,48 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
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
.update(drizzleDb.schemas.restoration)
.set({status: "ongoing"})
-13
View File
@@ -1,5 +1,4 @@
import {NextResponse} from "next/server";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {handleDatabases} from "./helpers";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db";
@@ -20,13 +19,6 @@ export type Body = {
databases: databaseAgent[]
}
// Function to test the get file url presigned local
export async function GET(request: Request) {
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
return Response.json({
message: url
})
}
export async function POST(
request: Request,
@@ -38,10 +30,8 @@ export async function POST(
const lastContact = new Date();
let message: string
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
console.log(message)
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
@@ -54,12 +44,10 @@ export async function POST(
if (!agent) {
message = "Agent not found"
console.log(message)
return NextResponse.json({error: message}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact)
await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
@@ -77,7 +65,6 @@ export async function POST(
},
databases: databasesResponse
}
console.log(response)
return Response.json(response)
} catch (error) {
+1 -13
View File
@@ -19,7 +19,6 @@ export async function GET(request: Request) {
new ReadableStream({
start(controller) {
console.log('Stream started');
const handleModification = (data: any) => {
console.log('Modification event triggered:', data);
controller.enqueue(`event: modification\n`);
@@ -45,15 +44,4 @@ export async function GET(request: Request) {
},
}
);
}
// export async function POST(request: Request) {
// console.log('POST request received');
// const data = await request.json();
// console.log('Data received:', data);
//
// // Emit the event to all connected clients
// eventEmitter.emit('modification', data);
//
// return new Response('Event sent', {status: 200});
// }
}
@@ -1,29 +1,47 @@
import * as fs from "node:fs";
import {NextResponse} from "next/server";
import path from "path";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Readable} from "node:stream";
export async function GET(
request: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
const {searchParams} = new URL(request.url);
const token = searchParams.get('token');
const expires = searchParams.get('expires');
const fileName = (await params).fileName
const pathFromUrl = searchParams.get('path');
const storageId = searchParams.get('storageId');
const uploadsDir = "private/uploads/files/";
const uploadPath = path.join(uploadsDir, fileName);
const crypto = require('crypto');
let filePath = null;
if (fs.existsSync(uploadPath)) {
filePath = uploadPath;
} else {
return NextResponse.json({error: "File not found"}, {status: 404})
if (!pathFromUrl || !storageId) {
return NextResponse.json({error: "Missing search params"}, {status: 404})
}
const input: StorageInput = {
action: "get",
data: {
path: pathFromUrl,
signedUrl: true,
},
metadata: {
storageId: storageId,
fileKind: "backups",
}
};
console.debug(input);
const result = await dispatchStorage(input, undefined, storageId);
if (!result.success) {
return NextResponse.json({error: "Enable to get file from privided storage channel, an error occurred !"})
}
const fileName = path.basename(pathFromUrl);
const crypto = require('crypto');
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
if (token !== expectedToken) {
return NextResponse.json(
@@ -40,7 +58,15 @@ export async function GET(
);
}
const fileStream = fs.createReadStream(filePath);
if (!result.file || !Buffer.isBuffer(result.file)) {
return NextResponse.json(
{error: "Invalid file payload"},
{status: 500}
);
}
const fileStream = Readable.from(result.file);
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
+102
View File
@@ -0,0 +1,102 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Readable} from "node:stream";
export async function GET(
req: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
const {searchParams} = new URL(req.url);
const fileName = (await params).fileName;
const storageId = searchParams.get('storageId');
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
const session = await auth.api.getSession({headers: await headers()});
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
if (!storageId) {
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
}
// const 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 contentType =
ext === "png"
? "image/png"
: ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "gif"
? "image/gif"
: ext === "webp"
? "image/webp"
: "application/octet-stream";
try {
const path = `images/${fileName}`;
const input: StorageInput = {
action: "get",
data: {
path: path,
},
metadata: {
storageId: storageId,
fileKind: "images"
}
}
const result = await dispatchStorage(input, undefined, storageId);
if (!result.file || !Buffer.isBuffer(result.file)) {
console.error(`An error occurred while getting file :`, result);
return NextResponse.json(
{error: "Invalid file payload"},
{status: 500}
);
}
const fileStream = Readable.from(result.file);
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(stream, {
headers: {
'Content-Disposition': `inline; filename="${fileName}"`,
"Cache-Control": "no-store",
"Content-Type": contentType,
},
});
} catch (err) {
console.error("Error streaming image:", err);
return NextResponse.json({error: "Error fetching file"}, {status: 500});
}
}
+23
View File
@@ -0,0 +1,23 @@
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const success = Boolean(code);
return new Response(
`
<!DOCTYPE html>
<html>
<body>
<h1>${success ? "Success" : "Failed"}</h1>
</body>
</html>
`,
{
status: success ? 200 : 400,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
}
);
}
-103
View File
@@ -1,103 +0,0 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
import {env} from "@/env.mjs";
import * as stream from "node:stream";
import path from "path";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import fs from "fs/promises";
function nodeStreamToWebStream(nodeStream: stream.Readable) {
return new ReadableStream({
start(controller) {
nodeStream.on("data", chunk => controller.enqueue(chunk));
nodeStream.on("end", () => controller.close());
nodeStream.on("error", err => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
}
const privateS3ImageDir = "images/";
export async function GET(
req: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
const fileName = (await params).fileName;
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
const session = await auth.api.getSession({headers: await headers()});
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) throw new Error("System settings not found.");
const storageType = settings.storage; // "local" or "s3"
const ext = fileName.split(".").pop()?.toLowerCase();
const contentType =
ext === "png"
? "image/png"
: ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "gif"
? "image/gif"
: ext === "webp"
? "image/webp"
: "application/octet-stream";
try {
if (storageType === "local") {
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
try {
await fs.access(filePath);
const file = await fs.readFile(filePath);
return new NextResponse(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch {
// if not found locally, fallback to S3
}
}
const exists = await checkFileExistsInBucket({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
const nodeStream = await getObjectFromClient({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
const webStream = nodeStreamToWebStream(nodeStream);
return new NextResponse(webStream, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch (err) {
console.error("Error streaming image:", err);
return NextResponse.json({error: "Error fetching file"}, {status: 500});
}
}
+3 -2
View File
@@ -6,6 +6,7 @@ import {ThemeProvider} from "@/features/theme/theme-provider";
import {Toaster} from "@/components/ui/sonner";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export type ProviderProps = PropsWithChildren<{}>;
@@ -21,8 +22,8 @@ export const Providers = (props: ProviderProps) => {
>
<ThemeMetaUpdaterRoot/>
<QueryClientProvider client={queryClient}>
<Toaster/>
{props.children}
<Toaster/>
{props.children}
</QueryClientProvider>
</ThemeProvider>
</Suspense>
+1 -1
View File
@@ -7,7 +7,7 @@ services:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: solucetechnologies/portabase:1.1.4-rc.2
image: solucetechnologies/portabase:latest
ports:
- '8887:80'
environment:
+1 -1
View File
@@ -2,7 +2,7 @@ name: portabase-dev
services:
db:
image: postgres:16-alpine
image: postgres:17-alpine
ports:
- "5433:5432"
volumes:
+26 -34
View File
@@ -1,45 +1,37 @@
FROM node:22-alpine AS base
ENV YARN_VERSION=4.9.1
RUN apk add --update --no-cache \
tzdata \
build-base \
libc6-compat \
openssl \
make \
tzdata
FROM base AS build-env
RUN apk add --update --no-cache \
build-base \
g++ \
jpeg-dev \
cairo-dev \
giflib-dev \
pango-dev \
make \
libtool \
autoconf \
automake \
libpng
automake
RUN corepack enable && corepack prepare yarn@${YARN_VERSION}
RUN yarn set version ${YARN_VERSION}
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM base AS deps
FROM build-env AS deps
WORKDIR /app
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm i --frozen-lockfile
RUN \
if [ -f yarn.lock ]; then yarn; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS dev
FROM build-env AS dev
WORKDIR /app
@@ -52,8 +44,11 @@ RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
# Rebuild the source code only when needed
FROM base AS builder
FROM build-env AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
@@ -61,12 +56,11 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
RUN pnpm run build
FROM base AS prod
@@ -109,5 +103,3 @@ ENV HOSTNAME="0.0.0.0"
USER nextjs
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
+3 -3
View File
@@ -3,10 +3,10 @@
set -euo pipefail
echo "▶ Running Drizzle codegen..."
npx drizzle-kit generate
pnpm drizzle-kit generate
echo "▶ Applying migrations..."
npx drizzle-kit migrate
pnpm drizzle-kit migrate
echo "▶ Starting Next.js dev server..."
exec npm run dev
exec pnpm dev
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.1.8",
"version": "1.2.4-rc.8",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -62,6 +62,7 @@
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"googleapis": "^170.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
"minio": "^8.0.5",
@@ -74,6 +75,7 @@
"nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1",
"pg": "^8.16.0",
"prettier": "^3.8.0",
"react": "^19.2.0",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.0",
@@ -85,9 +87,11 @@
"react-twc": "^1.4.2",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.3",
"sharp": "^0.34.5",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.3",
"swiper": "^12.0.3",
"tailwind-merge": "^3.3.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
@@ -97,6 +101,7 @@
"devDependencies": {
"@iconify/react": "^6.0.0",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.1",
"@tailwindcss/postcss": "^4.1.7",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
@@ -111,6 +116,7 @@
"eslint": "^9.39.0",
"eslint-config-next": "^16.0.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"framer-motion": "^12.24.7",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
@@ -118,5 +124,5 @@
"typescript": "^5.8.3",
"zenstack": "2.14.2"
},
"packageManager": "yarn@4.9.1"
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
}
+14431
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- argon2
- bcrypt
- cpu-features
- esbuild
- prisma
- protobufjs
- sharp
- ssh2
- unrs-resolver
- zenstack
+3 -2
View File
@@ -54,11 +54,12 @@ function checkRouteExists(pathname: string) {
/^\/api\/agent\/[^/]+\/status\/?$/,
/^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/[^/]+\/?$/,
/^\/api\/images\/[^/]+\/?$/,
/^\/api\/files\/images\/[^/]+\/?$/,
/^\/api\/files\/backups\/?$/,
/^\/api\/events\/?$/,
/^\/api\/init\/?$/,
/^\/api\/config\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
];
return routePatterns.some((pattern) => pattern.test(pathname));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Executable
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
set -e
if [ -z "$1" ]; then
echo "Usage: ./release <version>"
echo "Example: ./release v1.0.0"
exit 1
fi
VERSION=$1
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "main" ]; then
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
exit 1
fi
else
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
exit 1
fi
fi
CLEAN_VERSION=${VERSION#v}
CURRENT_DATE=$(date +%Y-%m-%d)
echo "Preparing release $VERSION..."
# package.json
if [ -f package.json ]; then
echo "Updating package.json..."
if sed --version >/dev/null 2>&1; then
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
else
sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
fi
fi
# pyproject.toml
if [ -f pyproject.toml ]; then
echo "Updating pyproject.toml..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
else
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
fi
fi
# Cargo.toml
if [ -f Cargo.toml ]; then
echo "Updating Cargo.toml..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
else
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
fi
fi
# CITATION.cff
if [ -f CITATION.cff ]; then
echo "Updating CITATION.cff..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
else
sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
fi
fi
git add .
if ! git diff-index --quiet HEAD --; then
echo "Committing changes..."
git commit -m "chore(release): $VERSION"
else
echo "No changes to commit. Proceeding to tag..."
fi
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Tag $VERSION already exists. Aborting."
exit 1
fi
echo "Creating tag $VERSION..."
git tag -a "$VERSION" -m "Release $VERSION"
echo "Pushing changes and tags to remote..."
git push
git push origin "$VERSION"
echo "Successfully released $VERSION!"
+2
View File
@@ -46,7 +46,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: R
...classNames,
}}
components={{
// @ts-ignore
IconLeft: ({ className, children: _children, ...props }) => <ChevronLeftIcon className={cn("size-4", className)} {...props} />,
// @ts-ignore
IconRight: ({ className, children: _children, ...props }) => <ChevronRightIcon className={cn("size-4", className)} {...props} />,
}}
{...props}
@@ -41,7 +41,7 @@ export const AuthLogoSection = () => {
/>
)}
<span className="absolute bottom-10 right-5 text-sm text-muted-foreground" style={style}>
<span className="absolute bottom-8 right-5 text-sm text-muted-foreground" style={style}>
v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span>
</div>
@@ -3,7 +3,6 @@
import {useEffect, useState} from "react";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
@@ -63,7 +62,6 @@ export const LoginForm = (props: loginFormProps) => {
toast.success("Login success");
},
onError: (error) => {
console.log(error);
toast.error(error.error.message);
},
}
@@ -28,7 +28,6 @@ export const resetPasswordAction = action
}
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
console.log(user)
if (!user) {
return {
success: false,
@@ -39,28 +38,6 @@ export const resetPasswordAction = action
};
}
const hashedPassword = await (await auth.$context).password.hash(parsedInput.schema.password);
console.log(hashedPassword)
console.log("ok")
// await (await auth.$context).internalAdapter.updatePassword(user.id, hashedPassword);
console.log("ici")
// await (await auth.$context).internalAdapter.deleteSessions(user.id);
// console.log("ici2")
// await (await auth.$context).internalAdapter.deleteVerificationValue(verification.id);
// console.log("ici3");
//
// (await auth.$context).internalAdapter.updateUser(user.id, {
// lastChangedPasswordAt: new Date(),
// });
// await auth.api.resetPassword({
// headers: await headers(),
// body: {
// newPassword: parsedInput.schema.password,
// token: parsedInput.token,
// },
// });
await (
await auth.$context
).internalAdapter.updateUser(user.id, {
@@ -44,7 +44,6 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
setTimeout(() => router.push("/"), 1400);
},
onError: (error: BetterAuthError) => {
console.log(error)
toast.error("An error occurred while resetting password");
},
});
@@ -1,21 +1,25 @@
"use client";
import {useRouter} from "next/navigation";
import {Info} from "lucide-react";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {CardContent, CardHeader} from "@/components/ui/card";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip";
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema";
import {PasswordInput} from "@/components/ui/password-input";
import {signUp} from "@/lib/auth/auth-client";
import Link from "next/link";
import {useZodForm} from "@/components/ui/form";
import {CardAuth} from "@/features/layout/card-auth";
import {FormField} from "@/components/ui/form";
import {FormItem} from "@/components/ui/form";
import {FormLabel} from "@/components/ui/form";
import {FormControl} from "@/components/ui/form";
import {FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {PasswordInput} from "@/components/ui/password-input";
import {Button} from "@/components/ui/button";
import Link from "next/link";
import {Info} from "lucide-react";
export type registerFormProps = {
defaultValues?: RegisterType;
@@ -26,9 +30,11 @@ export const RegisterForm = (props: registerFormProps) => {
const form = useZodForm({
schema: RegisterSchema,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: RegisterType) => {
// @ts-ignore
await signUp.email(values, {
onSuccess: () => {
toast.success(`Account successfully created`);
@@ -36,7 +42,6 @@ export const RegisterForm = (props: registerFormProps) => {
router.push(`/login`);
},
onError: (error) => {
console.log(error);
toast.error(error.error.message);
},
});
@@ -6,7 +6,7 @@ import {Form, FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/c
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {ResetPasswordSchema, ResetPasswordType} from "@/components/wrappers/auth/reset-password/reset-password-schema";
import {PasswordInput} from "@/components/ui/password-input";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {CardContent, CardHeader} from "@/components/ui/card";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
@@ -31,7 +31,6 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
token,
}, {
onSuccess: (response) => {
console.log(response);
toast.success("Password changed successfully.");
setTimeout(() => router.push("/"), 1000);
},
@@ -1,48 +1,3 @@
// "use client";
//
// import { Button } from "@/components/ui/button";
// import { useState } from "react";
// import { Loader2 } from "lucide-react";
//
// export type VariantButton = {
// secondary: string;
// default: string;
// outline: string;
// ghost: string;
// link: string;
// destructive: string;
// };
//
// export type ButtonWithConfirmProps = {
// icon?: any;
// text: string;
// variant?: keyof VariantButton;
// className?: string;
// onClick?: () => void;
// isPending?: boolean;
// };
//
// export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
// const [isConfirming, setIsConfirming] = useState(false);
//
// return (
// <Button
// onClick={() => {
// if (isConfirming && props.onClick) {
// props.onClick();
// } else {
// setIsConfirming(true);
// }
// }}
// variant={props.variant ? props.variant : "default"}
// className={props.className}
// >
// {props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
// {isConfirming ? "Are you sure ?" : `${props.text}`}
// <>{props.icon ? props.icon : null}</>
// </Button>
// );
// };
"use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {useState} from "react";
@@ -57,6 +12,7 @@ export type ButtonWithConfirmProps = {
button: {
main: {
className?: string;
type?: "button" | "submit" | "reset" | undefined;
text?: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
@@ -100,6 +56,7 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
)}
role="button">
<Button
type={props.button.main.type}
disabled={!!props.button.main.disabled}
variant={props.button.main.variant ?? "default"}
size={props.button.main.size ?? "default"}
@@ -108,7 +65,8 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
if (!props.button.main.disabled) setIsConfirming(true);
}}
>
{props.button.main.icon}
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.main.icon}
{props.button.main.text && <span>{props.button.main.text}</span>}
</Button>
</span>
@@ -109,7 +109,6 @@ export const ButtonWithLoading = ({
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
{children && children}
<>{icon ? icon : null}</>
{/*{icon && <span className="ml-2">{icon}</span>}*/}
</Button>
);
};
@@ -1,31 +0,0 @@
import {cn} from "@/lib/utils";
export type ConnectionCircleProps = {
date?: Date | null;
};
export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
let style = "bg-gray-300 border-gray-400";
if (date instanceof Date && !isNaN(date.getTime())) {
const now = Date.now();
const timestamp = date.getTime();
const interval_seconds = (now - timestamp) / 1000;
console.log({now, timestamp, interval_seconds});
if (interval_seconds < 55) {
style = "bg-green-400 border-green-600";
} else if (interval_seconds <= 60) {
style = "bg-orange-400 border-orange-600";
} else {
style = "bg-red-400 border-red-600";
}
} else {
console.warn("Invalid date passed to ConnectionCircle:", date);
}
console.log(style);
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
};
@@ -0,0 +1,46 @@
import {cn} from "@/lib/utils";
export type ConnectionIndicatorProps = {
date?: Date | null;
};
export const ConnectionIndicator = ({date}: ConnectionIndicatorProps) => {
let style = "bg-gray-300";
if (date instanceof Date && !isNaN(date.getTime())) {
const intervalSeconds = (Date.now() - date.getTime()) / 1000;
if (intervalSeconds < 55) {
style = "bg-green-500";
} else if (intervalSeconds <= 60) {
style = "bg-orange-400";
} else {
style = "bg-red-500";
}
}
return (
<div className="relative w-3 h-3">
<span
className={cn(
"absolute -inset-0.25 rounded-full opacity-60 animate-ping",
style
)}
style={{
animationDuration: "2s",
}}
/>
<div
className={cn(
"relative w-3 h-3 rounded-full shadow-sm animate-pulse",
style
)}
style={{
animationDuration: "2s",
}}
/>
</div>
);
};
@@ -0,0 +1,149 @@
"use client"
import {Pencil, Plus} from "lucide-react";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {useIsMobile} from "@/hooks/use-mobile";
import {useEffect, useState} from "react";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelForm} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
ChannelOrganisationForm
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form";
type ChannelAddModalProps = {
channel?: NotificationChannelWith | StorageChannelWith
organization?: OrganizationWithMembers;
open?: boolean;
onOpenChangeAction?: (open: boolean) => void;
adminView?: boolean;
organizations?: OrganizationWithMembers[]
trigger?: boolean;
kind: ChannelKind;
}
export const ChannelAddEditModal = ({
organization,
channel,
open = false,
onOpenChangeAction,
adminView,
organizations,
trigger = true,
kind
}: ChannelAddModalProps) => {
const isMobile = useIsMobile();
const [openInternal, setOpen] = useState(open);
const isLocalSystem = channel?.provider == "local";
const isCreate = !Boolean(channel);
useEffect(() => {
setOpen(open);
}, [open])
const channelText = getChannelTextBasedOnKind(kind)
return (
<Dialog open={openInternal} onOpenChange={(state) => {
onOpenChangeAction?.(state);
setOpen(state);
}}>
{trigger && (
<DialogTrigger asChild>
{isCreate ?
<Button>
<Plus/>{!isMobile && `Add ${channelText} channel`}
</Button>
:
<Button
variant="ghost"
size="icon"
>
<Pencil className="h-4 w-4"/>
</Button>
}
</DialogTrigger>
)}
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> {isCreate ? "Add" : "Edit"} {channelText} Channel</DialogTitle>
<DialogDescription>
Configure your {channelText.toLowerCase()} channel preferences.
</DialogDescription>
</DialogHeader>
<div>
<>
{!isLocalSystem ? (
<>
{adminView ?
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="configuration">Configuration</TabsTrigger>
<TabsTrigger value="organizations">Organizations</TabsTrigger>
</TabsList>
<TabsContent className="h-full justify-between" value="configuration">
<ChannelForm
kind={kind}
adminView={adminView}
defaultValues={channel}
organization={organization}
onSuccessAction={() => {
onOpenChangeAction?.(false)
setOpen(false);
}}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="organizations">
<ChannelOrganisationForm
defaultValues={channel}
kind={kind}
organizations={organizations}
/>
</TabsContent>
</Tabs>
:
<>
<ChannelForm
kind={kind}
adminView={adminView}
defaultValues={channel}
organization={organization}
onSuccessAction={() => {
onOpenChangeAction?.(false)
setOpen(false);
}}
/>
</>
}
</>
)
:
<>
<ChannelOrganisationForm
defaultValues={channel}
kind={kind}
organizations={organizations}
/>
</>
}
</>
</div>
</DialogContent>
</Dialog>
)
}
@@ -5,20 +5,31 @@ import {useRouter} from "next/navigation";
import {Trash2} from "lucide-react";
import {
removeNotificationChannelAction,
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
import {toast} from "sonner";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
removeStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
export type DeleteNotifierButtonProps = {
notificationChannelId: string;
export type DeleteChannelButtonProps = {
channelId: string;
kind: ChannelKind;
organizationId?: string;
};
export const DeleteNotifierButton = ({notificationChannelId, organizationId}: DeleteNotifierButtonProps) => {
export const DeleteChannelButton = ({channelId, organizationId, kind}: DeleteChannelButtonProps) => {
const router = useRouter();
const channelText = getChannelTextBasedOnKind(kind)
const mutation = useMutation({
mutationFn: async () => {
const result = await removeNotificationChannelAction({organizationId, notificationChannelId})
const result = kind === "notification" ? await removeNotificationChannelAction({
organizationId,
notificationChannelId: channelId
}) : await removeStorageChannelAction({organizationId, id: channelId})
const inner = result?.data;
if (inner?.success) {
@@ -32,8 +43,8 @@ export const DeleteNotifierButton = ({notificationChannelId, organizationId}: De
return (
<ButtonWithConfirm
title="Delete Notifier"
description="Are you sure you want to remove this notifier ? This action cannot be undone and will delete all alert policies related to this channel !"
title={`Delete ${channelText.toLowerCase()} channel`}
description={`Are you sure you want to remove this ${channelText.toLowerCase()} channel ? This action cannot be undone and will delete all alert policies related to this channel !`}
button={{
main: {
size: "icon",
@@ -0,0 +1,94 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Switch} from "@/components/ui/switch";
import {toast} from "sonner";
import {useState} from "react";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {
updateStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
export type EditChannelButtonProps = {
channel: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind: ChannelKind;
};
export const EditChannelButton = ({
organizations,
adminView = false,
channel,
organization,
kind
}: EditChannelButtonProps) => {
const router = useRouter();
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const isLocalSystem = channel.provider == "local";
const mutation = useMutation({
mutationFn: async (value: boolean) => {
const payload = {
data: {
name: channel.name,
provider: channel.provider,
config: channel.config as Record<string, any>,
enabled: value
},
id: channel.id
};
let result: any;
if (kind == "notification") {
// @ts-ignore
result = await updateNotificationChannelAction(payload)
} else if (kind == "storage") {
// @ts-ignore
result = await updateStorageChannelAction(payload)
} else {
toast.error("An error occurred while updating storage channel")
return;
}
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
return (
<>
{!isLocalSystem && (
<Switch checked={channel.enabled} onCheckedChange={async () => {
await mutation.mutateAsync(!channel.enabled)
}}/>
)}
<ChannelAddEditModal
kind={kind}
organizations={organizations}
adminView={adminView}
organization={organization}
channel={channel}
open={isAddModalOpen}
onOpenChangeAction={setIsAddModalOpen}
/>
</>
);
};
@@ -0,0 +1,88 @@
"use client";
import {Card} from "@/components/ui/card";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Badge} from "@/components/ui/badge";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
EditChannelButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel";
import {ChannelKind, getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
DeleteChannelButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-delete-channel";
export type ChannelCardProps = {
data: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind?: ChannelKind;
defaultStorageChannelId?: string | null | undefined
};
export const ChannelCard = (props: ChannelCardProps) => {
const {data, organization, kind, adminView, defaultStorageChannelId} = props;
const isMobile = useIsMobile()
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider == "local";
return (
<div className="block transition-all duration-200 rounded-xl">
<Card className="flex flex-row justify-between p-4">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getChannelIcon(data.provider)}
</div>
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
</div>
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
{isDefaultSystemStorage && (
<Badge variant="secondary" className="text-xs font-mono">
default
</Badge>
)}
</div>
</div>
{kind && (
<div className="flex items-center gap-2">
{(isOwned) && (
<>
<EditChannelButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
channel={data}
kind={kind}
/>
{!isLocalSystem && (
<DeleteChannelButton
kind={kind}
organizationId={organization?.id}
channelId={data.id}
/>
)}
</>
)}
</div>
)}
</Card>
</div>
);
};
@@ -0,0 +1,27 @@
import {z} from "zod";
export const ChannelFormSchema = z.object({
name: z
.string()
.min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 characters long"),
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
enabled: z.boolean().default(true),
});
export const NotificationChannelFormSchema = ChannelFormSchema.extend({
provider: z.enum(
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
{required_error: "Provider is required"}
),
});
export const StorageChannelFormSchema = ChannelFormSchema.extend({
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
});
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
export type StorageChannelFormType = z.infer<typeof StorageChannelFormSchema>;
@@ -0,0 +1,254 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Input} from "@/components/ui/input";
import {
addStorageChannelAction, updateStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
import {toast} from "sonner";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Button} from "@/components/ui/button";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {useEffect} from "react";
import {cn} from "@/lib/utils";
import {Card} from "@/components/ui/card";
import {ArrowLeft} from "lucide-react";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
import {
ChannelKind,
renderChannelForm
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
import {notificationProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
import {
ChannelTestButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button";
import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
type NotifierFormProps = {
onSuccessAction?: () => void;
organization?: OrganizationWithMembers;
defaultValues?: NotificationChannelWith | StorageChannelWith
adminView?: boolean
kind: ChannelKind
};
export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}: NotifierFormProps) => {
const router = useRouter();
const isCreate = !Boolean(defaultValues);
const form = useZodForm({
schema: kind == "notification" ? NotificationChannelFormSchema : StorageChannelFormSchema,
// @ts-ignore
defaultValues: {...defaultValues},
});
useEffect(() => {
form.reset(defaultValues ? {...defaultValues} : {});
}, [defaultValues]);
const mutationAddNotificationChannel = useMutation({
mutationFn: async (values: NotificationChannelFormType | StorageChannelFormType) => {
const payload = {
data: values,
...(organization && {organizationId: organization.id}),
...((defaultValues && {id: defaultValues.id}))
};
let result: any;
if (kind === "notification") {
// @ts-ignore
result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
} else if (kind === "storage") {
// @ts-ignore
result = isCreate ? await addStorageChannelAction(payload) : await updateStorageChannelAction(payload);
} else {
toast.error("An error occurred");
return;
}
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
isCreate && onSuccessAction?.();
router.refresh();
} else {
toast.error(inner?.actionError?.message);
isCreate && onSuccessAction?.();
}
}
});
const provider = form.watch("provider");
const channelTypes = kind == "notification" ? notificationProviders : storageProviders
const selectedProviderDetails = channelTypes.find(t => t.value === provider);
if (isCreate && !provider) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
{channelTypes.filter(p => p.value != "local").map((type) => {
const Icon = type.icon;
return (
<Card
key={type.value}
className={cn(
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all border",
type.preview
? "cursor-not-allowed bg-muted/60 border-border text-muted-foreground"
: "cursor-pointer bg-background hover:bg-accent/40 hover:border-primary/50"
)}
onClick={() => {
if (type.preview) return;
form.setValue("provider", type.value as any);
form.setValue("config", {});
}}
>
{type.preview && (
<div
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
<div
className="absolute bottom-0 right-0 w-38 h-5 flex items-center justify-center
transform -rotate-45 translate-x-14 -translate-y-4"
style={{backgroundColor: "#FE6702"}}
>
<span className="text-white text-[7px] pr-3 font-medium text-center w-full">COMING SOON</span>
</div>
</div>
)}
<div
className={cn(
"h-10 w-10 rounded-full flex items-center justify-center transition-colors",
type.preview
? "bg-muted"
: "bg-accent text-accent-foreground"
)}
>
<Icon
className={cn(
"h-6 w-6",
type.preview ? "text-muted-foreground" : "text-foreground"
)}
/>
</div>
<span
className={cn(
"font-medium text-sm text-center leading-tight",
type.preview ? "text-muted-foreground" : "text-foreground"
)}
>
{type.label}
</span>
</Card>
);
})}
</div>
);
}
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationAddNotificationChannel.mutateAsync(values);
}}
>
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
{selectedProviderDetails && (
<div
className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
<selectedProviderDetails.icon className="h-5 w-5"/>
</div>
)}
<div className="flex-1">
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
</div>
{isCreate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
form.setValue("provider", undefined as any);
form.setValue("config", undefined);
}}
>
<ArrowLeft className="h-4 w-4 mr-2"/>
Change
</Button>
)}
</div>
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Channel Name</FormLabel>
<FormControl>
<Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`}
value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="provider"
render={({field}) => (
<input type="hidden" {...field} value={field.value || ""}/>
)}
/>
{renderChannelForm(provider, form)}
<div className="flex justify-between mt-4">
<div>
{defaultValues && (
<ChannelTestButton
kind={kind}
organizationId={organization?.id}
channel={defaultValues}
/>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
onSuccessAction?.();
form.reset();
}}
>
Cancel
</Button>
<ButtonWithLoading isPending={mutationAddNotificationChannel.isPending}>
{isCreate ? "Add" : "Save"} Channel
</ButtonWithLoading>
</div>
</div>
</Form>
);
};
@@ -0,0 +1,88 @@
"use client"
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
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 {toast} from "sonner";
import {useIsMobile} from "@/hooks/use-mobile";
import {cn} from "@/lib/utils";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
type NotifierTestChannelButtonProps = {
channel: NotificationChannel | StorageChannel;
organizationId?: string;
kind: ChannelKind;
}
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
const channelText = getChannelTextBasedOnKind(kind)
const isMobile = useIsMobile()
const mutation = useMutation({
mutationFn: async () => {
if (kind === "notification") {
const payload: EventPayload = {
title: 'Test Channel',
message: `We are testing channel ${channel.name}`,
level: 'info',
};
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
if (result.success) {
toast.success(result.message);
} else {
toast.error("An error occurred while testing the notification channel, check your configuration");
}
} else if (kind === "storage") {
const input: StorageInput = {
action: "ping",
};
const result = await dispatchStorage(input, undefined, channel.id);
if (result.success) {
toast.success("Successfully connected to storage channel");
} else {
console.error(result);
toast.error("An error occurred while testing the storage channel, check your configuration");
}
} else {
toast.error("Not yet supported");
}
},
});
return (
<Button
type="button"
variant="default"
onClick={() => mutation.mutateAsync()}
disabled={mutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"
>
{mutation.isPending ? (
<>
<div
className={cn(" h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white", !isMobile && "mr-2")}/>
{!isMobile && `Sending...`}
</>
) : (
<div className="flex flex-row justify-center items-center">
{kind === "notification" ?
<>
<Send className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Channel`}
</>
:
<>
<ShieldCheck className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Storage`}
</>
}
</div>
)}
</Button>
)
}
@@ -6,11 +6,11 @@ import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db";
import {
NotificationChannelFormSchema
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {
NotificationChannelFormSchema
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
export const addNotificationChannelAction = userAction.schema(
@@ -20,7 +20,6 @@ export const addNotificationChannelAction = userAction.schema(
})
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {organizationId, data} = parsedInput;
try {
const [channel] = await db
.insert(drizzleDb.schemas.notificationChannel)
@@ -29,6 +28,7 @@ export const addNotificationChannelAction = userAction.schema(
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
organizationId: organizationId ?? null,
})
.returning();
@@ -128,11 +128,11 @@ export const removeNotificationChannelAction = userAction.schema(
export const updateNotificationChannelAction = userAction.schema(
z.object({
notificationChannelId: z.string(),
id: z.string(),
data: NotificationChannelFormSchema
})
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {notificationChannelId, data} = parsedInput;
const {id, data} = parsedInput;
try {
const [channel] = await db
@@ -143,7 +143,7 @@ export const updateNotificationChannelAction = userAction.schema(
config: data.config,
enabled: data.enabled ?? true,
}))
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId))
.where(eq(drizzleDb.schemas.notificationChannel.id, id))
.returning();
return {
@@ -154,7 +154,7 @@ export const updateNotificationChannelAction = userAction.schema(
},
actionSuccess: {
message: `Notification channel "${channel.name}" has been successfully updated.`,
messageParams: {notificationChannelId: channel.id},
messageParams: {id: channel.id},
},
};
} catch (error) {
@@ -165,7 +165,7 @@ export const updateNotificationChannelAction = userAction.schema(
message: "Failed to update notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {notificationChannelId: ""},
messageParams: {id: ""},
},
};
}
@@ -0,0 +1,30 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierDiscordFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.discordWebhook"
render={({field}) => (
<FormItem>
<FormLabel>Discord Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,43 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierGotifyFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.gotifyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Gotify Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.gotifyAppToken"
render={({field}) => (
<FormItem>
<FormLabel>Application Token</FormLabel>
<FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,90 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierNtfyFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.ntfyTopic"
render={({field}) => (
<FormItem>
<FormLabel>Topic Name</FormLabel>
<FormControl>
<Input {...field} placeholder="my-secret-topic"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.sh (default)"/>
</FormControl>
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyToken"
render={({field}) => (
<FormItem>
<FormLabel>Access Token (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="tk_..."/>
</FormControl>
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
<FormMessage/>
</FormItem>
)}
/>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.ntfyUsername"
render={({field}) => (
<FormItem>
<FormLabel>Basic Auth Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyPassword"
render={({field}) => (
<FormItem>
<FormLabel>Basic Auth Password (Optional)</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="password"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,48 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierTelegramFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.telegramBotToken"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Bot Token</FormLabel>
<FormControl>
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.telegramChatId"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-100123456789 or 123456789"/>
</FormControl>
<p className="text-xs text-muted-foreground">
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
For groups, add the bot to the group. <br/>
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
</p>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,65 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.webhookUrl"
render={({field}) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/api/webhook"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4">
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecretHeader"
render={({field}) => (
<FormItem>
<FormLabel>Header Name (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="X-Webhook-Secret"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecret"
render={({field}) => (
<FormItem>
<FormLabel>Secret Value (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Secret value..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>).
</p>
</>
)
}
@@ -0,0 +1,173 @@
"use server";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {
StorageChannelFormSchema
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
import {StorageChannel} from "@/db/schema/12_storage-channel";
export const addStorageChannelAction = userAction.schema(
z.object({
organizationId: z.string().optional(),
data: StorageChannelFormSchema
})
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
const {organizationId, data} = parsedInput;
try {
const [channel] = await db
.insert(drizzleDb.schemas.storageChannel)
.values({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
organizationId: organizationId ?? null
})
.returning();
if (organizationId) {
await db.insert(drizzleDb.schemas.organizationStorageChannel).values({
organizationId,
storageChannelId: channel.id,
});
}
return {
success: true,
value: {
...channel,
config: channel.config as JSON
},
actionSuccess: {
message: "Storage channel has been successfully created.",
messageParams: {id: channel.id},
},
};
} catch (error) {
console.error("Error:", error);
return {
success: false,
actionError: {
message: "Failed to create storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: ""},
},
};
}
});
export const removeStorageChannelAction = userAction.schema(
z.object({
organizationId: z.string().optional(),
id: z.string(),
})
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
const {organizationId, id} = parsedInput;
try {
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationStorageChannel)
.where(
and(
eq(drizzleDb.schemas.organizationStorageChannel.organizationId, organizationId),
eq(drizzleDb.schemas.organizationStorageChannel.storageChannelId, id)
)
);
}
const [deletedChannel] = await db
.delete(drizzleDb.schemas.storageChannel)
.where(eq(drizzleDb.schemas.storageChannel.id, id))
.returning();
if (!deletedChannel) {
return {
success: false,
actionError: {
message: "Storage channel not found.",
status: 404,
messageParams: {id: id},
},
};
}
return {
success: true,
value: {
...deletedChannel,
config: deletedChannel.config as JSON
},
actionSuccess: {
message: "Storage channel has been successfully removed.",
messageParams: {id: id},
},
};
} catch (error) {
console.error("Error:", error);
return {
success: false,
actionError: {
message: "Failed to remove storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: id},
},
};
}
});
export const updateStorageChannelAction = userAction.schema(
z.object({
id: z.string(),
data: StorageChannelFormSchema
})
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
const {id, data} = parsedInput;
try {
const [channel] = await db
.update(drizzleDb.schemas.storageChannel)
.set(withUpdatedAt({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
}))
.where(eq(drizzleDb.schemas.storageChannel.id, id))
.returning();
return {
success: true,
value: {
...channel,
config: channel.config as JSON
},
actionSuccess: {
message: `Storage channel "${channel.name}" has been successfully updated.`,
messageParams: {id: channel.id},
},
};
} catch (error) {
console.error("Error:", error);
return {
success: false,
actionError: {
message: "Failed to update storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: ""},
},
};
}
});
@@ -0,0 +1,151 @@
"use client";
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import {Button} from "@/components/ui/button";
import {PasswordInput} from "@/components/ui/password-input";
import {
googleDriveRefreshTokenAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/google-drive/helpers";
import {toast} from "sonner";
type StorageGoogleDriveFormProps = {
form: UseFormReturn<any>;
};
export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
const refreshToken = form.watch("config.refreshToken");
const isConnected = Boolean(refreshToken);
const handleConnect = () => {
const clientId = form.getValues("config.clientId");
const clientSecret = form.getValues("config.clientSecret");
const redirectUri = `${window.location.origin}/api/google/drive/callback`;
if (!clientId || !clientSecret) {
form.setError("config.clientId", {message: "Client ID and Secret are required"});
return;
}
const scope = encodeURIComponent("https://www.googleapis.com/auth/drive.file");
const oauthUrl =
`https://accounts.google.com/o/oauth2/v2/auth` +
`?client_id=${clientId}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&response_type=code` +
`&scope=${scope}` +
`&access_type=offline` +
`&prompt=consent`;
const oauthWindow = window.open(oauthUrl, "_blank", "width=500,height=600");
const interval = setInterval(async () => {
try {
if (!oauthWindow || oauthWindow.closed) {
clearInterval(interval);
return;
}
if (oauthWindow.location.href.startsWith(redirectUri)) {
const code = new URL(oauthWindow.location.href).searchParams.get("code");
if (!code) return;
const result = await googleDriveRefreshTokenAction({
code,
clientId,
clientSecret,
redirectUri,
});
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
if (!inner.value) {
form.setError("config", {message: "OAuth succeeded but no refresh token returned"});
return;
}
form.setValue("config.refreshToken", inner.value, {
shouldValidate: true,
shouldDirty: true,
});
} else {
toast.error(inner?.actionError?.message);
}
oauthWindow.close();
clearInterval(interval);
}
} catch {
// Ignore cross-origin errors until redirect
}
}, 500);
};
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.clientId"
render={({field}) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input {...field} placeholder="xxxx.apps.googleusercontent.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.clientSecret"
render={({field}) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.folderId"
render={({field}) => (
<FormItem>
<FormLabel>Folder ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<input type="hidden" {...form.register("config.refreshToken")} />
<div className="flex items-center gap-3">
<Button type="button" onClick={handleConnect}>
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
</Button>
{isConnected && (
<span className="text-sm text-green-600 font-medium">
Google Drive connected
</span>
)}
</div>
</>
);
};
@@ -0,0 +1,52 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
export const googleDriveRefreshTokenAction = userAction.schema(
z.object({
code: z.string(),
clientId: z.string(),
clientSecret: z.string(),
redirectUri: z.string(),
})).action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
const {code, clientId, clientSecret, redirectUri} = parsedInput;
try {
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code: code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
const tokens = await tokenRes.json();
return {
success: true,
value: tokens.refresh_token,
actionSuccess: {
message: "Refresh token successfully fetched",
messageParams: {code: code},
},
};
}catch {
return {
success: false,
actionError: {
message: "An error occurred",
status: 404,
messageParams: {code: code},
},
};
}
});
@@ -0,0 +1,102 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
import {Switch} from "@/components/ui/switch";
type StorageS3FormProps = {
form: UseFormReturn<any, any, any>
}
export const StorageS3Form = ({form}: StorageS3FormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.endPointUrl"
render={({field}) => (
<FormItem>
<FormLabel>Endpoind URL</FormLabel>
<FormControl>
<Input {...field} placeholder="s3.exemple.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.accessKey"
render={({field}) => (
<FormItem>
<FormLabel>Access Key</FormLabel>
<FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.secretKey"
render={({field}) => (
<FormItem>
<FormLabel>Secret Key</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.bucketName"
render={({field}) => (
<FormItem>
<FormLabel>Bucket name</FormLabel>
<FormControl>
<Input {...field} placeholder="portabase-dev"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input {...field} type="number" placeholder="443" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.useSSL"
render={({ field }) => (
<FormItem>
<FormLabel>Use SSL</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,60 @@
"use client"
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {useState} from "react";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
type ChannelsSectionProps = {
channels: NotificationChannelWith[] | StorageChannelWith[],
organizations: OrganizationWithMembers[],
kind: ChannelKind,
defaultStorageChannelId?: string | null | undefined
}
export const ChannelsSection = ({
organizations,
channels,
kind,
defaultStorageChannelId
}: ChannelsSectionProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const channelText = getChannelTextBasedOnKind(kind)
const hasChannels = channels.length > 0
return (
<div className="h-full">
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen}
adminView={false}
trigger={false}/>
{hasChannels ? (
<div className="h-full">
<CardsWithPagination
data={channels}
cardItem={ChannelCard}
cardsPerPage={8}
numberOfColumns={2}
adminView={true}
organizations={organizations}
kind={kind}
defaultStorageChannelId={defaultStorageChannelId}
/>
</div>
) : (
<EmptyStatePlaceholder
text={`No ${channelText} channels configured yet`}
onClick={() => {
setIsAddModalOpen(true)
}}
className="h-full"
/>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import {UseFormReturn} from "react-hook-form";
import {
NotifierSmtpForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/smtp.form";
import {
NotifierSlackForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/slack.form";
import {
NotifierDiscordForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/discord.form";
import {
NotifierTelegramForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/telegram.form";
import {
NotifierGotifyForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/gotify.form";
import {
NotifierNtfyForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form";
import {
NotifierWebhookForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form";
import {
notificationProviders,
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
import {LucideProps} from "lucide-react";
import {
StorageS3Form
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form";
import {
StorageGoogleDriveForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/google-drive.form";
export type ChannelKind = "notification" | "storage";
export function getChannelTextBasedOnKind(kind: ChannelKind) {
switch (kind) {
case "notification":
return "Notification";
case "storage":
return "Storage";
default:
return "Notification";
}
}
export type ProviderIconTypes = {
value: string
label: string
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>
preview?: boolean
} | {
value: string
label: string
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element
preview?: boolean
}
export const providerIcons: ProviderIconTypes[] = [
...notificationProviders,
...storageProviders,
];
export const getChannelIcon = (type: string) => {
const Icon = providerIcons.find((t) => t.value === type)?.icon
return Icon ? <Icon className="h-4 w-4"/> : null
}
export const renderChannelForm = (provider: string | undefined, form: UseFormReturn<any>) => {
switch (provider) {
case "smtp":
return <NotifierSmtpForm form={form}/>;
case "slack":
return <NotifierSlackForm form={form}/>;
case "discord":
return <NotifierDiscordForm form={form}/>;
case "telegram":
return <NotifierTelegramForm form={form}/>;
case "gotify":
return <NotifierGotifyForm form={form}/>;
case "ntfy":
return <NotifierNtfyForm form={form}/>;
case "webhook":
return <NotifierWebhookForm form={form}/>;
case "s3":
return <StorageS3Form form={form}/>
case "google-drive":
return <StorageGoogleDriveForm form={form}/>
case "local":
return <></>
default:
return null;
}
};
@@ -0,0 +1,532 @@
import {Mail} from "lucide-react";
import type { SVGProps } from 'react';
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
export const notificationProviders: ProviderIconTypes[] = [
{value: "smtp", label: "Email", icon: Mail},
{value: "slack", label: "Slack", icon: SlackIcon},
{value: "discord", label: "Discord", icon: DiscordIcon},
{value: "telegram", label: "Telegram", icon: TelegramIcon},
{value: "gotify", label: "Gotify", icon: GotifyIcon},
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
{value: "webhook", label: "Webhook", icon: WebhookIcon},
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
]
export function SlackIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><path fill="#e01e5a" d="M53.841 161.32c0 14.832-11.987 26.82-26.819 26.82S.203 176.152.203 161.32c0-14.831 11.987-26.818 26.82-26.818H53.84zm13.41 0c0-14.831 11.987-26.818 26.819-26.818s26.819 11.987 26.819 26.819v67.047c0 14.832-11.987 26.82-26.82 26.82c-14.83 0-26.818-11.988-26.818-26.82z"></path><path fill="#36c5f0" d="M94.07 53.638c-14.832 0-26.82-11.987-26.82-26.819S79.239 0 94.07 0s26.819 11.987 26.819 26.819v26.82zm0 13.613c14.832 0 26.819 11.987 26.819 26.819s-11.987 26.819-26.82 26.819H26.82C11.987 120.889 0 108.902 0 94.069c0-14.83 11.987-26.818 26.819-26.818z"></path><path fill="#2eb67d" d="M201.55 94.07c0-14.832 11.987-26.82 26.818-26.82s26.82 11.988 26.82 26.82s-11.988 26.819-26.82 26.819H201.55zm-13.41 0c0 14.832-11.988 26.819-26.82 26.819c-14.831 0-26.818-11.987-26.818-26.82V26.82C134.502 11.987 146.489 0 161.32 0s26.819 11.987 26.819 26.819z"></path><path fill="#ecb22e" d="M161.32 201.55c14.832 0 26.82 11.987 26.82 26.818s-11.988 26.82-26.82 26.82c-14.831 0-26.818-11.988-26.818-26.82V201.55zm0-13.41c-14.831 0-26.818-11.988-26.818-26.82c0-14.831 11.987-26.818 26.819-26.818h67.25c14.832 0 26.82 11.987 26.82 26.819s-11.988 26.819-26.82 26.819z"></path></svg>);
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} viewBox="0 0 256 199" {...props}><path fill="#5865f2" d="M216.856 16.597A208.5 208.5 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046q-29.538-4.442-58.533 0c-1.832-4.4-4.55-9.933-6.846-14.046a207.8 207.8 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161 161 0 0 0 79.735 175.3a136.4 136.4 0 0 1-21.846-10.632a109 109 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a132 132 0 0 0 5.355 4.237a136 136 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848c21.142-6.58 42.646-16.637 64.815-33.213c5.316-56.288-9.08-105.09-38.056-148.36M85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2s23.236 11.804 23.015 26.2c.02 14.375-10.148 26.18-23.015 26.18m85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2c0 14.375-10.148 26.18-23.015 26.18"></path></svg>);
}
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><defs><linearGradient id="SVGuySfwdaH" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stopColor="#2aabee"></stop><stop offset="100%" stopColor="#229ed9"></stop></linearGradient></defs><path fill="url(#SVGuySfwdaH)" d="M128 0C94.06 0 61.48 13.494 37.5 37.49A128.04 128.04 0 0 0 0 128c0 33.934 13.5 66.514 37.5 90.51C61.48 242.506 94.06 256 128 256s66.52-13.494 90.5-37.49c24-23.996 37.5-56.576 37.5-90.51s-13.5-66.514-37.5-90.51C194.52 13.494 161.94 0 128 0"></path><path fill="#fff" d="M57.94 126.648q55.98-24.384 74.64-32.152c35.56-14.786 42.94-17.354 47.76-17.441c1.06-.017 3.42.245 4.96 1.49c1.28 1.05 1.64 2.47 1.82 3.467c.16.996.38 3.266.2 5.038c-1.92 20.24-10.26 69.356-14.5 92.026c-1.78 9.592-5.32 12.808-8.74 13.122c-7.44.684-13.08-4.912-20.28-9.63c-11.26-7.386-17.62-11.982-28.56-19.188c-12.64-8.328-4.44-12.906 2.76-20.386c1.88-1.958 34.64-31.748 35.26-34.45c.08-.338.16-1.598-.6-2.262c-.74-.666-1.84-.438-2.64-.258c-1.14.256-19.12 12.152-54 35.686c-5.1 3.508-9.72 5.218-13.88 5.128c-4.56-.098-13.36-2.584-19.9-4.708c-8-2.606-14.38-3.984-13.82-8.41c.28-2.304 3.46-4.662 9.52-7.072"></path></svg>);
}
export function NtfyIcon(props: SVGProps<SVGSVGElement>) {
return ( <svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
id="Layer_1"
x={0}
y={0}
viewBox="0 0 512 512"
{...props}
>
<style>{".st1{fill:#fff}"}</style>
<linearGradient
id="SVGID_1_"
x1={115.045}
x2={142.93}
y1={512.747}
y2={491.741}
gradientTransform="matrix(15.8584 0 0 -15.8584 -1816.246 8200.18)"
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#348878",
}}
/>
<stop
offset={1}
style={{
stopColor: "#56bda8",
}}
/>
</linearGradient>
<path
d="M441.8 64.8H84.7c-24.5 0-44.6 19-44.6 42.1l.3 285.3-6.7 38.4 95.6-28.8h312.4c24.5 0 44.6-19 44.6-42.1V106.9c.1-23.2-20-42.1-44.5-42.1"
style={{
fill: "url(#SVGID_1_)",
}}
/>
<path
d="M78.1 32.7C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5zm0 46.9H443c13.1 0 22.1 9.5 22.1 19.6V379c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6"
className="st1"
/>
<path
d="M116.4 310.9v-34.8l73.1-38.7c2.4-1.2 4.6-2.1 6.8-2.7 2.3-.8 4.5-1.4 6.3-1.8l6-.6V230q-3-.3-6-1.2-2.85-.6-6.3-1.5c-2.2-.8-4.5-1.8-6.8-3l-73.1-38.7v-35.1l113 61.5v37.4z"
className="st1"
/>
<path
d="M110.5 139.7v50.4l76.6 40.6.5.2-.5.2-76.6 40.6v50.1l8.2-4.5L235.1 254v-46.4zm11.8 21.7 101.2 55.1V245l-101.2 55.1v-19.4l69.6-36.8.1-.1c1.9-1 3.8-1.7 5.8-2.3h.1l.2-.1c2.1-.7 3.9-1.2 5.5-1.6l11.1-1.1v-15.2l-5.4-.5c-1.7-.2-3.5-.5-5.1-1.1h-.2l-.2-.1c-2-.4-3.9-.9-5.9-1.4-2-.7-4-1.6-5.9-2.6l-.1-.1-69.6-36.7zm152.3 137.4h120.7v32.6H274.6z"
className="st1"
/>
<path
d="M268.7 292.1v46h132.4v-46zm11.7 13.4h109v19.2h-109z"
className="st1"
/>
</svg>)
}
export function GotifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
id="Layer_2"
x={0}
y={0}
viewBox="46.6 113.46 377.2 307.81"
{...props}
>
<style>
{
".st0{fill:#ddcba2}.st0,.st1,.st2,.st4,.st5{stroke:#000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10}.st1{fill:#71caee}.st2{fill:#fff}.st4{fill:#f0f0f0}.st5{fill:none}"
}
</style>
<switch>
<foreignObject
width={1}
height={1}
x={0}
y={0}
requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/"
/>
<g>
<path
d="M317.7 376.2c6.2-1.7 15.8 0 19.5 3.2 5.5 4.8 4.9 20.9 1.1 29-.8 1.7-1.8 3.4-3.2 4.4s-3.4 1.2-4.7 0-1.7-3.3-1.9-5.2c-.2-3.1-.2-6.2.3-9.2.2-1.3.2-2.9-.2-4.2-.6-2.2-2.5-3.1-4.5-3.5-3.4-.8-14.3-.7-19.5-.7-.8 0 1.5-9.1 2-9.7 2.6-2.9 7.5-3.1 11.1-4.1z"
className="st0"
/>
<path
d="m258.9 119.7-9-2.7c-4.6-1.4-9.2-2.8-14-2.5-2.8.2-6.1 1.3-6.9 4-.6 2-1.6 7.3-1.3 7.9 1.5 3.4 13.9 6.7 18.3 6.7"
className="st1"
/>
<path d="M392.6 177.9c-1.4 1.4-2.2 3.5-2.5 5.5-.2 1.4-.1 3 .5 4.3s1.8 2.3 3.1 3c1.3.6 2.8.9 4.3.9 1.1 0 2.3-.1 3.1-.9.6-.7.8-1.6.9-2.5.2-2.3-.1-4.7-.9-6.9-.4-1.1-.9-2.3-1.8-3.1-1.7-1.8-4.5-2.2-6.4-.5-.1 0-.2.1-.3.2" />
<path
d="M358.5 164.2c-1-1 0-2.7 1-3.7 5.8-5.2 15.1-4.6 21.8-.6 10.9 6.6 15.6 19.9 17.2 32.5.6 5.2.9 10.6-.5 15.7s-4.6 9.9-9.3 12.1c-1.1.5-2.3.9-3.4.5s-1.9-1.8-1.2-2.8c-9.4-13.6-19-26.8-20.9-43.2-.5-4.1-1.8-7.4-4.7-10.5z"
className="st2"
/>
<path
d="M134.7 328.4c-5.1-3.1-9.9-6.6-14.3-10.6-1.3-1.2-2.6-2.5-2.6-4.3 0-1.2.6-2.2 1.2-3.2.8-1.4 1.7-2.8 2.5-4.1 1.1-1.8 2.9-3.9 4.9-3.2.9.3 1.5 1.1 2 1.8 2.4 3.3 4.9 6.6 7.3 9.8 1.5 2 3.7 4.3 6.1 3.5"
className="st1"
/>
<path
d="M209.6 133c33.2-18 77.8-19.6 111.5-8.7 24.3 7.9 43.4 26.7 53.3 50 8.7 20.6 10.5 43.6 8.1 65.7-4.4 40.2-20.2 77.9-40.3 112.6-11.1 19-21.8 36-40.5 48.5-36.8 24.6-87.2 22.1-128.4 11.5-19.9-5.1-39.7-17.3-47.2-37.3-4.8-12.8-4.2-27.6 1.5-40 11.6-24.8 43.2-38.4 45.6-67.9.7-8.7-1.6-17.3-3.6-25.7-5.6-23.4-8.9-45.8 1.4-68.7 8.1-17.7 21.9-31 38.6-40z"
className="st1"
/>
<path
d="M189.8 151.4c-5.4-5.2-11.9-8.8-19-10.3-2.2-.5-4.7-.7-6.9.7-1.8 1.2-3.1 3.3-4.2 5.3-1.6 3-3 6.2-4.1 9.4-.4 1.2-.6 2.5 0 3.5.3.6.9.9 1.5 1.2 8.1 4.2 16.8 7.1 25.5 9.8"
className="st1"
/>
<path
d="M183.7 158.7c-2.5-1.8-16.8-12.1-18.7-4.8-.4 1.6.5 3.9 1.5 4.8"
className="st1"
/>
<path
d="M264.5 174.9c-.5.5-.9 1-1.3 1.6-9 11.6-12 27.9-9.3 42.1 1.7 9 5.9 17.9 13.2 23.4 19.3 14.6 51.5 13.5 68.4-1.5 24.4-21.7 13-67.6-14-78.8-17.6-7.2-43.7-1.6-57 13.2zm117.6 62.2c1.4-.1 2.9-.1 4.3.1.3 0 .7.1 1 .4.2.3.4.7.5 1.1 1 3.9.5 8.2.1 12.4-.1.9-.2 1.8-.6 2.6-1 2.1-3.1 2.7-4.7 2.7-.1 0-.2 0-.3-.1-.3-.2-.3-.7-.2-1.2.3-5.9-.1-11.9-.1-18q0 .15 0 0z"
className="st2"
/>
<path
d="M378.7 236.8c-1.4.4-2.5 2-2.8 4.4-.5 4.4-.7 8.9-.5 13.4 0 .9.1 1.9.5 2.4.2.3.5.4.8.4 1.6.3 4.1-.6 5.6-1 0 0 0-5.2-.1-8s-.1-6.1-.2-8.9v-2.2c.1-.7-2.6-.7-3.3-.5z"
className="st2"
/>
<path
d="M358.3 231.8c-.3 2.2.1 4.7 1.7 7.4 2.6 4.4 7 6.1 11.9 5.8 8.9-.6 25.3-5.4 27.5-15.7.6-3-.3-6.1-2.2-8.5-6.2-7.8-17.8-5.7-25.6-2-5.9 2.7-12.4 7-13.3 13z"
className="st0"
/>
<path
d="M386.4 208.6c2.2 1.4 3.7 3.8 4 7 .3 3.6-1.4 7.5-5 8.8-2.9 1.1-6.2.6-9.1-.4s-5.8-2.8-6.8-5.7c-.7-2-.3-4.3.7-6.1 1.1-1.8 2.8-3.2 4.7-4.1 3.9-1.8 8.4-1.6 11.5.5z"
style={{
fill: "#888e93",
stroke: "#000",
strokeWidth: 2,
strokeLinecap: "round",
strokeMiterlimit: 10,
}}
/>
<path
d="M414.7 262.6c2.4.6 4.8 2.1 5.6 4.4s.1 4.9-1.6 6.7-4.2 2.5-6.6 2.5c-.8 0-1.7-.1-2.4-.5-2.5-1.1-3.5-4-4.2-6.6-1.8-6.8 3.6-7.8 9.2-6.5z"
className="st0"
/>
<path
d="M267.1 284.7c2.3-4.5 141.3-36.2 144.7-31.6 3.4 4.5 15.8 88.2 9 90.4-6.8 2.3-119.8 37.3-126.6 35s-29.4-89.3-27.1-93.8z"
className="st4"
/>
<path
d="M294.2 378.5s54.3-74.6 59.9-76.9c5.7-2.3 67.3 41.3 67.3 41.3"
className="st5"
/>
<path
d="M267 287.7s86 38.8 91.6 36.6c5.7-2.3 53.1-71.2 53.1-71.2"
className="st4"
/>
<linearGradient
id="SVGID_1_"
x1={131.119}
x2={103.259}
y1={373.573}
y2={396.951}
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#71caee",
}}
/>
<stop
offset={0.041}
style={{
stopColor: "#83cae2",
}}
/>
<stop
offset={0.121}
style={{
stopColor: "#9fcace",
}}
/>
<stop
offset={0.211}
style={{
stopColor: "#b6cbbe",
}}
/>
<stop
offset={0.314}
style={{
stopColor: "#c7cbb1",
}}
/>
<stop
offset={0.438}
style={{
stopColor: "#d4cba8",
}}
/>
<stop
offset={0.606}
style={{
stopColor: "#dbcba3",
}}
/>
<stop
offset={1}
style={{
stopColor: "#ddcba2",
}}
/>
</linearGradient>
<path
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
style={{
fill: "url(#SVGID_1_)",
}}
/>
<path
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
className="st5"
/>
<linearGradient
id="SVGID_2_"
x1={265.033}
x2={275.301}
y1={280.295}
y2={302.314}
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#71caee",
}}
/>
<stop
offset={0.041}
style={{
stopColor: "#83cae2",
}}
/>
<stop
offset={0.121}
style={{
stopColor: "#9fcace",
}}
/>
<stop
offset={0.211}
style={{
stopColor: "#b6cbbe",
}}
/>
<stop
offset={0.314}
style={{
stopColor: "#c7cbb1",
}}
/>
<stop
offset={0.438}
style={{
stopColor: "#d4cba8",
}}
/>
<stop
offset={0.606}
style={{
stopColor: "#dbcba3",
}}
/>
<stop
offset={1}
style={{
stopColor: "#ddcba2",
}}
/>
</linearGradient>
<path
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
style={{
fill: "url(#SVGID_2_)",
}}
/>
<path
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
className="st5"
/>
<path d="M318.4 198.4c-2-.3-4.1.1-5.9 1.3-3.2 2.1-4.7 6.2-4.7 9.9 0 1.9.4 3.8 1.4 5.3 1.2 1.7 3.1 2.9 5.2 3.4 3.4.8 8.2.7 10.5-2.5 1-1.5 1.4-3.3 1.5-5.1.5-5.7-1.8-11.4-8-12.3" />
<path
d="M320.4 203.3c.9.3 1.7.8 2.1 1.7.4.8.4 1.7.3 2.5-.1 1-.6 2-1.5 2.7-.7.5-1.7.7-2.6.5s-1.7-.8-2.2-1.6c-1.1-1.6-.9-4.4.9-5.5.9-.4 2-.6 3-.3"
style={{
fill: "#fff",
}}
/>
<linearGradient
id="SVGID_3_"
x1={-18.456}
x2={110.382}
y1={252.286}
y2={252.286}
gradientTransform="rotate(-5.297 -26.409 -856.73)scale(.99997)"
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
<stop
offset={0.477}
style={{
stopColor: "#575756",
stopOpacity: 0.2,
}}
/>
<stop
offset={0.995}
style={{
stopColor: "#333",
stopOpacity: 0.001911551,
}}
/>
<stop
offset={1}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
</linearGradient>
<path
d="M81.6 222.4s56.5 68.4 123.9 42.4l6.6-29.7C150 272.2 82.8 214.3 82.8 214.3z"
style={{
fill: "url(#SVGID_3_)",
}}
/>
<linearGradient
id="SVGID_4_"
x1={110.198}
x2={283.737}
y1={262.493}
y2={262.493}
gradientTransform="matrix(.9991 .04164 -.04164 .9991 -36.323 1.051)"
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
<stop
offset={0.477}
style={{
stopColor: "#575756",
stopOpacity: 0.2,
}}
/>
<stop
offset={0.995}
style={{
stopColor: "#333",
stopOpacity: 0.001911551,
}}
/>
<stop
offset={1}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
</linearGradient>
<path
d="M64.2 234.9s63.1 101.5 157.8 78.9l14.2-38.5c-89.5 38.3-168.9-51-168.9-51z"
style={{
fill: "url(#SVGID_4_)",
}}
/>
<linearGradient
id="SVGID_5_"
x1={218.967}
x2={428.745}
y1={263.594}
y2={263.594}
gradientTransform="rotate(9.265 -38.832 -826.002)"
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
<stop
offset={0.477}
style={{
stopColor: "#575756",
stopOpacity: 0.2,
}}
/>
<stop
offset={0.995}
style={{
stopColor: "#333",
stopOpacity: 0.001911551,
}}
/>
<stop
offset={1}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
</linearGradient>
<path
d="M46.6 251.5s61.1 130.9 178 117.5l22.6-44.2c-113 33-195.3-85.7-195.3-85.7z"
style={{
fill: "url(#SVGID_5_)",
}}
/>
</g>
</switch>
</svg>
)
}
export function WebhookIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={2500}
height={2448}
fillRule="evenodd"
strokeLinejoin="round"
strokeMiterlimit={2}
clipRule="evenodd"
viewBox="-6.74 -6.74 223.55 218.799"
{...props}
>
<path
fill="#7547db"
d="M111.134 71.906a21 21 0 0 1-1.324.042c-11.314 0-20.5-9.186-20.5-20.5s9.186-20.5 20.5-20.5 20.5 9.186 20.5 20.5a20.4 20.4 0 0 1-4.582 12.913l22.66 41.655a51 51 0 0 1 17.422-3.068c28.148 0 51 22.852 51 51s-22.852 51-51 51a51.2 51.2 0 0 1-28.036-8.373 7.303 7.303 0 0 1-1.359-11.105c.015-.016.031-.033.061-.036a7.665 7.665 0 0 1 9.736-1.228 36.06 36.06 0 0 0 19.598 5.792c19.897 0 36.05-16.153 36.05-36.05s-16.153-36.05-36.05-36.05c-6.89 0-16.669 3.014-22.548 6.745a2.637 2.637 0 0 1-3.899-.845zm-9.412 88.614a51 51 0 0 1-6.054 16.62c-14.074 24.378-45.29 32.743-69.667 18.669S-6.74 150.518 7.333 126.14a51.2 51.2 0 0 1 21.27-20.094 7.3 7.3 0 0 1 6.167-.211 7.3 7.3 0 0 1 4.13 4.587c.006.022.012.043 0 .07a7.665 7.665 0 0 1-3.804 9.047 36.05 36.05 0 0 0-14.816 14.076c-9.948 17.23-4.036 39.297 13.195 49.246 17.231 9.948 39.298 4.035 49.246-13.196 3.445-5.967 5.724-15.943 5.433-22.9a2.632 2.632 0 0 1 2.681-2.953l59.054 1.499q.292-.59.626-1.168c5.657-9.798 18.205-13.161 28.003-7.504 9.8 5.658 13.161 18.206 7.504 28.004s-18.205 13.16-28.004 7.503a20.4 20.4 0 0 1-8.892-10.424zM76.97 90.555A51 51 0 0 1 65.601 77c-14.074-24.377-5.71-55.594 18.667-69.667S139.862 1.624 153.935 26a51.2 51.2 0 0 1 6.768 28.466 7.306 7.306 0 0 1-8.938 6.73c-.022-.005-.044-.011-.062-.035a7.664 7.664 0 0 1-5.932-7.817 36.04 36.04 0 0 0-4.783-19.87c-9.948-17.23-32.014-23.143-49.245-13.195S68.599 52.294 78.548 69.525c3.445 5.967 10.945 12.929 17.115 16.155a2.635 2.635 0 0 1 1.218 3.799l-30.826 50.393q.366.548.7 1.125c5.656 9.799 2.294 22.347-7.505 28.004s-22.346 2.295-28.003-7.504-2.295-22.346 7.503-28.003a20.4 20.4 0 0 1 13.474-2.488z"
/>
</svg>
)
}
export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 239" {...props}>
<defs>
<path id="logosMicrosoftTeams0"
d="M136.93 64.476v12.8a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.79-22.742h21.848c6.008.022 10.872 4.887 10.895 10.894Z"
opacity=".2"/>
</defs>
<defs>
<linearGradient id="logosMicrosoftTeams1" x1="17.372%" x2="82.628%" y1="-6.51%" y2="106.51%">
<stop offset="0%" stop-color="#5A62C3"/>
<stop offset="50%" stop-color="#4D55BD"/>
<stop offset="100%" stop-color="#3940AB"/>
</linearGradient>
</defs>
<path fill="#5059C9"
d="M178.563 89.302h66.125c6.248 0 11.312 5.065 11.312 11.312v60.231c0 22.96-18.613 41.574-41.573 41.574h-.197c-22.96.003-41.576-18.607-41.579-41.568V95.215a5.912 5.912 0 0 1 5.912-5.913Z"/>
<circle cx="223.256" cy="50.605" r="26.791" fill="#5059C9"/>
<circle cx="139.907" cy="38.698" r="38.698" fill="#7B83EB"/>
<path fill="#7B83EB"
d="M191.506 89.302H82.355c-6.173.153-11.056 5.276-10.913 11.449v68.697c-.862 37.044 28.445 67.785 65.488 68.692c37.043-.907 66.35-31.648 65.489-68.692v-68.697c.143-6.173-4.74-11.296-10.913-11.449Z"/>
<path
d="M142.884 89.302v96.268a10.96 10.96 0 0 1-6.787 10.062c-1.3.55-2.697.833-4.108.833H76.68c-.774-1.965-1.488-3.93-2.084-5.953a72.509 72.509 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h60.547Z"
opacity=".1"/>
<path
d="M136.93 89.302v102.222c0 1.411-.283 2.808-.833 4.108a10.96 10.96 0 0 1-10.062 6.787H79.48c-1.012-1.965-1.965-3.93-2.798-5.954a59.049 59.049 0 0 1-2.084-5.953a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
opacity=".2"/>
<path
d="M136.93 89.302v90.315c-.045 5.998-4.896 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
opacity=".2"/>
<path
d="M130.977 89.302v90.315c-.046 5.998-4.897 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h48.64Z"
opacity=".2"/>
<path
d="M142.884 58.523v18.753c-1.012.06-1.965.12-2.977.12c-1.012 0-1.965-.06-2.977-.12a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.791-22.742a33.082 33.082 0 0 1-1.905-5.954h29.708c6.007.023 10.872 4.887 10.895 10.895Z"
opacity=".1"/>
<use href="#logosMicrosoftTeams0" opacity=".2"/>
<use href="#logosMicrosoftTeams0" opacity=".2"/>
<path d="M130.977 64.476v11.848a38.698 38.698 0 0 1-26.791-22.743h15.896c6.008.023 10.872 4.888 10.895 10.895Z"
opacity=".2"/>
<path fill="url(#logosMicrosoftTeams1)"
d="M10.913 53.581h109.15c6.028 0 10.914 4.886 10.914 10.913v109.151c0 6.027-4.886 10.913-10.913 10.913H10.913C4.886 184.558 0 179.672 0 173.645V64.495C0 58.466 4.886 53.58 10.913 53.58Z"/>
<path fill="#FFF" d="M94.208 95.125h-21.82v59.416H58.487V95.125H36.769V83.599h57.439z"/>
</svg>)
}
@@ -0,0 +1,76 @@
import {Server} from "lucide-react";
import type {SVGProps} from "react";
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
export const storageProviders: ProviderIconTypes[] = [
{value: "local", label: "Local", icon: Server},
{value: "s3", label: "S3", icon: S3Icon},
{value: "google-drive", label: "Google Drive", icon: GoogleDriveIcon},
{value: "blob", label: "Azure Blob Storage", icon: BlobIcon, preview: true},
{value: "gcs", label: "Google Cloud Storage", icon: GCSIcon, preview: true},
]
export function S3Icon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} {...props} viewBox="0 0 24 24">
<path fill="currentColor"
d="M13.207.006a2.16 2.16 0 0 0-1.62.582a2.15 2.15 0 0 0-.095 3.035l3.408 3.55a3.042 3.042 0 0 1-.663 4.688l-.463.239V7.285a15.42 15.42 0 0 0-8.018 10.487v.017l6.549-3.328v7.621L13.779 24V13.682l.897-.463a4.443 4.443 0 0 0 1.22-7.03l-3.37-3.525a.75.75 0 0 1 .037-1.055a.75.75 0 0 1 1.056.038l.467.486l-.006.006l4.07 4.244a.057.057 0 0 0 .082 0a.06.06 0 0 0 0-.07l-3.14-5.143l-.149.143l.149-.145C14.494.393 13.829.054 13.207.006m-.902 9.865v2.994l-4.152 2.149a14 14 0 0 1 2.767-3.928a14 14 0 0 1 1.385-1.215"/>
</svg>
);
}
export function GoogleDriveIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} {...props} viewBox="0 0 256 229"><path fill="#0066da" d="m19.354 196.034l11.29 19.5c2.346 4.106 5.718 7.332 9.677 9.678q17.009-21.591 23.68-33.137q6.77-11.717 16.641-36.655q-26.604-3.502-40.32-3.502q-13.165 0-40.322 3.502c0 4.545 1.173 9.09 3.519 13.196z"/><path fill="#ea4335" d="M215.681 225.212c3.96-2.346 7.332-5.572 9.677-9.677l4.692-8.064l22.434-38.855a26.57 26.57 0 0 0 3.518-13.196q-27.315-3.502-40.247-3.502q-13.899 0-40.248 3.502q9.754 25.075 16.422 36.655q6.724 11.683 23.752 33.137"/><path fill="#00832d" d="M128.001 73.311q19.68-23.768 27.125-36.655q5.996-10.377 13.196-33.137C164.363 1.173 159.818 0 155.126 0h-54.25C96.184 0 91.64 1.32 87.68 3.519q9.16 26.103 15.544 37.154q7.056 12.213 24.777 32.638"/><path fill="#2684fc" d="M175.36 155.42H80.642l-40.32 69.792c3.958 2.346 8.503 3.519 13.195 3.519h148.968c4.692 0 9.238-1.32 13.196-3.52z"/><path fill="#00ac47" d="M128.001 73.311L87.681 3.52c-3.96 2.346-7.332 5.571-9.678 9.677L3.519 142.224A26.57 26.57 0 0 0 0 155.42h80.642z"/><path fill="#ffba00" d="m215.242 77.71l-37.243-64.514c-2.345-4.106-5.718-7.331-9.677-9.677l-40.32 69.792l47.358 82.109h80.496c0-4.546-1.173-9.09-3.519-13.196z"/></svg>
)
}
export function BlobIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width={256} height={199} {...props} viewBox="0 0 48 48">
<path fill="#2979ff"
d="M32.66,7H15.34c-0.715,0-1.375,0.381-1.732,1l-8.66,15c-0.357,0.619-0.357,1.381,0,2l8.66,15c0.357,0.619,1.018,1,1.732,1H32.66c0.715,0,1.375-0.381,1.732-1l8.66-15c0.357-0.619,0.357-1.381,0-2l-8.66-15C34.035,7.381,33.375,7,32.66,7z"></path>
<path fill="#fff"
d="M32,35H16c-0.552,0-1-0.448-1-1V14c0-0.552,0.448-1,1-1h13l4,4v17C33,34.552,32.552,35,32,35z"></path>
<path fill="#b1cfff" d="M29,13v3c0,0.552,0.448,1,1,1h3L29,13z"></path>
<path fill="#03a9f4"
d="M22.265 23.831H20.93V19.58l-1.312.383v-1.011l2.524-.873h.122V23.831zM28.702 21.48c0 .416-.049.776-.146 1.081-.097.303-.234.556-.411.756-.176.2-.387.349-.632.446-.245.097-.515.146-.81.146-.298 0-.57-.049-.816-.146-.246-.097-.457-.246-.633-.446-.176-.2-.315-.453-.413-.756-.099-.305-.147-.665-.147-1.081v-1.051c0-.413.049-.773.146-1.078.097-.305.234-.558.411-.758.176-.2.387-.349.632-.446C26.126 18.049 26.397 18 26.695 18c.295 0 .565.049.811.146.246.097.458.246.635.446.176.2.313.453.412.758.099.305.149.665.149 1.078V21.48zM27.37 20.26c0-.229-.016-.421-.047-.579-.032-.155-.076-.283-.134-.381-.058-.097-.129-.167-.212-.209-.083-.042-.176-.063-.282-.063-.105 0-.2.021-.283.063-.083.042-.153.112-.209.209-.057.097-.1.225-.13.381-.03.158-.045.35-.045.579v1.383c0 .234.014.431.045.589.03.158.075.286.133.381.058.097.129.166.213.208.084.042.179.063.284.063.103 0 .195-.021.278-.063.083-.042.154-.111.212-.208.058-.095.101-.223.132-.381.03-.158.046-.354.046-.589V20.26zM23.308 29.571c0 .416-.049.776-.146 1.081-.097.303-.234.556-.411.756-.176.2-.387.349-.632.446C21.874 31.951 21.604 32 21.309 32c-.298 0-.57-.049-.816-.146-.246-.097-.457-.246-.633-.446-.176-.2-.315-.453-.413-.756-.099-.305-.147-.665-.147-1.081V28.52c0-.413.049-.773.146-1.078.097-.305.234-.558.411-.758.176-.2.387-.349.632-.446.245-.097.516-.146.814-.146.295 0 .565.049.811.146.246.097.458.246.635.446.176.2.313.453.412.758s.149.665.149 1.078V29.571zM21.977 28.35c0-.229-.016-.421-.047-.579-.032-.155-.076-.283-.134-.381-.058-.097-.129-.167-.212-.209-.083-.042-.176-.063-.282-.063s-.2.021-.283.063c-.083.042-.153.112-.209.209-.057.097-.1.225-.13.381-.03.158-.045.35-.045.579v1.383c0 .234.014.431.045.589.03.158.075.286.133.381.058.097.129.166.213.208s.179.063.284.063c.103 0 .195-.021.278-.063.083-.042.154-.111.212-.208.058-.095.101-.223.132-.381.03-.158.046-.354.046-.589V28.35zM27.659 31.921h-1.335V27.67l-1.312.383v-1.011l2.524-.873h.122V31.921z"></path>
</svg>
);
}
export function GCSIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
id="standard_product_icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
{...props}
>
<g id="bounding_box">
<rect width="512" height="512" fill="none"/>
</g>
<g id="art">
<path
fill="#34a853"
d="M442,277.9H70c-8.8,0-16,7.2-16,16v148.1c0,8.8,7.2,16,16,16h107.2c1.5.5,3.1.7,4.8.7s3.3-.3,4.8-.7h255.2c8.8,0,16-7.2,16-16v-148.1c0-8.8-7.2-16-16-16ZM86,309.9h80v116.1h-80v-116.1ZM426,425.9h-228v-116.1h228v116.1Z"
/>
<path
fill="#fbbc04"
d="M442,54H70c-8.8,0-16,7.2-16,16v148.8c0,8.8,7.2,16,16,16h372c8.8,0,16-7.2,16-16V70c0-8.8-7.2-16-16-16ZM86,86h80v116.8h-80v-116.8ZM426,202.8h-228v-116.8h228v116.8Z"
/>
<path
fill="#ea4335"
d="M442,234.8h-16V86H54v-16c0-8.8,7.2-16,16-16h372c8.8,0,16,7.2,16,16v148.8c0,8.8-7.2,16-16,16Z"
/>
<path
fill="#4285f4"
d="M442,457.9h-16v-148.1H54v-16c0-8.8,7.2-16,16-16h372c8.8,0,16,7.2,16,16v148.1c0,8.8-7.2,16-16,16Z"
/>
<circle fill="#ea4335" cx="349" cy="144.4" r="37"/>
<circle fill="#4285f4" cx="349" cy="367.9" r="37"/>
</g>
</svg>
);
}
@@ -6,38 +6,38 @@ import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/u
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {
NotificationChannelsOrganizationSchema,
NotificationChannelsOrganizationType
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {
updateNotificationChannelsOrganizationAction
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action";
import {toast} from "sonner";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
ChannelsOrganizationSchema, ChannelsOrganizationType
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema";
import {
updateNotificationChannelsOrganizationAction, updateStorageChannelsOrganizationAction
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization.action";
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
type NotifierChannelOrganisationFormProps = {
type ChannelOrganisationFormProps = {
organizations?: OrganizationWithMembers[];
defaultValues?: NotificationChannelWith
defaultValues?: NotificationChannelWith | StorageChannelWith
kind: ChannelKind
};
export const NotifierChannelOrganisationForm = ({
organizations,
defaultValues,
}: NotifierChannelOrganisationFormProps) => {
export const ChannelOrganisationForm = ({
organizations,
defaultValues,
kind
}: ChannelOrganisationFormProps) => {
const router = useRouter();
console.log("defaultValues", defaultValues);
console.log("organizations", organizations);
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
const form = useZodForm({
schema: NotificationChannelsOrganizationSchema,
schema: ChannelsOrganizationSchema,
// @ts-ignore
defaultValues: {
organizations: defaultOrganizationIds
@@ -53,15 +53,15 @@ export const NotifierChannelOrganisationForm = ({
};
const mutationUpdateNotificationChannelOrganizations = useMutation({
mutationFn: async (values: NotificationChannelsOrganizationType) => {
const mutationUpdateChannelOrganizations = useMutation({
mutationFn: async (values: ChannelsOrganizationType) => {
const payload = {
data: values.organizations,
notificationChannelId: defaultValues?.id ?? ""
id: defaultValues?.id ?? ""
};
const result = await updateNotificationChannelsOrganizationAction(payload)
const result = kind === "notification" ? await updateNotificationChannelsOrganizationAction(payload) : await updateStorageChannelsOrganizationAction(payload)
const inner = result?.data;
if (inner?.success) {
@@ -80,7 +80,7 @@ export const NotifierChannelOrganisationForm = ({
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationUpdateNotificationChannelOrganizations.mutateAsync(values);
await mutationUpdateChannelOrganizations.mutateAsync(values);
}}
>
<FormField
@@ -104,7 +104,7 @@ export const NotifierChannelOrganisationForm = ({
<div className="flex justify-end">
<div className="flex gap-2 justify-end">
<ButtonWithLoading isPending={mutationUpdateNotificationChannelOrganizations.isPending}>
<ButtonWithLoading isPending={mutationUpdateChannelOrganizations.isPending}>
Save
</ButtonWithLoading>
</div>
@@ -0,0 +1,154 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
export const updateNotificationChannelsOrganizationAction = userAction
.schema(
z.object({
data: z.array(z.string()),
id: z.string(),
})
)
.action(async ({parsedInput , ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const notificationChannelId = parsedInput.id;
const notificationChannel = await db.query.notificationChannel.findFirst({
where: eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId),
with: {
organizations: true,
}
}) as NotificationChannelWith;
if (!notificationChannel) {
return {
success: false,
actionError: {
message: "Notification channel not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = notificationChannel.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
organizationId: organizationToAdd,
notificationChannelId: notificationChannelId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationNotificationChannel).where(and(inArray(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId,notificationChannelId))).execute();
}
return {
success: true,
value: null,
actionSuccess: {
message: "Notification channel organizations has been successfully updated.",
messageParams: {notificationChannelId: notificationChannelId},
},
};
} catch (error) {
console.error("Error updating notification channel:", error);
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the notification channel"},
},
};
}
});
export const updateStorageChannelsOrganizationAction = userAction
.schema(
z.object({
data: z.array(z.string()),
id: z.string(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const storageChannelId = parsedInput.id;
const storageChannel = await db.query.storageChannel.findFirst({
where: eq(drizzleDb.schemas.storageChannel.id, storageChannelId),
with: {
organizations: true,
}
}) as StorageChannelWith;
if (!storageChannel) {
return {
success: false,
actionError: {
message: "Storage channel not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = storageChannel.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationStorageChannel).values({
organizationId: organizationToAdd,
storageChannelId: storageChannelId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationStorageChannel).where(and(inArray(drizzleDb.schemas.organizationStorageChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationStorageChannel.storageChannelId, storageChannelId))).execute();
}
return {
success: true,
value: null,
actionSuccess: {
message: "Storage channel organizations has been successfully updated.",
messageParams: {storageChannelId: storageChannelId},
},
};
} catch (error) {
console.error("Error updating storage channel:", error);
return {
success: false,
actionError: {
message: "Failed to update storage channel.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the storage channel"},
},
};
}
});
@@ -0,0 +1,7 @@
import {z} from "zod";
export const ChannelsOrganizationSchema = z.object({
organizations: z.array(z.string())
});
export type ChannelsOrganizationType = z.infer<typeof ChannelsOrganizationSchema>;
@@ -1,50 +0,0 @@
"use client"
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {NotifierCard} from "@/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card";
import {useState} from "react";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
type NotificationChannelsSectionProps = {
notificationChannels: NotificationChannelWith[]
organizations: OrganizationWithMembers[]
}
export const NotificationChannelsSection = ({
organizations,
notificationChannels
}: NotificationChannelsSectionProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const hasNotifiers = notificationChannels.length > 0;
return (
<div className="h-full">
<NotifierAddEditModal open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen} adminView={false} trigger={false}/>
{hasNotifiers ? (
<div className="h-full">
<CardsWithPagination
data={notificationChannels}
cardItem={NotifierCard}
cardsPerPage={8}
numberOfColumns={2}
adminView={true}
organizations={organizations}
/>
</div>
) : (
<EmptyStatePlaceholder
text="No notification channels configured yet"
onClick={() => {
setIsAddModalOpen(true)
}}
className="h-full"
/>
)}
</div>
);
}
@@ -1,79 +0,0 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
export const updateNotificationChannelsOrganizationAction = userAction
.schema(
z.object({
data: z.array(z.string()),
notificationChannelId: z.string(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const notificationChannel = await db.query.notificationChannel.findFirst({
where: eq(drizzleDb.schemas.notificationChannel.id, parsedInput.notificationChannelId),
with: {
organizations: true,
}
}) as NotificationChannelWith;
if (!notificationChannel) {
return {
success: false,
actionError: {
message: "Notification channel not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = notificationChannel.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
organizationId: organizationToAdd,
notificationChannelId: parsedInput.notificationChannelId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationNotificationChannel).where(and(inArray(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, parsedInput.notificationChannelId))).execute();
}
return {
success: true,
value: null,
actionSuccess: {
message: "Notification channel organizations has been successfully updated.",
messageParams: {notificationChannelId: parsedInput.notificationChannelId},
},
};
} catch (error) {
console.error("Error updating notification channel:", error);
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the notification channel"},
},
};
}
});
@@ -1,7 +0,0 @@
import {z} from "zod";
export const NotificationChannelsOrganizationSchema = z.object({
organizations: z.array(z.string())
});
export type NotificationChannelsOrganizationType = z.infer<typeof NotificationChannelsOrganizationSchema>;
@@ -1,13 +0,0 @@
import {Mail, MessageSquare, Webhook} from "lucide-react";
export const getNotificationChannelIcon = (type: string) => {
const Icon = notificationTypes.find((t) => t.value === type)?.icon
return Icon ? <Icon className="h-4 w-4"/> : null
}
const notificationTypes = [
{value: "smtp", label: "Email", icon: Mail},
{value: "slack", label: "Slack", icon: MessageSquare},
{value: "webhook", label: "Webhook", icon: Webhook},
]
@@ -2,11 +2,11 @@
import {ColumnDef} from "@tanstack/react-table";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {humanReadableDate} from "@/utils/date-formatting";
import {CheckCircle2, XCircle} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {NotificationLogModal} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-log-modal";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelations>[] {
@@ -33,7 +33,7 @@ export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelation
<div className="flex items-center gap-2">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getNotificationChannelIcon(channel?.provider ?? "")}
{getChannelIcon(channel?.provider ?? "")}
</div>
{channel?.name}
</div>
@@ -89,7 +89,7 @@ export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelation
}
const getStatusIcon = (status: boolean) => {
export const getStatusIcon = (status: boolean) => {
switch (status) {
case true:
return <CheckCircle2 className="h-4 w-4"/>
@@ -98,10 +98,12 @@ const getStatusIcon = (status: boolean) => {
}
}
const getStatusColor = (status: string) => {
export const getStatusColor = (status: string) => {
switch (status) {
case "delivered":
return "bg-green-100 dark:bg-green-100/10"
case "success":
return "bg-green-100 dark:bg-green-100/10"
case "failed":
return "bg-red-100 dark:bg-red-100/10"
case "pending":
@@ -34,13 +34,11 @@ export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}
const mutationAddMemberOrganisation = useMutation({
mutationFn: async (data: AddMemberSchemaType) => {
console.log(data);
const result = await addMemberOrganizationAction({
userId: data.userId,
organizationId: organization.id,
role: "member",
});
console.log(result);
toast.success("Member successfully added!");
router.refresh();
onSuccessAction?.();
@@ -71,7 +69,6 @@ export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}
placeholder="Enter a user email"
entries={filteredUsers}
onSelect={(entySelected: any) => {
console.log("Form selection:", entySelected);
field.onChange(entySelected.value);
}}
/>
@@ -35,13 +35,11 @@ export const OrganizationDeleteMemberModal = ({ member, open, onOpenChangeAction
},
{
onSuccess: async (response) => {
console.log(response);
toast.success("Member successfully deleted!");
onOpenChangeAction(false);
router.refresh();
},
onError: async (error) => {
console.log(error);
toast.error("An error occurred while deleting member!");
onOpenChangeAction(false);
},
@@ -13,16 +13,14 @@ import {
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {authClient} from "@/lib/auth/auth-client";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {MemberRoleType} from "@/types/common";
import {MemberWithUser} from "@/db/schema/03_organization";
import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/update-member.action";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {
updateMemberRoleAdminAction
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema";
type OrganizationMemberChangeRoleModalProps = {
open: boolean;
@@ -49,7 +47,6 @@ export const OrganizationMemberChangeRoleModal = (props: OrganizationMemberChang
router.refresh();
},
onError: (error) => {
console.log(error);
toast.error("An error occurred while updating member");
onOpenChangeAction(false);
},
@@ -3,11 +3,11 @@ import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {Member} from "better-auth/plugins";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {db as dbClient} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema";
export const updateMemberRoleAdminAction = userAction.schema(
@@ -26,7 +26,7 @@ import {
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {sendEmail} from "@/lib/email/email-helper";
import {sendEmail} from "@/lib/email";
import {render} from "@react-email/components";
import EmailSettingsTest from "@/components/emails/email-settings-test";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
@@ -95,8 +95,6 @@ export const EmailForm = (props: EmailFormProps) => {
return (
<TooltipProvider>
<Card>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4 mt-3"
@@ -182,23 +180,6 @@ export const EmailForm = (props: EmailFormProps) => {
)}
/>
<div className="flex justify-between gap-4">
{/*{props.defaultValues?.smtpFrom && (*/}
{/* <ButtonWithLoading*/}
{/* type="button"*/}
{/* isPending={mutationSendEmailTest.isPending}*/}
{/* onClick={async () => {*/}
{/* await mutationSendEmailTest.mutateAsync();*/}
{/* }}*/}
{/* icon={<Send/>}*/}
{/* size="default"*/}
{/* className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"*/}
{/* >Send email test</ButtonWithLoading>*/}
{/*)}*/}
{props.defaultValues?.smtpFrom && (
<Tooltip>
<TooltipTrigger asChild>
@@ -227,14 +208,13 @@ export const EmailForm = (props: EmailFormProps) => {
)}
</Tooltip>
)}
<Button
type="submit"
>Save</Button>
<div className="flex justify-end">
<Button type="submit">
Save
</Button>
</div>
</div>
</Form>
</CardContent>
</Card>
</TooltipProvider>
);
};
@@ -1,49 +0,0 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
import {Download} from "lucide-react";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {toast} from "sonner";
export type AdminSettingsTabProps = {};
export const AdminSettingsSection = (props: AdminSettingsTabProps) => {
const handleDownloadKey = async () => {
let url: string = "";
const data = await getFileUrlPresignedLocal({dir: "private/keys/", fileName: "server_public.pem"})
if (data?.data?.success) {
url = data.data.value ?? "";
} else {
// @ts-ignore
const errorMessage = data?.data?.actionError?.message || "Failed to get file!";
toast.error(errorMessage);
}
window.open(url, "_self");
};
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<Card>
<CardHeader>
<CardTitle>Instance settings</CardTitle>
<CardDescription>Manage portabase settings</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Download Public Key</p>
<p className="text-xs text-muted-foreground">
Used for encrypting communications with this instance.
</p>
</div>
<Button onClick={handleDownloadKey} variant="outline" size="sm">
<Download className="h-4 w-4 mr-2"/>
Download
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,61 @@
"use client";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {Setting} from "@/db/schema/01_setting";
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 {StorageChannelWith} from "@/db/schema/12_storage-channel";
export type SettingsTabsProps = {
settings: Setting
storageChannels: StorageChannelWith[]
};
export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "email");
useEffect(() => {
const newTab = searchParams.get("tab") ?? "email";
setTab(newTab);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
return (
<div className="h-full mt-3">
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
<TabsList className='bg-background rounded-none border-b p-0 min-w-48'>
<TabsTrigger
value="email"
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'
>
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>
</TabsList>
<TabsContent className="h-full" value="email">
<SettingsEmailSection settings={settings}/>
</TabsContent>
<TabsContent className="h-full" value="storage">
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
</TabsContent>
</Tabs>
</div>
);
};

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