Merge pull request #105 from Portabase/feat/provider-validation

Feat/provider validation
This commit is contained in:
Charles GTE
2026-03-02 20:05:14 +01:00
committed by GitHub
16 changed files with 387 additions and 247 deletions
@@ -1,36 +0,0 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { notFound } from "next/navigation";
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
const { databaseId } = await props.params;
const dbItem = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
});
if (!dbItem) {
notFound();
}
return (
<Page>
<PageHeader>
<PageTitle>Edit {dbItem.name}</PageTitle>
</PageHeader>
<PageContent>
<DatabaseForm
databaseId={databaseId}
// @ts-ignore
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
/>
</PageContent>
</Page>
);
}
@@ -1,10 +1,19 @@
"use client" "use client";
import { Button, ButtonVariantsProps } from "@/components/ui/button"; import { Button, ButtonVariantsProps } from "@/components/ui/button";
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type ButtonWithConfirmProps = { export type ButtonWithConfirmProps = {
title: string; title: string;
@@ -45,7 +54,6 @@ export type ButtonWithConfirmProps = {
isPending?: boolean; isPending?: boolean;
}; };
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => { export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
@@ -92,16 +100,19 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
{props.button?.main.text && <span>{props.button.main.text}</span>} {props.button?.main.text && <span>{props.button.main.text}</span>}
</Button> </Button>
) : ( ) : (
<div onClick={(e) => { <div
onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsConfirming(true); setIsConfirming(true);
}}> }}
>
{props.children} {props.children}
</div> </div>
); );
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? ( const withTooltip =
isLegacy && props.button?.main.tooltipText && isDisabled ? (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -112,13 +123,13 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : triggerContent; ) : (
triggerContent
);
return ( return (
<Popover open={isConfirming} onOpenChange={setIsConfirming}> <Popover open={isConfirming} onOpenChange={setIsConfirming}>
<PopoverTrigger asChild> <PopoverTrigger asChild>{withTooltip}</PopoverTrigger>
{withTooltip}
</PopoverTrigger>
<PopoverContent <PopoverContent
className="w-80" className="w-80"
onPointerDownOutside={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()}
@@ -137,21 +148,47 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
<div className="grid gap-2"> <div className="grid gap-2">
<Button <Button
onClick={handleConfirm} onClick={handleConfirm}
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"} variant={
size={isLegacy ? (props.button?.main.size ?? "default") : "default"} isLegacy
className={cn(isLegacy ? props.button?.main.className : "", "w-full")} ? (props.button?.confirm.variant ?? "default")
: "default"
}
size={
isLegacy ? (props.button?.confirm.size ?? "default") : "default"
}
className={cn(
isLegacy ? props.button?.confirm.className : "",
"w-full",
)}
> >
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>} {props.isPending && (
<Loader2 className="animate-spin mr-4" size={16} />
)}
{isLegacy && props.button?.confirm.icon} {isLegacy && props.button?.confirm.icon}
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span> <span>
{isLegacy
? props.button?.confirm.text
: (props.confirmButtonText ?? "Confirm")}
</span>
</Button> </Button>
<Button <Button
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"} variant={
isLegacy
? (props.button?.cancel.variant ?? "outline")
: "outline"
}
onClick={handleCancel} onClick={handleCancel}
className={cn(isLegacy ? props.button?.main.className : "", "w-full")} className={cn(
size={isLegacy ? (props.button?.main.size ?? "default") : "default"} isLegacy ? props.button?.cancel.className : "",
"w-full",
)}
size={
isLegacy ? (props.button?.cancel.size ?? "default") : "default"
}
> >
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")} {isLegacy
? (props.button?.cancel.text ?? "Cancel")
: (props.cancelButtonText ?? "Cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,26 +1,69 @@
import {z} from "zod"; import {z} from "zod";
import {SlackChannelConfigSchema} from "./providers/notifications/forms/slack.schema";
import {SmtpChannelConfigSchema} from "./providers/notifications/forms/smtp.schema";
import {DiscordChannelConfigSchema} from "./providers/notifications/forms/discord.schema";
import {TelegramChannelConfigSchema} from "./providers/notifications/forms/telegram.schema";
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
export const ChannelFormSchema = z.object({ const BaseChannelFormSchema = z.object({
name: z name: z
.string() .string()
.min(5, "Name must be at least 5 characters long") .min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 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), enabled: z.boolean().default(true),
}); });
export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
BaseChannelFormSchema.extend({
provider: z.literal("slack"),
config: SlackChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("smtp"),
config: SmtpChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("discord"),
config: DiscordChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("telegram"),
config: TelegramChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("gotify"),
config: GotifyChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("ntfy"),
config: NtfyChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("webhook"),
config: WebhookChannelConfigSchema,
}),
]);
export const NotificationChannelFormSchema = ChannelFormSchema.extend({ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.enum( BaseChannelFormSchema.extend({
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"], provider: z.literal("s3"),
{required_error: "Provider is required"} config: S3ChannelConfigSchema,
), }),
}); BaseChannelFormSchema.extend({
provider: z.literal("google-drive"),
export const StorageChannelFormSchema = ChannelFormSchema.extend({ config: GoogleDriveChannelConfigSchema,
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}), }),
}); BaseChannelFormSchema.extend({
provider: z.literal("local"),
config: LocalChannelConfigSchema
})
]);
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>; export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
@@ -0,0 +1,5 @@
import {z} from "zod";
export const DiscordChannelConfigSchema = z.object({
discordWebhook: z.string().url("Must be a valid URL"),
});
@@ -0,0 +1,6 @@
import {z} from "zod";
export const GotifyChannelConfigSchema = z.object({
gotifyServerUrl: z.string().url("Must be a valid URL"),
gotifyAppToken: z.string().min(1, "App token is required"),
});
@@ -0,0 +1,9 @@
import {z} from "zod";
export const NtfyChannelConfigSchema = z.object({
ntfyTopic: z.string().min(1, "Topic is required"),
ntfyServerUrl: z.string().url("Must be a valid URL").optional().or(z.literal('')),
ntfyToken: z.string().optional(),
ntfyUsername: z.string().optional(),
ntfyPassword: z.string().optional(),
});
@@ -0,0 +1,5 @@
import {z} from "zod";
export const SlackChannelConfigSchema = z.object({
slackWebhook: z.string().url("Must be a valid URL"),
});
@@ -0,0 +1,10 @@
import {z} from "zod";
export const SmtpChannelConfigSchema = z.object({
host: z.string().min(1, "Host is required"),
port: z.coerce.number().min(1, "Port is required"),
user: z.string().min(1, "User is required"),
password: z.string().min(1, "Password is required"),
from: z.string().min(1, "From is required"),
to: z.string().min(1, "To is required"),
});
@@ -43,6 +43,22 @@ export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="config.telegramTopicId"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Topic ID</FormLabel>
<FormControl>
<Input {...field} placeholder="123456"/>
</FormControl>
<p className="text-xs text-muted-foreground">
Unique identifier for the target message thread (topic) of the forum; for forum supergroups only
</p>
<FormMessage/>
</FormItem>
)}
/>
</> </>
) )
} }
@@ -0,0 +1,20 @@
import { z } from "zod";
export const TelegramChannelConfigSchema = z
.object({
telegramBotToken: z.string().min(1, "Bot token is required"),
telegramChatId: z.string().min(1, "Chat ID is required"),
telegramTopicId: z.string().optional(),
})
.refine(
(data) => {
if (data.telegramTopicId && !data.telegramChatId) {
return false;
}
return true;
},
{
message: "Chat ID is required when a Topic ID is provided",
path: ["telegramChatId"],
},
);
@@ -0,0 +1,7 @@
import {z} from "zod";
export const WebhookChannelConfigSchema = z.object({
webhookUrl: z.string().url("Must be a valid URL"),
webhookSecretHeader: z.string().optional(),
webhookSecret: z.string().optional(),
});
@@ -0,0 +1,8 @@
import {z} from "zod";
export const GoogleDriveChannelConfigSchema = z.object({
clientId: z.string().min(1, "Client ID is required"),
clientSecret: z.string().min(1, "Client Secret is required"),
folderId: z.string().min(1, "Folder ID is required"),
refreshToken: z.string().optional(),
});
@@ -0,0 +1,3 @@
import {z} from "zod";
export const LocalChannelConfigSchema = z.object({});
@@ -0,0 +1,11 @@
import {z} from "zod";
export const S3ChannelConfigSchema = z.object({
endPointUrl: z.string().min(1, "Endpoint URL is required"),
region: z.string().min(1, "Region is required"),
accessKey: z.string().min(1, "Access Key is required"),
secretKey: z.string().min(1, "Secret Key is required"),
bucketName: z.string().min(1, "Bucket name is required"),
port: z.coerce.number().optional(),
ssl: z.boolean().optional().default(true),
});
@@ -1,17 +0,0 @@
"use client";
import { buttonVariants } from "@/components/ui/button";
import { GearIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { usePathname } from "next/navigation";
export type EditButtonProps = {};
export const EditButton = (props: EditButtonProps) => {
const pathname = usePathname();
return (
<Link className={buttonVariants({ variant: "outline" })} href={`${pathname}/edit`}>
<GearIcon className="w-7 h-7" />
</Link>
);
};
@@ -1,10 +1,14 @@
import type {EventPayload, DispatchResult} from '../types'; import type { EventPayload, DispatchResult } from "../types";
export async function sendTelegram( export async function sendTelegram(
config: { telegramBotToken: string; telegramChatId: string }, config: {
payload: EventPayload telegramBotToken: string;
telegramChatId: string;
telegramTopicId?: string;
},
payload: EventPayload,
): Promise<DispatchResult> { ): Promise<DispatchResult> {
const {telegramBotToken, telegramChatId} = config; const { telegramBotToken, telegramChatId, telegramTopicId } = config;
// Helper to escape HTML characters // Helper to escape HTML characters
const escapeHtml = (unsafe: string) => { const escapeHtml = (unsafe: string) => {
@@ -19,21 +23,30 @@ export async function sendTelegram(
const title = escapeHtml(payload.title); const title = escapeHtml(payload.title);
const message = escapeHtml(payload.message); const message = escapeHtml(payload.message);
const level = escapeHtml(payload.level.toUpperCase()); const level = escapeHtml(payload.level.toUpperCase());
const dataString = payload.data ? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000)) : ''; const dataString = payload.data
? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000))
: "";
const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ''}`; const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ""}`;
const body = { const body: Record<string, any> = {
chat_id: telegramChatId, chat_id: telegramChatId,
text, text,
parse_mode: 'HTML', parse_mode: "HTML",
}; };
const res = await fetch(`https://api.telegram.org/bot${telegramBotToken}/sendMessage`, { if (telegramTopicId) {
method: 'POST', body.message_thread_id = Number(telegramTopicId);
}
const res = await fetch(
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
{
method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'}, headers: { "Content-Type": "application/json" },
}); },
);
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const err = await res.text();
@@ -42,8 +55,8 @@ export async function sendTelegram(
return { return {
success: true, success: true,
provider: 'telegram', provider: "telegram",
message: 'Sent to Telegram', message: "Sent to Telegram",
response: await res.text(), response: await res.text(),
}; };
} }