fix(api): send correct staff emails and allow suppressing them (#3048)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 23:55:54 +02:00
1 parent f0b3c82cfd
commit 39f9beda5a
113 files changed
+2496 -206

No files matched your search

@@ -227,6 +227,13 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
}
}
pub fn opt_out_checkbox(name: &str, label: &str) -> Markup {
html! {
input type="hidden" name={(name) "_present"} value="1";
(checkbox(name, "true", label, true, true))
}
}
pub fn secondary_button_link(label: &str, href: &str) -> Markup {
html! {
a href=(href) role="button"
@@ -8,7 +8,8 @@ use crate::{
components::{
form::{
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
form_field_group, select_chevron, submit_button, text_input, textarea_input,
form_field_group, opt_out_checkbox, select_chevron, submit_button, text_input,
textarea_input,
},
page_container::page_header,
section_card::section_card_simple,
@@ -426,6 +427,7 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
},
))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
(form_actions(html! {
(danger_button("Schedule Deletion"))
}))
@@ -484,6 +486,13 @@ mod tests {
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn deletion_form_emails_each_user_by_default() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
@@ -9,7 +9,7 @@ use crate::{
components::{
badge::{BadgeVariant, badge},
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
form::csrf_input,
form::{csrf_input, opt_out_checkbox},
media::{guild_icon_url, initials, user_avatar_url},
message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
@@ -376,16 +376,7 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
(section_card(Some("Actions"), None, None, html! {
div class="flex flex-col gap-3" {
@if report.status == 0 {
form method="post"
action={(base) "/reports/" (&report.report_id) "/resolve"} {
(csrf_input(csrf_token))
button type="submit"
class="inline-flex w-full items-center justify-center gap-2 \
font-medium rounded-lg bg-neutral-900 text-white \
px-4 py-2 text-sm" {
"Resolve Report"
}
}
(resolve_report_form(base, &report.report_id, csrf_token))
}
@if report.report_type == 0 || report.report_type == 1 {
@if let Some(ref reported_id) = report.reported_user_id {
@@ -410,6 +401,29 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
}
}
fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup {
html! {
form method="post" action={(base) "/reports/" (report_id) "/resolve"} class="flex flex-col gap-3" {
(csrf_input(csrf_token))
label for="resolution" class="block text-sm font-medium text-neutral-700" {
"Public comment to the reporter (optional)"
}
textarea id="resolution" name="resolution" rows="3" maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary" {}
(opt_out_checkbox("notify_reporter", "Notify the reporter by DM and email"))
button type="submit"
class="inline-flex w-full items-center justify-center gap-2 \
font-medium rounded-lg bg-neutral-900 text-white \
px-4 py-2 text-sm" {
"Resolve Report"
}
}
}
}
fn nav_link(href: &str, label: &str) -> Markup {
html! {
a href=(href)
@@ -532,3 +546,18 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_form_offers_a_public_comment_and_notifies_the_reporter_by_default() {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(markup.contains(r#"action="/admin/reports/1500000000000000001/resolve""#));
assert!(markup.contains(r#"name="resolution""#));
assert!(markup.contains(r#"maxlength="512""#));
assert!(markup.contains(r#"name="notify_reporter" value="true" checked"#));
assert!(markup.contains(r#"name="notify_reporter_present" value="1""#));
}
}
@@ -8,7 +8,9 @@ use crate::{
},
config::AdminConfig,
templates::components::{
form::{checkbox, csrf_input, danger_button, form_actions, submit_button},
form::{
checkbox, csrf_input, danger_button, form_actions, opt_out_checkbox, submit_button,
},
page_container::card_with_header,
},
utils::timestamps::format_admin_timestamp,
@@ -142,9 +144,26 @@ fn ban_actions_card(
form method="post"
action={(base) "/users/" (user.id) "?action=unban&tab=moderation"} {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Unban User"))
}))
div class="space-y-3" {
(form_label("Public Reason (optional, shown to the user)"))
input type="text" name="public_reason"
placeholder="Enter public unban reason..." maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(form_label("Private Reason (optional, audit log)"))
input type="text" name="private_reason"
placeholder="Enter private unban reason (audit log)..."
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user that the suspension was lifted"))
(form_actions(html! {
(submit_button("Unban User"))
}))
}
}
} @else {
form method="post"
@@ -163,7 +182,7 @@ fn ban_actions_card(
}
(form_label("Public Reason (optional)"))
input type="text" name="reason"
placeholder="Enter public ban reason..."
placeholder="Enter public ban reason..." maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
@@ -175,6 +194,7 @@ fn ban_actions_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about this suspension (temporary bans only)"))
(form_actions(html! {
(submit_button("Ban/Suspend User"))
}))
@@ -365,7 +385,7 @@ fn deletion_card(
}
(form_label("Public Reason (optional)"))
input type="text" name="public_reason"
placeholder="Enter public reason..."
placeholder="Enter public reason..." maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
@@ -377,6 +397,7 @@ fn deletion_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about the scheduled deletion"))
(form_actions(html! {
(submit_button("Schedule Deletion"))
}))
@@ -778,6 +799,33 @@ mod tests {
assert!(!markup.contains("replace_pending_deletion_at"));
}
#[test]
fn schedule_form_emails_the_user_by_default() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn temp_ban_form_emails_the_user_by_default() {
let markup = ban_actions_card("/admin", &user(json!({})), "csrf", None).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn unban_form_separates_the_public_and_private_reasons() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let markup = ban_actions_card("/admin", &target, "csrf", None).into_string();
assert!(markup.contains("?action=unban&tab=moderation"));
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
assert!(markup.contains(
r#"name="public_reason" placeholder="Enter public unban reason..." maxlength="512""#
));
assert!(markup.contains(r#"name="private_reason""#));
}
#[test]
fn current_ban_is_the_entry_matching_the_ban_end_and_notes_attach_to_it() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));