diff --git a/.gitignore b/.gitignore index 67e803ba..b98786da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. .idea +.vscode # dependencies /node_modules /.pnp diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b81..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -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 diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cbd83f1d..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/portabase.iml b/.idea/portabase.iml deleted file mode 100644 index 7c469e8d..00000000 --- a/.idea/portabase.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b68b5659..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "WillLuke.nextjs.hasPrompted": true -} diff --git a/CITATION.cff b/CITATION.cff index 0ee73cd7..d176ff82 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -26,5 +26,5 @@ keywords: - web-ui - agent license: Apache-2.0 -version: 1.1.13 -date-released: "2026-01-16" +version: 1.2.0-rc.1 +date-released: "2026-01-18" diff --git a/app/(customer)/dashboard/(admin)/admin/settings/email/page.tsx b/app/(customer)/dashboard/(admin)/admin/settings/_email/page.tsx similarity index 100% rename from app/(customer)/dashboard/(admin)/admin/settings/email/page.tsx rename to app/(customer)/dashboard/(admin)/admin/settings/_email/page.tsx diff --git a/app/(customer)/dashboard/(admin)/admin/settings/storage/page.tsx b/app/(customer)/dashboard/(admin)/admin/settings/_storage/page.tsx similarity index 100% rename from app/(customer)/dashboard/(admin)/admin/settings/storage/page.tsx rename to app/(customer)/dashboard/(admin)/admin/settings/_storage/page.tsx diff --git a/app/(customer)/dashboard/(admin)/admin/settings/page.tsx b/app/(customer)/dashboard/(admin)/admin/settings/page.tsx new file mode 100644 index 00000000..7f803061 --- /dev/null +++ b/app/(customer)/dashboard/(admin)/admin/settings/page.tsx @@ -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 ( + + +
+ System settings +
+
+ + + +
+ ); +} diff --git a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx index a4bf48d3..28becfab 100644 --- a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx +++ b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx @@ -1,13 +1,12 @@ import {PageParams} from "@/types/next"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Metadata} from "next"; -import { - NotificationChannelsSection -} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section"; import {db} from "@/db"; -import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; -import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal"; +import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {desc, isNull} from "drizzle-orm"; +import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section"; +import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; +import * as drizzleDb from "@/db"; export const metadata: Metadata = { title: "Notification Channels", @@ -19,6 +18,7 @@ export default async function RoutePage(props: PageParams<{}>) { with: { organizations: true }, + where: isNull(drizzleDb.schemas.notificationChannel.organizationId), orderBy: desc(notificationChannel.createdAt) }) as NotificationChannelWith[] @@ -35,11 +35,14 @@ export default async function RoutePage(props: PageParams<{}>) { Notification channels - + {/**/} + - + {/**/} + ); diff --git a/app/(customer)/dashboard/(admin)/storages/channels/page.tsx b/app/(customer)/dashboard/(admin)/storages/channels/page.tsx new file mode 100644 index 00000000..e7d58774 --- /dev/null +++ b/app/(customer)/dashboard/(admin)/storages/channels/page.tsx @@ -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 ( + + + Storage channels + + + + + + + + + ); +} diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index 52f8923e..3cc4888a 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -2,7 +2,6 @@ import {PageParams} from "@/types/next"; import {notFound, redirect} from "next/navigation"; import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button"; -import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs"; import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi"; import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button"; import {db} from "@/db"; @@ -12,9 +11,13 @@ import {getOrganizationProjectDatabases} from "@/lib/services"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet"; import {capitalizeFirstLetter} from "@/utils/text"; -import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal"; import {getOrganizationChannels} from "@/db/services/notification-channel"; import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal"; +import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; +import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal"; +import {HardDrive, Megaphone} from "lucide-react"; +import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; +import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content"; export default async function RoutePage(props: PageParams<{ projectId: string; @@ -39,7 +42,8 @@ export default async function RoutePage(props: PageParams<{ with: { project: true, retentionPolicy: true, - alertPolicies: true + alertPolicies: true, + storagePolicies: true } }); @@ -51,6 +55,11 @@ export default async function RoutePage(props: PageParams<{ where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id), with: { restorations: true, + storages: { + with: { + storageChannel: true + } + } }, orderBy: (b, {desc}) => [desc(b.createdAt)], }); @@ -87,6 +96,10 @@ export default async function RoutePage(props: PageParams<{ const organizationChannels = await getOrganizationChannels(organization.id); const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled); + const organizationStorageChannels = await getOrganizationStorageChannels(organization.id); + const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled); + + const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const isMember = activeMember?.role === "member"; @@ -102,12 +115,22 @@ export default async function RoutePage(props: PageParams<{ {!isMember && (
- {/* Do not delete*/} - {/**/} - + } + channels={activeOrganizationChannels} + organizationId={organization.id} + /> + } + kind={"storage"} + channels={activeOrganizationStorageChannels} + organizationId={organization.id} + />
@@ -126,10 +149,16 @@ export default async function RoutePage(props: PageParams<{ - + + + ); diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx index 0798540e..7e8223ca 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx @@ -15,11 +15,7 @@ import {eq} from "drizzle-orm"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import * as drizzleDb from "@/db"; 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<{ projectId: string diff --git a/app/(customer)/dashboard/(organization)/settings/page.tsx b/app/(customer)/dashboard/(organization)/settings/page.tsx index 131e2d62..02bc5d12 100644 --- a/app/(customer)/dashboard/(organization)/settings/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/page.tsx @@ -3,21 +3,15 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/ import {currentUser} from "@/lib/auth/current-user"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {notFound} from "next/navigation"; -import { - DeleteOrganizationButton -} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button"; -import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings"; import {Metadata} from "next"; import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; import {getOrganizationChannels} from "@/db/services/notification-channel"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; -import {capitalizeFirstLetter} from "@/utils/text"; -import Link from "next/link"; -import {buttonVariants} from "@/components/ui/button"; -import {GearIcon} from "@radix-ui/react-icons"; +import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; +import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button"; import { - ButtonDeleteProject -} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project"; + EditButtonSettings +} from "@/components/wrappers/dashboard/organization/settings/edit-button-settings/edit-button-settings"; export const metadata: Metadata = { title: "Settings", @@ -33,6 +27,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) { } const notificationChannels = await getOrganizationChannels(organization.id) + const storageChannels = await getOrganizationStorageChannels(organization.id) const permissions = computeOrganizationPermissions(activeMember); @@ -62,6 +57,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) { activeMember={activeMember} organization={organization} notificationChannels={notificationChannels} + storageChannels={storageChannels} /> diff --git a/app/(customer)/dashboard/layout.tsx b/app/(customer)/dashboard/layout.tsx index 3e683bd0..bf04ccf7 100644 --- a/app/(customer)/dashboard/layout.tsx +++ b/app/(customer)/dashboard/layout.tsx @@ -6,6 +6,7 @@ import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sid import {Header} from "@/features/layout/Header"; import {currentUser} from "@/lib/auth/current-user"; import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater"; +import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; export default async function Layout({children}: { children: ReactNode }) { const user = await currentUser(); diff --git a/app/api/agent/[agentId]/backup/helpers.ts b/app/api/agent/[agentId]/backup/helpers.ts index 0df35e4f..3da9b06a 100644 --- a/app/api/agent/[agentId]/backup/helpers.ts +++ b/app/api/agent/[agentId]/backup/helpers.ts @@ -1,8 +1,5 @@ import fs from "node:fs"; import forge from "node-forge"; -import {EventPayload} from "@/features/notifications/types"; -import {dispatchNotification} from "@/features/notifications/dispatch"; -import {Database, DatabaseWith} from "@/db/schema/07_database"; export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise { diff --git a/app/api/agent/[agentId]/backup/route.ts b/app/api/agent/[agentId]/backup/route.ts index 5c468585..da6765ec 100644 --- a/app/api/agent/[agentId]/backup/route.ts +++ b/app/api/agent/[agentId]/backup/route.ts @@ -1,16 +1,15 @@ import {NextResponse} from "next/server"; import {isUuidv4} from "@/utils/verify-uuid"; -import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action"; import {v4 as uuidv4} from "uuid"; import {eventEmitter} from "../../../events/route"; import * as drizzleDb from "@/db"; import {db} from "@/db"; import {Backup} from "@/db/schema/07_database"; import {and, eq} from "drizzle-orm"; -import {env} from "@/env.mjs"; import {withUpdatedAt} from "@/db/utils"; import {decryptedDump, getFileExtension} from "./helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; +import {storeBackupFiles} from "@/features/storages/helpers"; export async function POST( request: Request, @@ -25,6 +24,7 @@ export async function POST( {status: 400} ); } + eventEmitter.emit('modification', {update: true}); const agentId = (await params).agentId; @@ -56,7 +56,8 @@ export async function POST( where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId), with: { project: true, - alertPolicies: true + alertPolicies: true, + storagePolicies: true } }); @@ -125,36 +126,8 @@ export async function POST( const uuid = uuidv4(); const fileName = `${uuid}${fileExtension}`; const buffer = Buffer.from(await decryptedFile.arrayBuffer()); - const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - if (!settings) { - throw new Error("System settings not found."); - } - - let success: boolean, message: string, filePath: string; - - const result = - settings.storage === "local" - ? await uploadLocalPrivate(fileName, buffer) - : await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!); - - ({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}); await sendNotificationsBackupRestore(database, "success_backup"); diff --git a/app/api/agent/[agentId]/status/helpers.ts b/app/api/agent/[agentId]/status/helpers.ts index 7db9bb10..88dd6d5c 100644 --- a/app/api/agent/[agentId]/status/helpers.ts +++ b/app/api/agent/[agentId]/status/helpers.ts @@ -12,6 +12,8 @@ import {ServerActionResult} from "@/types/action-type"; import {SafeActionResult} from "next-safe-action"; import {ZodString} from "zod"; import {withUpdatedAt} from "@/db/utils"; +import type {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/dispatch"; export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) { const databasesResponse = []; @@ -88,7 +90,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat 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 - const backupToRestore = await dbClient.query.backup.findFirst({ - where: eq(drizzleDb.schemas.backup.id, restoration.backupId), - with: { - database: { - with: { - project: true - } - } - } - }) - - const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - if (!settings) { - return NextResponse.json( - {error: "Unable to find settings"}, - {status: 500} - ); + // const backupToRestore = await dbClient.query.backup.findFirst({ + // where: eq(drizzleDb.schemas.backup.id, restoration.backupId), + // with: { + // database: { + // with: { + // project: true + // } + // } + // } + // }) + + // const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); + // if (!settings) { + // return NextResponse.json( + // {error: "Unable to find settings"}, + // {status: 500} + // ); + // } + + if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) { + restoreAction = false + continue; } - const fileName = backupToRestore?.file + const input: StorageInput = { + action: "get", + data: { + path: restoration.backupStorage.path, + signedUrl: true, + }, + }; - let data: SafeActionResult, object> | undefined try { + const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId); - if (settings.storage == "local") { - data = await getFileUrlPresignedLocal({fileName: fileName!}) - } else if (settings.storage == "s3") { - - data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`); - } - - if (data?.data?.success) { - urlBackup = data.data.value ?? ""; + if (result.success) { + urlBackup = result.url ?? ""; } else { await dbClient .update(drizzleDb.schemas.restoration) .set({status: "failed"}) .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); - // @ts-ignore - const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL"; + const errorMessage = "Failed to get backup URL"; console.error("Restoration failed: ", errorMessage); - continue; } } catch (err) { @@ -160,9 +165,48 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat .update(drizzleDb.schemas.restoration) .set({status: "failed"}) .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); - continue; } + + + // const fileName = backupToRestore?.file + // + // let data: SafeActionResult, object> | undefined + // + // try { + // + // if (settings.storage == "local") { + // data = await getFileUrlPresignedLocal({fileName: fileName!}) + // } else if (settings.storage == "s3") { + // + // data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`); + // } + // + // if (data?.data?.success) { + // urlBackup = data.data.value ?? ""; + // } else { + // await dbClient + // .update(drizzleDb.schemas.restoration) + // .set({status: "failed"}) + // .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); + // + // // @ts-ignore + // const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL"; + // console.error("Restoration failed: ", errorMessage); + // + // continue; + // } + // } catch (err) { + // console.error("Restoration crashed unexpectedly:", err); + // await dbClient + // .update(drizzleDb.schemas.restoration) + // .set({status: "failed"}) + // .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); + // + // continue; + // } await dbClient .update(drizzleDb.schemas.restoration) .set({status: "ongoing"}) diff --git a/app/api/agent/[agentId]/status/route.ts b/app/api/agent/[agentId]/status/route.ts index d3b31d13..a0749a0b 100644 --- a/app/api/agent/[agentId]/status/route.ts +++ b/app/api/agent/[agentId]/status/route.ts @@ -1,5 +1,4 @@ import {NextResponse} from "next/server"; -import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action"; import {handleDatabases} from "./helpers"; import {eventEmitter} from "../../../events/route"; import * as drizzleDb from "@/db"; @@ -20,13 +19,6 @@ export type Body = { databases: databaseAgent[] } -// Function to test the get file url presigned local -export async function GET(request: Request) { - const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"}) - return Response.json({ - message: url - }) -} export async function POST( request: Request, @@ -38,7 +30,6 @@ export async function POST( const lastContact = new Date(); let message: string - if (!isUuidv4(agentId)) { message = "agentId is not a valid uuid" return NextResponse.json( @@ -57,7 +48,6 @@ export async function POST( } const databasesResponse = await handleDatabases(body, agent, lastContact) - await db .update(drizzleDb.schemas.agent) .set(withUpdatedAt({ diff --git a/app/api/files/[fileName]/route.ts b/app/api/files/[fileName]/route.ts deleted file mode 100644 index f70347e0..00000000 --- a/app/api/files/[fileName]/route.ts +++ /dev/null @@ -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', - }, - }); -} - - diff --git a/app/api/files/route.ts b/app/api/files/route.ts new file mode 100644 index 00000000..d28203c4 --- /dev/null +++ b/app/api/files/route.ts @@ -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', + }, + }); +} + + diff --git a/app/api/images/[fileName]/route.ts b/app/api/images/[fileName]/route.ts index d31d850d..29358e56 100644 --- a/app/api/images/[fileName]/route.ts +++ b/app/api/images/[fileName]/route.ts @@ -8,43 +8,36 @@ import path from "path"; import {db} from "@/db"; import * as drizzleDb from "@/db"; import {eq} from "drizzle-orm"; -import fs from "fs/promises"; - -function nodeStreamToWebStream(nodeStream: stream.Readable) { - return new ReadableStream({ - start(controller) { - nodeStream.on("data", chunk => controller.enqueue(chunk)); - nodeStream.on("end", () => controller.close()); - nodeStream.on("error", err => controller.error(err)); - }, - cancel() { - nodeStream.destroy(); - }, - }); -} - -const privateS3ImageDir = "images/"; - +import {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/dispatch"; +import {Readable} from "node:stream"; export async function GET( req: Request, {params}: { params: Promise<{ fileName: string }> } ) { + + const fileName = (await params).fileName; + + if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400}); const session = await auth.api.getSession({headers: await headers()}); if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403}); - const [settings] = await db - .select() - .from(drizzleDb.schemas.setting) - .where(eq(drizzleDb.schemas.setting.name, "system")) - .limit(1); + const settings = await db.query.setting.findFirst({ + where: eq(drizzleDb.schemas.setting.name, "system"), + with: { + storageChannel: true + } + }); + + if (!settings || !settings.storageChannel) { + return NextResponse.json({error: "Unable to get settings or no default storage channel"}); + } - if (!settings) throw new Error("System settings not found."); - const storageType = settings.storage; // "local" or "s3" const ext = fileName.split(".").pop()?.toLowerCase(); const contentType = ext === "png" @@ -58,44 +51,43 @@ export async function GET( : "application/octet-stream"; try { - if (storageType === "local") { - const filePath = path.join(process.cwd(), "private/uploads/images", fileName); - - try { - await fs.access(filePath); - const file = await fs.readFile(filePath); - - return new NextResponse(file, { - headers: { - "Content-Type": contentType, - "Cache-Control": "no-store", - "Content-Disposition": `inline; filename="${fileName}"`, - }, - }); - } catch { - // if not found locally, fallback to S3 + + const path = `images/${fileName}`; + + const input: StorageInput = { + action: "get", + data: { + path: path, } } - const exists = await checkFileExistsInBucket({ - bucketName: env.S3_BUCKET_NAME!, - fileName: `${privateS3ImageDir}${fileName}`, - }); - if (!exists) return NextResponse.json({error: "File not found"}, {status: 404}); + const result = await dispatchStorage(input, undefined, settings.storageChannel.id); + + if (!result.file || !Buffer.isBuffer(result.file)) { + return NextResponse.json( + {error: "Invalid file payload"}, + {status: 500} + ); + } - const nodeStream = await getObjectFromClient({ - bucketName: env.S3_BUCKET_NAME!, - fileName: `${privateS3ImageDir}${fileName}`, + 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)); + }, }); - const webStream = nodeStreamToWebStream(nodeStream); - return new NextResponse(webStream, { + return new NextResponse(stream, { headers: { - "Content-Type": contentType, + 'Content-Disposition': `inline; filename="${fileName}"`, "Cache-Control": "no-store", - "Content-Disposition": `inline; filename="${fileName}"`, + "Content-Type": contentType, }, }); + } catch (err) { console.error("Error streaming image:", err); return NextResponse.json({error: "Error fetching file"}, {status: 500}); diff --git a/app/providers.tsx b/app/providers.tsx index 8ec7abcd..4c6dbe90 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -6,6 +6,7 @@ import {ThemeProvider} from "@/features/theme/theme-provider"; import {Toaster} from "@/components/ui/sonner"; import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root"; +import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; export type ProviderProps = PropsWithChildren<{}>; @@ -21,8 +22,8 @@ export const Providers = (props: ProviderProps) => { > - - {props.children} + + {props.children} diff --git a/docker-compose.yml b/docker-compose.yml index dac5796d..28637b2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,9 @@ name: portabase-dev services: db: - image: postgres:16-alpine + image: postgres:17-alpine ports: - - "5432:5432" + - "5433:5432" volumes: - postgres-data:/var/lib/postgresql/data environment: diff --git a/package.json b/package.json index bb2e2dac..26ba484e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portabase", - "version": "1.1.13", + "version": "1.2.0-rc.1", "private": true, "scripts": { "dev": "next dev --turbopack -p 8887", @@ -74,6 +74,7 @@ "nodemailer": "^7.0.3", "npm-check-updates": "^18.0.1", "pg": "^8.16.0", + "prettier": "^3.8.0", "react": "^19.2.0", "react-day-picker": "9.7.0", "react-dom": "^19.2.0", @@ -88,6 +89,7 @@ "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "sonner": "^2.0.3", + "swiper": "^12.0.3", "tailwind-merge": "^3.3.0", "uuid": "^11.1.0", "vaul": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8f2fba1..35d2d5b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: pg: specifier: ^8.16.0 version: 8.16.3 + prettier: + specifier: ^3.8.0 + version: 3.8.0 react: specifier: ^19.2.0 version: 19.2.3 @@ -230,6 +233,9 @@ importers: sonner: specifier: ^2.0.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: specifier: ^3.3.0 version: 3.4.0 @@ -5666,8 +5672,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.7.4: - resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + prettier@3.8.0: + resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==} engines: {node: '>=14'} hasBin: true @@ -6230,6 +6236,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swiper@12.0.3: + resolution: {integrity: sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==} + engines: {node: '>= 4.7.0'} + tailwind-merge@3.2.0: 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)': dependencies: html-to-text: 9.0.5 - prettier: 3.7.4 + prettier: 3.8.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) 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)': dependencies: html-to-text: 9.0.5 - prettier: 3.7.4 + prettier: 3.8.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -12791,7 +12801,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.7.4: {} + prettier@3.8.0: {} pretty-bytes@6.1.1: {} @@ -13556,6 +13566,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swiper@12.0.3: {} + tailwind-merge@3.2.0: {} tailwind-merge@3.4.0: {} diff --git a/proxy.ts b/proxy.ts index 55fd06e4..ac68b7eb 100644 --- a/proxy.ts +++ b/proxy.ts @@ -54,7 +54,7 @@ function checkRouteExists(pathname: string) { /^\/api\/agent\/[^/]+\/status\/?$/, /^\/api\/agent\/[^/]+\/backup\/?$/, /^\/api\/agent\/[^/]+\/restore\/?$/, - /^\/api\/files\/[^/]+\/?$/, + /^\/api\/files\/?$/, /^\/api\/images\/[^/]+\/?$/, /^\/api\/events\/?$/, /^\/api\/init\/?$/, diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 974fae3f..d35e9634 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -46,7 +46,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: R ...classNames, }} components={{ + // @ts-ignore IconLeft: ({ className, children: _children, ...props }) => , + // @ts-ignore IconRight: ({ className, children: _children, ...props }) => , }} {...props} diff --git a/src/components/wrappers/auth/auth-logo-section.tsx b/src/components/wrappers/auth/auth-logo-section.tsx index 81d1a0e5..11d3d30c 100644 --- a/src/components/wrappers/auth/auth-logo-section.tsx +++ b/src/components/wrappers/auth/auth-logo-section.tsx @@ -41,7 +41,7 @@ export const AuthLogoSection = () => { /> )} - + v{env.NEXT_PUBLIC_PROJECT_VERSION}
diff --git a/src/components/wrappers/auth/login/login-form/login-form.tsx b/src/components/wrappers/auth/login/login-form/login-form.tsx index cb94fb21..bed6ad47 100644 --- a/src/components/wrappers/auth/login/login-form/login-form.tsx +++ b/src/components/wrappers/auth/login/login-form/login-form.tsx @@ -3,7 +3,6 @@ import {useEffect, useState} from "react"; import {useMutation} from "@tanstack/react-query"; import {toast} from "sonner"; - import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {Input} from "@/components/ui/input"; import {Form} from "@/components/ui/form"; diff --git a/src/components/wrappers/auth/register/register-form/register-form.tsx b/src/components/wrappers/auth/register/register-form/register-form.tsx index 197c2998..4ef63a63 100644 --- a/src/components/wrappers/auth/register/register-form/register-form.tsx +++ b/src/components/wrappers/auth/register/register-form/register-form.tsx @@ -1,21 +1,25 @@ "use client"; import {useRouter} from "next/navigation"; -import {Info} from "lucide-react"; import {useMutation} from "@tanstack/react-query"; import {toast} from "sonner"; - -import {Card, CardContent, CardHeader} from "@/components/ui/card"; -import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; -import {Input} from "@/components/ui/input"; +import {CardContent, CardHeader} from "@/components/ui/card"; import {Form} from "@/components/ui/form"; -import {Button} from "@/components/ui/button"; import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip"; import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema"; -import {PasswordInput} from "@/components/ui/password-input"; import {signUp} from "@/lib/auth/auth-client"; -import Link from "next/link"; +import {useZodForm} from "@/components/ui/form"; import {CardAuth} from "@/features/layout/card-auth"; +import {FormField} from "@/components/ui/form"; +import {FormItem} from "@/components/ui/form"; +import {FormLabel} from "@/components/ui/form"; +import {FormControl} from "@/components/ui/form"; +import {FormMessage} from "@/components/ui/form"; +import {Input} from "@/components/ui/input"; +import {PasswordInput} from "@/components/ui/password-input"; +import {Button} from "@/components/ui/button"; +import Link from "next/link"; +import {Info} from "lucide-react"; export type registerFormProps = { defaultValues?: RegisterType; @@ -26,9 +30,11 @@ export const RegisterForm = (props: registerFormProps) => { const form = useZodForm({ schema: RegisterSchema, }); + const router = useRouter(); const mutation = useMutation({ mutationFn: async (values: RegisterType) => { + // @ts-ignore await signUp.email(values, { onSuccess: () => { toast.success(`Account successfully created`); diff --git a/src/components/wrappers/common/button/button-with-confirm.tsx b/src/components/wrappers/common/button/button-with-confirm.tsx index aa0a3d40..e4472e09 100644 --- a/src/components/wrappers/common/button/button-with-confirm.tsx +++ b/src/components/wrappers/common/button/button-with-confirm.tsx @@ -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 ( -// -// ); -// }; "use client" import {Button, ButtonVariantsProps} from "@/components/ui/button"; import {useState} from "react"; @@ -57,6 +12,7 @@ export type ButtonWithConfirmProps = { button: { main: { className?: string; + type?: "button" | "submit" | "reset" | undefined; text?: string; icon?: any; variant?: ButtonVariantsProps["variant"]; @@ -100,6 +56,7 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => { )} role="button"> diff --git a/src/components/wrappers/common/button/button-with-loading.tsx b/src/components/wrappers/common/button/button-with-loading.tsx index 0ea18290..65fa244e 100644 --- a/src/components/wrappers/common/button/button-with-loading.tsx +++ b/src/components/wrappers/common/button/button-with-loading.tsx @@ -109,7 +109,6 @@ export const ButtonWithLoading = ({ {isPending && } {children && children} <>{icon ? icon : null} - {/*{icon && {icon}}*/} ); }; diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx new file mode 100644 index 00000000..723fba26 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx @@ -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 ( + { + onOpenChangeAction?.(state); + setOpen(state); + }}> + {trigger && ( + + {isCreate ? + + : + + } + + )} + e.preventDefault()}> + + {isCreate ? "Add" : "Edit"} {channelText} Channel + + Configure your {channelText.toLowerCase()} channel preferences. + + +
+ <> + {!isLocalSystem ? ( + <> + {adminView ? + + + Configuration + Organizations + + + { + onOpenChangeAction?.(false) + setOpen(false); + }} + /> + + + + + + : + <> + { + onOpenChangeAction?.(false) + setOpen(false); + }} + /> + + } + + ) + + : + + <> + + + } + + + +
+
+
+ ) +} diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-card/button-delete-channel.tsx similarity index 57% rename from src/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-card/button-delete-channel.tsx index 089f8896..a8f8c29d 100644 --- a/src/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-card/button-delete-channel.tsx @@ -5,20 +5,31 @@ import {useRouter} from "next/navigation"; import {Trash2} from "lucide-react"; import { removeNotificationChannelAction, -} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action"; +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action"; import {toast} from "sonner"; +import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; +import { + removeStorageChannelAction +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action"; -export type DeleteNotifierButtonProps = { - notificationChannelId: string; +export type DeleteChannelButtonProps = { + channelId: string; + kind: ChannelKind; organizationId?: string; }; -export const DeleteNotifierButton = ({notificationChannelId, organizationId}: DeleteNotifierButtonProps) => { +export const DeleteChannelButton = ({channelId, organizationId, kind}: DeleteChannelButtonProps) => { const router = useRouter(); + const channelText = getChannelTextBasedOnKind(kind) + const mutation = useMutation({ mutationFn: async () => { - const result = await removeNotificationChannelAction({organizationId, notificationChannelId}) + + const result = kind === "notification" ? await removeNotificationChannelAction({ + organizationId, + notificationChannelId: channelId + }) : await removeStorageChannelAction({organizationId, id: channelId}) const inner = result?.data; if (inner?.success) { @@ -32,8 +43,8 @@ export const DeleteNotifierButton = ({notificationChannelId, organizationId}: De return ( { + 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, + 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 && ( + { + await mutation.mutateAsync(!channel.enabled) + }}/> + )} + + + ); +}; diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card.tsx new file mode 100644 index 00000000..7c6e1842 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card.tsx @@ -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 ( +
+ +
+
+ {getChannelIcon(data.provider)} +
+
+
+
+
+

{isMobile ? truncateWords(data.name, 2) : data.name}

+ + {data.provider} + + {isDefaultSystemStorage && ( + + default + + )} +
+
+ {kind && ( +
+ {(isOwned) && ( + <> + + {!isLocalSystem && ( + + )} + + )} +
+ )} + +
+ ); +}; + + diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema.ts b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema.ts new file mode 100644 index 00000000..b88d9c74 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema.ts @@ -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; +export type StorageChannelFormType = z.infer; diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx similarity index 72% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx index 3db99d1a..6c6dc907 100644 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx @@ -5,61 +5,50 @@ import {useMutation} from "@tanstack/react-query"; import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {Input} from "@/components/ui/input"; + import { - NotificationChannelFormSchema, NotificationChannelFormType -} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema"; -import { - addNotificationChannelAction, updateNotificationChannelAction -} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action"; + addStorageChannelAction, updateStorageChannelAction +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action"; import {toast} from "sonner"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; -import { - 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 {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 {notificationProviders} from "@/components/wrappers/dashboard/admin/notifications/helpers"; import {cn} from "@/lib/utils"; import {Card} from "@/components/ui/card"; import {ArrowLeft} from "lucide-react"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; +import { + NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema"; +import { + ChannelKind, + renderChannelForm +} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; +import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage"; +import {notificationProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/notification"; +import { + ChannelTestButton +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button"; +import { + addNotificationChannelAction, updateNotificationChannelAction +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action"; type NotifierFormProps = { onSuccessAction?: () => void; organization?: OrganizationWithMembers; - defaultValues?: NotificationChannelWith + defaultValues?: NotificationChannelWith | StorageChannelWith adminView?: boolean + kind: ChannelKind }; -export const NotifierForm = ({onSuccessAction, organization, defaultValues}: NotifierFormProps) => { +export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}: NotifierFormProps) => { const router = useRouter(); const isCreate = !Boolean(defaultValues); - const form = useZodForm({ - schema: NotificationChannelFormSchema, + schema: kind == "notification" ? NotificationChannelFormSchema : StorageChannelFormSchema, // @ts-ignore defaultValues: {...defaultValues}, }); @@ -69,16 +58,28 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not }, [defaultValues]); const mutationAddNotificationChannel = useMutation({ - mutationFn: async (values: NotificationChannelFormType) => { + mutationFn: async (values: NotificationChannelFormType | StorageChannelFormType) => { const payload = { data: values, ...(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; if (inner?.success) { @@ -93,12 +94,13 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not }); const provider = form.watch("provider"); - const selectedProviderDetails = notificationProviders.find(t => t.value === provider); + const channelTypes = kind == "notification" ? notificationProviders : storageProviders + const selectedProviderDetails = channelTypes.find(t => t.value === provider); if (isCreate && !provider) { return (
- {notificationProviders.map((type) => { + {channelTypes.filter(p => p.value != "local").map((type) => { const Icon = type.icon; return ( - {provider === "smtp" && ( - - )} - - {provider === "slack" && ( - - )} - - {provider === "discord" && ( - - )} - - {provider === "telegram" && ( - - )} - - {provider === "gotify" && ( - - )} - - {provider === "ntfy" && ( - - )} - - {provider === "webhook" && ( - - )} - + {renderChannelForm(provider, form)}
{defaultValues && ( - + )}
diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button.tsx new file mode 100644 index 00000000..e5a0f212 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action.ts b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action.ts similarity index 94% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action.ts rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action.ts index 5f773f4f..3914a8da 100644 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action.ts +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action.ts @@ -6,11 +6,11 @@ import * as drizzleDb from "@/db"; import {userAction} from "@/lib/safe-actions/actions"; import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {db} from "@/db"; -import { - NotificationChannelFormSchema -} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema"; import {and, eq} from "drizzle-orm"; import {withUpdatedAt} from "@/db/utils"; +import { + NotificationChannelFormSchema +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema"; export const addNotificationChannelAction = userAction.schema( @@ -20,7 +20,6 @@ export const addNotificationChannelAction = userAction.schema( }) ).action(async ({parsedInput}): Promise> => { const {organizationId, data} = parsedInput; - try { const [channel] = await db .insert(drizzleDb.schemas.notificationChannel) @@ -29,6 +28,7 @@ export const addNotificationChannelAction = userAction.schema( name: data.name, config: data.config, enabled: data.enabled ?? true, + organizationId: organizationId ?? null, }) .returning(); @@ -128,11 +128,11 @@ export const removeNotificationChannelAction = userAction.schema( export const updateNotificationChannelAction = userAction.schema( z.object({ - notificationChannelId: z.string(), + id: z.string(), data: NotificationChannelFormSchema }) ).action(async ({parsedInput}): Promise> => { - const {notificationChannelId, data} = parsedInput; + const {id, data} = parsedInput; try { const [channel] = await db @@ -143,7 +143,7 @@ export const updateNotificationChannelAction = userAction.schema( config: data.config, enabled: data.enabled ?? true, })) - .where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId)) + .where(eq(drizzleDb.schemas.notificationChannel.id, id)) .returning(); return { @@ -154,7 +154,7 @@ export const updateNotificationChannelAction = userAction.schema( }, actionSuccess: { message: `Notification channel "${channel.name}" has been successfully updated.`, - messageParams: {notificationChannelId: channel.id}, + messageParams: {id: channel.id}, }, }; } catch (error) { @@ -165,7 +165,7 @@ export const updateNotificationChannelAction = userAction.schema( message: "Failed to update notification channel.", status: 500, cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {notificationChannelId: ""}, + messageParams: {id: ""}, }, }; } diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-discord.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/discord.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-discord.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/discord.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-gotify.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/gotify.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-gotify.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/gotify.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-ntfy.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-ntfy.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/slack.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/slack.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/smtp.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/smtp.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-telegram.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/telegram.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-telegram.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/telegram.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-webhook.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-webhook.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form.tsx diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action.ts b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action.ts new file mode 100644 index 00000000..ed404de6 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action.ts @@ -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> => { + 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> => { + 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> => { + 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: ""}, + }, + }; + } +}); diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx new file mode 100644 index 00000000..9efef758 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx @@ -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 +} + +export const StorageS3Form = ({form}: StorageS3FormProps) => { + return ( + <> + + ( + + Endpoind URL + + + + + + )} + /> + ( + + Access Key + + + + + + )} + /> + ( + + Secret Key + + + + + + )} + /> + ( + + Bucket name + + + + + + )} + /> + ( + + Port + + + + + + )} + /> + + ( + + Use SSL + + + + + + )} + /> + + + ) +} diff --git a/src/components/wrappers/dashboard/admin/channels/channels-section.tsx b/src/components/wrappers/dashboard/admin/channels/channels-section.tsx new file mode 100644 index 00000000..4e632d7e --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channels-section.tsx @@ -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 ( +
+ + {hasChannels ? ( +
+ +
+ ) : ( + { + setIsAddModalOpen(true) + }} + className="h-full" + /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx b/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx new file mode 100644 index 00000000..47bd0285 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx @@ -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 & RefAttributes> + preview?: boolean +} | { + value: string + label: string + icon: (props: SVGProps) => 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 ? : null +} + + +export const renderChannelForm = (provider: string | undefined, form: UseFormReturn) => { + switch (provider) { + case "smtp": + return ; + case "slack": + return ; + case "discord": + return ; + case "telegram": + return ; + case "gotify": + return ; + case "ntfy": + return ; + case "webhook": + return ; + case "s3": + return + case "local": + return <> + default: + return null; + } +}; \ No newline at end of file diff --git a/src/components/wrappers/dashboard/admin/notifications/helpers.tsx b/src/components/wrappers/dashboard/admin/channels/helpers/notification.tsx similarity index 84% rename from src/components/wrappers/dashboard/admin/notifications/helpers.tsx rename to src/components/wrappers/dashboard/admin/channels/helpers/notification.tsx index 71c1e6a2..725e4a87 100644 --- a/src/components/wrappers/dashboard/admin/notifications/helpers.tsx +++ b/src/components/wrappers/dashboard/admin/channels/helpers/notification.tsx @@ -1,19 +1,11 @@ import {Mail} from "lucide-react"; -import type {SVGProps} from 'react'; +import type { SVGProps } from 'react'; +import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; + -export const getNotificationChannelIcon = (type: string) => { - const Icon = notificationProviders.find((t) => t.value === type)?.icon - return Icon ? : null -} -type NotificationProps = { - value: string - label: string - icon: any - preview?: boolean -} -export const notificationProviders: Array = [ +export const notificationProviders: ProviderIconTypes[] = [ {value: "smtp", label: "Email", icon: Mail}, {value: "slack", label: "Slack", icon: SlackIcon}, {value: "discord", label: "Discord", icon: DiscordIcon}, @@ -26,43 +18,21 @@ export const notificationProviders: Array = [ export function SlackIcon(props: SVGProps) { - return ( - - - - - ); + return (); } export function DiscordIcon(props: SVGProps) { - return ( - - ); + return (); } export function TelegramIcon(props: SVGProps) { - return ( - - - - - - - - - ); + return (); } + export function NtfyIcon(props: SVGProps) { - return () { 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" /> - + ) { 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" /> - + ) { ) } + export function MSTeamsIcon(props: SVGProps) { return ( diff --git a/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx b/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx new file mode 100644 index 00000000..91951fa0 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx @@ -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) { + return ( + + + + ); +} + diff --git a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form.tsx b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization-form.tsx similarity index 66% rename from src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form.tsx rename to src/components/wrappers/dashboard/admin/channels/organization/channels-organization-form.tsx index e413171a..3f7a5b80 100644 --- a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form.tsx +++ b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization-form.tsx @@ -6,26 +6,30 @@ import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/u import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; -import { - NotificationChannelsOrganizationSchema, - NotificationChannelsOrganizationType -} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema"; import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select"; -import { - updateNotificationChannelsOrganizationAction -} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action"; + import {toast} from "sonner"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; +import { + ChannelsOrganizationSchema, ChannelsOrganizationType +} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema"; +import { + updateNotificationChannelsOrganizationAction, updateStorageChannelsOrganizationAction +} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization.action"; +import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; -type NotifierChannelOrganisationFormProps = { +type ChannelOrganisationFormProps = { organizations?: OrganizationWithMembers[]; - defaultValues?: NotificationChannelWith + defaultValues?: NotificationChannelWith | StorageChannelWith + kind: ChannelKind }; -export const NotifierChannelOrganisationForm = ({ - organizations, - defaultValues, - }: NotifierChannelOrganisationFormProps) => { +export const ChannelOrganisationForm = ({ + organizations, + defaultValues, + kind + }: ChannelOrganisationFormProps) => { const router = useRouter(); @@ -33,7 +37,7 @@ export const NotifierChannelOrganisationForm = ({ const form = useZodForm({ - schema: NotificationChannelsOrganizationSchema, + schema: ChannelsOrganizationSchema, // @ts-ignore defaultValues: { organizations: defaultOrganizationIds @@ -49,15 +53,15 @@ export const NotifierChannelOrganisationForm = ({ }; - const mutationUpdateNotificationChannelOrganizations = useMutation({ - mutationFn: async (values: NotificationChannelsOrganizationType) => { + const mutationUpdateChannelOrganizations = useMutation({ + mutationFn: async (values: ChannelsOrganizationType) => { const payload = { data: values.organizations, - notificationChannelId: defaultValues?.id ?? "" + id: defaultValues?.id ?? "" }; - const result = await updateNotificationChannelsOrganizationAction(payload) + const result = kind === "notification" ? await updateNotificationChannelsOrganizationAction(payload) : await updateStorageChannelsOrganizationAction(payload) const inner = result?.data; if (inner?.success) { @@ -76,7 +80,7 @@ export const NotifierChannelOrganisationForm = ({ form={form} className="flex flex-col gap-4" onSubmit={async (values) => { - await mutationUpdateNotificationChannelOrganizations.mutateAsync(values); + await mutationUpdateChannelOrganizations.mutateAsync(values); }} >
- + Save
diff --git a/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.action.ts b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.action.ts new file mode 100644 index 00000000..c8481e93 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.action.ts @@ -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> => { + 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> => { + 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"}, + }, + }; + } + }); diff --git a/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema.ts b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema.ts new file mode 100644 index 00000000..3146c5d7 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema.ts @@ -0,0 +1,7 @@ +import {z} from "zod"; + +export const ChannelsOrganizationSchema = z.object({ + organizations: z.array(z.string()) +}); + +export type ChannelsOrganizationType = z.infer; diff --git a/src/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section.tsx b/src/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section.tsx deleted file mode 100644 index f3d6208c..00000000 --- a/src/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section.tsx +++ /dev/null @@ -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 ( -
- - {hasNotifiers ? ( -
- - -
- ) : ( - { - setIsAddModalOpen(true) - }} - className="h-full" - /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action.ts b/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action.ts deleted file mode 100644 index 395c685e..00000000 --- a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action.ts +++ /dev/null @@ -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> => { - 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"}, - }, - }; - } - }); diff --git a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema.ts b/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema.ts deleted file mode 100644 index efc13c62..00000000 --- a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {z} from "zod"; - -export const NotificationChannelsOrganizationSchema = z.object({ - organizations: z.array(z.string()) -}); - -export type NotificationChannelsOrganizationType = z.infer; diff --git a/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx b/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx index 3ae7de36..246563cc 100644 --- a/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx +++ b/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx @@ -2,11 +2,11 @@ import {ColumnDef} from "@tanstack/react-table"; import {NotificationLogWithRelations} from "@/db/services/notification-log"; -import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers"; import {humanReadableDate} from "@/utils/date-formatting"; import {CheckCircle2, XCircle} from "lucide-react"; import {Badge} from "@/components/ui/badge"; import {NotificationLogModal} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-log-modal"; +import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; export function notificationLogsColumns(): ColumnDef[] { @@ -33,7 +33,7 @@ export function notificationLogsColumns(): ColumnDef
- {getNotificationChannelIcon(channel?.provider ?? "")} + {getChannelIcon(channel?.provider ?? "")}
{channel?.name}
@@ -89,7 +89,7 @@ export function notificationLogsColumns(): ColumnDef { +export const getStatusIcon = (status: boolean) => { switch (status) { case true: return @@ -98,10 +98,12 @@ const getStatusIcon = (status: boolean) => { } } -const getStatusColor = (status: string) => { +export const getStatusColor = (status: string) => { switch (status) { case "delivered": return "bg-green-100 dark:bg-green-100/10" + case "success": + return "bg-green-100 dark:bg-green-100/10" case "failed": return "bg-red-100 dark:bg-red-100/10" case "pending": diff --git a/src/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role.tsx b/src/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role.tsx index f0577a8b..0aefb042 100644 --- a/src/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role.tsx +++ b/src/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role.tsx @@ -13,16 +13,14 @@ import { DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import {authClient} from "@/lib/auth/auth-client"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {MemberRoleType} from "@/types/common"; import {MemberWithUser} from "@/db/schema/03_organization"; -import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/update-member.action"; -import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema"; import { updateMemberRoleAdminAction } from "@/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action"; +import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema"; type OrganizationMemberChangeRoleModalProps = { open: boolean; diff --git a/src/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action.ts b/src/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action.ts index e7e7a24f..d20f59a1 100644 --- a/src/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action.ts +++ b/src/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action.ts @@ -3,11 +3,11 @@ import {userAction} from "@/lib/safe-actions/actions"; import {z} from "zod"; import {ServerActionResult} from "@/types/action-type"; import {Member} from "better-auth/plugins"; -import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema"; import {db as dbClient} from "@/db"; import * as drizzleDb from "@/db"; import {and, eq} from "drizzle-orm"; import {withUpdatedAt} from "@/db/utils"; +import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema"; export const updateMemberRoleAdminAction = userAction.schema( diff --git a/src/components/wrappers/dashboard/admin/settings/keys/admin-settings-section.tsx b/src/components/wrappers/dashboard/admin/settings/keys/admin-settings-section.tsx deleted file mode 100644 index fe33679e..00000000 --- a/src/components/wrappers/dashboard/admin/settings/keys/admin-settings-section.tsx +++ /dev/null @@ -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 ( -
- - - Instance settings - Manage portabase settings - - -
-
-

Download Public Key

-

- Used for encrypting communications with this instance. -

-
- -
-
-
-
- ); -}; diff --git a/src/components/wrappers/dashboard/admin/settings/settings-tabs.tsx b/src/components/wrappers/dashboard/admin/settings/settings-tabs.tsx new file mode 100644 index 00000000..f5edc2f8 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/settings/settings-tabs.tsx @@ -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(() => searchParams.get("tab") ?? "email"); + + + useEffect(() => { + const newTab = searchParams.get("tab") ?? "email"; + setTab(newTab); + }, [searchParams]); + + const handleChangeTab = (value: string) => { + router.push(`?tab=${value}`); + }; + + + return ( +
+ + + + Email + + + Default storage + + + + + + + + + +
+ + + ); +}; diff --git a/src/components/wrappers/dashboard/admin/settings/storage/settings-storage-section.tsx b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage-section.tsx index 5ff2ce97..c3bb8a47 100644 --- a/src/components/wrappers/dashboard/admin/settings/storage/settings-storage-section.tsx +++ b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage-section.tsx @@ -1,66 +1,52 @@ "use client" import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; -import {Info, ShieldCheck} 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 {Info} from "lucide-react"; 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 {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 { updateStorageSettingsAction -} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.action"; -import {Setting} from "@/db/schema/01_setting"; -import {S3FormType} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema"; +} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action"; +import {toast} from "sonner"; export type SettingsStorageSectionProps = { settings: Setting; + storageChannels: StorageChannelWith[]; }; -export const SettingsStorageSection = (props: SettingsStorageSectionProps) => { +export const SettingsStorageSection = ({settings, storageChannels}: SettingsStorageSectionProps) => { const router = useRouter(); - const mutation = useMutation({ - mutationFn: async () => { - const result = await checkConnexionToS3(); - if (result.error) { - toast.error("An error occured during the connexion !"); - } else { - toast.success("Connexion succeed!"); - } - }, + const form = useZodForm({ + schema: DefaultStorageSchema, + defaultValues: { + storageChannelId: settings.defaultStorageChannelId ?? undefined + } }); - const [isSwitched, setIsSwitched] = useState(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 mutation = useMutation({ + mutationFn: async (values: DefaultStorageType) => { + const result = await updateStorageSettingsAction({name: "system", data: values}) + const inner = result?.data; - const extractS3FormValues = (settings: Setting): S3FormType | undefined => { - if (!settings.s3EndPointUrl) return undefined; - return { - s3EndPointUrl: settings.s3EndPointUrl, - s3AccessKeyId: settings.s3AccessKeyId!, - s3SecretAccessKey: settings.s3SecretAccessKey!, - S3BucketName: settings.S3BucketName!, - }; - }; + if (inner?.success) { + toast.success(inner.actionSuccess?.message); + router.refresh(); + } else { + toast.error(inner?.actionError?.message); + } + } + }); return (
@@ -68,39 +54,49 @@ export const SettingsStorageSection = (props: SettingsStorageSectionProps) => { Informations - Actually you can only store you data in one place : s3 compatible or in local. For exemple you - cannot choose to store images in one place - and backups files in another. - + The default storage channel will be used by default to store your backups if no storage policy is + configured at the database level.
-
-
- - { - await HandleSwitchStorage(); - }} - id="storage-mode" +
{ + await mutation.mutateAsync(values); + }} + > +
+ ( + + + + )} /> + + Confirm +
-
- { - await mutation.mutateAsync(); - }} - icon={}>Test connexion -
-
- {isSwitched && ( -
- -
- )} +
); diff --git a/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.action.ts b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.action.ts new file mode 100644 index 00000000..cd7ee875 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.action.ts @@ -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> => { + 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", + }, + }; + } + }); + + diff --git a/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema.ts b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema.ts new file mode 100644 index 00000000..6b0fec3d --- /dev/null +++ b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const DefaultStorageSchema = z.object({ + storageChannelId: z.string(), +}); + +export type DefaultStorageType = z.infer; diff --git a/src/components/wrappers/dashboard/admin/users2/accounts/table-columns.tsx b/src/components/wrappers/dashboard/admin/users2/accounts/table-columns.tsx deleted file mode 100644 index 774b7a37..00000000 --- a/src/components/wrappers/dashboard/admin/users2/accounts/table-columns.tsx +++ /dev/null @@ -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[] = [ - { - id: "provider", - header: "Provider", - cell: ({row}) => { - return ( -
- {providerSwitch(row.original.providerId)} -
- - ) - - }, - }, - { - 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 ( -
- } - onClick={async () => { - await mutation.mutateAsync(); - }} - size="icon" - /> -
- ); - }, - }, -]; diff --git a/src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx b/src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx deleted file mode 100644 index 8bfd93e7..00000000 --- a/src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx +++ /dev/null @@ -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 ( -
- - - Active users - Manage your users - - - - - -
- ); -}; diff --git a/src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx b/src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx deleted file mode 100644 index 80886fa4..00000000 --- a/src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx +++ /dev/null @@ -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 ( -
- - , - }, - confirm: { - className: "w-full", - text: "Delete", - icon: , - variant: "destructive", - onClick: () => { - mutation.mutate() - }, - }, - cancel: { - className: "w-full", - text: "Cancel", - icon: , - variant: "outline", - }, - }} - isPending={mutation.isPending} - /> -
- ); -}; diff --git a/src/components/wrappers/dashboard/admin/users2/columns-users.tsx b/src/components/wrappers/dashboard/admin/users2/columns-users.tsx deleted file mode 100644 index 669e6fa5..00000000 --- a/src/components/wrappers/dashboard/admin/users2/columns-users.tsx +++ /dev/null @@ -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[] = [ - { - accessorKey: "role", - header: "Role", - cell: ({row}) => { - const [role, setRole] = useState(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 ( - handleUpdateRole()} - variant="outline" - > - {role} - - ); - }, - }, - { - accessorKey: "name", - header: "Name", - }, - { - accessorKey: "email", - header: "Email", - }, - { - accessorKey: "accounts", - header: "Provider(s)", - cell: ({row}) => { - return ( -
- {row.original.accounts.map((item) => ( -
- {providerSwitch(item.providerId, true)} -
- ))} -
- ) - } - }, - { - 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 ( - - ); - }, - }, -]; diff --git a/src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx b/src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx deleted file mode 100644 index 8c6e4760..00000000 --- a/src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx +++ /dev/null @@ -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[] = [ - { - 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 ( -
- {os.icon && - } - {os.showText && {os.name}} -
- ); - }, - }, - { - 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 ( -
- } - onClick={async () => { - await mutation.mutateAsync(); - }} - size="icon" - /> -
- ); - }, - }, -]; diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx deleted file mode 100644 index d0033ad3..00000000 --- a/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx +++ /dev/null @@ -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 ( - { - onOpenChangeAction?.(state); - setOpen(state); - }}> - {trigger && ( - - {isCreate ? - - : - - } - - )} - - e.preventDefault()}> - - {isCreate ? "Add" : "Edit"} Notification Channel - - Configure your notification channel preferences and event triggers. - - - - -
- {adminView ? - - - - Configuration - Organizations - - - { - onOpenChangeAction?.(false) - setOpen(false); - }} - /> - - - - - - - - - : - { - onOpenChangeAction?.(false) - setOpen(false); - }} - /> - } - -
- - -
-
- ) -} diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier.tsx deleted file mode 100644 index b8bff033..00000000 --- a/src/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier.tsx +++ /dev/null @@ -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, - 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 ( - <> - { - await mutation.mutateAsync(!notificationChannel.enabled) - }} - /> - - - ); -}; diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card.tsx deleted file mode 100644 index 21f75561..00000000 --- a/src/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card.tsx +++ /dev/null @@ -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 ( -
- -
-
- {getNotificationChannelIcon(data.provider)} -
-
-
- -
-
-

{isMobile ? truncateWords(data.name, 2) : data.name}

- - {data.provider} - -
-
- - -
- - -
- -
- ); -}; - - diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts deleted file mode 100644 index d5988ca1..00000000 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts +++ /dev/null @@ -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; diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx deleted file mode 100644 index 0070eb0f..00000000 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx +++ /dev/null @@ -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 ( - - ) -} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx index 6e21a381..3492071e 100644 --- a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx +++ b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx @@ -7,7 +7,7 @@ import { Layers, ChartArea, ShieldHalf, - Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks + Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse } from "lucide-react"; import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar"; import {authClient, useSession} from "@/lib/auth/auth-client"; @@ -22,7 +22,6 @@ export const SidebarMenuCustomMain = () => { const {data: session, isPending, error} = authClient.useSession(); const member = authClient.useActiveMember(); - if (isPending) return null; if (error || !session) { @@ -39,9 +38,6 @@ export const SidebarMenuCustomMain = () => { {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[] = [ { @@ -80,6 +76,16 @@ export const SidebarMenuCustomMain = () => { { 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", url: "/admin", @@ -88,19 +94,14 @@ export const SidebarMenuCustomMain = () => { type: "collapse", submenu: [ {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", url: "/admin/settings", icon: Settings, - details: true, - type: "collapse", - submenu: [ - {title: "Email", url: "/admin/settings/email", icon: Mail, type: "item"}, - {title: "Storage", url: "/admin/settings/storage", icon: PackageOpen, type: "item"}, - ], + type: "item", }, ], }); diff --git a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx index 77eab973..ebbf35a9 100644 --- a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx +++ b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx @@ -14,7 +14,7 @@ import { } from "@/components/ui/sidebar"; import { ChevronRight, ChevronDown, MoreHorizontal } from "lucide-react"; 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 { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; @@ -46,43 +46,18 @@ type SidebarMenuCustomBaseProps = { export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseProps) => { 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 normalizedPathname = normalize(pathname); - for (const group of items) { - for (const item of group.group_content) { - 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 isItemActive = (item: SidebarItem) => { + const fullUrl = item.not_from_base_url ? normalize(item.url) : normalize(`${baseUrl}${item.url}`); + if (item.details) return normalize(pathname).startsWith(fullUrl); + return normalize(pathname) === fullUrl; + }; const isSubActive = (item: SidebarItem) => { if (!item.submenu) return false; - return item.submenu.some((sub) => sub.url === activeItem); + return item.submenu.some((sub) => isItemActive(sub)); }; return ( @@ -91,7 +66,7 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP - + {group.label} {group.type === "collapse" && ( @@ -118,14 +93,10 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP {item.submenu.map((sub, subIdx) => ( - + setActiveItem(sub.url)} + href={sub.not_from_base_url ? sub.url : `${baseUrl}${sub.url}`} + className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-full justify-start")} > {sub.title} @@ -142,11 +113,10 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP return ( - + setActiveItem(item.url)} className={cn(buttonVariants({ variant: "ghost", size: "lg" }), "justify-start")} > @@ -163,11 +133,7 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP {item.dropdown.map((dropdown, dIdx) => ( {dropdown.title} diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy.action.ts b/src/components/wrappers/dashboard/database/alert-policy/alert-policy.action.ts deleted file mode 100644 index 4f031805..00000000 --- a/src/components/wrappers/dashboard/database/alert-policy/alert-policy.action.ts +++ /dev/null @@ -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> => { - 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> => { - 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> => { - 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", - }, - }; - } - }); \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts b/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts deleted file mode 100644 index 73a4b265..00000000 --- a/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts +++ /dev/null @@ -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; -export type AlertPolicyType = z.infer; - - -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"}, -]; \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx new file mode 100644 index 00000000..db3d42ac --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx @@ -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 ( +
+ + + + + + Actions + + {backup.status == "success" ? ( + <> + + openModal("restore", backup)} + > + Restore + + + openModal("download", backup)} + > + Download + + + ) : null} + + + openModal("delete", backup)} className="text-red-600"> + Delete + + + +
+ + ); +} + diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx new file mode 100644 index 00000000..3f15c23f --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx @@ -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, 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 ( + + + +
{ + await mutation.mutateAsync(values); + }} + > + + + {filteredBackupStorages.length > 0 ? + + + ( + + Choose a storage backup + + +
+ + {filteredBackupStorages.map((storage: BackupStorageWith) => ( + + + + )) ??

No storages available

} +
+
+
+ +
+ )} + /> + + : + + + Backup does not have files + +

You can safely delete the entire backup; no files seem to be related. Maybe an error + occurred.

+
+
+ } + +
+ {action === "delete" && ( + // mutationDeleteEntireBackup.mutateAsync()} + // isPending={mutationDeleteEntireBackup.isPending} + // disabled={mutationDeleteEntireBackup.isPending} + // > + // Delete entire backup + // + + + , + variant: "destructive", + onClick: async () => { + mutationDeleteEntireBackup.mutateAsync() + }, + }, + cancel: { + className: "w-full", + text: "Cancel", + icon: , + variant: "outline", + }, + }} + isPending={mutationDeleteEntireBackup.isPending} + /> + + )} + + {filteredBackupStorages.length > 0 && ( + + Confirm + + + )} +
+ +
+ ); +} diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx new file mode 100644 index 00000000..4ab099ce --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx @@ -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 ( + + + + {text} backup ? + + Select the backup storage + + + + + + + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts new file mode 100644 index 00000000..51813d32 --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts @@ -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> => { + 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> => { + 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> => { + 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`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> => { + 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"}, + }, + }; + } + }); \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions.schema.ts b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.schema.ts new file mode 100644 index 00000000..2dc473ce --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.schema.ts @@ -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; diff --git a/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx b/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx new file mode 100644 index 00000000..cdfa34ea --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx @@ -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(undefined); + +export const BackupModalProvider = ({children}: { children: ReactNode }) => { + const [open, setOpen] = useState(false); + const router = useRouter(); + const [action, setAction] = useState(null); + const [backup, setBackup] = useState(null); + + const openModal = (newAction: DatabaseActionKind, newBackup: BackupWith) => { + setAction(newAction); + setBackup(newBackup); + setOpen(true); + }; + + const closeModal = () => { + setOpen(false); + setAction(null); + setBackup(null); + router.refresh() + }; + + return ( + + {children} + + ); +}; + +export const useBackupModal = () => { + const context = useContext(BackupModalContext); + if (!context) throw new Error("useBackupModal must be used within BackupModalProvider"); + return context; +}; diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx b/src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx similarity index 52% rename from src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx rename to src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx index 573e7b1d..73940aa5 100644 --- a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx +++ b/src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx @@ -1,12 +1,7 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {InfoIcon, Plus, Trash2} from "lucide-react"; 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 {AlertPolicy} from "@/db/schema/10_alert-policy"; import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {Label} from "@/components/ui/label"; 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 {useMutation} from "@tanstack/react-query"; import {toast} from "sonner"; -import { - createAlertPoliciesAction, deleteAlertPoliciesAction, - updateAlertPoliciesAction -} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.action"; import {useRouter} from "next/navigation"; import {Switch} from "@/components/ui/switch"; -import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers"; import {Card} from "@/components/ui/card"; import Link from "next/link"; 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; - notificationChannels: NotificationChannel[]; + channels: NotificationChannel[] | StorageChannel[]; organizationId: string; 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[]) => { - return alertPolicies.filter((alertPolicy) => organizationNotificationChannels.includes(alertPolicy.notificationChannelId)).map((alertPolicy) => ({ - notificationChannelId: alertPolicy.notificationChannelId, - eventKinds: alertPolicy.eventKinds, - enabled: alertPolicy.enabled - })); - }; + const organizationChannels = channels.map(c => c.id); - const form = useZodForm({ - schema: AlertPoliciesSchema, - defaultValues: { - alertPolicies: database.alertPolicies && database.alertPolicies.length > 0 ? formattedAlertPoliciesList(database.alertPolicies) : [], - }, - }); + const filterByChannel = ( + items: T[] | undefined | null, + channelKey: K + ): T[] => items?.filter(item => organizationChannels.includes(item[channelKey] as string)) ?? []; - const {fields, append, remove} = useFieldArray({ - control: form.control, - name: "alertPolicies", - }) + 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 addAlertPolicy = () => { - append({notificationChannelId: "", eventKinds: [], enabled: true}); - } + const defaultPolicies: PolicyType[] = + kind === "notification" + ? formattedAlertPolicies + : formattedStoragePolicies.map(({ channelId, enabled }) => ({ channelId, enabled })); - const removeAlertPolicy = (index: number) => { - remove(index) - } + const form = useZodForm({ + schema: PoliciesSchema, + defaultValues: { policies: defaultPolicies }, + context: { kind } + }); - const onCancel = () => { - form.reset() - onSuccess?.() - } + const {fields, append, remove} = useFieldArray({ control: form.control, name: "policies" }); + const addPolicy = () => append({channelId: "", eventKinds: [], enabled: true}); + const removePolicyHandler = (index: number) => remove(index); + const onCancel = () => { form.reset(); onSuccess?.(); }; const mutation = useMutation({ - mutationFn: async ({alertPolicies}: AlertPoliciesType) => { - const defaultFormatedAlertPolicies = formattedAlertPoliciesList(database?.alertPolicies ?? []); - - const alertPoliciesToAdd = alertPolicies?.filter( - (alertPolicy) => !defaultFormatedAlertPolicies.some((a) => a.notificationChannelId == alertPolicy.notificationChannelId) - ) ?? []; + mutationFn: async ({policies}: PoliciesType) => { + const payload = policies.map(p => kind === "notification" ? p : { ...p, eventKinds: undefined }); - const alertPoliciesToRemove = defaultFormatedAlertPolicies.filter( - (alertPolicy) => !alertPolicies?.some((v) => v.notificationChannelId === alertPolicy.notificationChannelId) - ) ?? []; - - const alertPoliciesToUpdate = alertPolicies?.filter((alertPolicy) => { - const existing = defaultFormatedAlertPolicies.find((a) => a.notificationChannelId === alertPolicy.notificationChannelId); + const policiesToAdd = payload.filter( + (policy) => !defaultPolicies.some((a) => a.channelId === policy.channelId) + ); + const policiesToRemove = defaultPolicies.filter( + (policy) => !payload.some((v) => v.channelId === policy.channelId) + ); + const policiesToUpdate = payload.filter((policy) => { + const existing = defaultPolicies.find((a) => a.channelId === policy.channelId); return existing && - (existing.eventKinds !== alertPolicy.eventKinds || existing.enabled !== alertPolicy.enabled); - }) ?? []; - + (existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled); + }); - const results = await Promise.allSettled([ - alertPoliciesToAdd.length > 0 - ? createAlertPoliciesAction({ - databaseId: database.id, - alertPolicies: alertPoliciesToAdd, - }) - : Promise.resolve(null), - alertPoliciesToUpdate.length > 0 - ? updateAlertPoliciesAction({ - databaseId: database.id, - alertPolicies: alertPoliciesToUpdate, - }) - : Promise.resolve(null), + console.log(policiesToUpdate); + console.log(policiesToAdd); - alertPoliciesToRemove.length > 0 - ? deleteAlertPoliciesAction({ - databaseId: database.id, - alertPolicies: alertPoliciesToRemove as AlertPolicyType[], - }) - : Promise.resolve(null), - ]); + const promises = kind === "notification" + ? [ + policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null, + policiesToUpdate.length > 0 ? await updateAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToUpdate}) : null, + policiesToRemove.length > 0 ? await deleteAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToRemove}) : null, + ] + : [ + policiesToAdd.length > 0 ? await createStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToAdd}) : null, + policiesToUpdate.length > 0 ? await updateStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToUpdate}) : null, + policiesToRemove.length > 0 ? await deleteStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToRemove}) : null, + ]; + const results = await Promise.allSettled(promises); const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); - if (rejected) { - throw new Error(rejected.reason?.message || "Network or server error"); - } + if (rejected) throw new Error(rejected.reason?.message || "Network or server error"); const failedActions = results .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") .map(r => r.value) - .filter((value): value is { data: { success: false; actionError: any } } => - 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); - } + .filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false); + if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed"); return {success: true}; }, - onSuccess: () => { - toast.success("Alert policies saved successfully"); - //onSuccess?.(); - router.refresh(); - }, - onError: (error: any) => { - toast.error(error.message || "Failed to save alert policies"); - }, + onSuccess: () => { toast.success("Policies saved successfully"); router.refresh(); }, + onError: (error: any) => { toast.error(error.message || "Failed to save policies"); }, }); - return ( -
{ - await mutation.mutateAsync(values); - }} - > + { + + if (kind === "notification") { + 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) + } + }>
- + {!isMobile && (

- 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"}

)}
- {organizationNotificationChannels.length === 0 ? ( -
+ {channels.length === 0 ? ( +
-

No notification channels

+

No channels

- Please configure - notification channels in your organization settings first. + Please + configure {channelText.toLowerCase()} channels + in your organization settings first.

) : fields.length === 0 ? ( -
+
-

No alert policies

+

No policies

- Click "Add Policy" to start receiving notifications. + {kind === "notification" ? `Click "Add Policy" to start receiving notifications.` : `Click "Add Policy" to use this storage.`}

) : ( @@ -211,24 +206,17 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
{ - const selectedIds = form - .watch("alertPolicies") - .map((a: AlertPolicyType) => a.notificationChannelId) - .filter(Boolean); - - const availableNotificationChannels = notificationChannels.filter( - (channel) => - channel.id.toString() === field.value?.toString() || - !selectedIds.includes(channel.id.toString()) + name={`policies.${index}.channelId`} + render={({field}) => { + const selectedIds = form.watch("policies").map((a: PolicyType) => a.channelId).filter(Boolean); + const availableChannels = channels.filter( + (channel) => channel.id.toString() === field.value?.toString() || !selectedIds.includes(channel.id.toString()) ); - - const selectedChannel = notificationChannels.find(c => c.id === field.value); + const selectedChannel = channels.find(c => c.id === field.value); return ( @@ -239,13 +227,13 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, {selectedChannel && (
- {getNotificationChannelIcon(selectedChannel.provider)} + {getChannelIcon(selectedChannel.provider)}
- {selectedChannel.name} + {selectedChannel.name} - {selectedChannel.provider} + {selectedChannel.provider}
)} @@ -253,16 +241,12 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, - {availableNotificationChannels.map(channel => ( + {availableChannels.map(channel => (
-
- {getNotificationChannelIcon(channel.provider)} -
+
{getChannelIcon(channel.provider)}
{channel.name} - - ({channel.provider}) - + ({channel.provider})
))} @@ -274,14 +258,13 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, }} />
+
- + ( + name={`policies.${index}.enabled`} + render={({field}) => (
@@ -297,43 +280,41 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, )} />
+
-
- ( - - - Trigger Events - - -
- -
-
- -
- )} - /> + + {kind === "notification" && ( + ( + + Trigger Events + +
+ +
+
+ +
+ )} + /> + )}
))} @@ -343,12 +324,8 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
- - Cancel - - - Save Changes - + Cancel + Save Changes
); diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx b/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx similarity index 54% rename from src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx rename to src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx index 9279fee7..a2a9a8fa 100644 --- a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx +++ b/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx @@ -1,7 +1,5 @@ "use client" -import {Megaphone} from "lucide-react"; - -import {useState} from "react"; +import {ReactNode, useState} from "react"; import { Dialog, DialogContent, @@ -11,36 +9,46 @@ import { DialogTrigger } from "@/components/ui/dialog"; 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 {NotificationChannel} from "@/db/schema/09_notification-channel"; import {Separator} from "@/components/ui/separator"; 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; - notificationChannels: NotificationChannel[]; + channels: NotificationChannel[] | StorageChannel[]; 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 channelText = getChannelTextBasedOnKind(kind) - - const notificationsChannelsFiltered = notificationChannels + const channelsFiltered = channels .filter((channel) => channel.enabled) - const notificationsChannelsIds = notificationsChannelsFiltered + const channelsIds = channelsFiltered .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 (