feat/onboarding (#337)

* feat(onboarding): add cookie helper, types and mock data

* feat(onboarding): add stepper, contextual preview and shell

* feat(onboarding): add sso-gate step

* feat(onboarding): add account-info step with real signup

* feat(onboarding): add security step

* feat(onboarding): add preferences step

* fix(onboarding): validate avatar upload size/type and handle FileReader errors

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

* feat(onboarding): add notifier step reusing real provider list

* feat(onboarding): add storage step reusing real provider list

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

* feat(onboarding): add agent-waiting step with simulated ping

* feat(onboarding): add project-create step

* feat(onboarding): add db-settings step with per-db repeat

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

* feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie

* fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written

* docs: add onboarding channel config + style refactor spec

* docs: add conditional step skip when no agents to spec

* docs(onboarding): add implementation plan for channel config flow and style refactor

* feat(onboarding): extend OnboardingChannel with name and config fields

* feat(onboarding): add two-phase config flow and org-combobox style to notifier step

* fix(onboarding): prevent duplicate notifier providers and guard Continue button

* feat(onboarding): add two-phase config flow and org-combobox style to storage step

* style(onboarding): restyle sso-gate provider buttons to org-combobox card style

* style(onboarding): restyle security method choice to org-combobox card style

* style(onboarding): restyle theme toggles in preferences step to org-combobox card style

* style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style

* feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created

* feat(onboarding): update type system — meta, firstName/lastName, org.id, databases

* feat(onboarding): add settings.onboarding column + markOnboardingDoneAction

* feat(onboarding): add resolveOnboardingState server function

* fix(onboarding): remove unused imports from onboarding-state.ts

* style(onboarding): add branding to shell, fix progress bar full width

* feat(onboarding): replace preview panel with stepper checklist

* feat(onboarding): add step-login replacing sso-gate

* fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors

* feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate

* feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step

* feat(onboarding): step-security reads passkeyEnabled from flowData.meta

* feat(onboarding): step-org-create calls real createOrganizationAction

* fix(onboarding): step-org-create — simplify error handling, throw on empty name

* feat(onboarding): notifier + storage steps call real actions + findOrCreate on back

* feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate

* feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery

* feat(onboarding): add step-agent-key with edgeKey generation via server action

* feat(onboarding): step-project-create uses real action + real DBs from flowData

* feat(onboarding): wire steps — add login + agent-key, remove sso-gate

* feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume

* chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation

* feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org

* feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact

* feat(onboarding): agent-waiting step is now skippable

* feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state

* fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false

* fix(onboarding): verify session after passkey registration, fallback to signIn.passkey()

* feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image

* feat(onboarding): remove org logo upload — orgs have no logo

* feat(onboarding): show Next button in shell when navigating backwards

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

* fix(onboarding): trim project name, standardize error narrowing in org/project hooks

* fix(onboarding): use narrowed createData.value in use-create-org

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

* refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files

* docs: add onboarding theme persistence & progress bar fix spec

* docs: add onboarding theme & progress bar implementation plan

* fix(onboarding): persist theme to db on selection in StepPreferences

* fix(onboarding): recalculate progress bar from full STEP_ORDER position

* fix(theme): sync user theme from DB on all routes, not only dashboard

* fix(theme): use current theme as fallback in preferences, prevent session override of localStorage

* fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected

* fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash

* fix(onboarding): resume at db-settings instead of finish if no database exists for the project

* fix(onboarding): redirect to agent-create if project exists but no agent is found

* fix(onboarding): allow going from agent-create to project-create if a project already exists

* fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create

* fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist

* feat(onboarding): allow user to choose between passkey and password during registration if both are enabled

* feat(onboarding): replace tabs with single form and multiple buttons for auth choice

* style(onboarding): improve empty auth state UI with lucide icon and better layout

* fix(onboarding): completely hide back button on the first step instead of disabling it

* fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step

* fix(onboarding): correctly show select placeholder when default value does not exist in the list

* refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly

* feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading

* fix(onboarding): skip db-settings step if no database is found on the agent

* fix(onboarding): do not skip db settings if agent is connected but no databases found

* refactor(onboarding): skip db settings entirely if no database is selected in project create

* feat(onboarding): auto-save database selections on project create instantly

* style(onboarding): replace button opacity flash with specific spinner animation on db select

* feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

* feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence

* fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys

* docs(sdd): append hotfix summary for applyMutation and list keys

* fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

* fix(settings): make avatar selector, dicebear picker and tab bar responsive

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

* refactor(agents): reorganize into actions/components/schemas/hooks

* refactor(auth): reorganize into actions/components/schemas

* refactor(channel): reorganize into components/schemas, nest notifications+storages under components

* refactor(database): reorganize into actions/components/schemas

* refactor(layout): move all components to components/

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

* refactor(notifications): reorganize into components/types/utils

* refactor(organizations): reorganize into actions/components/schemas/hooks/utils

* refactor(profile): reorganize into actions/components/schemas

* refactor(projects): reorganize into actions/components/schemas

* refactor(settings): reorganize into actions/components/schemas/hooks

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

* refactor(theme): move all components to components/

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

* refactor(users): reorganize into actions/components/schemas

* fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths)

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

* fix: persist onboarding=true on boot when SKIP_ONBOARDING is set

* feat: remove avatarMode/dicebearStyle from DB schema and types

* feat: simplify resolveAvatarUrl — custom image first, gravatar fallback

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

* feat: add avatar reset button and explanatory tooltip on profile page

* fix: move avatar tooltip to top-right badge overlay on avatar

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Théo LAGACHE
2026-06-27 17:08:38 +02:00
committed by GitHub
co-authored by Théo LAGACHE charles-gauthereau
parent 40d3228db6
commit 40247efddd
451 changed files with 15904 additions and 5054 deletions
@@ -0,0 +1,200 @@
"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 { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { NotificationChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
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,
organizationId: organizationId ?? null,
})
.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) {
const 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) {
const 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({
id: z.string(),
data: NotificationChannelFormSchema,
}),
)
.action(
async ({
parsedInput,
}): Promise<ServerActionResult<NotificationChannel>> => {
const { id, 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, id))
.returning();
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: `Notification channel "${channel.name}" has been successfully updated.`,
messageParams: { id: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: "" },
},
};
}
},
);
@@ -0,0 +1,38 @@
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
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>
<PasswordInput
{...field}
placeholder="e.g. https://discord.com/api/webhooks/..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -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,48 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendDiscord(
config: { discordWebhook: string },
payload: EventPayload
): Promise<DispatchResult> {
const {discordWebhook: webhookUrl} = config;
const embeds = [
{
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
description: payload.message,
color: payload.level === 'critical' ? 15158332 : payload.level === 'warning' ? 16776960 : 3447003,
fields: payload.data ? [
{
name: 'Data',
value: `
${JSON.stringify(payload.data, null, 2).substring(0, 1000)}
`,
inline: false
}
] : [],
timestamp: new Date().toISOString(),
}
];
const body = {
embeds,
};
const res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Discord error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'discord',
message: 'Sent to Discord',
response: res.statusText,
};
}
@@ -0,0 +1,49 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
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="e.g. https://gotify.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.gotifyAppToken"
render={({ field }) => (
<FormItem>
<FormLabel>Application Token *</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="e.g. gotify-app-token" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -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,34 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendGotify(
config: { gotifyServerUrl: string; gotifyAppToken: string },
payload: EventPayload
): Promise<DispatchResult> {
const {gotifyServerUrl, gotifyAppToken} = config;
const baseUrl = gotifyServerUrl.replace(/\/$/, "");
const body = {
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
message: `${payload.message}\n\n${payload.data ? `Data:\n${JSON.stringify(payload.data, null, 2)}` : ''}`,
priority: payload.level === 'critical' ? 8 : payload.level === 'warning' ? 5 : 2,
};
const res = await fetch(`${baseUrl}/message?token=${gotifyAppToken}`, {
method: 'POST',
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Gotify error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'gotify',
message: 'Sent to Gotify',
response: await res.json(),
};
}
@@ -0,0 +1,56 @@
"use server"
import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/types';
import {sendSlack} from './slack';
import {sendSmtp} from './smtp';
import {sendDiscord} from "@/features/channel/components/notifications/discord";
import {sendTelegram} from "@/features/channel/components/notifications/telegram";
import {sendGotify} from "@/features/channel/components/notifications/gotify";
import {sendNtfy} from "@/features/channel/components/notifications/ntfy";
import {sendWebhook} from "@/features/channel/components/notifications/webhook";
import {sendNextcloud} from "@/features/channel/components/notifications/nextcloud";
import {sendPushover} from "@/features/channel/components/notifications/pushover";
import {sendTeams} from "@/features/channel/components/notifications/teams"
const handlers: Record<
ProviderKind,
(config: any, payload: EventPayload) => Promise<DispatchResult>
> = {
slack: sendSlack,
smtp: sendSmtp,
discord: sendDiscord,
telegram: sendTelegram,
gotify: sendGotify,
ntfy: sendNtfy,
webhook: sendWebhook,
nextcloud: sendNextcloud,
teams: sendTeams,
pushover: sendPushover,
};
export async function dispatchViaProvider(
kind: ProviderKind,
config: any,
payload: EventPayload,
channelId: string
): Promise<DispatchResult> {
const handler = handlers[kind];
if (!handler) {
return {
success: false,
channelId,
provider: kind,
error: `Unsupported provider: ${kind}`,
};
}
try {
return await handler(config, payload);
} catch (err: any) {
return {
success: false,
channelId,
provider: kind,
error: err.message || 'Unknown error',
};
}
}
@@ -0,0 +1,62 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierNextcloudFormProps = {
form: UseFormReturn<any, any, any>;
};
export const NotifierNextcloudForm = ({ form }: NotifierNextcloudFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.nextcloudUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Nextcloud URL *</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. https://cloud.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.nextcloudBotToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token *</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. j3yujpuh" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.nextcloudBotSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Secret *</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="HMAC signing secret" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const NextcloudChannelConfigSchema = z.object({
nextcloudUrl: z.string().url("Must be a valid URL"),
nextcloudBotToken: z.string().min(1, "Bot token is required"),
nextcloudBotSecret: z.string().min(1, "Bot secret is required"),
});
@@ -0,0 +1,88 @@
import { createHmac, randomBytes } from "crypto";
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
type NextcloudConfig = {
nextcloudUrl: string;
nextcloudBotToken: string;
nextcloudBotSecret: string;
};
function formatPayloadData(data: unknown): string {
if (!data) {
return "";
}
if (typeof data === "string") {
return data;
}
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
export async function sendNextcloud(
config: NextcloudConfig,
payload: EventPayload
): Promise<DispatchResult> {
const {
nextcloudUrl,
nextcloudBotToken,
nextcloudBotSecret,
} = config;
const payloadData = formatPayloadData(payload.data);
const messageParts = [
`[${payload.level.toUpperCase()}] ${payload.title}`,
payload.message,
];
if (payloadData) {
messageParts.push(`Payload:\n${payloadData}`);
}
const message = messageParts.join("\n\n");
const random = randomBytes(32).toString("hex");
const signature = createHmac("sha256", nextcloudBotSecret)
.update(random + message)
.digest("hex");
const baseUrl = nextcloudUrl.replace(/\/$/, "");
const res = await fetch(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${nextcloudBotToken}/message`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random,
"X-Nextcloud-Talk-Bot-Signature": signature,
},
body: JSON.stringify({
message,
}),
}
);
if (!res.ok) {
const err = await res.text();
throw new Error(
`Nextcloud error: ${res.status} ${err}`
);
}
return {
success: true,
provider: "nextcloud",
message: "Sent to Nextcloud Talk",
response: await res.text(),
};
}
@@ -0,0 +1,91 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
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="e.g. team-alerts"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. https://ntfy.example.com"/>
</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</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="e.g. tk_xxxxx"/>
</FormControl>
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
<FormMessage/>
</FormItem>
)}
/>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.ntfyUsername"
render={({field}) => (
<FormItem>
<FormLabel>Basic Auth Username</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. notifier-user"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyPassword"
render={({field}) => (
<FormItem>
<FormLabel>Basic Auth Password</FormLabel>
<FormControl>
<PasswordInput placeholder="e.g. notifier-password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -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,80 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
const getPriority = (level?: string): number => {
switch (level) {
case 'critical': return 5;
case 'error': return 4;
case 'warning': return 3;
case 'info':
default: return 2;
}
};
const formatData = (data: any): string => {
if (!data || typeof data !== 'object') return '';
return '\n\nDetails:\n' + Object.entries(data)
.map(([key, value]) => {
const stringVal = value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
return `- ${key}: ${stringVal}`;
})
.join('\n');
};
const getTags = (level?: string): string[] => {
const baseTags = ['floppy_disk'];
if (level === 'critical') baseTags.push('rotating_light');
else if (level === 'error') baseTags.push('x');
else if (level === 'warning') baseTags.push('warning');
else baseTags.push('information_source');
return baseTags;
};
export async function sendNtfy(
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
payload: EventPayload
): Promise<DispatchResult> {
const {ntfyServerUrl, ntfyTopic, ntfyToken, ntfyUsername, ntfyPassword} = config;
const baseUrl = (ntfyServerUrl || "https://ntfy.sh").replace(/\/$/, "");
const body = {
topic: ntfyTopic,
title: payload.title,
message: payload.message + formatData(payload.data),
priority: getPriority(payload.level),
tags: getTags(payload.level),
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (ntfyToken) {
headers['Authorization'] = `Bearer ${ntfyToken}`;
}
if (ntfyUsername && ntfyPassword) {
const credentials = btoa(`${ntfyUsername}:${ntfyPassword}`);
headers['Authorization'] = `Basic ${credentials}`;
}
const res = await fetch(`${baseUrl}`, {
method: 'POST',
body: JSON.stringify(body),
headers: headers,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`ntfy error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'ntfy',
message: 'Sent to ntfy',
response: await res.json(),
};
}
@@ -0,0 +1,100 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {PUSHOVER_PRIORITIES} from "@/features/channel/components/notifications/pushover.schema";
type NotifierPushoverFormProps = {
form: UseFormReturn<any, any, any>;
};
export const NotifierPushoverForm = ({form}: NotifierPushoverFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.pushoverUserKey"
render={({field}) => (
<FormItem>
<FormLabel>User Key *</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="Your 30-character Pushover user key"/>
</FormControl>
<p className="text-xs text-muted-foreground">
Found at the top of your Pushover dashboard at pushover.net.
</p>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.pushoverApiToken"
render={({field}) => (
<FormItem>
<FormLabel>App API Token *</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="Your 30-character application API token"/>
</FormControl>
<p className="text-xs text-muted-foreground">
Create an application at pushover.net/apps to get an API token.
</p>
<FormMessage/>
</FormItem>
)}
/>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.pushoverPriority"
render={({field}) => (
<FormItem>
<FormLabel>Message Priority</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? "0"}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select priority"/>
</SelectTrigger>
</FormControl>
<SelectContent>
{PUSHOVER_PRIORITIES.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Emergency priority repeats every 60 seconds until acknowledged (max 1 hour).
</p>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.pushoverDevice"
render={({field}) => (
<FormItem>
<FormLabel>Device Name</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} placeholder="e.g. iphone (leave empty for all devices)"/>
</FormControl>
<p className="text-xs text-muted-foreground">
Target a specific registered device. Leave empty to send to all devices.
</p>
<FormMessage/>
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,16 @@
import {z} from "zod";
export const PUSHOVER_PRIORITIES = [
{value: "-2", label: "Lowest - no sound, no vibration, no alert"},
{value: "-1", label: "Low - quiet notification, no sound"},
{value: "0", label: "Normal - default sound and alert"},
{value: "1", label: "High - bypass quiet hours"},
{value: "2", label: "Emergency - repeat until acknowledged"},
] as const;
export const PushoverChannelConfigSchema = z.object({
pushoverUserKey: z.string().min(30, "User key must be 30 characters").max(30, "User key must be 30 characters"),
pushoverApiToken: z.string().min(30, "API token must be 30 characters").max(30, "API token must be 30 characters"),
pushoverPriority: z.enum(["-2", "-1", "0", "1", "2"]).default("0"),
pushoverDevice: z.string().max(25, "Device name must be at most 25 characters").optional().or(z.literal("")),
});
@@ -0,0 +1,65 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendPushover(
config: {
pushoverUserKey: string;
pushoverApiToken: string;
pushoverPriority: string;
pushoverDevice?: string;
},
payload: EventPayload
): Promise<DispatchResult> {
const {pushoverUserKey, pushoverApiToken, pushoverDevice} = config;
const priority = parseInt(config.pushoverPriority ?? "0", 10);
const rawTitle = `[${payload.level.toUpperCase()}] ${payload.title}`;
const rawMessage = payload.data
? `${payload.message}\n\nData:\n${JSON.stringify(payload.data)}`
: payload.message;
const body: Record<string, string | number> = {
token: pushoverApiToken,
user: pushoverUserKey,
title: rawTitle.length > 250 ? rawTitle.slice(0, 249) + "…" : rawTitle,
message: rawMessage.length > 1024 ? rawMessage.slice(0, 1023) + "…" : rawMessage,
priority,
};
if (pushoverDevice) {
body.device = pushoverDevice;
}
// Emergency priority requires retry + expire
if (priority === 2) {
body.retry = 60;
body.expire = 3600;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
let res: Response;
try {
res = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
const json = await res.json().catch(() => null);
if (!res.ok) {
const errors = json?.errors?.join(", ") ?? res.statusText;
throw new Error(`Pushover error ${res.status}: ${errors}`);
}
return {
success: true,
provider: "pushover",
message: "Sent via Pushover",
response: json,
};
}
@@ -0,0 +1,38 @@
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
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>
<PasswordInput
{...field}
placeholder="e.g. https://hooks.slack.com/services/..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -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,46 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendSlack(
config: { slackWebhook: string },
payload: EventPayload
): Promise<DispatchResult> {
const {slackWebhook: webhookUrl} = config;
const text = `*[${payload.level}] ${payload.title}*\n${payload.message}`;
const blocks = [
{
type: 'section',
text: {type: 'mrkdwn', text: `*[${payload.level.toUpperCase()}] ${payload.title}*`},
},
{type: 'section', text: {type: 'mrkdwn', text: payload.message}},
payload.data
? {
type: 'context',
elements: [{type: 'mrkdwn', text: `*Data:* \`\`\`${JSON.stringify(payload.data, null, 2)}\`\`\``}],
}
: null,
].filter(Boolean);
const body = {
text,
blocks,
};
const res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Slack error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'slack',
message: 'Sent to Slack',
response: await res.text(),
};
}
@@ -0,0 +1,105 @@
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="e.g. smtp.example.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="e.g. 587" 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="e.g. noreply@example.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={`e.g. "Portabase" <noreply@example.com>`} {...field}
value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.to"
render={({field}) => (
<FormItem>
<FormLabel>To Email *</FormLabel>
<FormControl>
<Input placeholder={"e.g. alerts@example.com, team@example.com"} {...field}
value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -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"),
});
@@ -0,0 +1,46 @@
"use server";
import { render } from "@react-email/render";
import nodemailer from "nodemailer";
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
import EmailNotification from "@/components/emails/email-notification";
export async function sendSmtp(
config: {
host: string;
port: number;
secure: boolean;
user: string;
password: string;
from: string;
to: string | string[];
},
payload: EventPayload
): Promise<DispatchResult> {
const transporter = nodemailer.createTransport({
pool: true,
host: config.host,
port: config.port,
secure: config.secure,
auth: { user: config.user, pass: config.password },
});
await transporter.verify();
const info = await transporter.sendMail({
from: config.from,
to: Array.isArray(config.to) ? config.to.join(", ") : config.to,
subject: `[${payload.level.toUpperCase()}] ${payload.title}`,
html: await render(EmailNotification({
payload: payload
})),
});
return {
success: true,
provider: "smtp",
message: `Email sent: ${config.to}`,
response: info,
};
}
@@ -0,0 +1,38 @@
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierTeamsFormProps = {
form: UseFormReturn<any, any, any>;
};
export const NotifierTeamsForm = ({ form }: NotifierTeamsFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.teamsWebhook"
render={({ field }) => (
<FormItem>
<FormLabel>Teams Webhook URL *</FormLabel>
<FormControl>
<PasswordInput
{...field}
placeholder="e.g. https://xxxxx.logic.azure.com:443/xxxxx"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,5 @@
import {z} from "zod";
export const TeamsChannelConfigSchema = z.object({
teamsWebhook: z.string().url("Must be a valid URL"),
});
@@ -0,0 +1,93 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
export async function sendTeams(
config: { teamsWebhook: string },
payload: EventPayload
): Promise<DispatchResult> {
const { teamsWebhook: webhookUrl } = config;
const levelColor: Record<string, string> = {
critical: 'attention',
warning: 'warning',
info: 'good',
};
const accentColor = levelColor[payload.level] ?? 'default';
const bodyItems: object[] = [
{
type: 'TextBlock',
text: `**[${payload.level.toUpperCase()}] ${payload.title}**`,
wrap: true,
color: accentColor,
size: 'Medium',
weight: 'Bolder',
},
{
type: 'TextBlock',
text: payload.message,
wrap: true,
},
];
if (payload.data) {
bodyItems.push(
{
type: 'TextBlock',
text: '**Data:**',
wrap: true,
weight: 'Bolder',
},
{
type: 'TextBlock',
text: JSON.stringify(payload.data, null, 2).substring(0, 1000),
wrap: true,
fontType: 'Monospace',
}
);
}
const adaptiveCard = {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.2',
body: bodyItems,
};
const body = {
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
contentUrl: null,
content: adaptiveCard,
},
],
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
let res: Response;
try {
res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
const err = await res.text();
throw new Error(`Teams error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'teams',
message: 'Sent to Microsoft Teams',
response: res.statusText,
};
}
@@ -0,0 +1,84 @@
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
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>
<PasswordInput
{...field}
placeholder="e.g. 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="e.g. -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>
)}
/>
<FormField
control={form.control}
name="config.telegramTopicId"
render={({ field }) => (
<FormItem>
<FormLabel>Telegram Topic ID</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. 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,62 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
export async function sendTelegram(
config: {
telegramBotToken: string;
telegramChatId: string;
telegramTopicId?: string;
},
payload: EventPayload,
): Promise<DispatchResult> {
const { telegramBotToken, telegramChatId, telegramTopicId } = config;
// Helper to escape HTML characters
const escapeHtml = (unsafe: string) => {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
const title = escapeHtml(payload.title);
const message = escapeHtml(payload.message);
const level = escapeHtml(payload.level.toUpperCase());
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 body: Record<string, any> = {
chat_id: telegramChatId,
text,
parse_mode: "HTML",
};
if (telegramTopicId) {
body.message_thread_id = Number(telegramTopicId);
}
const res = await fetch(
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
},
);
if (!res.ok) {
const err = await res.text();
throw new Error(`Telegram error: ${res.status} ${err}`);
}
return {
success: true,
provider: "telegram",
message: "Sent to Telegram",
response: await res.text(),
};
}
@@ -0,0 +1,122 @@
"use client";
import { useFieldArray, UseFormReturn } from "react-hook-form";
import { Plus, Trash2 } from "lucide-react";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any>;
};
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "config.webhookHeaders",
});
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL *</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g. https://example.com/api/webhook"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Custom Headers</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: "", value: "" })}
>
<Plus className="w-4 h-4 mr-2" />
Add Header
</Button>
</div>
{fields.length === 0 && (
<p className="text-xs text-muted-foreground">
No custom headers. Add headers to send with each webhook request.
</p>
)}
<div className="space-y-2">
{fields.map((headerField, index) => (
<div key={headerField.id} className="flex gap-2">
<div className="flex-1">
<FormField
control={form.control}
name={`config.webhookHeaders.${index}.key`}
render={({ field }) => (
<FormItem>
<FormLabel>Header Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g. X-Api-Key"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name={`config.webhookHeaders.${index}.value`}
render={({ field }) => (
<FormItem>
<FormLabel>Header Value</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Header value"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="self-end">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</div>
</>
);
};
@@ -0,0 +1,16 @@
import { z } from "zod";
export const WebhookChannelConfigSchema = z.object({
webhookUrl: z.string().url("Must be a valid URL"),
webhookHeaders: z
.array(
z.object({
key: z.string().min(1, "Header name is required"),
value: z.string(),
}),
)
.optional()
.default([]),
});
export type WebhookChannelConfig = z.infer<typeof WebhookChannelConfigSchema>;
@@ -0,0 +1,49 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
type WebhookConfig = {
webhookUrl: string;
webhookHeaders?: { key: string; value: string }[];
webhookSecret?: string;
webhookSecretHeader?: string;
};
export async function sendWebhook(
config: WebhookConfig,
payload: EventPayload
): Promise<DispatchResult> {
const { webhookUrl, webhookHeaders, webhookSecret, webhookSecretHeader } = config;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'Portabase-Notifier/1.0',
};
// New format: iterate custom headers array
if (webhookHeaders && webhookHeaders.length > 0) {
const RESERVED = new Set(['content-type', 'user-agent']);
for (const { key, value } of webhookHeaders) {
if (key && !RESERVED.has(key.toLowerCase())) headers[key] = value;
}
} else if (webhookSecret) {
// Legacy format: single secret header pair
headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
}
const res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(payload),
headers,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Webhook error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'webhook',
message: 'Sent to Webhook',
response: await res.text(),
};
}