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 (
+
+ );
+};
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 };
+};