Compare commits

...
55 Commits
Author SHA1 Message Date
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 da5c65d5a6 chore(release): 1.1.11 2026-01-11 13:41:24 +01:00
169 changed files with 28090 additions and 2972 deletions
+4 -15
View File
@@ -1,18 +1,18 @@
# Environnement # Environnement
NODE_ENV=production NODE_ENV=development
# Base de données # Base de données
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public DATABASE_URL=postgresql://devuser:changeme@localhost:5432/devdb?schema=public
# Projet # Projet
PROJECT_NAME="Portabase" PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager" PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io PROJECT_URL=http://localhost:8887
PROJECT_SECRET= PROJECT_SECRET=
# SMTP (email) # SMTP (email)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM= SMTP_FROM=
@@ -22,16 +22,5 @@ AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET= AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD= AUTH_GOOGLE_METHOD=
# S3
S3_ENDPOINT=http://app.s3.portabase.io
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET_NAME=portabase
S3_PORT=9000
S3_USE_SSL=true
# Storage Type (s3, local)
STORAGE_TYPE=local
# Retention # Retention
RETENTION_CRON="* * * * *" RETENTION_CRON="* * * * *"
+1 -66
View File
@@ -1,69 +1,3 @@
#name: Docker Publish
#
#on:
# workflow_call:
# inputs:
# image_name:
# required: false
# type: string
# default: 'solucetechnologies/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
#
#jobs:
# build-and-push:
# runs-on: ubuntu-latest
# permissions:
# packages: write
# contents: read
# steps:
# - name: Check out the repo
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
#
# - name: Log in to Docker Hub
# uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
# with:
# username: ${{ secrets.DOCKER_USERNAME }}
# password: ${{ secrets.DOCKER_PASSWORD }}
#
# - name: Set tags
# id: set-tags
# run: |
# REF_NAME=${GITHUB_REF#refs/tags/}
# TAGS="${{ inputs.image_name }}:$REF_NAME"
# if [[ "${{ inputs.add_latest }}" == "true" ]]; then
# TAGS="$TAGS,${{ inputs.image_name }}:latest"
# fi
# echo "tags=$TAGS" >> $GITHUB_OUTPUT
#
# - name: Build and push Docker image
# uses: docker/build-push-action@v6
# with:
# context: .
# file: ${{ inputs.dockerfile }}
# push: true
# tags: ${{ steps.set-tags.outputs.tags }}
# target: ${{ inputs.target }}
name: Docker Publish name: Docker Publish
on: on:
@@ -119,6 +53,7 @@ jobs:
TAGS2="${{ inputs.image_name2 }}:$REF_NAME" TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
if [[ "${{ inputs.add_latest }}" == "true" ]]; then if [[ "${{ inputs.add_latest }}" == "true" ]]; then
TAGS="$TAGS,${{ inputs.image_name }}:latest" TAGS="$TAGS,${{ inputs.image_name }}:latest"
TAGS2="$TAGS2,${{ inputs.image_name2 }}:latest"
fi fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "tags2=$TAGS2" >> $GITHUB_OUTPUT echo "tags2=$TAGS2" >> $GITHUB_OUTPUT
+3
View File
@@ -1,5 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea .idea
.vscode
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
@@ -41,3 +42,5 @@ public/uploads/*
private/uploads/* private/uploads/*
/.env /.env
certificates
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/portabase.iml" filepath="$PROJECT_DIR$/.idea/portabase.iml" />
</modules>
</component>
</project>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-3
View File
@@ -1,3 +0,0 @@
{
"WillLuke.nextjs.hasPrompted": true
}
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.1.12-rc.4 version: 1.2.0
date-released: "2026-01-11" date-released: "2026-01-18"
+2 -2
View File
@@ -8,8 +8,8 @@ import {CardAuth} from "@/features/layout/card-auth";
export default async function GuardPage() { export default async function GuardPage() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get("better-auth.two_factor")?.value; const token = cookieStore.get("better-auth.two_factor")?.value || cookieStore.get("__Secure-better-auth.two_factor")?.value;
if (!token) { if (!token) {
redirect("/login"); redirect("/login");
} }
@@ -0,0 +1,40 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
const storageChannels = await db.query.storageChannel.findMany({
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
}) as StorageChannelWith[]
if (!settings || !storageChannels ) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System settings</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} settings={settings} />
</PageContent>
</Page>
);
}
@@ -1,13 +1,12 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Metadata} from "next"; import {Metadata} from "next";
import {
NotificationChannelsSection
} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section";
import {db} from "@/db"; import {db} from "@/db";
import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import * as drizzleDb from "@/db";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Notification Channels", title: "Notification Channels",
@@ -19,6 +18,7 @@ export default async function RoutePage(props: PageParams<{}>) {
with: { with: {
organizations: true organizations: true
}, },
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
orderBy: desc(notificationChannel.createdAt) orderBy: desc(notificationChannel.createdAt)
}) as NotificationChannelWith[] }) as NotificationChannelWith[]
@@ -35,11 +35,14 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader> <PageHeader>
<PageTitle>Notification channels</PageTitle> <PageTitle>Notification channels</PageTitle>
<PageActions> <PageActions>
<NotifierAddEditModal adminView={false}/> {/*<NotifierAddEditModal adminView={false}/>*/}
<ChannelAddEditModal kind="notification" adminView={false}/>
</PageActions> </PageActions>
</PageHeader> </PageHeader>
<PageContent> <PageContent>
<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/> {/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
<ChannelsSection kind="notification" organizations={organizations}
channels={notificationChannels}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -0,0 +1,50 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Metadata} from "next";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
export const metadata: Metadata = {
title: "Storage Channels",
};
export default async function RoutePage(props: PageParams<{}>) {
const storageChannels = await db.query.storageChannel.findMany({
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
}) as StorageChannelWith[]
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
});
return (
<Page>
<PageHeader>
<PageTitle>Storage channels</PageTitle>
<PageActions>
<ChannelAddEditModal kind={"storage"} adminView={false}/>
</PageActions>
</PageHeader>
<PageContent>
<ChannelsSection defaultStorageChannelId={settings?.defaultStorageChannelId} kind={"storage"} organizations={organizations} channels={storageChannels}/>
</PageContent>
</Page>
);
}
@@ -2,7 +2,6 @@ import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation"; import {notFound, redirect} from "next/navigation";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button"; 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 {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button"; import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db"; import {db} from "@/db";
@@ -12,9 +11,13 @@ import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet"; import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {capitalizeFirstLetter} from "@/utils/text"; 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 {getOrganizationChannels} from "@/db/services/notification-channel";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal"; 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<{ export default async function RoutePage(props: PageParams<{
projectId: string; projectId: string;
@@ -39,7 +42,8 @@ export default async function RoutePage(props: PageParams<{
with: { with: {
project: true, project: true,
retentionPolicy: true, retentionPolicy: true,
alertPolicies: true alertPolicies: true,
storagePolicies: true
} }
}); });
@@ -51,6 +55,11 @@ export default async function RoutePage(props: PageParams<{
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id), where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: { with: {
restorations: true, restorations: true,
storages: {
with: {
storageChannel: true
}
}
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], orderBy: (b, {desc}) => [desc(b.createdAt)],
}); });
@@ -87,6 +96,10 @@ export default async function RoutePage(props: PageParams<{
const organizationChannels = await getOrganizationChannels(organization.id); const organizationChannels = await getOrganizationChannels(organization.id);
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled); const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const organizationStorageChannels = await getOrganizationStorageChannels(organization.id);
const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
@@ -102,12 +115,22 @@ export default async function RoutePage(props: PageParams<{
{!isMember && ( {!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full "> <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Do not delete*/}
{/*<EditButton/>*/}
<RetentionPolicySheet database={dbItem}/> <RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/> <CronButton database={dbItem}/>
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels} <ChannelPoliciesModal
organizationId={organization.id}/> 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}/> <ImportModal database={dbItem}/>
</div> </div>
<div className="flex items-center gap-2"> <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"> <PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups} <DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/> totalBackups={totalBackups}/>
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem} <BackupModalProvider>
isAlreadyRestore={isAlreadyRestore} <DatabaseContent
backups={backups} activeMember={activeMember}
restorations={restorations}/> settings={settings}
database={dbItem}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
/>
</BackupModalProvider>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -15,11 +15,7 @@ import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text"; import {capitalizeFirstLetter} from "@/utils/text";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(props: PageParams<{
projectId: string projectId: string
@@ -3,21 +3,15 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {
DeleteOrganizationButton
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
import {Metadata} from "next"; import {Metadata} from "next";
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel"; import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {capitalizeFirstLetter} from "@/utils/text"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import Link from "next/link"; import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import { import {
ButtonDeleteProject EditButtonSettings
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project"; } from "@/components/wrappers/dashboard/organization/settings/edit-button-settings/edit-button-settings";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Settings", title: "Settings",
@@ -33,6 +27,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
} }
const notificationChannels = await getOrganizationChannels(organization.id) const notificationChannels = await getOrganizationChannels(organization.id)
const storageChannels = await getOrganizationStorageChannels(organization.id)
const permissions = computeOrganizationPermissions(activeMember); const permissions = computeOrganizationPermissions(activeMember);
@@ -62,6 +57,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
activeMember={activeMember} activeMember={activeMember}
organization={organization} organization={organization}
notificationChannels={notificationChannels} notificationChannels={notificationChannels}
storageChannels={storageChannels}
/> />
</PageContent> </PageContent>
</Page> </Page>
+1
View File
@@ -6,6 +6,7 @@ import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sid
import {Header} from "@/features/layout/Header"; import {Header} from "@/features/layout/Header";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater"; import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export default async function Layout({children}: { children: ReactNode }) { export default async function Layout({children}: { children: ReactNode }) {
const user = await currentUser(); const user = await currentUser();
@@ -1,8 +1,5 @@
import fs from "node:fs"; import fs from "node:fs";
import forge from "node-forge"; 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> { export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
+5 -32
View File
@@ -1,16 +1,15 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route"; import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {Backup} from "@/db/schema/07_database"; import {Backup} from "@/db/schema/07_database";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {decryptedDump, getFileExtension} from "./helpers"; import {decryptedDump, getFileExtension} from "./helpers";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {storeBackupFiles} from "@/features/storages/helpers";
export async function POST( export async function POST(
request: Request, request: Request,
@@ -25,6 +24,7 @@ export async function POST(
{status: 400} {status: 400}
); );
} }
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
const agentId = (await params).agentId; const agentId = (await params).agentId;
@@ -56,7 +56,8 @@ export async function POST(
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId), where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: { with: {
project: true, project: true,
alertPolicies: true alertPolicies: true,
storagePolicies: true
} }
}); });
@@ -125,36 +126,8 @@ export async function POST(
const uuid = uuidv4(); const uuid = uuidv4();
const fileName = `${uuid}${fileExtension}`; const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer()); const buffer = Buffer.from(await decryptedFile.arrayBuffer());
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
throw new Error("System settings not found.");
}
let success: boolean, message: string, filePath: string;
const result =
settings.storage === "local"
? await uploadLocalPrivate(fileName, buffer)
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
({success, message, filePath} = result);
if (!success) {
return NextResponse.json(
{error: message},
{status: 500}
);
}
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
file: fileName,
fileSize: fileSizeBytes,
status: 'success',
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
await storeBackupFiles(backup, database, buffer, fileName)
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "success_backup"); await sendNotificationsBackupRestore(database, "success_backup");
+78 -34
View File
@@ -12,6 +12,8 @@ import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action"; import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod"; import {ZodString} from "zod";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) { export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
const databasesResponse = []; const databasesResponse = [];
@@ -88,7 +90,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
const restoration = await dbClient.query.restoration.findFirst({ const restoration = await dbClient.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")) where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
with: {
backupStorage: true
}
}) })
@@ -105,53 +110,53 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
restoreAction = true restoreAction = true
const backupToRestore = await dbClient.query.backup.findFirst({ // const backupToRestore = await dbClient.query.backup.findFirst({
where: eq(drizzleDb.schemas.backup.id, restoration.backupId), // where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
with: { // with: {
database: { // database: {
with: { // with: {
project: true // project: true
} // }
} // }
} // }
}) // })
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); // const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) { // if (!settings) {
return NextResponse.json( // return NextResponse.json(
{error: "Unable to find settings"}, // {error: "Unable to find settings"},
{status: 500} // {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,
},
};
let data: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string>, object> | undefined
try { try {
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
if (settings.storage == "local") { if (result.success) {
data = await getFileUrlPresignedLocal({fileName: fileName!}) urlBackup = result.url ?? "";
} else if (settings.storage == "s3") {
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
}
if (data?.data?.success) {
urlBackup = data.data.value ?? "";
} else { } else {
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "failed"}) .set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
// @ts-ignore const errorMessage = "Failed to get backup URL";
const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
console.error("Restoration failed: ", errorMessage); console.error("Restoration failed: ", errorMessage);
continue; continue;
} }
} catch (err) { } catch (err) {
@@ -160,9 +165,48 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "failed"}) .set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue; continue;
} }
// const fileName = backupToRestore?.file
//
// let data: SafeActionResult<string, ZodString, readonly [], {
// _errors?: string[] | undefined;
// }, readonly [], ServerActionResult<string>, object> | undefined
//
// try {
//
// if (settings.storage == "local") {
// data = await getFileUrlPresignedLocal({fileName: fileName!})
// } else if (settings.storage == "s3") {
//
// data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
// }
//
// if (data?.data?.success) {
// urlBackup = data.data.value ?? "";
// } else {
// await dbClient
// .update(drizzleDb.schemas.restoration)
// .set({status: "failed"})
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
//
// // @ts-ignore
// const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
// console.error("Restoration failed: ", errorMessage);
//
// continue;
// }
// } catch (err) {
// console.error("Restoration crashed unexpectedly:", err);
// await dbClient
// .update(drizzleDb.schemas.restoration)
// .set({status: "failed"})
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
//
// continue;
// }
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "ongoing"}) .set({status: "ongoing"})
-10
View File
@@ -1,5 +1,4 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {handleDatabases} from "./helpers"; import {handleDatabases} from "./helpers";
import {eventEmitter} from "../../../events/route"; import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
@@ -20,13 +19,6 @@ export type Body = {
databases: databaseAgent[] databases: databaseAgent[]
} }
// Function to test the get file url presigned local
export async function GET(request: Request) {
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
return Response.json({
message: url
})
}
export async function POST( export async function POST(
request: Request, request: Request,
@@ -38,7 +30,6 @@ export async function POST(
const lastContact = new Date(); const lastContact = new Date();
let message: string let message: string
if (!isUuidv4(agentId)) { if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid" message = "agentId is not a valid uuid"
return NextResponse.json( return NextResponse.json(
@@ -57,7 +48,6 @@ export async function POST(
} }
const databasesResponse = await handleDatabases(body, agent, lastContact) const databasesResponse = await handleDatabases(body, agent, lastContact)
await db await db
.update(drizzleDb.schemas.agent) .update(drizzleDb.schemas.agent)
.set(withUpdatedAt({ .set(withUpdatedAt({
-60
View File
@@ -1,60 +0,0 @@
import * as fs from "node:fs";
import {NextResponse} from "next/server";
import path from "path";
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 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})
}
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
if (token !== expectedToken) {
return NextResponse.json(
{error: 'Invalid signed token'},
{status: 403}
);
}
const expiresAt = parseInt(expires!, 10);
if (Date.now() > expiresAt) {
return NextResponse.json(
{error: 'Signed token expired'},
{status: 403}
);
}
const fileStream = fs.createReadStream(filePath);
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': `attachment; filename="${fileName}"`,
'Content-Type': 'application/octet-stream',
},
});
}
+92
View File
@@ -0,0 +1,92 @@
import {NextResponse} from "next/server";
import path from "path";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Readable} from "node:stream";
export async function GET(
request: Request,
) {
const {searchParams} = new URL(request.url);
const token = searchParams.get('token');
const expires = searchParams.get('expires');
const pathFromUrl = searchParams.get('path');
if (!pathFromUrl) {
return NextResponse.json({error: "Missing file path in search params"}, {status: 404})
}
const localStorageChannel = await db.query.storageChannel.findFirst({
where: eq(drizzleDb.schemas.storageChannel.provider, "local"),
})
if (!localStorageChannel) {
return NextResponse.json({error: "No local storage channel found"})
}
const input: StorageInput = {
action: "get",
data: {
path: pathFromUrl,
signedUrl: true,
},
};
console.debug(input);
const result = await dispatchStorage(input, undefined, localStorageChannel.id);
if (!result.success) {
return NextResponse.json({error: "Enable to get file from local 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(
{error: 'Invalid signed token'},
{status: 403}
);
}
const expiresAt = parseInt(expires!, 10);
if (Date.now() > expiresAt) {
return NextResponse.json(
{error: 'Signed token expired'},
{status: 403}
);
}
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));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(stream, {
headers: {
'Content-Disposition': `attachment; filename="${fileName}"`,
'Content-Type': 'application/octet-stream',
},
});
}
+45 -53
View File
@@ -8,43 +8,36 @@ import path from "path";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import fs from "fs/promises"; import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
function nodeStreamToWebStream(nodeStream: stream.Readable) { import {Readable} from "node:stream";
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( export async function GET(
req: Request, req: Request,
{params}: { params: Promise<{ fileName: string }> } {params}: { params: Promise<{ fileName: string }> }
) { ) {
const fileName = (await params).fileName; const fileName = (await params).fileName;
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400}); if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
const session = await auth.api.getSession({headers: await headers()}); const session = await auth.api.getSession({headers: await headers()});
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403}); if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
const [settings] = await db const settings = await db.query.setting.findFirst({
.select() where: eq(drizzleDb.schemas.setting.name, "system"),
.from(drizzleDb.schemas.setting) with: {
.where(eq(drizzleDb.schemas.setting.name, "system")) storageChannel: true
.limit(1); }
});
if (!settings || !settings.storageChannel) {
return NextResponse.json({error: "Unable to get settings or no default storage channel"});
}
if (!settings) throw new Error("System settings not found.");
const storageType = settings.storage; // "local" or "s3"
const ext = fileName.split(".").pop()?.toLowerCase(); const ext = fileName.split(".").pop()?.toLowerCase();
const contentType = const contentType =
ext === "png" ext === "png"
@@ -58,44 +51,43 @@ export async function GET(
: "application/octet-stream"; : "application/octet-stream";
try { try {
if (storageType === "local") {
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
try { const path = `images/${fileName}`;
await fs.access(filePath);
const file = await fs.readFile(filePath);
return new NextResponse(file, { const input: StorageInput = {
headers: { action: "get",
"Content-Type": contentType, data: {
"Cache-Control": "no-store", path: path,
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch {
// if not found locally, fallback to S3
} }
} }
const exists = await checkFileExistsInBucket({ const result = await dispatchStorage(input, undefined, settings.storageChannel.id);
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
const nodeStream = await getObjectFromClient({ if (!result.file || !Buffer.isBuffer(result.file)) {
bucketName: env.S3_BUCKET_NAME!, return NextResponse.json(
fileName: `${privateS3ImageDir}${fileName}`, {error: "Invalid file payload"},
}); {status: 500}
const webStream = nodeStreamToWebStream(nodeStream); );
}
return new NextResponse(webStream, { const fileStream = Readable.from(result.file);
headers: {
"Content-Type": contentType, const stream = new ReadableStream({
"Cache-Control": "no-store", start(controller) {
"Content-Disposition": `inline; filename="${fileName}"`, 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) { } catch (err) {
console.error("Error streaming image:", err); console.error("Error streaming image:", err);
return NextResponse.json({error: "Error fetching file"}, {status: 500}); 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 {Toaster} from "@/components/ui/sonner";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root"; import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export type ProviderProps = PropsWithChildren<{}>; export type ProviderProps = PropsWithChildren<{}>;
@@ -21,8 +22,8 @@ export const Providers = (props: ProviderProps) => {
> >
<ThemeMetaUpdaterRoot/> <ThemeMetaUpdaterRoot/>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster/> <Toaster/>
{props.children} {props.children}
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>
</Suspense> </Suspense>
+1 -1
View File
@@ -2,7 +2,7 @@ name: portabase-dev
services: services:
db: db:
image: postgres:16-alpine image: postgres:17-alpine
ports: ports:
- "5433:5432" - "5433:5432"
volumes: volumes:
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.1.12-rc.4", "version": "1.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -74,6 +74,7 @@
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1", "npm-check-updates": "^18.0.1",
"pg": "^8.16.0", "pg": "^8.16.0",
"prettier": "^3.8.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "9.7.0", "react-day-picker": "9.7.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -88,6 +89,7 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"swiper": "^12.0.3",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
+17 -5
View File
@@ -188,6 +188,9 @@ importers:
pg: pg:
specifier: ^8.16.0 specifier: ^8.16.0
version: 8.16.3 version: 8.16.3
prettier:
specifier: ^3.8.0
version: 3.8.0
react: react:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.3 version: 19.2.3
@@ -230,6 +233,9 @@ importers:
sonner: sonner:
specifier: ^2.0.3 specifier: ^2.0.3
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
swiper:
specifier: ^12.0.3
version: 12.0.3
tailwind-merge: tailwind-merge:
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.4.0 version: 3.4.0
@@ -5666,8 +5672,8 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier@3.7.4: prettier@3.8.0:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==}
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
@@ -6230,6 +6236,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
swiper@12.0.3:
resolution: {integrity: sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==}
engines: {node: '>= 4.7.0'}
tailwind-merge@3.2.0: tailwind-merge@3.2.0:
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
@@ -9466,7 +9476,7 @@ snapshots:
'@react-email/render@1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@react-email/render@1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies: dependencies:
html-to-text: 9.0.5 html-to-text: 9.0.5
prettier: 3.7.4 prettier: 3.8.0
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
react-promise-suspense: 0.3.4 react-promise-suspense: 0.3.4
@@ -9474,7 +9484,7 @@ snapshots:
'@react-email/render@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@react-email/render@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies: dependencies:
html-to-text: 9.0.5 html-to-text: 9.0.5
prettier: 3.7.4 prettier: 3.8.0
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
@@ -12791,7 +12801,7 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier@3.7.4: {} prettier@3.8.0: {}
pretty-bytes@6.1.1: {} pretty-bytes@6.1.1: {}
@@ -13556,6 +13566,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
swiper@12.0.3: {}
tailwind-merge@3.2.0: {} tailwind-merge@3.2.0: {}
tailwind-merge@3.4.0: {} tailwind-merge@3.4.0: {}
+1 -1
View File
@@ -54,7 +54,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/agent\/[^/]+\/status\/?$/, /^\/api\/agent\/[^/]+\/status\/?$/,
/^\/api\/agent\/[^/]+\/backup\/?$/, /^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/, /^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/[^/]+\/?$/, /^\/api\/files\/?$/,
/^\/api\/images\/[^/]+\/?$/, /^\/api\/images\/[^/]+\/?$/,
/^\/api\/events\/?$/, /^\/api\/events\/?$/,
/^\/api\/init\/?$/, /^\/api\/init\/?$/,
+2
View File
@@ -46,7 +46,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: R
...classNames, ...classNames,
}} }}
components={{ components={{
// @ts-ignore
IconLeft: ({ className, children: _children, ...props }) => <ChevronLeftIcon className={cn("size-4", className)} {...props} />, IconLeft: ({ className, children: _children, ...props }) => <ChevronLeftIcon className={cn("size-4", className)} {...props} />,
// @ts-ignore
IconRight: ({ className, children: _children, ...props }) => <ChevronRightIcon className={cn("size-4", className)} {...props} />, IconRight: ({ className, children: _children, ...props }) => <ChevronRightIcon className={cn("size-4", className)} {...props} />,
}} }}
{...props} {...props}
@@ -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} v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span> </span>
</div> </div>
@@ -3,7 +3,6 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner"; import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"; import {Form} from "@/components/ui/form";
@@ -1,21 +1,25 @@
"use client"; "use client";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {Info} from "lucide-react";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner"; import {toast} from "sonner";
import {CardContent, CardHeader} from "@/components/ui/card";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"; import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip"; import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip";
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema"; import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema";
import {PasswordInput} from "@/components/ui/password-input";
import {signUp} from "@/lib/auth/auth-client"; import {signUp} from "@/lib/auth/auth-client";
import Link from "next/link"; import {useZodForm} from "@/components/ui/form";
import {CardAuth} from "@/features/layout/card-auth"; import {CardAuth} from "@/features/layout/card-auth";
import {FormField} from "@/components/ui/form";
import {FormItem} from "@/components/ui/form";
import {FormLabel} from "@/components/ui/form";
import {FormControl} from "@/components/ui/form";
import {FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {PasswordInput} from "@/components/ui/password-input";
import {Button} from "@/components/ui/button";
import Link from "next/link";
import {Info} from "lucide-react";
export type registerFormProps = { export type registerFormProps = {
defaultValues?: RegisterType; defaultValues?: RegisterType;
@@ -26,9 +30,11 @@ export const RegisterForm = (props: registerFormProps) => {
const form = useZodForm({ const form = useZodForm({
schema: RegisterSchema, schema: RegisterSchema,
}); });
const router = useRouter(); const router = useRouter();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: RegisterType) => { mutationFn: async (values: RegisterType) => {
// @ts-ignore
await signUp.email(values, { await signUp.email(values, {
onSuccess: () => { onSuccess: () => {
toast.success(`Account successfully created`); toast.success(`Account successfully created`);
@@ -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" "use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button"; import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {useState} from "react"; import {useState} from "react";
@@ -57,6 +12,7 @@ export type ButtonWithConfirmProps = {
button: { button: {
main: { main: {
className?: string; className?: string;
type?: "button" | "submit" | "reset" | undefined;
text?: string; text?: string;
icon?: any; icon?: any;
variant?: ButtonVariantsProps["variant"]; variant?: ButtonVariantsProps["variant"];
@@ -100,6 +56,7 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
)} )}
role="button"> role="button">
<Button <Button
type={props.button.main.type}
disabled={!!props.button.main.disabled} disabled={!!props.button.main.disabled}
variant={props.button.main.variant ?? "default"} variant={props.button.main.variant ?? "default"}
size={props.button.main.size ?? "default"} size={props.button.main.size ?? "default"}
@@ -108,7 +65,8 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
if (!props.button.main.disabled) setIsConfirming(true); 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>} {props.button.main.text && <span>{props.button.main.text}</span>}
</Button> </Button>
</span> </span>
@@ -109,7 +109,6 @@ export const ButtonWithLoading = ({
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />} {isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
{children && children} {children && children}
<>{icon ? icon : null}</> <>{icon ? icon : null}</>
{/*{icon && <span className="ml-2">{icon}</span>}*/}
</Button> </Button>
); );
}; };
@@ -1,27 +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;
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);
}
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 {Trash2} from "lucide-react";
import { import {
removeNotificationChannelAction, removeNotificationChannelAction,
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action"; } from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
import {toast} from "sonner"; import {toast} from "sonner";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
removeStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
export type DeleteNotifierButtonProps = { export type DeleteChannelButtonProps = {
notificationChannelId: string; channelId: string;
kind: ChannelKind;
organizationId?: string; organizationId?: string;
}; };
export const DeleteNotifierButton = ({notificationChannelId, organizationId}: DeleteNotifierButtonProps) => { export const DeleteChannelButton = ({channelId, organizationId, kind}: DeleteChannelButtonProps) => {
const router = useRouter(); const router = useRouter();
const channelText = getChannelTextBasedOnKind(kind)
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const result = await removeNotificationChannelAction({organizationId, notificationChannelId})
const result = kind === "notification" ? await removeNotificationChannelAction({
organizationId,
notificationChannelId: channelId
}) : await removeStorageChannelAction({organizationId, id: channelId})
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
@@ -32,8 +43,8 @@ export const DeleteNotifierButton = ({notificationChannelId, organizationId}: De
return ( return (
<ButtonWithConfirm <ButtonWithConfirm
title="Delete Notifier" title={`Delete ${channelText.toLowerCase()} channel`}
description="Are you sure you want to remove this notifier ? This action cannot be undone and will delete all alert policies related to this channel !" description={`Are you sure you want to remove this ${channelText.toLowerCase()} channel ? This action cannot be undone and will delete all alert policies related to this channel !`}
button={{ button={{
main: { main: {
size: "icon", size: "icon",
@@ -0,0 +1,94 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Switch} from "@/components/ui/switch";
import {toast} from "sonner";
import {useState} from "react";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {
updateStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
export type EditChannelButtonProps = {
channel: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind: ChannelKind;
};
export const EditChannelButton = ({
organizations,
adminView = false,
channel,
organization,
kind
}: EditChannelButtonProps) => {
const router = useRouter();
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const isLocalSystem = channel.provider == "local";
const mutation = useMutation({
mutationFn: async (value: boolean) => {
const payload = {
data: {
name: channel.name,
provider: channel.provider,
config: channel.config as Record<string, any>,
enabled: value
},
id: channel.id
};
let result: any;
if (kind == "notification") {
// @ts-ignore
result = await updateNotificationChannelAction(payload)
} else if (kind == "storage") {
// @ts-ignore
result = await updateStorageChannelAction(payload)
} else {
toast.error("An error occurred while updating storage channel")
return;
}
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
return (
<>
{!isLocalSystem && (
<Switch checked={channel.enabled} onCheckedChange={async () => {
await mutation.mutateAsync(!channel.enabled)
}}/>
)}
<ChannelAddEditModal
kind={kind}
organizations={organizations}
adminView={adminView}
organization={organization}
channel={channel}
open={isAddModalOpen}
onOpenChangeAction={setIsAddModalOpen}
/>
</>
);
};
@@ -0,0 +1,88 @@
"use client";
import {Card} from "@/components/ui/card";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Badge} from "@/components/ui/badge";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
EditChannelButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel";
import {ChannelKind, getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
DeleteChannelButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-delete-channel";
export type ChannelCardProps = {
data: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind?: ChannelKind;
defaultStorageChannelId?: string | null | undefined
};
export const ChannelCard = (props: ChannelCardProps) => {
const {data, organization, kind, adminView, defaultStorageChannelId} = props;
const isMobile = useIsMobile()
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider == "local";
return (
<div className="block transition-all duration-200 rounded-xl">
<Card className="flex flex-row justify-between p-4">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getChannelIcon(data.provider)}
</div>
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
</div>
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
{isDefaultSystemStorage && (
<Badge variant="secondary" className="text-xs font-mono">
default
</Badge>
)}
</div>
</div>
{kind && (
<div className="flex items-center gap-2">
{(isOwned) && (
<>
<EditChannelButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
channel={data}
kind={kind}
/>
{!isLocalSystem && (
<DeleteChannelButton
kind={kind}
organizationId={organization?.id}
channelId={data.id}
/>
)}
</>
)}
</div>
)}
</Card>
</div>
);
};
@@ -0,0 +1,27 @@
import {z} from "zod";
export const ChannelFormSchema = z.object({
name: z
.string()
.min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 characters long"),
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
enabled: z.boolean().default(true),
});
export const NotificationChannelFormSchema = ChannelFormSchema.extend({
provider: z.enum(
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
{required_error: "Provider is required"}
),
});
export const StorageChannelFormSchema = ChannelFormSchema.extend({
provider: z.enum(["local", "s3"], {required_error: "Provider is required"}),
});
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
export type StorageChannelFormType = z.infer<typeof StorageChannelFormSchema>;
@@ -5,61 +5,50 @@ import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import { import {
NotificationChannelFormSchema, NotificationChannelFormType addStorageChannelAction, updateStorageChannelAction
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema"; } from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
import {toast} from "sonner"; import {toast} from "sonner";
import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {
NotifierSmtpForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form";
import {
NotifierSlackForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form";
import {
NotifierDiscordForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-discord.form";
import {
NotifierTelegramForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-telegram.form";
import {
NotifierGotifyForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-gotify.form";
import {
NotifierNtfyForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-ntfy.form";
import {
NotifierWebhookForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-webhook.form";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {
NotifierTestChannelButton
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button";
import {useEffect} from "react"; import {useEffect} from "react";
import {notificationTypes} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {Card} from "@/components/ui/card"; import {Card} from "@/components/ui/card";
import {ArrowLeft} from "lucide-react"; 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 = { type NotifierFormProps = {
onSuccessAction?: () => void; onSuccessAction?: () => void;
organization?: OrganizationWithMembers; organization?: OrganizationWithMembers;
defaultValues?: NotificationChannelWith defaultValues?: NotificationChannelWith | StorageChannelWith
adminView?: boolean adminView?: boolean
kind: ChannelKind
}; };
export const NotifierForm = ({onSuccessAction, organization, defaultValues}: NotifierFormProps) => { export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}: NotifierFormProps) => {
const router = useRouter(); const router = useRouter();
const isCreate = !Boolean(defaultValues); const isCreate = !Boolean(defaultValues);
const form = useZodForm({ const form = useZodForm({
schema: NotificationChannelFormSchema, schema: kind == "notification" ? NotificationChannelFormSchema : StorageChannelFormSchema,
// @ts-ignore // @ts-ignore
defaultValues: {...defaultValues}, defaultValues: {...defaultValues},
}); });
@@ -69,16 +58,28 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
}, [defaultValues]); }, [defaultValues]);
const mutationAddNotificationChannel = useMutation({ const mutationAddNotificationChannel = useMutation({
mutationFn: async (values: NotificationChannelFormType) => { mutationFn: async (values: NotificationChannelFormType | StorageChannelFormType) => {
const payload = { const payload = {
data: values, data: values,
...(organization && {organizationId: organization.id}), ...(organization && {organizationId: organization.id}),
...((defaultValues && {notificationChannelId: defaultValues.id})) ...((defaultValues && {id: defaultValues.id}))
}; };
// @ts-ignore
const result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload); 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; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
@@ -93,29 +94,55 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
}); });
const provider = form.watch("provider"); const provider = form.watch("provider");
const selectedProviderDetails = notificationTypes.find(t => t.value === provider); const channelTypes = kind == "notification" ? notificationProviders : storageProviders
const selectedProviderDetails = channelTypes.find(t => t.value === provider);
if (isCreate && !provider) { if (isCreate && !provider) {
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
{notificationTypes.map((type) => { {channelTypes.filter(p => p.value != "local").map((type) => {
const Icon = type.icon; const Icon = type.icon;
return ( return (
<Card <Card
key={type.value} key={type.value}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-3 p-4 cursor-pointer hover:bg-accent/50 hover:border-primary/50 transition-all", "relative flex flex-col items-center justify-center gap-3 p-4 transition-all",
type.preview
? "cursor-not-allowed bg-gray-200 border-gray-300 opacity-70"
: "cursor-pointer hover:bg-accent/50 hover:border-primary/50"
)} )}
onClick={() => { onClick={() => {
if (type.preview) return;
form.setValue("provider", type.value as any); form.setValue("provider", type.value as any);
form.setValue("config", {}); form.setValue("config", {});
}} }}
> >
<div className="h-10 w-10 bg-secondary rounded-full flex items-center justify-center"> {type.preview && (
<Icon className="h-6 w-6 text-foreground"/> <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-6 flex items-center justify-center
transform -rotate-45 translate-x-14 -translate-y-4"
style={{backgroundColor: "#FE6702"}}
>
<span className="text-white text-[8px] 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",
type.preview ? "bg-gray-400" : "bg-secondary"
)}>
<Icon className={cn("h-6 w-6 text-foreground", type.preview && "text-gray-700")}/>
</div> </div>
<span className="font-medium text-sm">{type.label}</span> <span className={cn(
"font-medium text-sm align-middle text-center",
type.preview ? "text-gray-500" : "text-foreground"
)}>
{type.label}
</span>
</Card> </Card>
); );
})} })}
</div> </div>
@@ -132,7 +159,8 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
> >
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border"> <div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
{selectedProviderDetails && ( {selectedProviderDetails && (
<div className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm"> <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"/> <selectedProviderDetails.icon className="h-5 w-5"/>
</div> </div>
)} )}
@@ -163,7 +191,8 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
<FormItem> <FormItem>
<FormLabel>Channel Name</FormLabel> <FormLabel>Channel Name</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`} value={field.value ?? ""}/> <Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`}
value={field.value ?? ""}/>
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@@ -174,44 +203,20 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
control={form.control} control={form.control}
name="provider" name="provider"
render={({field}) => ( render={({field}) => (
<input type="hidden" {...field} value={field.value || ""} /> <input type="hidden" {...field} value={field.value || ""}/>
)} )}
/> />
{provider === "smtp" && ( {renderChannelForm(provider, form)}
<NotifierSmtpForm form={form}/>
)}
{provider === "slack" && (
<NotifierSlackForm form={form}/>
)}
{provider === "discord" && (
<NotifierDiscordForm form={form}/>
)}
{provider === "telegram" && (
<NotifierTelegramForm form={form}/>
)}
{provider === "gotify" && (
<NotifierGotifyForm form={form}/>
)}
{provider === "ntfy" && (
<NotifierNtfyForm form={form}/>
)}
{provider === "webhook" && (
<NotifierWebhookForm form={form}/>
)}
<div className="flex justify-between mt-4"> <div className="flex justify-between mt-4">
<div> <div>
{defaultValues && ( {defaultValues && (
<NotifierTestChannelButton organizationId={organization?.id} <ChannelTestButton
notificationChannel={defaultValues}/> kind={kind}
organizationId={organization?.id}
channel={defaultValues}
/>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -0,0 +1,87 @@
"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 {
toast.error("An error occurred while testing the storage channel, check your configuration");
}
} else {
toast.error("Not yet supported");
}
},
});
return (
<Button
type="button"
variant="default"
onClick={() => mutation.mutateAsync()}
disabled={mutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"
>
{mutation.isPending ? (
<>
<div
className={cn(" h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white", !isMobile && "mr-2")}/>
{!isMobile && `Sending...`}
</>
) : (
<div className="flex flex-row justify-center items-center">
{kind === "notification" ?
<>
<Send className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Channel`}
</>
:
<>
<ShieldCheck className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Storage`}
</>
}
</div>
)}
</Button>
)
}
@@ -6,11 +6,11 @@ import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions"; import {userAction} from "@/lib/safe-actions/actions";
import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db"; import {db} from "@/db";
import {
NotificationChannelFormSchema
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {
NotificationChannelFormSchema
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
export const addNotificationChannelAction = userAction.schema( export const addNotificationChannelAction = userAction.schema(
@@ -20,7 +20,6 @@ export const addNotificationChannelAction = userAction.schema(
}) })
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => { ).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {organizationId, data} = parsedInput; const {organizationId, data} = parsedInput;
try { try {
const [channel] = await db const [channel] = await db
.insert(drizzleDb.schemas.notificationChannel) .insert(drizzleDb.schemas.notificationChannel)
@@ -29,6 +28,7 @@ export const addNotificationChannelAction = userAction.schema(
name: data.name, name: data.name,
config: data.config, config: data.config,
enabled: data.enabled ?? true, enabled: data.enabled ?? true,
organizationId: organizationId ?? null,
}) })
.returning(); .returning();
@@ -128,11 +128,11 @@ export const removeNotificationChannelAction = userAction.schema(
export const updateNotificationChannelAction = userAction.schema( export const updateNotificationChannelAction = userAction.schema(
z.object({ z.object({
notificationChannelId: z.string(), id: z.string(),
data: NotificationChannelFormSchema data: NotificationChannelFormSchema
}) })
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => { ).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {notificationChannelId, data} = parsedInput; const {id, data} = parsedInput;
try { try {
const [channel] = await db const [channel] = await db
@@ -143,7 +143,7 @@ export const updateNotificationChannelAction = userAction.schema(
config: data.config, config: data.config,
enabled: data.enabled ?? true, enabled: data.enabled ?? true,
})) }))
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId)) .where(eq(drizzleDb.schemas.notificationChannel.id, id))
.returning(); .returning();
return { return {
@@ -154,7 +154,7 @@ export const updateNotificationChannelAction = userAction.schema(
}, },
actionSuccess: { actionSuccess: {
message: `Notification channel "${channel.name}" has been successfully updated.`, message: `Notification channel "${channel.name}" has been successfully updated.`,
messageParams: {notificationChannelId: channel.id}, messageParams: {id: channel.id},
}, },
}; };
} catch (error) { } catch (error) {
@@ -165,7 +165,7 @@ export const updateNotificationChannelAction = userAction.schema(
message: "Failed to update notification channel.", message: "Failed to update notification channel.",
status: 500, status: 500,
cause: error instanceof Error ? error.message : "Unknown error", cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {notificationChannelId: ""}, messageParams: {id: ""},
}, },
}; };
} }
@@ -0,0 +1,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,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,94 @@
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";
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 "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,18 @@
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},
]
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>
);
}
@@ -6,26 +6,30 @@ import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/u
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; 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 {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 {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[]; organizations?: OrganizationWithMembers[];
defaultValues?: NotificationChannelWith defaultValues?: NotificationChannelWith | StorageChannelWith
kind: ChannelKind
}; };
export const NotifierChannelOrganisationForm = ({ export const ChannelOrganisationForm = ({
organizations, organizations,
defaultValues, defaultValues,
}: NotifierChannelOrganisationFormProps) => { kind
}: ChannelOrganisationFormProps) => {
const router = useRouter(); const router = useRouter();
@@ -33,7 +37,7 @@ export const NotifierChannelOrganisationForm = ({
const form = useZodForm({ const form = useZodForm({
schema: NotificationChannelsOrganizationSchema, schema: ChannelsOrganizationSchema,
// @ts-ignore // @ts-ignore
defaultValues: { defaultValues: {
organizations: defaultOrganizationIds organizations: defaultOrganizationIds
@@ -49,15 +53,15 @@ export const NotifierChannelOrganisationForm = ({
}; };
const mutationUpdateNotificationChannelOrganizations = useMutation({ const mutationUpdateChannelOrganizations = useMutation({
mutationFn: async (values: NotificationChannelsOrganizationType) => { mutationFn: async (values: ChannelsOrganizationType) => {
const payload = { const payload = {
data: values.organizations, 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; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
@@ -76,7 +80,7 @@ export const NotifierChannelOrganisationForm = ({
form={form} form={form}
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={async (values) => { onSubmit={async (values) => {
await mutationUpdateNotificationChannelOrganizations.mutateAsync(values); await mutationUpdateChannelOrganizations.mutateAsync(values);
}} }}
> >
<FormField <FormField
@@ -100,7 +104,7 @@ export const NotifierChannelOrganisationForm = ({
<div className="flex justify-end"> <div className="flex justify-end">
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<ButtonWithLoading isPending={mutationUpdateNotificationChannelOrganizations.isPending}> <ButtonWithLoading isPending={mutationUpdateChannelOrganizations.isPending}>
Save Save
</ButtonWithLoading> </ButtonWithLoading>
</div> </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,487 +0,0 @@
import {Mail} from "lucide-react";
import type { SVGProps } from 'react';
export const getNotificationChannelIcon = (type: string) => {
const Icon = notificationTypes.find((t) => t.value === type)?.icon
return Icon ? <Icon className="h-4 w-4"/> : null
}
export const notificationTypes = [
{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},
]
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>
)
}
@@ -2,11 +2,11 @@
import {ColumnDef} from "@tanstack/react-table"; import {ColumnDef} from "@tanstack/react-table";
import {NotificationLogWithRelations} from "@/db/services/notification-log"; import {NotificationLogWithRelations} from "@/db/services/notification-log";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {humanReadableDate} from "@/utils/date-formatting"; import {humanReadableDate} from "@/utils/date-formatting";
import {CheckCircle2, XCircle} from "lucide-react"; import {CheckCircle2, XCircle} from "lucide-react";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {NotificationLogModal} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-log-modal"; 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>[] { export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelations>[] {
@@ -33,7 +33,7 @@ export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelation
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border"> className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getNotificationChannelIcon(channel?.provider ?? "")} {getChannelIcon(channel?.provider ?? "")}
</div> </div>
{channel?.name} {channel?.name}
</div> </div>
@@ -89,7 +89,7 @@ export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelation
} }
const getStatusIcon = (status: boolean) => { export const getStatusIcon = (status: boolean) => {
switch (status) { switch (status) {
case true: case true:
return <CheckCircle2 className="h-4 w-4"/> 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) { switch (status) {
case "delivered": case "delivered":
return "bg-green-100 dark:bg-green-100/10" return "bg-green-100 dark:bg-green-100/10"
case "success":
return "bg-green-100 dark:bg-green-100/10"
case "failed": case "failed":
return "bg-red-100 dark:bg-red-100/10" return "bg-red-100 dark:bg-red-100/10"
case "pending": case "pending":
@@ -13,16 +13,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle DialogTitle
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {authClient} from "@/lib/auth/auth-client";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {MemberRoleType} from "@/types/common"; import {MemberRoleType} from "@/types/common";
import {MemberWithUser} from "@/db/schema/03_organization"; 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 { import {
updateMemberRoleAdminAction updateMemberRoleAdminAction
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action"; } from "@/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema";
type OrganizationMemberChangeRoleModalProps = { type OrganizationMemberChangeRoleModalProps = {
open: boolean; open: boolean;
@@ -3,11 +3,11 @@ import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod"; import {z} from "zod";
import {ServerActionResult} from "@/types/action-type"; import {ServerActionResult} from "@/types/action-type";
import {Member} from "better-auth/plugins"; import {Member} from "better-auth/plugins";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {db as dbClient} from "@/db"; import {db as dbClient} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema";
export const updateMemberRoleAdminAction = userAction.schema( export const updateMemberRoleAdminAction = userAction.schema(
@@ -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>
);
};
@@ -1,106 +1,102 @@
"use client" "use client"
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {Info, ShieldCheck} from "lucide-react"; import {Info} from "lucide-react";
import {Switch} from "@/components/ui/switch";
import {Label} from "@/components/ui/label";
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/storage-s3-form";
import {useState} from "react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {checkConnexionToS3} from "@/features/upload/public/upload.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {Setting} from "@/db/schema/01_setting";
import {Form, FormField, FormItem, useZodForm} from "@/components/ui/form";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query";
import {
DefaultStorageSchema,
DefaultStorageType
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import { import {
updateStorageSettingsAction updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.action"; } from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
import {Setting} from "@/db/schema/01_setting"; import {toast} from "sonner";
import {S3FormType} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema";
export type SettingsStorageSectionProps = { export type SettingsStorageSectionProps = {
settings: Setting; settings: Setting;
storageChannels: StorageChannelWith[];
}; };
export const SettingsStorageSection = (props: SettingsStorageSectionProps) => { export const SettingsStorageSection = ({settings, storageChannels}: SettingsStorageSectionProps) => {
const router = useRouter(); const router = useRouter();
const form = useZodForm({
schema: DefaultStorageSchema,
defaultValues: {
storageChannelId: settings.defaultStorageChannelId ?? undefined
}
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async (values: DefaultStorageType) => {
const result = await checkConnexionToS3(); const result = await updateStorageSettingsAction({name: "system", data: values})
if (result.error) { const inner = result?.data;
toast.error("An error occured during the connexion !");
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else { } else {
toast.success("Connexion succeed!"); toast.error(inner?.actionError?.message);
} }
}, }
}); });
const [isSwitched, setIsSwitched] = useState<boolean>(props.settings.storage !== "local");
const updateMutation = useMutation({
mutationFn: () => updateStorageSettingsAction({name: "system", data: {storage: isSwitched ? "s3" : "local"}}),
onSuccess: () => {
toast.success(`Settings updated successfully.`);
router.refresh();
},
onError: () => {
toast.error(`An error occurred while updating settings information.`);
},
});
const HandleSwitchStorage = async () => {
setIsSwitched(!isSwitched);
await updateMutation.mutateAsync();
};
const extractS3FormValues = (settings: Setting): S3FormType | undefined => {
if (!settings.s3EndPointUrl) return undefined;
return {
s3EndPointUrl: settings.s3EndPointUrl,
s3AccessKeyId: settings.s3AccessKeyId!,
s3SecretAccessKey: settings.s3SecretAccessKey!,
S3BucketName: settings.S3BucketName!,
};
};
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<Alert className="mt-3"> <Alert className="mt-3">
<Info className="h-4 w-4"/> <Info className="h-4 w-4"/>
<AlertTitle>Informations</AlertTitle> <AlertTitle>Informations</AlertTitle>
<AlertDescription> <AlertDescription>
Actually you can only store you data in one place : s3 compatible or in local. For exemple you The default storage channel will be used by default to store your backups if no storage policy is
cannot choose to store images in one place configured at the database level. </AlertDescription>
and backups files in another.
</AlertDescription>
</Alert> </Alert>
<div className="flex flex-col h-full py-4 "> <div className="flex flex-col h-full py-4 ">
<div className="flex items-center justify-between space-x-2"> <Form
<div className="flex items-center space-x-2"> form={form}
<Label htmlFor="storage-mode">Storage Mode (Local/s3 compatible)</Label> onSubmit={async (values) => {
<Switch await mutation.mutateAsync(values);
checked={isSwitched} }}
onCheckedChange={async () => { >
await HandleSwitchStorage(); <div className="flex items-center gap-3">
}} <FormField
id="storage-mode" control={form.control}
name="storageChannelId"
render={({field}) => (
<FormItem className="flex items-center justify-center">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-90 h-full mb-0">
<SelectValue placeholder="Select a default storage channel"/>
</SelectTrigger>
<SelectContent>
{storageChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
<div className="flex items-center gap-2">
{getChannelIcon(channel.provider)}
<span className="font-medium">{channel.name}</span>
<span
className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
{channel.provider}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/> />
<ButtonWithLoading type="submit">
Confirm
</ButtonWithLoading>
</div> </div>
<div> </Form>
<ButtonWithLoading
size={"default"}
disabled={!isSwitched}
isPending={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync();
}}
icon={<ShieldCheck/>}>Test connexion</ButtonWithLoading>
</div>
</div>
{isSwitched && (
<div className="mt-5">
<StorageS3Form defaultValues={extractS3FormValues(props.settings)}/>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,50 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {ServerActionResult} from "@/types/action-type";
import {Setting} from "@/db/schema/01_setting";
import {z} from "zod";
import {DefaultStorageSchema} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema";
export const updateStorageSettingsAction = userAction
.schema(
z.object({
name: z.string(),
data: DefaultStorageSchema,
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Setting>> => {
const {name, data} = parsedInput;
try {
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
defaultStorageChannelId: data.storageChannelId,
})
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return {
success: true,
value: updatedSettings,
actionSuccess: {
message: "Settings successfully updated",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed update settings.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,7 @@
import { z } from "zod";
export const DefaultStorageSchema = z.object({
storageChannelId: z.string(),
});
export type DefaultStorageType = z.infer<typeof DefaultStorageSchema>;
@@ -46,14 +46,14 @@ export function usersListColumns(): ColumnDef<User>[] {
<TableCell <TableCell
className="text-right text-muted-foreground">{formatLocalizedDate(row.original.createdAt)}</TableCell> className="text-right text-muted-foreground">{formatLocalizedDate(row.original.createdAt)}</TableCell>
</TableRow> </TableRow>
{/*{row.original.lastConnectedAt && (*/} {row.original.lastConnectedAt && (
{/* <TableRow>*/} <TableRow>
{/* <TableCell>Last connected</TableCell>*/} <TableCell>Last connected</TableCell>
{/* <TableCell className="text-right text-muted-foreground">*/} <TableCell className="text-right text-muted-foreground">
{/* {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt, locale)})*/} {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt)})
{/* </TableCell>*/} </TableCell>
{/* </TableRow>*/} </TableRow>
{/*)}*/} )}
{row.original.lastChangedPasswordAt && ( {row.original.lastChangedPasswordAt && (
<TableRow> <TableRow>
<TableCell>Last password change</TableCell> <TableCell>Last password change</TableCell>
@@ -1,75 +0,0 @@
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {ColumnDef} from "@tanstack/react-table";
import {Unlink} from "lucide-react";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {unlinkUserProviderAction} from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
import {Account} from "better-auth";
export const accountsColumns: ColumnDef<Account>[] = [
{
id: "provider",
header: "Provider",
cell: ({row}) => {
return (
<div>
{providerSwitch(row.original.providerId)}
</div>
)
},
},
{
header: "Action",
id: "actions",
cell: ({row, table}) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: async () => {
if (row.original.providerId === "credential") {
toast.error(`This provider cannot be unlinked.`);
router.refresh();
return;
}
if (table.getRowModel().rows.length <= 1) {
toast.error(`You only have one provider linked to your account. Please add more one to unlink this`);
router.refresh();
return;
}
const status = await unlinkUserProviderAction({
provider: row.original.providerId,
account: row.original.accountId,
});
if (status?.serverError || !status) {
toast.error(status?.serverError);
return;
}
toast.success(`Provider unlinked successfully.`);
router.refresh();
},
});
return (
<div className="flex items-center gap-2">
<ButtonWithLoading
variant="outline"
disabled={row.original.providerId === "credential" || table.getRowModel().rows.length <= 1}
icon={<Unlink color="red" size={15}/>}
onClick={async () => {
await mutation.mutateAsync();
}}
size="icon"
/>
</div>
);
},
},
];
@@ -1,29 +0,0 @@
import {UserWithAccounts} from "@/db/schema/02_user";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/users/columns-users";
export type AdminUsersTableProps = {
users: UserWithAccounts[];
};
export const AdminUsersTable = (props: AdminUsersTableProps) => {
const {users} = props;
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<Card className="h-full ">
<CardHeader>
<CardTitle>Active users</CardTitle>
<CardDescription>Manage your users</CardDescription>
</CardHeader>
<CardContent className="h-full">
<DataTable
enableSelect={false}
columns={usersColumnsAdmin}
data={users}/>
</CardContent>
</Card>
</div>
);
};
@@ -1,61 +0,0 @@
"use client";
import {Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {deleteUserAction} from "@/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action";
export type ButtonDeleteUserProps = {
userId: string;
disabled?: boolean;
};
export const ButtonDeleteUser = (props: ButtonDeleteUserProps) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: () => deleteUserAction(props.userId),
onSuccess: async () => {
toast.success("User deleted successfully.");
router.refresh();
},
});
return (
<div className="flex items-center gap-2">
<ButtonWithConfirm
title={""}
description="Are you sure you want to remove this user? This action cannot be undone."
button={{
main: {
disabled: !!props.disabled,
text: "",
variant: "outline",
size: "sm",
icon: <Trash2 color="red" size={15}/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: () => {
mutation.mutate()
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
isPending={mutation.isPending}
/>
</div>
);
};
@@ -1,108 +0,0 @@
"use client"
import {ColumnDef} from "@tanstack/react-table";
import {Badge} from "@/components/ui/badge";
import {updateUserAction} from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {UserWithAccounts} from "@/db/schema/02_user";
import {authClient, useSession} from "@/lib/auth/auth-client";
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/users/button-delete-use";
import {formatLocalizedDate} from "@/utils/date-formatting";
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
{
accessorKey: "role",
header: "Role",
cell: ({row}) => {
const [role, setRole] = useState<string>(row.getValue("role"));
const {data: session, isPending, error} = authClient.useSession();
const isSuperAdmin = session?.user.role == "superadmin";
const isCurrentUser = session?.user.email === row.original.email;
const updateMutation = useMutation({
mutationFn: () => updateUserAction({id: row.original.id, data: {role: role}}),
onSuccess: () => {
toast.success(`User updated successfully.`);
},
onError: () => {
toast.error(`An error occurred while updating user information.`);
},
});
const handleUpdateRole = async () => {
const nextRole = role === "admin" ? "pending" : role === "pending" ? "user" : "admin";
setRole(nextRole);
await updateMutation.mutateAsync();
};
if (isPending) return null;
if (error || !session) {
return null;
}
return (
<Badge
className={isCurrentUser || !isSuperAdmin ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
onClick={isCurrentUser || !isSuperAdmin ? undefined : () => handleUpdateRole()}
variant="outline"
>
{role}
</Badge>
);
},
},
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "accounts",
header: "Provider(s)",
cell: ({row}) => {
return (
<div className="flex flex-row items-center gap-x-2">
{row.original.accounts.map((item) => (
<div key={item.id}>
{providerSwitch(item.providerId, true)}
</div>
))}
</div>
)
}
},
{
accessorKey: "updatedAt",
header: "Updated At",
cell: ({row}) => {
return formatLocalizedDate(row.getValue("updatedAt"))
},
},
{
header: "Action",
id: "actions",
cell: ({row}) => {
const router = useRouter();
const {data: session, isPending, error} = useSession();
const isSuperAdmin = session?.user.role == "superadmin";
if (isPending || error) return null;
return (
<ButtonDeleteUser
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
userId={row.original.id}/>
);
},
},
];
@@ -1,89 +0,0 @@
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {ColumnDef} from "@tanstack/react-table";
import {Session} from "better-auth";
import {Unlink} from "lucide-react";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import detectOSWithUA from "@/utils/os-parser";
import {Icon} from "@iconify/react";
import {authClient} from "@/lib/auth/auth-client";
import {timeAgo} from "@/utils/date-formatting";
import {deleteUserSessionAction} from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
export const sessionsColumns: ColumnDef<Session>[] = [
{
accessorKey: "expiresAt",
header: "Expires At",
cell: ({row}) => {
return timeAgo(row.original.expiresAt);
},
},
{
id: "device",
header: "Device",
cell: ({row}) => {
const os = detectOSWithUA(row.original.userAgent!);
return (
<div className="flex flex-row gap-x-2 items-center pt-4 pb-4">
{os.icon &&
<Icon icon={`logos:${os.icon.name}`} height={os.icon.size.height} width={os.icon.size.width}/>}
{os.showText && <span>{os.name}</span>}
</div>
);
},
},
{
accessorKey: "userAgent",
header: "User Agent",
},
{
header: "Action",
id: "actions",
cell: ({row}) => {
const router = useRouter();
const {data: session, isPending, error} = authClient.useSession();
const mutation = useMutation({
mutationFn: async () => {
if (session?.session.id === row.original.id) {
toast.error(`Unable to unlink active session.`);
router.refresh();
return;
}
const status = await deleteUserSessionAction(row.original.token);
if (status?.serverError || !status) {
toast.error(status?.serverError);
return;
}
toast.success(`Session deleted successfully.`);
router.refresh();
},
});
if (isPending || error) return null;
return (
<div className="flex items-center gap-2">
<ButtonWithLoading
isPending={mutation.isPending}
variant="outline"
disabled={session?.session.id === row.original.id}
icon={<Unlink color="red" size={15}/>}
onClick={async () => {
await mutation.mutateAsync();
}}
size="icon"
/>
</div>
);
},
},
];
@@ -3,10 +3,10 @@
import {useState} from "react"; import {useState} from "react";
import Link from "next/link"; import Link from "next/link";
import {Card} from "@/components/ui/card"; import {Card} from "@/components/ui/card";
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle"; import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentWith} from "@/db/schema/08_agent"; import {AgentWith} from "@/db/schema/08_agent";
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, ShieldCheck} from "lucide-react"; import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database} from "lucide-react";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text"; import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
@@ -86,7 +86,7 @@ export const AgentCard = (props: agentCardProps) => {
</div> </div>
<div className="scale-110"> <div className="scale-110">
<ConnectionCircle date={agent.lastContact}/> <ConnectionIndicator date={agent.lastContact}/>
</div> </div>
<ChevronRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" /> <ChevronRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
@@ -1,127 +0,0 @@
"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 {NotifierForm} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannel, 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 {
NotifierChannelOrganisationForm
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form";
type OrganizationNotifierAddModalProps = {
notificationChannel?: NotificationChannelWith
organization?: OrganizationWithMembers;
open?: boolean;
onOpenChangeAction?: (open: boolean) => void;
adminView?: boolean;
organizations?: OrganizationWithMembers[]
trigger?: boolean;
}
export const NotifierAddEditModal = ({
organization,
notificationChannel,
open = false,
onOpenChangeAction,
adminView,
organizations ,
trigger= true
}: OrganizationNotifierAddModalProps) => {
const isMobile = useIsMobile();
const [openInternal, setOpen] = useState(open);
const isCreate = !Boolean(notificationChannel);
useEffect(() => {
setOpen(open);
},[open])
return (
<Dialog open={openInternal} onOpenChange={(state) => {
onOpenChangeAction?.(state);
setOpen(state);
}}>
{trigger && (
<DialogTrigger asChild>
{isCreate ?
<Button>
<Plus/>{!isMobile && `Add notification 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"} Notification Channel</DialogTitle>
<DialogDescription>
Configure your notification channel preferences and event triggers.
</DialogDescription>
</DialogHeader>
<div>
{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">
<NotifierForm
adminView={adminView}
defaultValues={notificationChannel}
organization={organization}
onSuccessAction={() => {
onOpenChangeAction?.(false)
setOpen(false);
}}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="organizations">
<NotifierChannelOrganisationForm
defaultValues={notificationChannel}
organizations={organizations}
/>
</TabsContent>
</Tabs>
:
<NotifierForm
adminView={adminView}
defaultValues={notificationChannel}
organization={organization}
onSuccessAction={() => {
onOpenChangeAction?.(false)
setOpen(false);
}}
/>
}
</div>
</DialogContent>
</Dialog>
)
}
@@ -1,72 +0,0 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Switch} from "@/components/ui/switch";
import {
updateNotificationChannelAction
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
import {toast} from "sonner";
import {useState} from "react";
import {Organization, OrganizationWithMembers} from "@/db/schema/03_organization";
export type EditNotifierButtonProps = {
notificationChannel: NotificationChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
};
export const EditNotifierButton = ({
organizations,
adminView = false,
notificationChannel,
organization
}: EditNotifierButtonProps) => {
const router = useRouter();
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const mutation = useMutation({
mutationFn: async (value: boolean) => {
const payload = {
data: {
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config as Record<string, any>,
enabled: value
},
notificationChannelId: notificationChannel.id
};
const result = await updateNotificationChannelAction(payload);
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
return (
<>
<Switch checked={notificationChannel.enabled} onCheckedChange={async () => {
await mutation.mutateAsync(!notificationChannel.enabled)
}}
/>
<NotifierAddEditModal
organizations={organizations}
adminView={adminView}
organization={organization}
notificationChannel={notificationChannel}
open={isAddModalOpen}
onOpenChangeAction={setIsAddModalOpen}
/>
</>
);
};
@@ -1,63 +0,0 @@
"use client";
import {Card} from "@/components/ui/card";
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Badge} from "@/components/ui/badge";
import {
DeleteNotifierButton
} from "@/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier";
import {EditNotifierButton} from "@/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
export type NotifierCardProps = {
data: NotificationChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
};
export const NotifierCard = (props: NotifierCardProps) => {
const {data, organization} = props;
const isMobile = useIsMobile()
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">
{getNotificationChannelIcon(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>
</div>
</div>
<div className="flex items-center gap-2">
<EditNotifierButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
notificationChannel={data}/>
<DeleteNotifierButton
organizationId={organization?.id}
notificationChannelId={data.id}
/>
</div>
</Card>
</div>
);
};
@@ -1,21 +0,0 @@
import {z} from "zod";
export const NotificationChannelFormSchema = 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"),
provider: z.enum(["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"], {
required_error: "Provider is required",
}),
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
enabled: z.boolean().default(true),
});
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
@@ -1,61 +0,0 @@
"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} from "lucide-react";
import {toast} from "sonner";
import {useIsMobile} from "@/hooks/use-mobile";
import {cn} from "@/lib/utils";
type NotifierTestChannelButtonProps = {
notificationChannel: NotificationChannel;
organizationId?: string;
}
export const NotifierTestChannelButton = ({notificationChannel, organizationId}: NotifierTestChannelButtonProps) => {
const isMobile = useIsMobile()
const mutation = useMutation({
mutationFn: async () => {
const payload: EventPayload = {
title: 'Test Channel',
message: `We are testing channel ${notificationChannel.name}`,
level: 'info',
// data: {host: 'db-prod-01', error: 'connection timeout'},
};
const result = await dispatchNotification(payload, undefined, notificationChannel.id, organizationId);
if (result.success) {
toast.success(result.message);
} else {
toast.error("An error occurred while testing the notification channel");
}
},
});
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">
<Send className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Channel`}
</div>
)}
</Button>
)
}
@@ -26,7 +26,7 @@ type ProfileModalProps = {
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => { export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background"> <DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle> <DialogTitle>Settings</DialogTitle>
<DialogDescription>Manage your account settings</DialogDescription> <DialogDescription>Manage your account settings</DialogDescription>
@@ -7,7 +7,7 @@ import {
Layers, Layers,
ChartArea, ChartArea,
ShieldHalf, ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse
} from "lucide-react"; } from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar"; import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient, useSession} from "@/lib/auth/auth-client"; import {authClient, useSession} from "@/lib/auth/auth-client";
@@ -22,7 +22,6 @@ export const SidebarMenuCustomMain = () => {
const {data: session, isPending, error} = authClient.useSession(); const {data: session, isPending, error} = authClient.useSession();
const member = authClient.useActiveMember(); const member = authClient.useActiveMember();
if (isPending) return null; if (isPending) return null;
if (error || !session) { if (error || !session) {
@@ -39,9 +38,6 @@ export const SidebarMenuCustomMain = () => {
{title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"} {title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"}
]; ];
// if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
// groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
// }
const items: SidebarGroupItem[] = [ const items: SidebarGroupItem[] = [
{ {
@@ -80,6 +76,16 @@ export const SidebarMenuCustomMain = () => {
{ title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item" }, { title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item" },
], ],
}, },
{
title: "Storages",
url: "/storages",
icon: Warehouse,
details: true,
type: "collapse",
submenu: [
{ title: "Channels", url: "/storages/channels", icon: Blocks, type: "item" },
],
},
{ {
title: "Access management", title: "Access management",
url: "/admin", url: "/admin",
@@ -88,19 +94,14 @@ export const SidebarMenuCustomMain = () => {
type: "collapse", type: "collapse",
submenu: [ submenu: [
{title: "Users", url: "/admin/users", icon: Users, type: "item"}, {title: "Users", url: "/admin/users", icon: Users, type: "item"},
{title: "Organizations", url: "/admin/organizations", icon: Building, type: "item"}, {title: "Organizations", url: "/admin/organizations", icon: Building, type: "item", details: true},
], ],
}, },
{ {
title: "Settings", title: "Settings",
url: "/admin/settings", url: "/admin/settings",
icon: Settings, icon: Settings,
details: true, type: "item",
type: "collapse",
submenu: [
{title: "Email", url: "/admin/settings/email", icon: Mail, type: "item"},
{title: "Storage", url: "/admin/settings/storage", icon: PackageOpen, type: "item"},
],
}, },
], ],
}); });
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { ChevronRight, ChevronDown, MoreHorizontal } from "lucide-react"; import { ChevronRight, ChevronDown, MoreHorizontal } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import React, { useEffect, useState } from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
@@ -46,43 +46,18 @@ type SidebarMenuCustomBaseProps = {
export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseProps) => { export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseProps) => {
const pathname = usePathname(); const pathname = usePathname();
const [activeItem, setActiveItem] = useState("");
useEffect(() => { const normalize = (url: string) => url.split("?")[0].replace(/\/$/, "");
const normalize = (url: string) => url.split("?")[0].replace(/\/$/, "");
const findActiveUrl = () => { const isItemActive = (item: SidebarItem) => {
const normalizedPathname = normalize(pathname); const fullUrl = item.not_from_base_url ? normalize(item.url) : normalize(`${baseUrl}${item.url}`);
for (const group of items) { if (item.details) return normalize(pathname).startsWith(fullUrl);
for (const item of group.group_content) { return normalize(pathname) === fullUrl;
if (item.submenu) { };
for (const subItem of item.submenu) {
const subItemUrl = normalize(subItem.url);
const subFullUrl = normalize(`${baseUrl}${subItemUrl}`);
if (normalizedPathname === subFullUrl || (subItem.details && normalizedPathname.startsWith(`${subFullUrl}/`))) {
return subItemUrl;
}
}
}
const itemUrl = normalize(item.url);
const fullUrl = item.not_from_base_url ? itemUrl : normalize(`${baseUrl}${itemUrl}`);
if (normalizedPathname === fullUrl || (item.details && normalizedPathname.startsWith(`${fullUrl}/`))) {
return itemUrl;
}
}
}
return "";
};
setActiveItem(findActiveUrl());
}, [pathname, baseUrl, items]);
const isSubActive = (item: SidebarItem) => { const isSubActive = (item: SidebarItem) => {
if (!item.submenu) return false; if (!item.submenu) return false;
return item.submenu.some((sub) => sub.url === activeItem); return item.submenu.some((sub) => isItemActive(sub));
}; };
return ( return (
@@ -91,7 +66,7 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP
<Collapsible key={index} defaultOpen className="group/group-collapsible"> <Collapsible key={index} defaultOpen className="group/group-collapsible">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel asChild> <SidebarGroupLabel asChild>
<CollapsibleTrigger disabled={group.type == "list"} className="flex w-full items-center text-sm font-medium text-sidebar-foreground/70"> <CollapsibleTrigger disabled={group.type === "list"} className="flex w-full items-center text-sm font-medium text-sidebar-foreground/70">
{group.label} {group.label}
{group.type === "collapse" && ( {group.type === "collapse" && (
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/group-collapsible:rotate-180" /> <ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/group-collapsible:rotate-180" />
@@ -118,14 +93,10 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP
<SidebarMenuSub> <SidebarMenuSub>
{item.submenu.map((sub, subIdx) => ( {item.submenu.map((sub, subIdx) => (
<SidebarMenuSubItem key={subIdx}> <SidebarMenuSubItem key={subIdx}>
<SidebarMenuSubButton asChild isActive={activeItem === sub.url}> <SidebarMenuSubButton asChild isActive={isItemActive(sub)}>
<Link <Link
className={cn( href={sub.not_from_base_url ? sub.url : `${baseUrl}${sub.url}`}
buttonVariants({ variant: "ghost", size: "sm" }), className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-full justify-start")}
"w-full justify-start"
)}
href={`${baseUrl}${sub.url}`}
onClick={() => setActiveItem(sub.url)}
> >
<sub.icon /> <sub.icon />
<span>{sub.title}</span> <span>{sub.title}</span>
@@ -142,11 +113,10 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP
return ( return (
<SidebarMenuItem key={idx}> <SidebarMenuItem key={idx}>
<SidebarMenuButton asChild isActive={activeItem === item.url} tooltip={item.title}> <SidebarMenuButton asChild isActive={isItemActive(item)} tooltip={item.title}>
<Link <Link
href={item.redirect || item.not_from_base_url ? item.url : `${baseUrl}${item.url}`} href={item.redirect || item.not_from_base_url ? item.url : `${baseUrl}${item.url}`}
target={item.redirect ? "_blank" : ""} target={item.redirect ? "_blank" : ""}
onClick={() => setActiveItem(item.url)}
className={cn(buttonVariants({ variant: "ghost", size: "lg" }), "justify-start")} className={cn(buttonVariants({ variant: "ghost", size: "lg" }), "justify-start")}
> >
<item.icon /> <item.icon />
@@ -163,11 +133,7 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP
{item.dropdown.map((dropdown, dIdx) => ( {item.dropdown.map((dropdown, dIdx) => (
<DropdownMenuItem key={dIdx} asChild> <DropdownMenuItem key={dIdx} asChild>
<Link <Link
href={ href={dropdown.redirect || dropdown.not_from_base_url ? dropdown.url : `${baseUrl}${dropdown.url}`}
dropdown.redirect || dropdown.not_from_base_url
? dropdown.url
: `${baseUrl}${dropdown.url}`
}
target={dropdown.redirect ? "_blank" : ""} target={dropdown.redirect ? "_blank" : ""}
> >
<span>{dropdown.title}</span> <span>{dropdown.title}</span>
@@ -1,185 +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 * as drizzleDb from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {
AlertPolicySchema
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.schema";
import {AlertPolicy} from "@/db/schema/10_alert-policy";
export const createAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string(),
alertPolicies: z.array(AlertPolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
try {
const valuesToInsert = parsedInput.alertPolicies.map((policy) => ({
databaseId: parsedInput.databaseId,
notificationChannelId: policy.notificationChannelId,
eventKinds: policy.eventKinds,
enabled: policy.enabled,
}));
const insertedPolicies = await db
.insert(drizzleDb.schemas.alertPolicy)
.values(valuesToInsert)
.returning();
return {
success: true,
value: insertedPolicies,
actionSuccess: {
message: `Alert policies successfully added`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to add policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const updateAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
alertPolicies: z.array(AlertPolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
const {databaseId, alertPolicies} = parsedInput;
try {
const updatedPolicies = await db.transaction(async (tx) => {
const results: AlertPolicy[] = [];
for (const policy of alertPolicies) {
const {notificationChannelId, ...updateData} = policy;
const updated = await tx
.update(drizzleDb.schemas.alertPolicy)
.set(withUpdatedAt({
...updateData,
}))
.where(
and(
eq(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelId),
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId)
)
)
.returning();
if (updated[0]) {
results.push(updated[0]);
}
}
return results;
});
if (updatedPolicies.length === 0) {
return {
success: false,
actionError: {message: "No policies were updated."},
};
}
return {
success: true,
value: updatedPolicies,
actionSuccess: {
message: `Successfully updated ${updatedPolicies.length} alert policy(ies).`,
},
};
} catch (error) {
console.error("Update alert policies failed:", error);
return {
success: false,
actionError: {
message: "Failed to update alert policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const deleteAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
alertPolicies: z.array(AlertPolicySchema),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<AlertPolicy[]>> => {
const { databaseId, alertPolicies } = parsedInput;
try {
const notificationChannelIds = alertPolicies.map((alertPolicy) => alertPolicy.notificationChannelId);
const policiesToDelete = await db
.select()
.from(drizzleDb.schemas.alertPolicy)
.where(
and(
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId),
inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds)
)
);
if (policiesToDelete.length === 0) {
return {
success: false,
actionError: { message: "No alert policies found to delete." },
};
}
await db
.delete(drizzleDb.schemas.alertPolicy)
.where(
and(
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId),
inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds)
)
);
return {
success: true,
value: policiesToDelete,
actionSuccess: {
message: `Successfully deleted ${policiesToDelete.length} alert policy(ies).`,
},
};
} catch (error) {
console.error("Delete alert policies failed:", error);
return {
success: false,
actionError: {
message: "Failed to delete alert policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -1,27 +0,0 @@
import {z} from "zod";
export const AlertPolicySchema =
z.object({
notificationChannelId: z.string().min(1, "Please select a notification channel"),
eventKinds: z.enum(['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report']).array().nonempty(),
enabled: z.boolean().default(true),
}
)
export const AlertPoliciesSchema = z.object({
alertPolicies: z.array(AlertPolicySchema)
});
export type AlertPoliciesType = z.infer<typeof AlertPoliciesSchema>;
export type AlertPolicyType = z.infer<typeof AlertPolicySchema>;
export const EVENT_KIND_OPTIONS = [
{label: "Error Backup", value: "error_backup"},
{label: "Error Restore", value: "error_restore"},
{label: "Success Restore", value: "success_restore"},
{label: "Success Backup", value: "success_backup"},
{label: "Weekly Report", value: "weekly_report"},
];
@@ -0,0 +1,73 @@
"use client";
import {BackupWith} from "@/db/schema/07_database";
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {Button} from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from "@/components/ui/dropdown-menu";
import {MoreHorizontal, Trash2, Download} from "lucide-react";
import {ReloadIcon} from "@radix-ui/react-icons";
import {cn} from "@/lib/utils";
import {MemberWithUser} from "@/db/schema/03_organization";
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
interface DatabaseActionsCellProps {
backup: BackupWith;
activeMember: MemberWithUser;
isAlreadyRestore: boolean;
}
export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore}: DatabaseActionsCellProps) {
const {openModal} = useBackupModal();
if (backup.deletedAt || activeMember.role === "member") return null;
return (
<div className={cn("flex items-center space-x-2")}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="w-4 h-4"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{backup.status == "success" ? (
<>
<TooltipCustom disabled={isAlreadyRestore}
text="Already a restoration waiting">
<DropdownMenuItem
disabled={isAlreadyRestore}
onSelect={() => openModal("restore", backup)}
>
<ReloadIcon/> Restore
</DropdownMenuItem>
</TooltipCustom>
<DropdownMenuItem
onSelect={() => openModal("download", backup)}
>
<Download/> Download
</DropdownMenuItem>
</>
) : null}
<DropdownMenuSeparator/>
<DropdownMenuItem onSelect={() => openModal("delete", backup)} className="text-red-600">
<Trash2/> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,280 @@
"use client"
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
import React from "react";
import {Swiper, SwiperSlide} from "swiper/react";
import "swiper/css";
import "swiper/css/pagination";
import {Pagination, Mousewheel} from "swiper/modules";
import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {
BackupActionsSchema,
BackupActionsType
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
import {TooltipProvider} from "@/components/ui/tooltip";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {Badge} from "@/components/ui/badge";
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
downloadBackupAction
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import {toast} from "sonner";
import {SafeActionResult} from "next-safe-action";
import {ServerActionResult} from "@/types/action-type";
import {ZodString} from "zod";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {AlertCircleIcon, Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
type BackupActionsFormProps = {
backup: BackupWith;
action: DatabaseActionKind;
}
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
const isMobile = useIsMobile();
const {closeModal} = useBackupModal();
const form = useZodForm({
schema: BackupActionsSchema,
});
const mutation = useMutation({
mutationFn: async (values: BackupActionsType) => {
let result: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
if (action === "download") {
result = await downloadBackupAction({backupStorageId: values.backupStorageId})
} else if (action === "restore") {
result = await createRestorationBackupAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id
})
} else if (action === "delete") {
result = await deleteBackupStorageAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
})
}
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
if (action === "download") {
console.log(inner.value)
const url = inner.value
if (typeof url === "string") {
window.open(url, "_self");
}
closeModal()
} else if (action === "restore") {
closeModal()
} else if (action === "delete") {
closeModal()
} else {
closeModal()
}
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.")
closeModal()
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
}
}
},
});
const mutationDeleteEntireBackup = useMutation({
mutationFn: async () => {
const result = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
})
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
closeModal()
} else {
toast.error(inner?.actionError?.message);
}
},
});
return (
<TooltipProvider>
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
{filteredBackupStorages.length > 0 ?
<FormField
control={form.control}
name="backupStorageId"
render={({field}) => (
<FormItem>
<FormLabel>Choose a storage backup</FormLabel>
<FormControl>
<div style={{height: "250px"}}>
<Swiper
direction="vertical"
slidesPerView={3.5}
spaceBetween={10}
// pagination={{ clickable: true }}
mousewheel={{releaseOnEdges: true, forceToAxis: true}}
modules={[Pagination, Mousewheel]}
className="mySwiper"
style={{height: "100%"}}
>
{filteredBackupStorages.map((storage: BackupStorageWith) => (
<SwiperSlide key={storage.id}>
<button
disabled={action !== "delete" && storage.status.toLowerCase() !== "success"}
type="button"
onClick={() => field.onChange(storage.id)}
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
${field.value === storage.id
? "border-foreground bg-background"
: "border-border bg-background" + ((storage.status.toLowerCase() === "success" || action === "delete") ? " hover:border-muted-foreground" : "")}
${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
field.value === storage.id ? "border-foreground" : "border-muted-foreground"
} flex items-center justify-center`}
>
{field.value === storage.id &&
<div className="h-2 w-2 rounded-full bg-foreground"/>}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getChannelIcon(storage.storageChannel?.provider || "")}
<h3 className="font-medium text-foreground">
{isMobile ? truncateWords(storage?.storageChannel?.name ?? "", 2) : storage.storageChannel?.name}
</h3>
<Badge variant="secondary"
className="text-xs font-mono">
{storage.storageChannel?.provider}
</Badge>
</div>
<Badge variant="outline"
className={`gap-1.5 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge>
</div>
</div>
</div>
</div>
</button>
</SwiperSlide>
)) ?? <p>No storages available</p>}
</Swiper>
</div>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
:
<Alert>
<AlertCircleIcon/>
<AlertTitle>Backup does not have files</AlertTitle>
<AlertDescription>
<p>You can safely delete the entire backup; no files seem to be related. Maybe an error
occurred.</p>
</AlertDescription>
</Alert>
}
<div className="flex flex-row items-center gap-x-4 w-full">
{action === "delete" && (
// <ButtonWithLoading
// type="button"
// variant="destructive"
// onClick={() => mutationDeleteEntireBackup.mutateAsync()}
// isPending={mutationDeleteEntireBackup.isPending}
// disabled={mutationDeleteEntireBackup.isPending}
// >
// Delete entire backup
// </ButtonWithLoading>
<ButtonWithConfirm
title={"Delete entire backup"}
description={"Are you sure you want to delete this entire backup?"}
button={{
main: {
type: "button",
variant: "destructive",
text: "Delete entire backup",
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: async () => {
mutationDeleteEntireBackup.mutateAsync()
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
isPending={mutationDeleteEntireBackup.isPending}
/>
)}
{filteredBackupStorages.length > 0 && (
<ButtonWithLoading
type="submit"
isPending={mutation.isPending}
disabled={mutation.isPending}
className="ml-auto"
>
Confirm
</ButtonWithLoading>
)}
</div>
</Form>
</TooltipProvider>
);
}
@@ -0,0 +1,40 @@
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {Separator} from "@/components/ui/separator";
import {BackupActionsForm} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-form";
import {
getBackupActionTextBasedOnActionKind,
useBackupModal
} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
type DatabaseActionsModalProps = {}
export const DatabaseBackupActionsModal = ({}: DatabaseActionsModalProps) => {
const {open, action, backup, closeModal} = useBackupModal();
if (!backup || !action) return null;
const text = getBackupActionTextBasedOnActionKind(action);
return (
<Dialog open={open} onOpenChange={closeModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{text} backup ?</DialogTitle>
<DialogDescription>
Select the backup storage
</DialogDescription>
<Separator className="mt-3 mb-3"/>
</DialogHeader>
<BackupActionsForm backup={backup} action={action}/>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,339 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {ServerActionResult} from "@/types/action-type";
import {z} from "zod";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {db} from "@/db";
import {and, eq, isNull, ne, sql} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {Backup, Restoration} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
export const downloadBackupAction = userAction.schema(
z.object({
backupStorageId: z.string(),
})
).action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
const {backupStorageId} = parsedInput;
try {
const backupStorage = await db.query.backupStorage.findFirst({
where: eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
});
if (!backupStorage) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupStorageId: backupStorageId},
},
};
}
if (backupStorage.status != "success" || !backupStorage.path) {
return {
success: false,
actionError: {
message: "An error occurred.",
status: 500,
messageParams: {backupStorageId: backupStorageId},
},
}
}
const input: StorageInput = {
action: "get",
data: {
path: backupStorage.path,
signedUrl: true,
},
};
console.log(input)
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
console.log(result);
return {
success: result.success,
value: result.url,
actionSuccess: {
message: "Backup Storage downloaded successfully.",
messageParams: {backupStorageId: backupStorageId},
},
};
} catch (error) {
console.error("Error:", error);
return {
success: false,
actionError: {
message: "Failed to get presigned url.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {backupStorageId: backupStorageId},
},
};
}
});
export const createRestorationBackupAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
backupStorageId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try {
const restorationData = await db
.insert(drizzleDb.schemas.restoration)
.values({
databaseId: parsedInput.databaseId,
backupId: parsedInput.backupId,
backupStorageId: parsedInput.backupStorageId,
status: "waiting",
})
.returning()
.execute();
const createdRestoration = restorationData[0];
return {
success: true,
value: createdRestoration,
actionSuccess: {
message: "Restoration has been successfully created.",
messageParams: {restorationId: createdRestoration.id},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to create restoration.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error creating the restoration"},
},
};
}
});
export const deleteBackupStorageAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
backupStorageId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
const {backupId, databaseId, backupStorageId} = parsedInput;
try {
const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId))
});
if (!backup) {
return {
success: false,
actionError: {
message: "Backup not found.",
status: 404,
messageParams: {backupStorageId: backupStorageId},
},
}
}
const backupStorage = await db.query.backupStorage.findFirst({
where: eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
});
if (!backupStorage) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupStorageId: backupStorageId},
},
};
}
const [{count}] = await db
.select({count: sql<number>`count(*)`})
.from(drizzleDb.schemas.backupStorage)
.where(
and(
eq(drizzleDb.schemas.backupStorage.backupId, backupId),
isNull(drizzleDb.schemas.backupStorage.deletedAt),
ne(drizzleDb.schemas.backupStorage.id, backupStorageId)
)
);
if (Number(count) === 0) {
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
deletedAt: new Date(),
status: backup.status == "ongoing" ? "failed" : backup.status
}))
.where(and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId)))
}
await db
.update(drizzleDb.schemas.backupStorage)
.set(withUpdatedAt({
deletedAt: new Date(),
}))
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId))
if (backupStorage.status != "success" || !backupStorage.path) {
return {
success: false,
actionError: {
message: "An error occurred.",
status: 500,
messageParams: {backupStorageId: backupStorageId},
},
}
}
const input: StorageInput = {
action: "delete",
data: {
path: backupStorage.path,
},
};
await dispatchStorage(input, undefined, backupStorage.storageChannelId);
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup deleted successfully.`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the backup"},
},
};
}
});
export const deleteBackupAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
const {backupId, databaseId} = parsedInput;
try {
const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId))
});
if (!backup) {
return {
success: false,
actionError: {
message: "Backup not found.",
status: 404,
messageParams: {backupId: backupId},
},
}
}
const backupStorages = await db.query.backupStorage.findMany({
where: eq(drizzleDb.schemas.backupStorage.backupId, backupId),
});
if (!backupStorages) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupId: backupId},
},
};
}
for (const backupStorage of backupStorages) {
await db
.update(drizzleDb.schemas.backupStorage)
.set(withUpdatedAt({
deletedAt: new Date(),
}))
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id))
if (backupStorage.status != "success" || !backupStorage.path) {
continue;
}
const input: StorageInput = {
action: "delete",
data: {
path: backupStorage.path,
},
};
await dispatchStorage(input, undefined, backupStorage.storageChannelId);
}
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
deletedAt: new Date(),
status: backup.status == "ongoing" ? "failed" : backup.status
}))
.where(and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId)))
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup deleted successfully (ref: ${parsedInput.backupId}).`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the backup"},
},
};
}
});
@@ -0,0 +1,10 @@
"use client";
import { z } from "zod";
import {zString} from "@/lib/zod";
export const BackupActionsSchema = z.object({
backupStorageId: zString(),
});
export type BackupActionsType = z.infer<typeof BackupActionsSchema>;
@@ -0,0 +1,63 @@
"use client";
import {createContext, useContext, useState, ReactNode} from "react";
import {BackupWith} from "@/db/schema/07_database";
import {useRouter} from "next/navigation";
export type DatabaseActionKind = "restore" | "download" | "delete";
export function getBackupActionTextBasedOnActionKind(kind: DatabaseActionKind) {
switch (kind) {
case "restore":
return "Restore";
case "download":
return "Download";
case "delete":
return "Delete";
default:
return "Unknown";
}
}
type BackupModalContextType = {
open: boolean;
action: DatabaseActionKind | null;
backup: BackupWith | null;
openModal: (action: DatabaseActionKind, backup: BackupWith) => void;
closeModal: () => void;
};
const BackupModalContext = createContext<BackupModalContextType | undefined>(undefined);
export const BackupModalProvider = ({children}: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
const router = useRouter();
const [action, setAction] = useState<DatabaseActionKind | null>(null);
const [backup, setBackup] = useState<BackupWith | null>(null);
const openModal = (newAction: DatabaseActionKind, newBackup: BackupWith) => {
setAction(newAction);
setBackup(newBackup);
setOpen(true);
};
const closeModal = () => {
setOpen(false);
setAction(null);
setBackup(null);
router.refresh()
};
return (
<BackupModalContext.Provider value={{open, action, backup, openModal, closeModal}}>
{children}
</BackupModalContext.Provider>
);
};
export const useBackupModal = () => {
const context = useContext(BackupModalContext);
if (!context) throw new Error("useBackupModal must be used within BackupModalProvider");
return context;
};
@@ -1,12 +1,7 @@
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {InfoIcon, Plus, Trash2} from "lucide-react"; import {InfoIcon, Plus, Trash2} from "lucide-react";
import {useFieldArray} from "react-hook-form"; import {useFieldArray} from "react-hook-form";
import {
AlertPoliciesSchema, AlertPoliciesType, AlertPolicyType,
EVENT_KIND_OPTIONS
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.schema";
import {DatabaseWith} from "@/db/schema/07_database"; import {DatabaseWith} from "@/db/schema/07_database";
import {AlertPolicy} from "@/db/schema/10_alert-policy";
import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Label} from "@/components/ui/label"; import {Label} from "@/components/ui/label";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
@@ -15,192 +10,192 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/c
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select"; import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner"; import {toast} from "sonner";
import {
createAlertPoliciesAction, deleteAlertPoliciesAction,
updateAlertPoliciesAction
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.action";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {Switch} from "@/components/ui/switch"; import {Switch} from "@/components/ui/switch";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {Card} from "@/components/ui/card"; import {Card} from "@/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
import {
ChannelKind,
getChannelIcon,
getChannelTextBasedOnKind
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {
EVENT_KIND_OPTIONS,
PoliciesSchema,
PoliciesType,
PolicyType
} from "@/components/wrappers/dashboard/database/channels-policy/policy.schema";
import {
createAlertPoliciesAction, createStoragePoliciesAction, deleteAlertPoliciesAction, deleteStoragePoliciesAction,
updateAlertPoliciesAction, updateStoragePoliciesAction
} from "@/components/wrappers/dashboard/database/channels-policy/policy.action";
type AlertPolicyFormProps = { type ChannelPoliciesFormProps = {
onSuccess?: () => void; onSuccess?: () => void;
notificationChannels: NotificationChannel[]; channels: NotificationChannel[] | StorageChannel[];
organizationId: string; organizationId: string;
database: DatabaseWith; database: DatabaseWith;
kind: ChannelKind
}; };
export const AlertPolicyForm = ({database, notificationChannels, organizationId, onSuccess}: AlertPolicyFormProps) => {
const router = useRouter()
const isMobile = useIsMobile()
const organizationNotificationChannels = notificationChannels.map(channel => channel.id) ?? [];
export const ChannelPoliciesForm = ({
database,
channels,
organizationId,
onSuccess,
kind
}: ChannelPoliciesFormProps) => {
const router = useRouter();
const isMobile = useIsMobile();
const channelText = getChannelTextBasedOnKind(kind);
const formattedAlertPoliciesList = (alertPolicies: AlertPolicy[]) => { const organizationChannels = channels.map(c => c.id);
return alertPolicies.filter((alertPolicy) => organizationNotificationChannels.includes(alertPolicy.notificationChannelId)).map((alertPolicy) => ({
notificationChannelId: alertPolicy.notificationChannelId, const filterByChannel = <T, K extends keyof T>(
eventKinds: alertPolicy.eventKinds, items: T[] | undefined | null,
enabled: alertPolicy.enabled channelKey: K
): T[] => items?.filter(item => organizationChannels.includes(item[channelKey] as string)) ?? [];
const formattedAlertPolicies = filterByChannel(database.alertPolicies, "notificationChannelId")
.map(({notificationChannelId, eventKinds, enabled}) => ({
channelId: notificationChannelId,
eventKinds,
enabled
})); }));
};
const formattedStoragePolicies = filterByChannel(database.storagePolicies, "storageChannelId")
.map(({storageChannelId, enabled}) => ({
channelId: storageChannelId,
enabled
}));
const defaultPolicies: PolicyType[] =
kind === "notification"
? formattedAlertPolicies
: formattedStoragePolicies.map(({ channelId, enabled }) => ({ channelId, enabled }));
const form = useZodForm({ const form = useZodForm({
schema: AlertPoliciesSchema, schema: PoliciesSchema,
defaultValues: { defaultValues: { policies: defaultPolicies },
alertPolicies: database.alertPolicies && database.alertPolicies.length > 0 ? formattedAlertPoliciesList(database.alertPolicies) : [], context: { kind }
},
}); });
const {fields, append, remove} = useFieldArray({ const {fields, append, remove} = useFieldArray({ control: form.control, name: "policies" });
control: form.control,
name: "alertPolicies",
})
const addAlertPolicy = () => {
append({notificationChannelId: "", eventKinds: [], enabled: true});
}
const removeAlertPolicy = (index: number) => {
remove(index)
}
const onCancel = () => {
form.reset()
onSuccess?.()
}
const addPolicy = () => append({channelId: "", eventKinds: [], enabled: true});
const removePolicyHandler = (index: number) => remove(index);
const onCancel = () => { form.reset(); onSuccess?.(); };
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async ({alertPolicies}: AlertPoliciesType) => { mutationFn: async ({policies}: PoliciesType) => {
const defaultFormatedAlertPolicies = formattedAlertPoliciesList(database?.alertPolicies ?? []); const payload = policies.map(p => kind === "notification" ? p : { ...p, eventKinds: undefined });
const alertPoliciesToAdd = alertPolicies?.filter( const policiesToAdd = payload.filter(
(alertPolicy) => !defaultFormatedAlertPolicies.some((a) => a.notificationChannelId == alertPolicy.notificationChannelId) (policy) => !defaultPolicies.some((a) => a.channelId === policy.channelId)
) ?? []; );
const policiesToRemove = defaultPolicies.filter(
const alertPoliciesToRemove = defaultFormatedAlertPolicies.filter( (policy) => !payload.some((v) => v.channelId === policy.channelId)
(alertPolicy) => !alertPolicies?.some((v) => v.notificationChannelId === alertPolicy.notificationChannelId) );
) ?? []; const policiesToUpdate = payload.filter((policy) => {
const existing = defaultPolicies.find((a) => a.channelId === policy.channelId);
const alertPoliciesToUpdate = alertPolicies?.filter((alertPolicy) => {
const existing = defaultFormatedAlertPolicies.find((a) => a.notificationChannelId === alertPolicy.notificationChannelId);
return existing && return existing &&
(existing.eventKinds !== alertPolicy.eventKinds || existing.enabled !== alertPolicy.enabled); (existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
}) ?? []; });
const results = await Promise.allSettled([ console.log(policiesToUpdate);
alertPoliciesToAdd.length > 0 console.log(policiesToAdd);
? createAlertPoliciesAction({
databaseId: database.id,
alertPolicies: alertPoliciesToAdd,
})
: Promise.resolve(null),
alertPoliciesToUpdate.length > 0 const promises = kind === "notification"
? updateAlertPoliciesAction({ ? [
databaseId: database.id, policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null,
alertPolicies: alertPoliciesToUpdate, policiesToUpdate.length > 0 ? await updateAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToUpdate}) : null,
}) policiesToRemove.length > 0 ? await deleteAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToRemove}) : null,
: Promise.resolve(null), ]
: [
alertPoliciesToRemove.length > 0 policiesToAdd.length > 0 ? await createStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToAdd}) : null,
? deleteAlertPoliciesAction({ policiesToUpdate.length > 0 ? await updateStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToUpdate}) : null,
databaseId: database.id, policiesToRemove.length > 0 ? await deleteStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToRemove}) : null,
alertPolicies: alertPoliciesToRemove as AlertPolicyType[], ];
})
: Promise.resolve(null),
]);
const results = await Promise.allSettled(promises);
const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (rejected) { if (rejected) throw new Error(rejected.reason?.message || "Network or server error");
throw new Error(rejected.reason?.message || "Network or server error");
}
const failedActions = results const failedActions = results
.filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled") .filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled")
.map(r => r.value) .map(r => r.value)
.filter((value): value is { data: { success: false; actionError: any } } => .filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false);
value !== null && typeof value === "object" && value.data.success === false
);
if (failedActions.length > 0) {
const firstError = failedActions[0].data.actionError;
const message = firstError?.message || "One or more operations failed";
throw new Error(message);
}
if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed");
return {success: true}; return {success: true};
}, },
onSuccess: () => { onSuccess: () => { toast.success("Policies saved successfully"); router.refresh(); },
toast.success("Alert policies saved successfully"); onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
//onSuccess?.();
router.refresh();
},
onError: (error: any) => {
toast.error(error.message || "Failed to save alert policies");
},
}); });
return ( return (
<Form <Form form={form} className="flex flex-col gap-6" onSubmit={
form={form} async (values) => {
className="flex flex-col gap-6"
onSubmit={async (values) => { if (kind === "notification") {
await mutation.mutateAsync(values); for (const policy of values.policies) {
}} if (!policy.eventKinds || policy.eventKinds.length === 0) {
> toast.error("Please select at least one event for all notification policies");
return;
}
}
}
await mutation.mutateAsync(values)
}
}>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label className="text-base font-medium">Configure Alerts</Label> <Label className="text-base font-medium">
Configure {kind === "notification" ? "Alerts" : "Storages"}
</Label>
{!isMobile && ( {!isMobile && (
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Choose which channels receive notifications for specific events. {kind === "notification"
? "Choose which channels receive notifications for specific events."
: "Choose which storage to use with your database"}
</p> </p>
)} )}
</div> </div>
<Button <Button
disabled={ disabled={fields.length >= channels.length || channels.length === 0}
fields.length >= notificationChannels.length ||
notificationChannels.length === 0
}
type="button" type="button"
size="sm" size="sm"
className="h-8" className="h-8"
onClick={addAlertPolicy}> onClick={addPolicy}>
<Plus className="w-4 h-4 mr-1.5"/> <Plus className="w-4 h-4 mr-1.5"/> Add Policy
Add Policy
</Button> </Button>
</div> </div>
<div className="space-y-3 w-full"> <div className="space-y-3 w-full">
{organizationNotificationChannels.length === 0 ? ( {channels.length === 0 ? (
<div <div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<InfoIcon className="h-8 w-8 text-muted-foreground/50"/> <InfoIcon className="h-8 w-8 text-muted-foreground/50"/>
<p className="font-medium text-sm text-foreground">No notification channels</p> <p className="font-medium text-sm text-foreground">No channels</p>
<p className="text-xs text-muted-foreground max-w-xs"> <p className="text-xs text-muted-foreground max-w-xs">
Please <Link href={`/dashboard/settings`} Please <Link href={`/dashboard/settings`} className="underline underline-offset-4 hover:text-primary transition-colors">
className="underline underline-offset-4 hover:text-primary transition-colors">configure configure {channelText.toLowerCase()} channels
notification channels</Link> in your organization settings first. </Link> in your organization settings first.
</p> </p>
</div> </div>
) : fields.length === 0 ? ( ) : fields.length === 0 ? (
<div <div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"> <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<Plus className="h-4 w-4 text-primary"/> <Plus className="h-4 w-4 text-primary"/>
</div> </div>
<p className="font-medium text-sm text-foreground">No alert policies</p> <p className="font-medium text-sm text-foreground">No policies</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Click "Add Policy" to start receiving notifications. {kind === "notification" ? `Click "Add Policy" to start receiving notifications.` : `Click "Add Policy" to use this storage.`}
</p> </p>
</div> </div>
) : ( ) : (
@@ -211,24 +206,17 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
<div className="flex flex-row gap-2 items-start md:items-end flex-nowrap min-w-0 "> <div className="flex flex-row gap-2 items-start md:items-end flex-nowrap min-w-0 ">
<div className="flex-1 min-w-0 flex flex-col gap-1.5"> <div className="flex-1 min-w-0 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
Notification Channel {channelText} Channel
</Label> </Label>
<FormField <FormField
control={form.control} control={form.control}
name={`alertPolicies.${index}.notificationChannelId`} name={`policies.${index}.channelId`}
render={({ field }) => { render={({field}) => {
const selectedIds = form const selectedIds = form.watch("policies").map((a: PolicyType) => a.channelId).filter(Boolean);
.watch("alertPolicies") const availableChannels = channels.filter(
.map((a: AlertPolicyType) => a.notificationChannelId) (channel) => channel.id.toString() === field.value?.toString() || !selectedIds.includes(channel.id.toString())
.filter(Boolean);
const availableNotificationChannels = notificationChannels.filter(
(channel) =>
channel.id.toString() === field.value?.toString() ||
!selectedIds.includes(channel.id.toString())
); );
const selectedChannel = channels.find(c => c.id === field.value);
const selectedChannel = notificationChannels.find(c => c.id === field.value);
return ( return (
<FormItem className="space-y-0 min-w-0"> <FormItem className="space-y-0 min-w-0">
@@ -239,13 +227,13 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
{selectedChannel && ( {selectedChannel && (
<div className="flex items-center gap-2 min-w-0 w-full"> <div className="flex items-center gap-2 min-w-0 w-full">
<div className="flex items-center justify-center h-4 w-4 shrink-0"> <div className="flex items-center justify-center h-4 w-4 shrink-0">
{getNotificationChannelIcon(selectedChannel.provider)} {getChannelIcon(selectedChannel.provider)}
</div> </div>
<span className="truncate font-medium text-sm min-w-0"> <span className="truncate font-medium text-sm min-w-0">
{selectedChannel.name} {selectedChannel.name}
</span> </span>
<span className="shrink-0 text-[9px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground font-mono uppercase"> <span className="shrink-0 text-[9px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground font-mono uppercase">
{selectedChannel.provider} {selectedChannel.provider}
</span> </span>
</div> </div>
)} )}
@@ -253,16 +241,12 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{availableNotificationChannels.map(channel => ( {availableChannels.map(channel => (
<SelectItem key={channel.id.toString()} value={channel.id.toString()}> <SelectItem key={channel.id.toString()} value={channel.id.toString()}>
<div className="flex items-center gap-2 w-full min-w-0"> <div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0"> <div className="text-muted-foreground scale-90 shrink-0">{getChannelIcon(channel.provider)}</div>
{getNotificationChannelIcon(channel.provider)}
</div>
<span className="font-medium truncate min-w-0">{channel.name}</span> <span className="font-medium truncate min-w-0">{channel.name}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0"> <span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">({channel.provider})</span>
({channel.provider})
</span>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -274,14 +258,13 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
}} }}
/> />
</div> </div>
<div className="flex flex-col gap-1.5 shrink-0"> <div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">Status</Label>
Status
</Label>
<FormField <FormField
control={form.control} control={form.control}
name={`alertPolicies.${index}.enabled`} name={`policies.${index}.enabled`}
render={({ field }) => ( render={({field}) => (
<FormItem className="space-y-0"> <FormItem className="space-y-0">
<FormControl> <FormControl>
<div className="flex items-center h-9 px-1 md:px-3 rounded-md border border-input bg-background justify-between min-w-0"> <div className="flex items-center h-9 px-1 md:px-3 rounded-md border border-input bg-background justify-between min-w-0">
@@ -297,43 +280,41 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
)} )}
/> />
</div> </div>
<div className="flex flex-col gap-1.5 shrink-0 mt-auto"> <div className="flex flex-col gap-1.5 shrink-0 mt-auto">
<Button <Button type="button" variant="outline" size="icon"
type="button" className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 hover:bg-destructive/10 transition-colors border-input bg-background"
variant="outline" onClick={() => removePolicyHandler(index)}>
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 hover:bg-destructive/10 transition-colors border-input bg-background"
onClick={() => removeAlertPolicy(index)}
>
<Trash2 className="w-4 h-4"/> <Trash2 className="w-4 h-4"/>
</Button> </Button>
</div> </div>
</div> </div>
<FormField
control={form.control} {kind === "notification" && (
name={`alertPolicies.${index}.eventKinds`} <FormField
render={({ field }) => ( control={form.control}
<FormItem className="space-y-1.5 min-w-0"> name={`policies.${index}.eventKinds`}
<FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"> render={({field}) => (
Trigger Events <FormItem className="space-y-1.5 min-w-0">
</FormLabel> <FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Trigger Events</FormLabel>
<FormControl> <FormControl>
<div className="max-w-full overflow-hidden"> <div className="max-w-full overflow-hidden">
<MultiSelect <MultiSelect
options={EVENT_KIND_OPTIONS} options={EVENT_KIND_OPTIONS}
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value ?? []} defaultValue={field.value ?? []}
placeholder={isMobile ? "Select events...": "Select events to trigger notifications..."} placeholder={isMobile ? "Select events..." : "Select events to trigger notifications..."}
variant="inverted" variant="inverted"
animation={0} animation={0}
className="bg-background/50 w-full min-w-0 flex-wrap" className="bg-background/50 w-full min-w-0 flex-wrap"
/> />
</div> </div>
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
)} )}
/> />
)}
</div> </div>
</Card> </Card>
))} ))}
@@ -343,12 +324,8 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
</div> </div>
<div className="flex gap-3 justify-end pt-2 border-t mt-2"> <div className="flex gap-3 justify-end pt-2 border-t mt-2">
<ButtonWithLoading variant="outline" type="button" onClick={onCancel}> <ButtonWithLoading variant="outline" type="button" onClick={onCancel}>Cancel</ButtonWithLoading>
Cancel <ButtonWithLoading isPending={mutation.isPending}>Save Changes</ButtonWithLoading>
</ButtonWithLoading>
<ButtonWithLoading isPending={mutation.isPending}>
Save Changes
</ButtonWithLoading>
</div> </div>
</Form> </Form>
); );
@@ -1,7 +1,5 @@
"use client" "use client"
import {Megaphone} from "lucide-react"; import {ReactNode, useState} from "react";
import {useState} from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,36 +9,46 @@ import {
DialogTrigger DialogTrigger
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {AlertPolicyForm} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-form";
import {DatabaseWith} from "@/db/schema/07_database"; import {DatabaseWith} from "@/db/schema/07_database";
import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Separator} from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {ChannelPoliciesForm} from "@/components/wrappers/dashboard/database/channels-policy/policy-form";
type AlertPolicyModalProps = {
type ChannelPoliciesModalProps = {
database: DatabaseWith; database: DatabaseWith;
notificationChannels: NotificationChannel[]; channels: NotificationChannel[] | StorageChannel[];
organizationId: string; organizationId: string;
kind: ChannelKind;
icon: ReactNode;
} }
export const AlertPolicyModal = ({database, notificationChannels, organizationId}: AlertPolicyModalProps) => { export const ChannelPoliciesModal = ({icon, kind, database, channels, organizationId}: ChannelPoliciesModalProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const channelText = getChannelTextBasedOnKind(kind)
const channelsFiltered = channels
const notificationsChannelsFiltered = notificationChannels
.filter((channel) => channel.enabled) .filter((channel) => channel.enabled)
const notificationsChannelsIds = notificationsChannelsFiltered const channelsIds = channelsFiltered
.map(channel => channel.id); .map(channel => channel.id);
console.log(channelsIds);
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId));
const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId));
const activePolicies = database.alertPolicies?.filter((policy) => notificationsChannelsIds.includes(policy.notificationChannelId));
const activePolicies = kind === "notification" ? activeAlertPolicies : activeStoragePolicies;
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)} className="relative"> <Button variant="outline" onClick={() => setOpen(true)} className="relative">
<Megaphone/> {icon}
{activePolicies && activePolicies.length > 0 && ( {activePolicies && activePolicies.length > 0 && (
<Badge <Badge
className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full p-0 text-[10px] flex items-center justify-center" className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full p-0 text-[10px] flex items-center justify-center"
@@ -52,17 +60,17 @@ export const AlertPolicyModal = ({database, notificationChannels, organizationId
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Alert policies</DialogTitle> <DialogTitle>{channelText} policies</DialogTitle>
<DialogDescription> <DialogDescription>
Add and manage your database alert policies Add and manage your database {channelText.toLowerCase()} policies
</DialogDescription> </DialogDescription>
<Separator className="mt-3 mb-3"/> <Separator className="mt-3 mb-3"/>
<AlertPolicyForm <ChannelPoliciesForm
organizationId={organizationId} organizationId={organizationId}
notificationChannels={notificationsChannelsFiltered} channels={channels}
database={database} database={database}
onSuccess={() => setOpen(false)} onSuccess={() => setOpen(false)}
kind={kind}
/> />
</DialogHeader> </DialogHeader>
</DialogContent> </DialogContent>

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