diff --git a/Cargo.lock b/Cargo.lock
index d9251ff27..1f7c53bd5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3544,6 +3544,22 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "handlebars"
+version = "6.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4633d16a2350341713c379d6d06a4b9e1845329386026a49ce4fd09c2f3b16f6"
+dependencies = [
+ "derive_builder",
+ "log",
+ "num-order",
+ "pest",
+ "pest_derive",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -5257,6 +5273,21 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-modular"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0"
+
+[[package]]
+name = "num-order"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
+dependencies = [
+ "num-modular",
+]
+
[[package]]
name = "num-rational"
version = "0.2.4"
@@ -6576,6 +6607,7 @@ dependencies = [
"remux-sdks",
"serde",
"serde_json",
+ "strum",
"urlencoding",
"uuid",
"web-sys",
@@ -6676,6 +6708,7 @@ dependencies = [
"flate2",
"futures",
"futures-util",
+ "handlebars",
"headers",
"http 1.4.0",
"http-body 1.0.1",
diff --git a/crates/remux-dashboard/Cargo.toml b/crates/remux-dashboard/Cargo.toml
index 104d9758e..c7c65dd24 100644
--- a/crates/remux-dashboard/Cargo.toml
+++ b/crates/remux-dashboard/Cargo.toml
@@ -18,6 +18,8 @@ js-sys = "0.3"
serde_json = "1"
base64 = "0.22"
urlencoding = "2.1.3"
+# `IntoEnumIterator`, for the `EnumIter` the SDK derives on `NotificationType`.
+strum = "0.27"
[profile.release]
opt-level = "z"
diff --git a/crates/remux-dashboard/src/layout.rs b/crates/remux-dashboard/src/layout.rs
index d344ebd93..a71af0681 100644
--- a/crates/remux-dashboard/src/layout.rs
+++ b/crates/remux-dashboard/src/layout.rs
@@ -110,6 +110,7 @@ pub fn DashboardLayout() -> Element {
Route::SettingsBrandingRoute => "Branding",
Route::SettingsIntroRoute => "Intro",
Route::SettingsRemuxdbRoute => "Remuxdb",
+ Route::SettingsWebhooksRoute => "Webhooks",
Route::AccessUsersRoute => "Users",
Route::AccessApiKeysRoute => "API Keys",
Route::TasksRoute => "Tasks",
@@ -198,6 +199,7 @@ pub fn DashboardLayout() -> Element {
| Route::SettingsBrandingRoute
| Route::SettingsIntroRoute
| Route::SettingsRemuxdbRoute
+ | Route::SettingsWebhooksRoute
),
NavSubItem {
label: "General",
@@ -234,6 +236,11 @@ pub fn DashboardLayout() -> Element {
active: route == Route::SettingsBrandingRoute,
on_click: move |_| { navigator().push(Route::SettingsBrandingRoute); sidebar_open.set(false); },
}
+ NavSubItem {
+ label: "Webhooks",
+ active: route == Route::SettingsWebhooksRoute,
+ on_click: move |_| { navigator().push(Route::SettingsWebhooksRoute); sidebar_open.set(false); },
+ }
}
SidebarGroup {
diff --git a/crates/remux-dashboard/src/pages/mod.rs b/crates/remux-dashboard/src/pages/mod.rs
index cacdfc8a2..42f3d410b 100644
--- a/crates/remux-dashboard/src/pages/mod.rs
+++ b/crates/remux-dashboard/src/pages/mod.rs
@@ -8,6 +8,7 @@ pub mod iptv;
pub mod settings;
pub mod streams;
pub mod users;
+pub mod webhooks;
pub use addons::AddonsPage;
pub use api_keys::ApiKeysPage;
@@ -22,3 +23,4 @@ pub use settings::{
};
pub use streams::StreamGroupsCard;
pub use users::UsersPage;
+pub use webhooks::WebhooksPage;
diff --git a/crates/remux-dashboard/src/pages/webhooks.rs b/crates/remux-dashboard/src/pages/webhooks.rs
new file mode 100644
index 000000000..f08a2ed78
--- /dev/null
+++ b/crates/remux-dashboard/src/pages/webhooks.rs
@@ -0,0 +1,1596 @@
+//! Admin page for outgoing webhooks.
+//!
+//! Two rules govern everything in this module.
+//!
+//! **A webhook URL is a credential.** Discord's is
+//! `https://discord.com/api/webhooks/{id}/{token}`, so the URL is never logged
+//! and the list renders only a prefix that stops short of any Discord token.
+//!
+//! **Every mutation sends a complete [`WebhookDto`].** `WebhookDto` carries no
+//! `#[serde(default)]`, so a payload missing one field is a 422 rather than a
+//! partial update — even the one-click enable toggle rebuilds the full row.
+
+use crate::{
+ components::{Card, EmptyState, ErrorAlert, FormGroup, LoadingText, ToggleRow},
+ state::AppState,
+};
+use dioxus::prelude::*;
+use remux_sdks::remux::{
+ CreateWebhook, DeleteWebhook, DiscordMentionType, GetUsers, GetWebhooks,
+ NotificationType, TestWebhook, UpdateWebhook, UserDto, WebhookDestination,
+ WebhookDto, WebhookItemTypes, WebhookKeyValue, WebhookTestResult, DISCORD_TEMPLATE,
+};
+use remux_sdks::ClientError;
+use std::{collections::HashMap, str::FromStr};
+use strum::IntoEnumIterator;
+use uuid::Uuid;
+
+/// The colour the server injects when a Discord hook names none (`0x3399FF`),
+/// mirrored here so the field is never blank.
+///
+/// Lower-case on purpose: `` round-trips its value in lower
+/// case and [`WebhookForm::to_dto`] stores the normalized form, so the swatch,
+/// the text field, the stored value and the placeholder are all one string.
+const DEFAULT_EMBED_COLOR: &str = "#3399ff";
+
+/// Every [`NotificationType`], in the order the SDK declares them.
+///
+/// Derived from the enum rather than listed here: a variant added to the SDK
+/// reaches the checkbox list on its own, with no second list to fall out of
+/// step. `EnumIter` yields declaration order, which is the order the form and
+/// [`sorted_notification_types`] treat as canonical.
+fn notification_types() -> impl Iterator- {
+ NotificationType::iter()
+}
+
+/// Labels for the seven [`WebhookItemTypes`] flags, indexed the same way as
+/// [`item_type_flag`] / [`set_item_type_flag`].
+const ITEM_TYPE_LABELS: [&str; 7] = [
+ "Movies", "Episodes", "Series", "Seasons", "Albums", "Songs", "Videos",
+];
+
+const MENTION_TYPES: [DiscordMentionType; 3] = [
+ DiscordMentionType::None,
+ DiscordMentionType::Here,
+ DiscordMentionType::Everyone,
+];
+
+/// How much of a webhook URL the list shows. 48 characters stop inside the id
+/// segment of a Discord webhook URL — well before the token.
+const URL_PREVIEW_LEN: usize = 48;
+
+// ---------------------------------------------------------------------------
+// Pure helpers
+// ---------------------------------------------------------------------------
+
+/// A display-only prefix of `url`, char-boundary safe.
+fn truncate_url(url: &str, max: usize) -> String {
+ if url
+ .chars()
+ .count()
+ <= max
+ {
+ return url.to_string();
+ }
+ let head: String = url
+ .chars()
+ .take(max)
+ .collect();
+ format!("{head}…")
+}
+
+/// `raw` as a `#rrggbb` string an `` accepts, or `None` when
+/// it is not a six-digit hex colour. Accepts a leading `#` or not, any case.
+fn normalize_hex_color(raw: &str) -> Option {
+ let trimmed = raw.trim();
+ let digits = trimmed
+ .strip_prefix('#')
+ .unwrap_or(trimmed);
+ if digits.len() != 6
+ || !digits
+ .chars()
+ .all(|c| c.is_ascii_hexdigit())
+ {
+ return None;
+ }
+ Some(format!("#{}", digits.to_ascii_lowercase()))
+}
+
+/// What to feed the colour swatch: the operator's colour when it parses, the
+/// server's default otherwise, so the widget is never blank while they type.
+fn color_input_value(raw: &str) -> String {
+ normalize_hex_color(raw).unwrap_or_else(|| DEFAULT_EMBED_COLOR.to_string())
+}
+
+fn item_type_flag(types: &WebhookItemTypes, idx: usize) -> bool {
+ match idx {
+ 0 => types.movies,
+ 1 => types.episodes,
+ 2 => types.series,
+ 3 => types.seasons,
+ 4 => types.albums,
+ 5 => types.songs,
+ 6 => types.videos,
+ _ => false,
+ }
+}
+
+fn set_item_type_flag(types: &mut WebhookItemTypes, idx: usize, value: bool) {
+ match idx {
+ 0 => types.movies = value,
+ 1 => types.episodes = value,
+ 2 => types.series = value,
+ 3 => types.seasons = value,
+ 4 => types.albums = value,
+ 5 => types.songs = value,
+ 6 => types.videos = value,
+ _ => {}
+ }
+}
+
+fn destination_label(destination: &WebhookDestination) -> &'static str {
+ match destination {
+ WebhookDestination::Generic { .. } => "Generic",
+ WebhookDestination::Discord { .. } => "Discord",
+ }
+}
+
+/// Reuses the existing user-badge variants rather than adding CSS.
+fn destination_badge_class(destination: &WebhookDestination) -> &'static str {
+ match destination {
+ WebhookDestination::Generic { .. } => "user-badge user-badge-self",
+ WebhookDestination::Discord { .. } => "user-badge user-badge-admin",
+ }
+}
+
+/// `Some(trimmed)` unless the field is blank — the server treats an empty
+/// Discord option and an absent one differently (`if_exist` blocks hinge on it),
+/// so a blank input must serialize as `null`, not `""`.
+fn non_empty(value: &str) -> Option {
+ let trimmed = value.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
+}
+
+/// `selected` in the canonical order, de-duplicated, so the payload does not
+/// depend on the order the operator ticked the boxes.
+fn sorted_notification_types(selected: &[NotificationType]) -> Vec {
+ notification_types()
+ .filter(|t| selected.contains(t))
+ .collect()
+}
+
+/// The message an operator sees when a mutation fails. `ClientError`'s
+/// `Display` also carries the status and endpoint; `user_message()` is the half
+/// the rest of the dashboard shows.
+fn action_failure(action: &str, error: &ClientError) -> String {
+ format!("Failed to {action}: {}", error.user_message())
+}
+
+/// One line describing a completed test. A refused delivery is a *result*, not
+/// an error: the API call succeeded and returned `success: false`.
+fn test_message(result: &WebhookTestResult) -> String {
+ if result.success {
+ match result.status_code {
+ Some(code) => format!("Test delivered — HTTP {code}"),
+ None => "Test delivered".to_string(),
+ }
+ } else {
+ let detail = result
+ .error
+ .clone()
+ .unwrap_or_else(|| "delivery failed".to_string());
+ match result.status_code {
+ Some(code) => format!("Test failed (HTTP {code}) — {detail}"),
+ None => format!("Test failed — {detail}"),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Form state
+// ---------------------------------------------------------------------------
+
+/// The editable shape of a webhook.
+///
+/// Both destinations' options live here at once, so flipping the selector back
+/// and forth never discards the headers the operator typed. Which half reaches
+/// the wire is decided by `discord` in [`WebhookForm::to_dto`].
+///
+/// `created_at` / `updated_at` are deliberately absent: they are server-owned
+/// (create stamps both, update preserves `created_at` and bumps `updated_at`),
+/// so the form sends `null` for them and loses nothing.
+#[derive(Clone, PartialEq)]
+pub struct WebhookForm {
+ /// `None` for a webhook that does not exist yet.
+ id: Option,
+ name: String,
+ enabled: bool,
+ url: String,
+ template: String,
+ discord: bool,
+ headers: Vec,
+ fields: Vec,
+ avatar_url: String,
+ bot_username: String,
+ embed_color: String,
+ mention_type: DiscordMentionType,
+ notification_types: Vec,
+ user_filter: Vec,
+ item_types: WebhookItemTypes,
+ send_all_properties: bool,
+ trim_whitespace: bool,
+ skip_empty_message_body: bool,
+}
+
+impl Default for WebhookForm {
+ fn default() -> Self {
+ Self {
+ id: None,
+ name: String::new(),
+ enabled: true,
+ url: String::new(),
+ template: String::new(),
+ discord: false,
+ headers: Vec::new(),
+ fields: Vec::new(),
+ avatar_url: String::new(),
+ bot_username: String::new(),
+ embed_color: DEFAULT_EMBED_COLOR.to_string(),
+ mention_type: DiscordMentionType::None,
+ notification_types: Vec::new(),
+ user_filter: Vec::new(),
+ item_types: WebhookItemTypes::default(),
+ send_all_properties: false,
+ trim_whitespace: false,
+ skip_empty_message_body: false,
+ }
+ }
+}
+
+impl WebhookForm {
+ fn from_dto(dto: &WebhookDto) -> Self {
+ let mut form = Self {
+ id: Some(dto.id),
+ name: dto
+ .name
+ .clone(),
+ enabled: dto.enabled,
+ url: dto
+ .url
+ .clone(),
+ template: dto
+ .template
+ .clone(),
+ notification_types: dto
+ .notification_types
+ .clone(),
+ user_filter: dto
+ .user_filter
+ .clone(),
+ item_types: dto
+ .item_types
+ .clone(),
+ send_all_properties: dto.send_all_properties,
+ trim_whitespace: dto.trim_whitespace,
+ skip_empty_message_body: dto.skip_empty_message_body,
+ ..Self::default()
+ };
+ match &dto.destination {
+ WebhookDestination::Generic { headers, fields } => {
+ form.discord = false;
+ form.headers = headers.clone();
+ form.fields = fields.clone();
+ }
+ WebhookDestination::Discord {
+ avatar_url,
+ bot_username,
+ embed_color,
+ mention_type,
+ } => {
+ form.discord = true;
+ form.avatar_url = avatar_url
+ .clone()
+ .unwrap_or_default();
+ form.bot_username = bot_username
+ .clone()
+ .unwrap_or_default();
+ // A hook stored without a colour gets the default the server
+ // injects anyway, rather than an empty swatch.
+ form.embed_color = embed_color
+ .clone()
+ .filter(|c| {
+ !c.trim()
+ .is_empty()
+ })
+ .unwrap_or_else(|| DEFAULT_EMBED_COLOR.to_string());
+ form.mention_type = *mention_type;
+ }
+ }
+ form
+ }
+
+ /// A **complete** DTO. Never build a partial one: the server's
+ /// `WebhookDto` has no field defaults, so an omitted field is a 422.
+ fn to_dto(&self) -> WebhookDto {
+ let destination = if self.discord {
+ WebhookDestination::Discord {
+ avatar_url: non_empty(&self.avatar_url),
+ bot_username: non_empty(&self.bot_username),
+ // What the swatch shows, what is stored and what reaches
+ // Discord must be one value: an unparseable colour is saved as
+ // `null`, so the server injects the default already displayed.
+ embed_color: normalize_hex_color(&self.embed_color),
+ mention_type: self.mention_type,
+ }
+ } else {
+ WebhookDestination::Generic {
+ headers: self
+ .headers
+ .clone(),
+ fields: self
+ .fields
+ .clone(),
+ }
+ };
+ WebhookDto {
+ // Ignored by the server on create; it assigns a fresh id.
+ id: self
+ .id
+ .unwrap_or_else(Uuid::nil),
+ name: self
+ .name
+ .trim()
+ .to_string(),
+ enabled: self.enabled,
+ url: self
+ .url
+ .trim()
+ .to_string(),
+ template: self
+ .template
+ .clone(),
+ destination,
+ notification_types: sorted_notification_types(&self.notification_types),
+ user_filter: self
+ .user_filter
+ .clone(),
+ item_types: self
+ .item_types
+ .clone(),
+ send_all_properties: self.send_all_properties,
+ trim_whitespace: self.trim_whitespace,
+ skip_empty_message_body: self.skip_empty_message_body,
+ created_at: None,
+ updated_at: None,
+ }
+ }
+
+ fn is_valid(&self) -> bool {
+ !self
+ .name
+ .trim()
+ .is_empty()
+ && !self
+ .url
+ .trim()
+ .is_empty()
+ }
+}
+
+/// Switch the destination, pre-filling the stock Discord template when — and
+/// only when — the operator has not written one.
+fn apply_destination_change(form: &mut WebhookForm, discord: bool) {
+ form.discord = discord;
+ if discord
+ && form
+ .template
+ .trim()
+ .is_empty()
+ {
+ form.template = DISCORD_TEMPLATE.to_string();
+ }
+}
+
+/// `hook` with `enabled` flipped — a whole DTO, not a patch.
+fn dto_with_enabled(hook: &WebhookDto, enabled: bool) -> WebhookDto {
+ WebhookDto {
+ enabled,
+ ..hook.clone()
+ }
+}
+
+/// Outcome of the per-row "Test" button.
+#[derive(Clone)]
+enum TestState {
+ Running,
+ /// The API call succeeded. `WebhookTestResult::success` says whether the
+ /// *delivery* did.
+ Done(WebhookTestResult),
+ /// The API call itself failed — transport, auth, or a 4xx/5xx from remux.
+ Failed(String),
+}
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
+
+#[component]
+pub fn WebhooksPage(app_state: AppState) -> Element {
+ let mut hooks: Signal> = use_signal(Vec::new);
+ let mut users: Signal> = use_signal(Vec::new);
+ let mut loading = use_signal(|| true);
+ let mut error = use_signal(|| Option::::None);
+ let mut refresh = use_signal(|| 0_u32);
+
+ // Kept apart from `error`, which the list effect clears on every successful
+ // reload and which only renders when the page is not loading: a mutation
+ // failure has to survive the reload it triggers.
+ let mut action_error: Signal