mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
feat: backend storage
This commit is contained in:
@@ -1,127 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {Pencil, Plus} from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotifierForm} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {
|
||||
NotifierChannelOrganisationForm
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form";
|
||||
|
||||
type OrganizationNotifierAddModalProps = {
|
||||
notificationChannel?: NotificationChannelWith
|
||||
organization?: OrganizationWithMembers;
|
||||
open?: boolean;
|
||||
onOpenChangeAction?: (open: boolean) => void;
|
||||
adminView?: boolean;
|
||||
organizations?: OrganizationWithMembers[]
|
||||
trigger?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export const NotifierAddEditModal = ({
|
||||
organization,
|
||||
notificationChannel,
|
||||
open = false,
|
||||
onOpenChangeAction,
|
||||
adminView,
|
||||
organizations ,
|
||||
trigger= true
|
||||
}: OrganizationNotifierAddModalProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openInternal, setOpen] = useState(open);
|
||||
|
||||
const isCreate = !Boolean(notificationChannel);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(open);
|
||||
},[open])
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={openInternal} onOpenChange={(state) => {
|
||||
onOpenChangeAction?.(state);
|
||||
setOpen(state);
|
||||
}}>
|
||||
{trigger && (
|
||||
<DialogTrigger asChild>
|
||||
{isCreate ?
|
||||
<Button>
|
||||
<Plus/>{!isMobile && `Add notification channel`}
|
||||
</Button>
|
||||
:
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4"/>
|
||||
</Button>
|
||||
}
|
||||
</DialogTrigger>
|
||||
)}
|
||||
|
||||
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle> {isCreate ? "Add" : "Edit"} Notification Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your notification channel preferences and event triggers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div>
|
||||
{adminView ?
|
||||
|
||||
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="configuration">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="organizations">Organizations</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-full justify-between" value="configuration">
|
||||
<NotifierForm
|
||||
adminView={adminView}
|
||||
defaultValues={notificationChannel}
|
||||
organization={organization}
|
||||
onSuccessAction={() => {
|
||||
onOpenChangeAction?.(false)
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent className="h-full justify-between" value="organizations">
|
||||
|
||||
<NotifierChannelOrganisationForm
|
||||
defaultValues={notificationChannel}
|
||||
organizations={organizations}
|
||||
/>
|
||||
|
||||
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
:
|
||||
<NotifierForm
|
||||
adminView={adminView}
|
||||
defaultValues={notificationChannel}
|
||||
organization={organization}
|
||||
onSuccessAction={() => {
|
||||
onOpenChangeAction?.(false)
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {
|
||||
removeNotificationChannelAction,
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export type DeleteNotifierButtonProps = {
|
||||
notificationChannelId: string;
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export const DeleteNotifierButton = ({notificationChannelId, organizationId}: DeleteNotifierButtonProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await removeNotificationChannelAction({organizationId, notificationChannelId})
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
title="Delete Notifier"
|
||||
description="Are you sure you want to remove this notifier ? This action cannot be undone and will delete all alert policies related to this channel !"
|
||||
button={{
|
||||
main: {
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
|
||||
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {
|
||||
updateNotificationChannelAction
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
|
||||
import {toast} from "sonner";
|
||||
import {useState} from "react";
|
||||
import {Organization, OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
|
||||
export type EditNotifierButtonProps = {
|
||||
notificationChannel: NotificationChannelWith;
|
||||
organization?: OrganizationWithMembers;
|
||||
organizations?: OrganizationWithMembers[];
|
||||
adminView?: boolean;
|
||||
};
|
||||
|
||||
export const EditNotifierButton = ({
|
||||
organizations,
|
||||
adminView = false,
|
||||
notificationChannel,
|
||||
organization
|
||||
}: EditNotifierButtonProps) => {
|
||||
const router = useRouter();
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (value: boolean) => {
|
||||
|
||||
const payload = {
|
||||
data: {
|
||||
name: notificationChannel.name,
|
||||
provider: notificationChannel.provider,
|
||||
config: notificationChannel.config as Record<string, any>,
|
||||
enabled: value
|
||||
},
|
||||
notificationChannelId: notificationChannel.id
|
||||
};
|
||||
|
||||
const result = await updateNotificationChannelAction(payload);
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch checked={notificationChannel.enabled} onCheckedChange={async () => {
|
||||
await mutation.mutateAsync(!notificationChannel.enabled)
|
||||
}}
|
||||
/>
|
||||
<NotifierAddEditModal
|
||||
organizations={organizations}
|
||||
adminView={adminView}
|
||||
organization={organization}
|
||||
notificationChannel={notificationChannel}
|
||||
open={isAddModalOpen}
|
||||
onOpenChangeAction={setIsAddModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {
|
||||
DeleteNotifierButton
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier";
|
||||
import {EditNotifierButton} from "@/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
|
||||
import {truncateWords} from "@/utils/text";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
|
||||
export type NotifierCardProps = {
|
||||
data: NotificationChannelWith;
|
||||
organization?: OrganizationWithMembers;
|
||||
organizations?: OrganizationWithMembers[];
|
||||
adminView?: boolean;
|
||||
};
|
||||
|
||||
export const NotifierCard = (props: NotifierCardProps) => {
|
||||
const {data, organization} = props;
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<div className="block transition-all duration-200 rounded-xl">
|
||||
<Card className="flex flex-row justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
|
||||
{getNotificationChannelIcon(data.provider)}
|
||||
</div>
|
||||
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start w-full">
|
||||
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
|
||||
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{data.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EditNotifierButton
|
||||
organizations={props.organizations}
|
||||
adminView={props.adminView}
|
||||
organization={organization}
|
||||
notificationChannel={data}/>
|
||||
<DeleteNotifierButton
|
||||
organizationId={organization?.id}
|
||||
notificationChannelId={data.id}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
"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 {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";
|
||||
|
||||
|
||||
export const addNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
data: NotificationChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {organizationId, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.insert(drizzleDb.schemas.notificationChannel)
|
||||
.values({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (organizationId) {
|
||||
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
|
||||
organizationId,
|
||||
notificationChannelId: channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully created.",
|
||||
messageParams: {notificationChannelId: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to create notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const removeNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
notificationChannelId: z.string(),
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {organizationId, notificationChannelId} = parsedInput;
|
||||
|
||||
try {
|
||||
if (organizationId) {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.organizationNotificationChannel)
|
||||
.where(
|
||||
and(
|
||||
eq(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationId),
|
||||
eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, notificationChannelId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [deletedChannel] = await db
|
||||
.delete(drizzleDb.schemas.notificationChannel)
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId))
|
||||
.returning();
|
||||
|
||||
if (!deletedChannel) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Notification channel not found.",
|
||||
status: 404,
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...deletedChannel,
|
||||
config: deletedChannel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully removed.",
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to remove notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const updateNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
notificationChannelId: z.string(),
|
||||
data: NotificationChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {notificationChannelId, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.update(drizzleDb.schemas.notificationChannel)
|
||||
.set(withUpdatedAt({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: `Notification channel "${channel.name}" has been successfully updated.`,
|
||||
messageParams: {notificationChannelId: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import {z} from "zod";
|
||||
|
||||
|
||||
export const NotificationChannelFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(5, "Name must be at least 5 characters long")
|
||||
.max(40, "Name must be at most 40 characters long"),
|
||||
|
||||
provider: z.enum(["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"], {
|
||||
required_error: "Provider is required",
|
||||
}),
|
||||
|
||||
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
|
||||
|
||||
|
||||
enabled: z.boolean().default(true),
|
||||
|
||||
});
|
||||
|
||||
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
||||
@@ -1,236 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
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";
|
||||
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 {notificationTypes} from "@/components/wrappers/dashboard/admin/notifications/helpers";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {ArrowLeft} from "lucide-react";
|
||||
|
||||
type NotifierFormProps = {
|
||||
onSuccessAction?: () => void;
|
||||
organization?: OrganizationWithMembers;
|
||||
defaultValues?: NotificationChannelWith
|
||||
adminView?: boolean
|
||||
};
|
||||
|
||||
export const NotifierForm = ({onSuccessAction, organization, defaultValues}: NotifierFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(defaultValues);
|
||||
|
||||
|
||||
const form = useZodForm({
|
||||
schema: NotificationChannelFormSchema,
|
||||
// @ts-ignore
|
||||
defaultValues: {...defaultValues},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues ? {...defaultValues} : {});
|
||||
}, [defaultValues]);
|
||||
|
||||
const mutationAddNotificationChannel = useMutation({
|
||||
mutationFn: async (values: NotificationChannelFormType) => {
|
||||
|
||||
const payload = {
|
||||
data: values,
|
||||
...(organization && {organizationId: organization.id}),
|
||||
...((defaultValues && {notificationChannelId: defaultValues.id}))
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
isCreate && onSuccessAction?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
isCreate && onSuccessAction?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const provider = form.watch("provider");
|
||||
const selectedProviderDetails = notificationTypes.find(t => t.value === provider);
|
||||
|
||||
if (isCreate && !provider) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
|
||||
{notificationTypes.map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 p-4 cursor-pointer hover:bg-accent/50 hover:border-primary/50 transition-all",
|
||||
)}
|
||||
onClick={() => {
|
||||
form.setValue("provider", type.value as any);
|
||||
form.setValue("config", {});
|
||||
}}
|
||||
>
|
||||
<div className="h-10 w-10 bg-secondary rounded-full flex items-center justify-center">
|
||||
<Icon className="h-6 w-6 text-foreground"/>
|
||||
</div>
|
||||
<span className="font-medium text-sm">{type.label}</span>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationAddNotificationChannel.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
|
||||
{selectedProviderDetails && (
|
||||
<div className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
|
||||
<selectedProviderDetails.icon className="h-5 w-5"/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
|
||||
</div>
|
||||
{isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("provider", undefined as any);
|
||||
form.setValue("config", undefined);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({field}) => (
|
||||
<input type="hidden" {...field} value={field.value || ""} />
|
||||
)}
|
||||
/>
|
||||
|
||||
{provider === "smtp" && (
|
||||
<NotifierSmtpForm form={form}/>
|
||||
)}
|
||||
|
||||
{provider === "slack" && (
|
||||
<NotifierSlackForm form={form}/>
|
||||
)}
|
||||
|
||||
{provider === "discord" && (
|
||||
<NotifierDiscordForm form={form}/>
|
||||
)}
|
||||
|
||||
{provider === "telegram" && (
|
||||
<NotifierTelegramForm form={form}/>
|
||||
)}
|
||||
|
||||
{provider === "gotify" && (
|
||||
<NotifierGotifyForm form={form}/>
|
||||
)}
|
||||
|
||||
{provider === "ntfy" && (
|
||||
<NotifierNtfyForm form={form}/>
|
||||
)}
|
||||
|
||||
{provider === "webhook" && (
|
||||
<NotifierWebhookForm form={form}/>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<div>
|
||||
{defaultValues && (
|
||||
<NotifierTestChannelButton organizationId={organization?.id}
|
||||
notificationChannel={defaultValues}/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSuccessAction?.();
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading isPending={mutationAddNotificationChannel.isPending}>
|
||||
{isCreate ? "Add" : "Save"} Channel
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
"use client"
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||
import {EventPayload} from "@/features/notifications/types";
|
||||
import {Send} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
type NotifierTestChannelButtonProps = {
|
||||
notificationChannel: NotificationChannel;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export const NotifierTestChannelButton = ({notificationChannel, organizationId}: NotifierTestChannelButtonProps) => {
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
const payload: EventPayload = {
|
||||
title: 'Test Channel',
|
||||
message: `We are testing channel ${notificationChannel.name}`,
|
||||
level: 'info',
|
||||
// data: {host: 'db-prod-01', error: 'connection timeout'},
|
||||
};
|
||||
|
||||
const result = await dispatchNotification(payload, undefined, notificationChannel.id, organizationId);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error("An error occurred while testing the notification channel");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => mutation.mutateAsync()}
|
||||
disabled={mutation.isPending}
|
||||
className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<div className={cn(" h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white", !isMobile && "mr-2")}/>
|
||||
{!isMobile && `Sending...`}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-row justify-center items-center">
|
||||
<Send className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Channel`}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
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";
|
||||
|
||||
|
||||
type NotifierDiscordFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.discordWebhook"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
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";
|
||||
|
||||
|
||||
type NotifierGotifyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gotify Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyAppToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
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";
|
||||
|
||||
|
||||
type NotifierNtfyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyTopic"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-secret-topic"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://ntfy.sh (default)"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Token (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="tk_..."/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
|
||||
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";
|
||||
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => {
|
||||
return(
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.slackWebhook"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://hooks.slack.com/services/..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
|
||||
export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.host"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="smtp.gmail.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="456" type="number" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.user"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="contact@exemple.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.from"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={`"Portabase" <exemple@portabase.io>`} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.to"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"contact@portabase.io, contact2@portabase.io"} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
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";
|
||||
|
||||
|
||||
type NotifierTelegramFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramBotToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramChatId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="-100123456789 or 123456789"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
|
||||
For groups, add the bot to the group. <br/>
|
||||
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
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";
|
||||
|
||||
|
||||
type NotifierWebhookFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/api/webhook"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecretHeader"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="X-Webhook-Secret"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecret"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Value (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Secret value..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>).
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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";
|
||||
@@ -80,6 +80,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",
|
||||
|
||||
Reference in New Issue
Block a user