From dbd95d9867f798d7d1c925a06cbd66510d47c96c Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 4 Jul 2026 19:25:09 +0200 Subject: [PATCH] feat: Move integrations to separate page, add testing email API --- CLAUDE.md | 8 +++ apps/api/src/locales/@vitnode/core/en.json | 42 ++++++++++-- .../(vitnode-core)/core/debug/page.tsx | 12 ---- .../core/system/integrations/page.tsx | 43 ++++++++++++ apps/docs/src/locales/@vitnode/core/en.json | 42 ++++++++++-- apps/docs/src/vitnode.api.config.ts | 3 - .../modules/admin/debug/debug.admin.module.ts | 7 +- .../admin/debug/routes/integrations.route.ts | 2 +- .../debug/routes/send-test-email.route.ts | 66 +++++++++++++++++++ packages/vitnode/src/api/plugin.ts | 4 ++ packages/vitnode/src/emails/test-email.tsx | 21 ++++++ .../src/lib/api/get-next-cron-run-date.ts | 4 +- .../src/lib/api/should-cron-job-run.ts | 4 +- packages/vitnode/src/locales/en.json | 42 ++++++++++-- .../src/routes/admin/core/debug/page.tsx | 12 ---- .../admin/core/system/integrations/page.tsx | 43 ++++++++++++ .../layouts/sidebar/nav/get-admin-nav.tsx | 17 +++++ .../integrations/integration-card.tsx | 10 ++- .../integrations/integrations-view.tsx | 15 ++++- .../integrations/send-test-email/content.tsx | 59 +++++++++++++++++ .../send-test-email/mutation-api.ts | 28 ++++++++ .../send-test-email/send-test-email.tsx | 49 ++++++++++++++ .../integrations/send-test-email/use-form.ts | 47 +++++++++++++ 23 files changed, 529 insertions(+), 51 deletions(-) create mode 100644 CLAUDE.md create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/integrations/page.tsx create mode 100644 packages/vitnode/src/api/modules/admin/debug/routes/send-test-email.route.ts create mode 100644 packages/vitnode/src/emails/test-email.tsx create mode 100644 packages/vitnode/src/routes/admin/core/system/integrations/page.tsx rename packages/vitnode/src/views/admin/views/core/{debug => system}/integrations/integration-card.tsx (93%) rename packages/vitnode/src/views/admin/views/core/{debug => system}/integrations/integrations-view.tsx (89%) create mode 100644 packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/mutation-api.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/use-form.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a68744305 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# VitNode + +- Always write and run unit tests in vitest for all new features and bug fixes. +- Don't use `React.FC` for defining React components. Instead, use the arrow function syntax. +- Don't use `any` type in TypeScript and use `unknown` as less as possible. +- Use `AutoForm` for forms instead of manually creating form components. +- After create/edit/delete operations, always refresh the data in the table to reflect the changes with notification using toast `sonner`. +- Use `React.lazy` and `Suspense` for code splitting and lazy loading for content-heavy dialogs like dialogs in forms. diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 573f78594..27fef00d9 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -12,6 +12,9 @@ "@vitnode/core:debug": "Debug Panel", "@vitnode/core:debug:can_view": "View debug panel", "@vitnode/core:debug:can_clear_cache": "Clear cache", + "@vitnode/core:system": "System", + "@vitnode/core:system:can_view": "View integrations", + "@vitnode/core:system:can_send_test_email": "Send test email", "@vitnode/core:staff_moderators": "Staff: Moderators", "@vitnode/core:staff_moderators:can_view": "View moderators list", "@vitnode/core:staff_moderators:can_create": "Create moderators", @@ -265,6 +268,10 @@ "debug": "Debug Panel", "log_out": "Log Out" }, + "system": { + "title": "System", + "integrations": "Integrations" + }, "advanced": { "title": "Advanced", "cron": "Cron Jobs" @@ -477,9 +484,7 @@ } } }, - "debug": { - "title": "Debug Panel", - "desc": "Check logs, errors, and other debug information.", + "system": { "integrations": { "title": "Integrations", "desc": "Status of the core services that power this instance.", @@ -500,7 +505,30 @@ }, "email": { "title": "Email", - "desc": "Transactional emails such as sign-up confirmations and password resets." + "desc": "Transactional emails such as sign-up confirmations and password resets.", + "test": { + "label": "Test email", + "title": "Send a test email", + "desc": "Send an email to any address to confirm your email adapter is working.", + "info": { + "title": "How to verify your configuration", + "desc": "If the message arrives in the inbox, your email adapter is configured correctly. If it doesn't show up, double-check your adapter settings and the system logs." + }, + "to": { + "label": "Recipient", + "invalid": "Please enter a valid email address." + }, + "subject": { + "label": "Subject", + "default": "VitNode test email" + }, + "content": { + "label": "Content", + "default": "This is a test email sent from the VitNode admin panel." + }, + "submit": "Send test email", + "success": "Test email sent. Check the recipient's inbox to confirm your configuration." + } }, "captcha": { "title": "Captcha", @@ -517,7 +545,11 @@ "not_configured": "No cron adapter configured — jobs won't run automatically.", "jobs": "{count, plural, =0 {No jobs scheduled} one {# job scheduled} other {# jobs scheduled}}" } - }, + } + }, + "debug": { + "title": "Debug Panel", + "desc": "Check logs, errors, and other debug information.", "actions": { "clear_cache": { "label": "Clear Cache", diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/debug/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/debug/page.tsx index d6710daef..76bd11f1a 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/debug/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/debug/page.tsx @@ -8,10 +8,6 @@ import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; import { HeaderContent } from "@vitnode/core/components/ui/header-content"; import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; import { ClearCacheAction } from "@vitnode/core/views/admin/views/core/debug/actions/clear-cache/clear-cache"; -import { - IntegrationsView, - IntegrationsViewSkeleton, -} from "@vitnode/core/views/admin/views/core/debug/integrations/integrations-view"; const SystemLogsView = dynamic(async () => import("@vitnode/core/views/admin/views/core/debug/system-logs/system-logs-view").then( @@ -50,14 +46,6 @@ export default async function Page( {canClearCache && } - - }> - - - }> diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/integrations/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/integrations/page.tsx new file mode 100644 index 000000000..c165e60f2 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/integrations/page.tsx @@ -0,0 +1,43 @@ +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import React from "react"; + +import { I18nProvider } from "@vitnode/core/components/i18n-provider"; +import { HeaderContent } from "@vitnode/core/components/ui/header-content"; +import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; +import { + IntegrationsView, + IntegrationsViewSkeleton, +} from "@vitnode/core/views/admin/views/core/system/integrations/integrations-view"; + +export const generateMetadata = async () => { + const t = await getTranslations("admin.system.integrations"); + + return { + title: t("title"), + description: t("desc"), + }; +}; + +export default async function Page() { + const [t, canView] = await Promise.all([ + getTranslations("admin.system.integrations"), + checkAdminPermissionApi({ module: "system", permission: "can_view" }), + ]); + + if (!canView) { + notFound(); + } + + return ( + +
+ + + }> + + +
+
+ ); +} diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 573f78594..27fef00d9 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -12,6 +12,9 @@ "@vitnode/core:debug": "Debug Panel", "@vitnode/core:debug:can_view": "View debug panel", "@vitnode/core:debug:can_clear_cache": "Clear cache", + "@vitnode/core:system": "System", + "@vitnode/core:system:can_view": "View integrations", + "@vitnode/core:system:can_send_test_email": "Send test email", "@vitnode/core:staff_moderators": "Staff: Moderators", "@vitnode/core:staff_moderators:can_view": "View moderators list", "@vitnode/core:staff_moderators:can_create": "Create moderators", @@ -265,6 +268,10 @@ "debug": "Debug Panel", "log_out": "Log Out" }, + "system": { + "title": "System", + "integrations": "Integrations" + }, "advanced": { "title": "Advanced", "cron": "Cron Jobs" @@ -477,9 +484,7 @@ } } }, - "debug": { - "title": "Debug Panel", - "desc": "Check logs, errors, and other debug information.", + "system": { "integrations": { "title": "Integrations", "desc": "Status of the core services that power this instance.", @@ -500,7 +505,30 @@ }, "email": { "title": "Email", - "desc": "Transactional emails such as sign-up confirmations and password resets." + "desc": "Transactional emails such as sign-up confirmations and password resets.", + "test": { + "label": "Test email", + "title": "Send a test email", + "desc": "Send an email to any address to confirm your email adapter is working.", + "info": { + "title": "How to verify your configuration", + "desc": "If the message arrives in the inbox, your email adapter is configured correctly. If it doesn't show up, double-check your adapter settings and the system logs." + }, + "to": { + "label": "Recipient", + "invalid": "Please enter a valid email address." + }, + "subject": { + "label": "Subject", + "default": "VitNode test email" + }, + "content": { + "label": "Content", + "default": "This is a test email sent from the VitNode admin panel." + }, + "submit": "Send test email", + "success": "Test email sent. Check the recipient's inbox to confirm your configuration." + } }, "captcha": { "title": "Captcha", @@ -517,7 +545,11 @@ "not_configured": "No cron adapter configured — jobs won't run automatically.", "jobs": "{count, plural, =0 {No jobs scheduled} one {# job scheduled} other {# jobs scheduled}}" } - }, + } + }, + "debug": { + "title": "Debug Panel", + "desc": "Check logs, errors, and other debug information.", "actions": { "clear_cache": { "label": "Clear Cache", diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts index 13baf843f..eaf6bef50 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -13,9 +13,6 @@ export const POSTGRES_URL = export const vitNodeApiConfig = buildApiConfig({ pathToMessages: async path => await import(`./locales/${path}`), - // Redis is opt-in: only enabled when REDIS_URL is set. Without it the cache - // is a no-op, the rate limiter uses in-memory storage, and WebSockets run in - // single-instance mode. redis: process.env.REDIS_URL ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD } : undefined, diff --git a/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts b/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts index bc48c07ae..7b39f0a43 100644 --- a/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts @@ -2,9 +2,14 @@ import { CONFIG_PLUGIN } from "../../../../config"; import { buildModule } from "../../../lib/module"; import { integrationsDebugAdminRoute } from "./routes/integrations.route"; import { logsDebugAdminRoute } from "./routes/logs.route"; +import { sendTestEmailDebugAdminRoute } from "./routes/send-test-email.route"; export const debugAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "debug", - routes: [logsDebugAdminRoute, integrationsDebugAdminRoute], + routes: [ + logsDebugAdminRoute, + integrationsDebugAdminRoute, + sendTestEmailDebugAdminRoute, + ], }); diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts index 075e77595..5c89bf6a8 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts @@ -8,7 +8,7 @@ import { buildRoute } from "../../../../lib/route"; export const integrationsDebugAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "debug", permission: "can_view" }, + adminStaffPermission: { module: "system", permission: "can_view" }, route: { method: "get", description: diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/send-test-email.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/send-test-email.route.ts new file mode 100644 index 000000000..7adc8e834 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/debug/routes/send-test-email.route.ts @@ -0,0 +1,66 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import TestEmailTemplate from "@/emails/test-email"; + +export const zodSendTestEmailSchema = z.object({ + content: z.string().min(1).max(5000), + subject: z.string().min(1).max(200), + to: z.email(), +}); + +export const sendTestEmailDebugAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "system", permission: "can_send_test_email" }, + route: { + method: "post", + description: + "Send a test email to verify that the email adapter is configured correctly.", + path: "/send-test-email", + request: { + body: { + required: true, + content: { + "application/json": { + schema: zodSendTestEmailSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ success: z.boolean() }), + }, + }, + description: "Test email sent", + }, + 400: { + description: "Email adapter not configured", + }, + }, + }, + handler: async c => { + // The adapter check gives a clear error instead of the generic 500 the + // email model would otherwise throw when no provider is configured. + if (!c.get("core").email?.adapter) { + throw new HTTPException(400, { + message: "Email provider not configured", + }); + } + + const { to, subject, content } = c.req.valid("json"); + + await c.get("email").send({ + to, + locale: "en", + subject, + content: props => TestEmailTemplate({ ...props, content }), + }); + + return c.json({ success: true }); + }, +}); diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index de772a6da..f55b716a0 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -25,6 +25,10 @@ export const newBuildPluginApiCore = buildApiPlugin({ "can_view", { permission: "can_clear_cache", dependsOn: ["can_view"] }, ], + system: [ + "can_view", + { permission: "can_send_test_email", dependsOn: ["can_view"] }, + ], staff_moderators: [ "can_view", { permission: "can_create", dependsOn: ["can_view"] }, diff --git a/packages/vitnode/src/emails/test-email.tsx b/packages/vitnode/src/emails/test-email.tsx new file mode 100644 index 000000000..ef523086b --- /dev/null +++ b/packages/vitnode/src/emails/test-email.tsx @@ -0,0 +1,21 @@ +import { Text } from "@react-email/components"; + +import DefaultTemplateEmail, { + type DefaultTemplateEmailProps, +} from "./default-template"; + +export default function TestEmailTemplate({ + content, + ...props +}: DefaultTemplateEmailProps & { content: string }) { + return ( + + {content} + + ); +} + +TestEmailTemplate.PreviewProps = { + ...DefaultTemplateEmail.PreviewProps, + content: "This is a test email sent from the VitNode admin panel.", +} satisfies DefaultTemplateEmailProps & { content: string }; diff --git a/packages/vitnode/src/lib/api/get-next-cron-run-date.ts b/packages/vitnode/src/lib/api/get-next-cron-run-date.ts index 31cee8de3..5b4463abc 100644 --- a/packages/vitnode/src/lib/api/get-next-cron-run-date.ts +++ b/packages/vitnode/src/lib/api/get-next-cron-run-date.ts @@ -1,4 +1,4 @@ -import cronParser from "cron-parser"; +import { CronExpressionParser } from "cron-parser"; export const getNextCronRunDate = ( schedule: string, @@ -9,7 +9,7 @@ export const getNextCronRunDate = ( currentDate: lastRun ?? new Date(0), }; - const interval = cronParser.parse(schedule, options); + const interval = CronExpressionParser.parse(schedule, options); return interval.next().toDate(); } catch (err) { diff --git a/packages/vitnode/src/lib/api/should-cron-job-run.ts b/packages/vitnode/src/lib/api/should-cron-job-run.ts index 1a9846335..e706ef8be 100644 --- a/packages/vitnode/src/lib/api/should-cron-job-run.ts +++ b/packages/vitnode/src/lib/api/should-cron-job-run.ts @@ -1,4 +1,4 @@ -import cronParser from "cron-parser"; +import { CronExpressionParser } from "cron-parser"; export const shouldCronJobRun = ( schedule: string, @@ -10,7 +10,7 @@ export const shouldCronJobRun = ( currentDate: lastRun ?? new Date(0), }; - const interval = cronParser.parse(schedule, options); + const interval = CronExpressionParser.parse(schedule, options); const nextScheduledRun = interval.next().toDate(); return nextScheduledRun <= now; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 573f78594..27fef00d9 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -12,6 +12,9 @@ "@vitnode/core:debug": "Debug Panel", "@vitnode/core:debug:can_view": "View debug panel", "@vitnode/core:debug:can_clear_cache": "Clear cache", + "@vitnode/core:system": "System", + "@vitnode/core:system:can_view": "View integrations", + "@vitnode/core:system:can_send_test_email": "Send test email", "@vitnode/core:staff_moderators": "Staff: Moderators", "@vitnode/core:staff_moderators:can_view": "View moderators list", "@vitnode/core:staff_moderators:can_create": "Create moderators", @@ -265,6 +268,10 @@ "debug": "Debug Panel", "log_out": "Log Out" }, + "system": { + "title": "System", + "integrations": "Integrations" + }, "advanced": { "title": "Advanced", "cron": "Cron Jobs" @@ -477,9 +484,7 @@ } } }, - "debug": { - "title": "Debug Panel", - "desc": "Check logs, errors, and other debug information.", + "system": { "integrations": { "title": "Integrations", "desc": "Status of the core services that power this instance.", @@ -500,7 +505,30 @@ }, "email": { "title": "Email", - "desc": "Transactional emails such as sign-up confirmations and password resets." + "desc": "Transactional emails such as sign-up confirmations and password resets.", + "test": { + "label": "Test email", + "title": "Send a test email", + "desc": "Send an email to any address to confirm your email adapter is working.", + "info": { + "title": "How to verify your configuration", + "desc": "If the message arrives in the inbox, your email adapter is configured correctly. If it doesn't show up, double-check your adapter settings and the system logs." + }, + "to": { + "label": "Recipient", + "invalid": "Please enter a valid email address." + }, + "subject": { + "label": "Subject", + "default": "VitNode test email" + }, + "content": { + "label": "Content", + "default": "This is a test email sent from the VitNode admin panel." + }, + "submit": "Send test email", + "success": "Test email sent. Check the recipient's inbox to confirm your configuration." + } }, "captcha": { "title": "Captcha", @@ -517,7 +545,11 @@ "not_configured": "No cron adapter configured — jobs won't run automatically.", "jobs": "{count, plural, =0 {No jobs scheduled} one {# job scheduled} other {# jobs scheduled}}" } - }, + } + }, + "debug": { + "title": "Debug Panel", + "desc": "Check logs, errors, and other debug information.", "actions": { "clear_cache": { "label": "Clear Cache", diff --git a/packages/vitnode/src/routes/admin/core/debug/page.tsx b/packages/vitnode/src/routes/admin/core/debug/page.tsx index a8c504bb6..d3e73773f 100644 --- a/packages/vitnode/src/routes/admin/core/debug/page.tsx +++ b/packages/vitnode/src/routes/admin/core/debug/page.tsx @@ -8,10 +8,6 @@ import { DataTableSkeleton } from "@/components/table/data-table"; import { HeaderContent } from "@/components/ui/header-content"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { ClearCacheAction } from "@/views/admin/views/core/debug/actions/clear-cache/clear-cache"; -import { - IntegrationsView, - IntegrationsViewSkeleton, -} from "@/views/admin/views/core/debug/integrations/integrations-view"; const SystemLogsView = dynamic(async () => import("@/views/admin/views/core/debug/system-logs/system-logs-view").then( @@ -50,14 +46,6 @@ export default async function Page( {canClearCache && }
- - }> - - - }> diff --git a/packages/vitnode/src/routes/admin/core/system/integrations/page.tsx b/packages/vitnode/src/routes/admin/core/system/integrations/page.tsx new file mode 100644 index 000000000..73635cc60 --- /dev/null +++ b/packages/vitnode/src/routes/admin/core/system/integrations/page.tsx @@ -0,0 +1,43 @@ +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import React from "react"; + +import { I18nProvider } from "@/components/i18n-provider"; +import { HeaderContent } from "@/components/ui/header-content"; +import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; +import { + IntegrationsView, + IntegrationsViewSkeleton, +} from "@/views/admin/views/core/system/integrations/integrations-view"; + +export const generateMetadata = async () => { + const t = await getTranslations("admin.system.integrations"); + + return { + title: t("title"), + description: t("desc"), + }; +}; + +export default async function Page() { + const [t, canView] = await Promise.all([ + getTranslations("admin.system.integrations"), + checkAdminPermissionApi({ module: "system", permission: "can_view" }), + ]); + + if (!canView) { + notFound(); + } + + return ( + +
+ + + }> + + +
+
+ ); +} diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx index f8c360aac..b9b7c2c4e 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx @@ -1,5 +1,6 @@ import { LayoutDashboardIcon, + ServerIcon, ShieldUserIcon, UsersRoundIcon, WrenchIcon, @@ -162,6 +163,22 @@ export const getAdminNav = async ({ }, ], }, + { + href: "/admin/core/system", + title: t("admin.global.nav.system.title"), + icon: , + items: [ + { + title: t("admin.global.nav.system.integrations"), + href: "/admin/core/system/integrations", + permission: { + plugin: CONFIG_PLUGIN.pluginId, + module: "system", + permission: "can_view", + }, + }, + ], + }, { href: "/admin/core/advanced", title: t("admin.global.nav.advanced.title"), diff --git a/packages/vitnode/src/views/admin/views/core/debug/integrations/integration-card.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/integration-card.tsx similarity index 93% rename from packages/vitnode/src/views/admin/views/core/debug/integrations/integration-card.tsx rename to packages/vitnode/src/views/admin/views/core/system/integrations/integration-card.tsx index 10f5558e8..bd470d62d 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/integrations/integration-card.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/integration-card.tsx @@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"; export type IntegrationStatus = "active" | "inactive" | "warning"; export interface IntegrationCardProps { + action?: React.ReactNode; description: string; href: string; Icon: LucideIcon; @@ -27,6 +28,7 @@ export interface IntegrationCardProps { } export const IntegrationCard = ({ + action, description, href, Icon, @@ -91,7 +93,13 @@ export const IntegrationCard = ({
{meta}
) : null} - + + {action} active ? "active" : "inactive"; export const IntegrationsView = async () => { - const [t, data] = await Promise.all([ - getTranslations("admin.debug.integrations"), + const [t, data, canSendTestEmail] = await Promise.all([ + getTranslations("admin.system.integrations"), getIntegrationsData(), + checkAdminPermissionApi({ + module: "system", + permission: "can_send_test_email", + }), ]); const statusLabel = (status: IntegrationStatus) => t(`status.${status}`); @@ -87,6 +93,11 @@ export const IntegrationsView = async () => { /> + ) : undefined + } description={t("email.desc")} href={DOCS_URLS.email} Icon={MailIcon} diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/content.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/content.tsx new file mode 100644 index 000000000..2b98f0083 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/content.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { MailCheckIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +import { useFormSendTestEmail } from "./use-form"; + +export const ContentSendTestEmail = () => { + const t = useTranslations("admin.system.integrations.email.test"); + const { onSubmit, formSchema } = useFormSendTestEmail(); + + return ( +
+ + + {t("info.title")} + {t("info.desc")} + + + ( + + ), + }, + { + id: "subject", + component: props => ( + + ), + }, + { + id: "content", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + mode="all" + onSubmit={onSubmit} + submitButtonProps={{ + children: t("submit"), + }} + /> +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/mutation-api.ts b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/mutation-api.ts new file mode 100644 index 000000000..1a304b982 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/mutation-api.ts @@ -0,0 +1,28 @@ +"use server"; + +import type { z } from "zod"; + +import type { zodSendTestEmailSchema } from "@/api/modules/admin/debug/routes/send-test-email.route"; + +import { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; +import { fetcher } from "@/lib/fetcher"; + +export const sendTestEmailMutation = async ( + body: z.infer, +) => { + const res = await fetcher(debugAdminModule, { + prefixPath: "/admin", + path: "/send-test-email", + method: "post", + module: "debug", + args: { + body, + }, + }); + + if (!res.ok) { + return { error: await res.text() }; + } + + return { data: await res.json() }; +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email.tsx new file mode 100644 index 000000000..b88af8785 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { SendIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; + +const ContentSendTestEmail = React.lazy(async () => + import("./content").then(module => ({ + default: module.ContentSendTestEmail, + })), +); + +export const SendTestEmailAction = () => { + const t = useTranslations("admin.system.integrations.email.test"); + + return ( + + }> + + {t("label")} + + + + + + + {t("title")} + + {t("desc")} + + + }> + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/use-form.ts b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/use-form.ts new file mode 100644 index 000000000..4479497d3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/use-form.ts @@ -0,0 +1,47 @@ +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { useDialog } from "@/components/ui/dialog"; + +import { sendTestEmailMutation } from "./mutation-api"; + +export const useFormSendTestEmail = () => { + const t = useTranslations("admin.system.integrations.email.test"); + const tError = useTranslations("core.global.errors"); + const { setOpen, setIsDirty } = useDialog(); + + const formSchema = z.object({ + to: z.email({ message: t("to.invalid") }).default(""), + subject: z + .string({ message: tError("field_required") }) + .min(1, tError("field_required")) + .max(200) + .default(t("subject.default")), + content: z + .string({ message: tError("field_required") }) + .min(1, tError("field_required")) + .max(5000) + .default(t("content.default")), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const mutation = await sendTestEmailMutation(values); + + if (mutation.data) { + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("success")); + + return; + } + + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; + + return { onSubmit, formSchema }; +};