mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(admin): apply audit logs and side effects to bulk actions (#2758)
This commit is contained in:
1 parent
a9dc74a520
commit
91a2604e9e
42 files changed
+1988
-916
No files matched your search
@@ -54,6 +54,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
"bulk-actions",
|
||||
[
|
||||
acl::BULK_UPDATE_USER_FLAGS,
|
||||
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
@@ -264,3 +265,27 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
)],
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
|
||||
let item = NAV_SECTIONS
|
||||
.iter()
|
||||
.flat_map(|section| section.items)
|
||||
.find(|item| item.active_key == "bulk-actions")
|
||||
.expect("bulk actions nav item");
|
||||
for required in [
|
||||
acl::BULK_UPDATE_USER_FLAGS,
|
||||
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
acl::BULK_DELETE_USER_MESSAGES,
|
||||
] {
|
||||
assert!(item.required_acls.contains(&required), "{required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
||||
("", "Any"),
|
||||
("user", "User"),
|
||||
("guild", "Guild"),
|
||||
("bulk_job", "Bulk job"),
|
||||
("email_domain", "Email domain"),
|
||||
("ip", "IP"),
|
||||
("phrase", "Phrase"),
|
||||
@@ -156,3 +157,24 @@ pub fn audit_logs_page(
|
||||
};
|
||||
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_type_filter_offers_bulk_jobs() {
|
||||
let params = AuditLogsParams {
|
||||
query: "",
|
||||
admin_user_id: "",
|
||||
target_id: "",
|
||||
target_type: "bulk_job",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
current_page: 0,
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,14 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}))
|
||||
},
|
||||
"bulk_job" => html! {
|
||||
(job_link(base, &entry.target_id, html! {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Bulk job" }
|
||||
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
|
||||
}
|
||||
}))
|
||||
},
|
||||
"message" => html! {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Message" }
|
||||
@@ -147,6 +155,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn job_link(base: &str, job_id: &str, display: Markup) -> Markup {
|
||||
html! {
|
||||
a href={(base) "/jobs/" (job_id)} title={"Job " (job_id)}
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
|
||||
(display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_href(base: &str, channel_id: &str) -> String {
|
||||
format!(
|
||||
"{base}/messages?channel_id={}&context_limit=50",
|
||||
@@ -310,3 +327,36 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"log_id": "1900000000000000001",
|
||||
"admin_user_id": "1500000000000000001",
|
||||
"action": "bulk_schedule_deletion",
|
||||
"target_id": target_id,
|
||||
"target_type": target_type,
|
||||
"audit_log_reason": "Raid cleanup",
|
||||
"created_at": "2026-09-14T12:00:00.000Z"
|
||||
}))
|
||||
.expect("audit log fixture must deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_job_targets_link_to_the_job_detail_page() {
|
||||
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
|
||||
assert!(markup.contains("Bulk job"));
|
||||
assert!(!markup.contains("/admin/users/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_target_types_stay_unlinked() {
|
||||
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
|
||||
assert!(!markup.contains("<a "));
|
||||
assert!(markup.contains("Email domain"));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ use crate::{
|
||||
templates::{
|
||||
components::{
|
||||
form::{
|
||||
checkbox, csrf_input, danger_button, form_actions, form_field_group, select_input,
|
||||
submit_button, text_input, textarea_input,
|
||||
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
form_field_group, select_chevron, submit_button, text_input, textarea_input,
|
||||
},
|
||||
page_container::page_header,
|
||||
section_card::section_card_simple,
|
||||
@@ -395,16 +395,33 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(select_input("reason_code", "Deletion Reason", DELETION_REASONS, "1"))
|
||||
(form_field_group("Deletion Reason", "reason_code", true, None,
|
||||
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
|
||||
html! {
|
||||
div class="relative" {
|
||||
select id="reason_code" name="reason_code" required
|
||||
class=(FORM_SELECT_CLASS) {
|
||||
option value="" selected { "Select a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
},
|
||||
))
|
||||
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
|
||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None, None, html! {
|
||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||
value="14" min="14" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-2 focus:ring-brand-primary/20";
|
||||
}))
|
||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
|
||||
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
|
||||
html! {
|
||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||
value="60" min="14" max="365" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-2 focus:ring-brand-primary/20";
|
||||
},
|
||||
))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(form_actions(html! {
|
||||
(danger_button("Schedule Deletion"))
|
||||
@@ -449,6 +466,21 @@ mod tests {
|
||||
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_has_no_preselected_reason() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
|
||||
for (value, _) in DELETION_REASONS {
|
||||
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_defaults_to_the_moderation_retention_floor() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_grid_can_clear_the_deprecated_clone_features() {
|
||||
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
|
||||
|
||||
Reference in new issue
Block a user