feat(admin): require a resolution and fix panel layout and copy (#3335)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:40:34 +02:00
1 parent 4db13b0375
commit 6534ee4300
57 files changed
+1186 -255

No files matched your search

@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::Archive,
templates::components::form::{checkbox, csrf_input, submit_button},
@@ -72,7 +73,7 @@ fn archives_table(base: &str, archives: &[Archive]) -> Markup {
@for archive in archives {
tr {
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
(archive.requested_at)
(format_admin_timestamp(&archive.requested_at))
}
td class="px-4 py-3 text-sm text-neutral-900" {
(status_text(archive))
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::AuditLogEntry,
templates::pages::audit_logs_table::{
@@ -27,7 +28,7 @@ fn log_row(base_path: &str, log: &AuditLogEntry, index: usize) -> Markup {
(table_row(html! {
(table_cell(true, html! {
span class="text-sm whitespace-nowrap" {
(log.created_at)
(format_admin_timestamp(&log.created_at))
}
}))
(table_cell(false, badge(&format_action(action), action_badge_variant(action))))
@@ -222,7 +222,7 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
div class="checkbox-custom" {
(PreEscaped(CHECKMARK_SVG))
}
span class="min-w-0 break-all text-sm leading-5 text-neutral-900" { (label) }
span class="min-w-0 [overflow-wrap:anywhere] text-sm leading-5 text-neutral-900" { (label) }
}
}
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::admin_flags::user_flag_bits;
use crate::utils::timestamps::format_admin_timestamp;
use maud::{Markup, html};
pub mod premium_types {
@@ -22,18 +23,21 @@ fn premium_tooltip(
if is_self_hosted {
let name = self_hosted_premium_name?;
return Some(match premium_since {
Some(since) => format!("{name} subscriber since {since}"),
Some(since) => format!("{name} subscriber since {}", format_admin_timestamp(since)),
None => name.to_owned(),
});
}
Some(if premium_type == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
Some(since) => format!("Fluxer Visionary since {}", format_admin_timestamp(since)),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
Some(since) => format!(
"Fluxer Plutonium subscriber since {}",
format_admin_timestamp(since)
),
None => "Fluxer Plutonium".into(),
}
})
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{CreateAdminApiKeyResponse, FlashMessage, ListAdminApiKeyEntry},
config::AdminConfig,
@@ -127,14 +128,14 @@ fn api_key_item(base: &str, csrf_token: &str, key: &ListAdminApiKeyEntry) -> Mar
"Key ID: " (key_id)
}
p class="text-sm text-neutral-500" {
"Created: " (created_at)
"Created: " (format_admin_timestamp(created_at))
}
p class="text-sm text-neutral-500" {
"Created by: " (key.created_by_user_id)
}
@if let Some(ref expires_at) = key.expires_at {
p class="text-sm text-neutral-500" {
"Expires: " (expires_at)
"Expires: " (format_admin_timestamp(expires_at))
}
}
@if !key.acls.is_empty() {
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::Archive,
config::AdminConfig,
@@ -69,7 +70,7 @@ fn archive_table(archives: &[Archive], base: &str) -> Markup {
span class="text-sm" { (archive.requested_by) }
}))
(table_cell(false, html! {
span class="text-sm" { (archive.requested_at) }
span class="text-sm" { (format_admin_timestamp(&archive.requested_at)) }
}))
(table_cell(false, html! {
div class="flex flex-col gap-1" {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::noun_for;
use crate::{
api::types::AuditLogsListResponse,
config::AdminConfig,
@@ -120,7 +121,12 @@ pub fn audit_logs_page(
.current_page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
let showing = format!("Showing {} of {} entries", entries.len(), total);
let showing = format!(
"Showing {} of {} {}",
entries.len(),
total,
noun_for(total, "entry", "entries")
);
html! {
(page_header_with_actions("Audit Logs", None, html! {
span class="text-sm text-neutral-500" { (showing) }
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{
AuditLogChannelSummary, AuditLogEntry, AuditLogGuildSummary, AuditLogUserSummary,
@@ -273,7 +274,7 @@ pub fn log_row(base: &str, entry: &AuditLogEntry, index: usize) -> Markup {
(table_row(html! {
(table_cell(true, html! {
span class="text-sm whitespace-nowrap" {
(entry.created_at)
(format_admin_timestamp(&entry.created_at))
}
}))
(table_cell(false, badge(&format_action(&entry.action), action_badge_variant(&entry.action))))
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -35,7 +36,7 @@ pub fn ip_ban_duration_label(hours: u32) -> String {
.iter()
.find(|(value, _)| *value == hours)
.map_or_else(
|| format!("{hours} hours"),
|| count_noun(u64::from(hours), "hour", "hours"),
|(_, label)| (*label).to_owned(),
)
}
@@ -72,6 +72,10 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
name: "SPAMMER",
value: 1 << 6,
},
UserFlag {
name: "PROFILE_HIDDEN",
value: 1 << 7,
},
UserFlag {
name: "HIGH_GLOBAL_RATE_LIMIT",
value: 1 << 33,
+2 -1
View File
@@ -3,6 +3,7 @@
use super::gateway_leaderboard;
use super::gateway_nodes;
use crate::utils::plural::count_noun;
use crate::{
api::types::{GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse},
config::AdminConfig,
@@ -43,7 +44,7 @@ pub fn gateway_page(config: &AdminConfig, auth: &AuthContext, p: &GatewayPagePar
))
@if let Some(count) = p.reload_result {
div class="rounded-lg border p-4 bg-green-50 border-green-200 text-green-700" {
"Successfully reloaded " (count) " guilds!"
"Successfully reloaded " (count_noun(count, "guild", "guilds")) "!"
}
}
div class="space-y-6" {
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{GuildInfo, GuildMember, ListGuildMembersResponse},
config::AdminConfig,
@@ -149,11 +151,11 @@ fn member_card(
"ID: " (member.user.id)
}
p class="text-sm text-neutral-500" {
"Joined: " (member.joined_at)
"Joined: " (format_admin_timestamp(&member.joined_at))
}
@if !member.roles.is_empty() {
p class="text-sm text-neutral-500" {
(member.roles.len()) " roles"
(count_noun(member.roles.len() as u64, "role", "roles"))
}
}
}
@@ -4,9 +4,10 @@ use crate::{
api::types::{GuildInfo, ReportEntry},
config::AdminConfig,
templates::{
components::{page_container::card_with_header, table::data_table},
components::page_container::card_with_header,
pages::user_detail_tabs::reports::{
format_status, reported_at_cell, reporter_label, type_and_category_cell,
format_status, report_tab_table, reported_at_cell, reporter_label,
type_and_category_cell,
},
},
};
@@ -43,7 +44,7 @@ pub fn reports_tab(
}
}))
} @else {
(data_table(
(report_tab_table(
&["Reported At", "Type / Category", "Reporter", "Status", "Actions"],
html! {
@for report in reports {
@@ -82,7 +83,7 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
tr class="hover:bg-neutral-50 transition-colors" {
(reported_at_cell(base, report))
(type_and_category_cell(report))
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
@if let Some(ref rid) = report.reporter_id {
a href={(base) "/users/" (rid)}
class="hover:underline" {
@@ -92,10 +93,13 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
span { (reporter_label(report)) }
}
}
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
(format_status(report.status))
}
td class="whitespace-nowrap px-4 py-3 text-sm" {
td class="whitespace-nowrap px-2 py-3 text-sm xl:px-4" {
div class="mb-1 text-neutral-900 xl:hidden" data-status-compact=(report.report_id) {
(format_status(report.status))
}
a href={(base) "/reports/" (report.report_id)}
class="inline-flex items-center rounded-md border border-neutral-300 \
bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 \
@@ -138,14 +142,17 @@ mod tests {
"{markup}"
);
assert!(markup.contains("Avery Reporter "), "{markup}");
assert_eq!(
markup.matches("whitespace-nowrap px-4").count(),
2,
assert!(
markup.contains(r#"<td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell">Pending</td>"#),
"{markup}"
);
assert!(
markup.contains(r#"<div class="mb-1 text-neutral-900 xl:hidden" data-status-compact="1800000000000000006">Pending</div>"#),
"{markup}"
);
assert!(markup.contains(">Pending<"), "{markup}");
assert!(markup.contains(">View<"), "{markup}");
assert_eq!(markup.matches(">Spam<").count(), 1, "{markup}");
assert_eq!(markup.matches(">Spam<").count(), 2, "{markup}");
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::GuildInfo,
config::AdminConfig,
@@ -97,7 +98,7 @@ pub fn guilds_list_page(
} @else if let Some(count) = result_state.total {
(page_header_with_actions("Guilds", None, html! {
p class="text-sm font-normal text-neutral-500" {
"Found " (count) (if count == 1 { " result" } else { " results" }) " (showing "
"Found " (count_noun(count, "result", "results")) " (showing "
(result_state.guilds.map(|guilds| guilds.len()).unwrap_or_default())
")"
}
@@ -654,7 +654,7 @@ fn integrations_config_section(
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
@@ -1364,7 +1364,7 @@ fn experiment_delivery_section(
"How often every client revalidates its experiment assignments. This is instance-wide \
and covers every experiment, not just the ones above. Raising the interval sheds \
request volume and makes a change take longer to reach a client. Raising the jitter \
spreads a fleet that has synchronised on one tick back out across the interval.",
spreads a fleet that has synchronized on one tick back out across the interval.",
html! {
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
(csrf_input(csrf_token))
@@ -17,7 +17,7 @@ use crate::{
};
use maud::{Markup, html};
use super::jobs_list_helpers::status_badge;
use super::jobs_list_helpers::{job_time, status_badge};
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
job.get(key).and_then(|v| v.as_str()).unwrap_or("")
@@ -113,10 +113,10 @@ fn overview_section(job: &serde_json::Value) -> Markup {
html! {
(data_field_mono("Task", task_type))
(data_field_mono("Lane", if lane.is_empty() { "\u{2014}" } else { lane }))
(data_field_text("Created", if created_at.is_empty() { "\u{2014}" } else { created_at }))
(data_field_text("Started", if started_at.is_empty() { "\u{2014}" } else { started_at }))
(data_field_text("Completed", if completed_at.is_empty() { "\u{2014}" } else { completed_at }))
(data_field_text("Run-at", run_at))
(data_field_text("Created", &job_time(created_at)))
(data_field_text("Started", &job_time(started_at)))
(data_field_text("Completed", &job_time(completed_at)))
(data_field_text("Run-at", &job_time(run_at)))
(data_field_text("Attempts", &format!("{attempts}/{max_attempts}")))
(data_field_mono("Scheduled by", requester_display))
(data_field_text("Audit reason", if audit_reason.is_empty() { "\u{2014}" } else { audit_reason }))
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -25,7 +26,7 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
(select_input("status", "Status", &[
("", "Any"), ("queued", "Queued"), ("running", "Running"),
("succeeded", "Succeeded"), ("cancelled", "Cancelled"),
("succeeded", "Succeeded"), ("cancelled", "Canceled"),
("deadletter", "Dead-letter"),
], p.status_filter))
div class="flex flex-col gap-2" {
@@ -69,8 +70,8 @@ pub struct JobsListParams<'a> {
pub fn jobs_list_page(config: &AdminConfig, auth: &AuthContext, params: &JobsListParams) -> Markup {
let base = &config.base_path;
let count_label = format!(
"{} jobs{}",
params.jobs.len(),
"{}{}",
count_noun(params.jobs.len() as u64, "job", "jobs"),
if params.next_cursor.is_some() {
" (more available)"
} else {
@@ -4,18 +4,28 @@ use crate::templates::components::{
badge::{BadgeVariant, badge},
table::{data_table, table_cell, table_row},
};
use crate::utils::timestamps::format_admin_timestamp;
use maud::{Markup, html};
pub(crate) fn status_badge(status: &str) -> Markup {
let variant = match status {
"queued" => BadgeVariant::Default,
"running" => BadgeVariant::Info,
"succeeded" => BadgeVariant::Success,
"failed" | "deadletter" => BadgeVariant::Danger,
"cancelled" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
let (label, variant) = match status {
"queued" => ("Queued", BadgeVariant::Default),
"running" => ("Running", BadgeVariant::Info),
"succeeded" => ("Succeeded", BadgeVariant::Success),
"failed" => ("Failed", BadgeVariant::Danger),
"deadletter" => ("Dead-letter", BadgeVariant::Danger),
"cancelled" => ("Canceled", BadgeVariant::Warning),
other => (other, BadgeVariant::Default),
};
badge(status, variant)
badge(label, variant)
}
pub(crate) fn job_time(value: &str) -> String {
match value {
"" | "\u{2014}" => "\u{2014}".to_owned(),
"immediate" => "Immediate".to_owned(),
other => format_admin_timestamp(other),
}
}
pub(crate) fn format_progress(job: &serde_json::Value) -> String {
@@ -82,7 +92,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
table_row(html! {
(table_cell(true, html! {
span class="whitespace-nowrap text-sm" { (created_at) }
span class="whitespace-nowrap text-sm" { (job_time(created_at)) }
}))
(table_cell(false, html! {
a href={(base) "/jobs/" (job_id)}
@@ -186,3 +196,22 @@ pub(crate) fn next_page_link(
}
}
}
#[cfg(test)]
mod tests {
use super::status_badge;
#[test]
fn status_badges_use_us_spelling_and_readable_labels() {
let canceled = status_badge("cancelled").into_string();
assert!(canceled.contains(">Canceled<"));
assert!(!canceled.contains("Cancelled"));
assert!(
status_badge("deadletter")
.into_string()
.contains(">Dead-letter<")
);
assert!(status_badge("running").into_string().contains(">Running<"));
assert!(status_badge("mystery").into_string().contains(">mystery<"));
}
}
@@ -2,9 +2,12 @@
use crate::{
acl,
api::types::{
AdminUser, GuildDetailInfo, ReportEntry, ReportFlowAnswerStepEntry, ReportFlowAnswersEntry,
ReportProfileSnapshot, ReportProfileSnapshotAsset,
api::{
generated::types::UpdateReportRequestResolution,
types::{
AdminUser, GuildDetailInfo, ReportEntry, ReportFlowAnswerStepEntry,
ReportFlowAnswersEntry, ReportProfileSnapshot, ReportProfileSnapshotAsset,
},
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -15,7 +18,9 @@ use crate::{
data_field, data_field_link_mono, data_field_mono, data_field_muted,
data_field_text,
},
form::{FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox},
form::{
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox, select_chevron,
},
media::{guild_icon_url, initials, user_avatar_url},
message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
@@ -576,19 +581,42 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
}
}
const RESOLUTION_CHOICES: [(UpdateReportRequestResolution, &str); 3] = [
(UpdateReportRequestResolution::Actioned, "Action taken"),
(UpdateReportRequestResolution::NoViolation, "No violation"),
(UpdateReportRequestResolution::Duplicate, "Duplicate"),
];
pub fn resolution_choice(
value: Option<&str>,
) -> Option<(UpdateReportRequestResolution, &'static str)> {
let value = value?.trim();
RESOLUTION_CHOICES
.into_iter()
.find(|(resolution, _)| resolution.to_string() == value)
}
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"
data-admin-refresh-on-success="#report-detail" {
(csrf_input(csrf_token))
label for="resolution" class="block text-sm font-medium text-neutral-700" {
label for="resolution" class=(FORM_LABEL_CLASS) { "Resolution" }
div class="relative" {
select id="resolution" name="resolution" required
class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} {
option value="" selected disabled { "Choose one" }
@for (resolution, label) in RESOLUTION_CHOICES {
option value=(resolution) { (label) }
}
}
(select_chevron())
}
label for="public_comment" class=(FORM_LABEL_CLASS) {
"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" {}
textarea id="public_comment" name="public_comment" rows="3" maxlength="512"
class={(FORM_CONTROL_CLASS) " px-3 py-2"} {}
(opt_out_checkbox("notify_reporter", "Include the public comment in the reporter notice"))
p class="text-neutral-500 text-xs" data-resolve-notice-help="" {
"The reporter is told the report was resolved either way, unless their account is gone or barred from reporting. \
@@ -1142,6 +1170,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -1741,7 +1770,7 @@ mod tests {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(markup.contains(r#"action="/admin/reports/1500000000000000001/resolve""#));
assert!(markup.contains(r##"data-admin-refresh-on-success="#report-detail""##));
assert!(markup.contains(r#"name="resolution""#));
assert!(markup.contains(r#"<textarea id="public_comment" name="public_comment""#));
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""#));
@@ -1760,6 +1789,57 @@ mod tests {
assert!(!markup.contains("Notify the reporter"), "{markup}");
}
#[test]
fn resolve_form_requires_one_of_the_three_resolutions() {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(
markup.contains(r#"<select id="resolution" name="resolution" required"#),
"{markup}"
);
assert!(
markup.contains(r#"<option value="" selected disabled>Choose one</option>"#),
"{markup}"
);
let options: Vec<&str> = markup
.split("<option value=\"")
.skip(2)
.map(|option| option.split("</option>").next().unwrap_or(""))
.collect();
assert_eq!(
options,
[
r#"actioned">Action taken"#,
r#"no_violation">No violation"#,
r#"duplicate">Duplicate"#
]
);
}
#[test]
fn resolution_choice_accepts_only_the_wire_values() {
assert_eq!(
resolution_choice(Some("actioned")),
Some((UpdateReportRequestResolution::Actioned, "Action taken"))
);
assert_eq!(
resolution_choice(Some(" no_violation ")),
Some((UpdateReportRequestResolution::NoViolation, "No violation"))
);
assert_eq!(
resolution_choice(Some("duplicate")),
Some((UpdateReportRequestResolution::Duplicate, "Duplicate"))
);
for missing in [
None,
Some(""),
Some("Action taken"),
Some("auto_resolved"),
Some("unspecified"),
] {
assert_eq!(resolution_choice(missing), None, "{missing:?}");
}
}
#[test]
fn reported_user_shows_a_bot_badge_only_for_bot_accounts() {
let config = test_config();
+147 -11
View File
@@ -20,7 +20,7 @@ use crate::{
layout::admin_layout,
pages::user_detail_tabs::reports::tagged_user,
},
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
utils::{plural::count_noun, timestamps::format_admin_timestamp, user_tag::user_tag},
};
use maud::{Markup, PreEscaped, html};
@@ -80,7 +80,7 @@ fn report_count_summary(result: Option<&SearchReportsResponse>) -> Markup {
html! {
@if let Some(result) = result {
p class="text-neutral-500 text-sm" {
"Found " (result.total) (if result.total == 1 { " result" } else { " results" })
"Found " (count_noun(result.total, "result", "results"))
" (showing " (result.reports.len()) ")"
}
}
@@ -405,7 +405,7 @@ fn reported_webhook_author(webhook: &ReportedWebhook<'_>) -> Markup {
div class="flex flex-col items-start gap-0.5" {
(webhook_identity(webhook))
a href=(webhook.reports_href) title="Reports about this webhook"
class="font-mono text-blue-600 text-xs hover:underline" {
class="font-mono text-blue-600 text-xs [overflow-wrap:normal] hover:underline" {
"Webhook ID: " (webhook.id)
}
@if let Some(ref creator) = webhook.creator {
@@ -452,11 +452,14 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
let rows = html! {
@for report in reports {
tr class="hover:bg-neutral-50 transition-colors" {
td class="whitespace-nowrap px-3 py-3 text-neutral-600 text-sm" {
td class="hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell" {
(format_admin_timestamp(&report.reported_at))
}
td class="px-3 py-3 text-sm" {
div class="flex flex-col items-start gap-1" {
span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact=(report.report_id) {
(format_admin_timestamp(&report.reported_at))
}
@let (type_label, type_variant) = format_report_type(report.report_type);
(badge(type_label, type_variant))
@let reason_label = report.reason.as_deref().map(|reason| report.reason_label.as_deref().unwrap_or(reason));
@@ -486,20 +489,23 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
}
}
}
td class="px-3 py-3 text-sm" {
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
(reporter_cell(config, report))
}
td class="px-3 py-3 text-sm" {
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
(reported_cell(config, report))
}
td class="whitespace-nowrap px-3 py-3 text-sm" {
@let (label, variant) = format_status(report.status);
@let (status_label, status_variant) = format_status(report.status);
td class="hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell" {
span data-status-pill=(report.report_id) {
(badge(label, variant))
(badge(status_label, status_variant))
}
}
td class="whitespace-nowrap px-3 py-3 text-sm" {
div class="flex flex-col items-start gap-1" {
span class="xl:hidden" data-status-pill-compact=(report.report_id) {
(badge(status_label, status_variant))
}
button type="button"
data-drawer-open="report-peek"
data-drawer-href={(base) "/reports/" (report.report_id) "/fragment"}
@@ -530,8 +536,15 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
(table_container(table(html! {
(table_head(html! {
tr {
@for header in headers {
th class="whitespace-nowrap px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" {
@for (index, header) in headers.iter().enumerate() {
th class={"px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" (match index {
0 | 4 => " hidden whitespace-nowrap xl:table-cell",
5 => " xl:whitespace-nowrap",
_ => " whitespace-nowrap",
})} {
@if index == 5 {
span class="xl:hidden" { "Status / " }
}
(header)
}
}
@@ -627,3 +640,126 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
.join("&");
format!("{}/reports?{}", config.base_path, query)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ProxyConfig, RuntimeEnv};
use serde_json::json;
fn test_config() -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: String::new(),
port: 0,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
fn report() -> ReportEntry {
let mut value = json!({
"report_id": "1556709309709027556",
"reporter_id": "1556709306000000001",
"reporter_username": "reporter_80f33d09e88a",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "5193",
"reported_at": "2026-10-06T04:33:00Z",
"status": 0,
"report_type": 1,
"category": "harassment",
"reported_user_id": "1556709306000000002",
"reported_user_username": "target_bc57b33ca5c4",
"reported_user_discriminator": "8316"
});
for key in [
"reporter_tag",
"reporter_email",
"reporter_full_legal_name",
"reporter_country_of_residence",
"additional_info",
"reported_user_tag",
"reported_user_global_name",
"reported_user_avatar_hash",
"reported_guild_id",
"reported_guild_name",
"reported_guild_icon_hash",
"reported_message_id",
"reported_channel_id",
"reported_channel_name",
"reported_channel_nsfw",
"reported_guild_invite_code",
"reported_guild_nsfw_level",
"reported_guild_nsfw",
"reported_guild_content_warning_level",
"reported_guild_content_warning_text",
"reported_channel_nsfw_override",
"reported_channel_content_warning_level",
"reported_channel_content_warning_text",
"reported_channel_effective_nsfw",
"reported_channel_effective_content_warning_level",
"reported_channel_effective_content_warning_text",
"resolved_at",
"resolved_by_admin_id",
"public_comment",
"mutual_dm_channel_id",
"message_context",
] {
value[key] = serde_json::Value::Null;
}
serde_json::from_value(value).expect("report fixture")
}
#[test]
fn narrow_layout_moves_date_and_status_into_visible_columns() {
let markup = render_reports_table(&test_config(), &[report()]).into_string();
assert!(markup.contains(
r#"<span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact="1556709309709027556">Oct 6, 2026, 4:33 AM UTC</span>"#
));
assert!(markup.contains(
r#"<span class="xl:hidden" data-status-pill-compact="1556709309709027556">"#
));
assert!(markup.contains(r#"<span data-status-pill="1556709309709027556">"#));
assert_eq!(
markup
.matches("hidden whitespace-nowrap xl:table-cell")
.count(),
2
);
assert_eq!(
markup
.matches(
"hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell"
)
.count(),
1
);
assert_eq!(
markup
.matches("hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell")
.count(),
1
);
assert_eq!(
markup
.matches("[overflow-wrap:anywhere] [&amp;_.whitespace-nowrap]:whitespace-normal")
.count(),
2
);
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::noun_for;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -139,7 +140,7 @@ fn status_section(base: &str, rs: &RefreshStatus) -> Markup {
@if status_str == "completed" {
@if let (Some(idx), Some(tot)) = (rs.indexed, rs.total) {
p class="text-sm text-neutral-700" {
"Indexed " (idx) " / " (tot) " items"
"Indexed " (idx) " / " (tot) " " (noun_for(tot, "item", "items"))
}
}
}
@@ -16,6 +16,7 @@ use crate::{
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
},
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
@@ -160,7 +161,7 @@ fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
}))
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
(table_cell(true, html! { (entry.created_at.as_deref().map(format_admin_timestamp).unwrap_or_default()) }))
(table_cell(false, html! {
form method="post" action=(action_url) {
(csrf_input(csrf_token))
@@ -216,11 +216,11 @@ fn session_entry(base: &str, s: &UserSession, is_tombstone: bool) -> Markup {
span class="ml-2 inline-flex items-center rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-600" { "Terminated" }
}
}))
(meta_cell("Created", html! { (s.created_at) }))
(meta_cell("Created", html! { (format_admin_timestamp(&s.created_at)) }))
@if let Some(ref d) = s.deleted_at {
(meta_cell("Terminated", html! { (d) }))
(meta_cell("Terminated", html! { (format_admin_timestamp(d)) }))
} @else {
(meta_cell("Last Used", html! { (s.approx_last_used_at) }))
(meta_cell("Last Used", html! { (format_admin_timestamp(&s.approx_last_used_at)) }))
}
div class="md:col-span-3" {
(meta_cell("IP Address", html! {
@@ -388,11 +388,11 @@ fn webauthn_credential_row(
p class="text-sm text-neutral-900" { (credential.name) }
}
td class="py-2 pr-4" {
p class="text-sm text-neutral-900" { (credential.created_at) }
p class="text-sm text-neutral-900" { (format_admin_timestamp(&credential.created_at)) }
}
td class="py-2 pr-4" {
p class="text-sm text-neutral-900" {
(credential.last_used_at.as_deref().unwrap_or("Never"))
(credential.last_used_at.as_deref().map_or_else(|| "Never".to_string(), format_admin_timestamp))
}
}
td class="py-2" {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::DmChannel, config::AdminConfig,
templates::components::page_container::card_with_header,
@@ -85,7 +86,7 @@ fn group_dm_card(base: &str, user_id: &str, channel: &DmChannel) -> Markup {
}
div class="space-y-1" {
p class="text-sm text-neutral-500" {
(channel.recipients.len()) " recipients \u{00b7} Status: "
(count_noun(channel.recipients.len() as u64, "recipient", "recipients")) " \u{00b7} Status: "
@if channel.is_open { "Open" } @else { "Closed" }
}
@if let Some(o) = owner {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::GuildInfo,
config::AdminConfig,
@@ -63,7 +64,7 @@ fn guild_row(config: &AdminConfig, user_id: &str, guild: &GuildInfo) -> Markup {
}
}
span class="text-xs text-neutral-500" {
(guild.member_count) " members"
(count_noun(guild.member_count, "member", "members"))
}
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::{count_noun, noun_for};
use crate::{
acl,
api::{
@@ -359,13 +360,13 @@ fn deletion_card(
div class="space-y-3" {
(form_label("Private Reason"))
input type="text" name="private_reason" required
placeholder="Why this deletion is being cancelled (audit log)..."
placeholder="Why this deletion is being canceled (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";
@if !username_sign_in {
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
(checkbox("notify_user", "true", "Email the user that the deletion was canceled", false, true))
}
(checkbox("confirm", "true", &confirmation, false, true))
(form_actions(html! {
@@ -567,7 +568,7 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
"Status: " (message_shred_status_label(status_value))
}
p class="text-sm text-neutral-700" {
"Requested " (requested) " entries, skipped " (skipped) " entries"
"Requested " (count_noun(requested, "entry", "entries")) ", skipped " (count_noun(skipped, "entry", "entries"))
}
@if status_value == "in_progress" {
div class="space-y-2" {
@@ -582,17 +583,17 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
}
@if status_value == "completed" {
p class="text-sm text-neutral-700" {
"Deleted " (processed) " / " (total) " entries"
"Deleted " (processed) " / " (total) " " (noun_for(total, "entry", "entries"))
}
}
@if let Some(started_at) = value_str(status, "started_at") {
p class="text-xs text-neutral-500" { "Started " (started_at) }
p class="text-xs text-neutral-500" { "Started " (format_admin_timestamp(started_at)) }
}
@if let Some(completed_at) = value_str(status, "completed_at") {
p class="text-xs text-neutral-500" { "Completed " (completed_at) }
p class="text-xs text-neutral-500" { "Completed " (format_admin_timestamp(completed_at)) }
}
@if let Some(failed_at) = value_str(status, "failed_at") {
p class="text-xs text-red-600" { "Failed " (failed_at) }
p class="text-xs text-red-600" { "Failed " (format_admin_timestamp(failed_at)) }
}
@if let Some(error) = value_str(status, "error") {
p class="text-sm text-red-600" { (error) }
@@ -66,13 +66,13 @@ fn render_overview_tab(
div class="space-y-6" {
@if let Some(until) = &user.temp_banned_until {
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" {
"Temporarily banned until " (until)
"Temporarily banned until " (format_admin_timestamp(until))
}
}
@if user.temp_banned_until.is_none() {
@if let Some(pending) = &user.pending_deletion_at {
div class="rounded-lg border border-orange-200 bg-orange-50 p-4 text-sm text-orange-900" {
div class="font-medium" { "Scheduled for deletion: " (pending) }
div class="font-medium" { "Scheduled for deletion: " (format_admin_timestamp(pending)) }
@if let Some(code) = user.deletion_reason_code {
div class="mt-1" { "Reason code: " (code) }
}
@@ -101,7 +101,7 @@ fn render_overview_tab(
@if let Some(pending) = &user.pending_bulk_message_deletion_at {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="font-medium text-neutral-700 text-sm" {
"Bulk message deletion scheduled for " (pending)
"Bulk message deletion scheduled for " (format_admin_timestamp(pending))
}
@if acl::has_permission(admin_acls, acl::USER_CANCEL_BULK_MESSAGE_DELETION) {
form method="post"
@@ -197,10 +197,10 @@ fn render_overview_tab(
}
}))
@if let Some(ref value) = user.premium_grace_ends_at {
(detail_row("Grace Period Ends", html! { (value) }))
(detail_row("Grace Period Ends", html! { (format_admin_timestamp(value)) }))
}
(detail_row("Last Active", html! {
(user.last_active_at.as_deref().unwrap_or("Never"))
(user.last_active_at.as_deref().map_or_else(|| "Never".to_string(), format_admin_timestamp))
}))
@if acl::has_permission(admin_acls, acl::USER_VIEW_IP) {
(detail_row("Last IP", html! {
@@ -239,7 +239,7 @@ fn render_overview_tab(
div class="py-3 text-sm" {
dt class="font-medium text-neutral-900" {
(entry.field)
span class="ml-2 font-normal text-xs text-neutral-500" { (entry.event_at) }
span class="ml-2 font-normal text-xs text-neutral-500" { (format_admin_timestamp(&entry.event_at)) }
}
dd class="mt-1 font-mono text-xs text-neutral-700" {
(entry.old_value.as_deref().unwrap_or("null"))
@@ -599,6 +599,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -644,4 +645,23 @@ mod tests {
fn email_mode_shows_the_email_row() {
assert!(render_email_row(false).contains("[email protected]"));
}
#[test]
fn last_active_and_grace_end_use_the_panel_date_format() {
let user: AdminUser = serde_json::from_value(serde_json::json!({
"id": "1500000000000000001",
"username": "target",
"discriminator": "0001",
"last_active_at": "2026-10-06T20:23:38.591Z",
"premium_grace_ends_at": "2026-11-01T08:00:00Z"
}))
.expect("valid admin user");
let html =
render_overview_tab(&test_config(), &user, &[], "csrf", None, None, false, false)
.into_string();
assert!(!html.contains("2026-10-06T20:23"));
assert!(!html.contains("2026-11-01T08:00"));
assert!(html.contains(&format_admin_timestamp("2026-10-06T20:23:38.591Z")));
assert!(html.contains(&format_admin_timestamp("2026-11-01T08:00:00Z")));
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{ListUserRelationshipsResponse, RelationshipEntry},
config::AdminConfig,
@@ -176,7 +177,7 @@ fn relationship_row(
}
@if let Some(ref since) = entry.since {
p class="text-xs text-neutral-500" {
"Since: " (since)
"Since: " (format_admin_timestamp(since))
}
}
}
@@ -8,7 +8,7 @@ use crate::{
page_container::card_with_header,
report_category::{reason_repeats_category, report_category},
report_webhook::{reported_webhook, webhook_identity},
table::data_table,
table::{table, table_body, table_container, table_head},
},
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
};
@@ -108,7 +108,7 @@ fn report_section(
} else {
vec!["Reported At", "Type / Category", "Reporter", "Status"]
};
(data_table(
(report_tab_table(
&headers,
html! {
@for report in reports {
@@ -196,19 +196,52 @@ pub(crate) fn reporter_label(report: &ReportEntry) -> Markup {
}
}
pub(crate) fn report_tab_table(headers: &[&str], rows: Markup) -> Markup {
let has_actions = headers.last() == Some(&"Actions");
html! {
(table_container(table(html! {
(table_head(html! {
tr {
@for (index, header) in headers.iter().enumerate() {
@let folded = index == 1 || (has_actions && *header == "Status");
th class={"px-2 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider xl:px-4" (if folded { " hidden whitespace-nowrap xl:table-cell" } else { " whitespace-nowrap" })} {
@if has_actions && index + 1 == headers.len() {
span class="xl:hidden" { "Status / " }
}
(header)
}
}
}
}))
(table_body(rows))
})))
}
}
pub(crate) fn reported_at_cell(base: &str, report: &ReportEntry) -> Markup {
html! {
td class="px-3 py-3 text-sm text-neutral-900" {
td class="px-2 py-3 text-sm text-neutral-900 xl:px-3" {
div { (format_admin_timestamp(&report.reported_at)) }
a href={(base) "/reports/" (report.report_id)}
class="whitespace-nowrap font-mono text-blue-600 text-xs tracking-tight hover:underline" {
(report.report_id)
}
div class="mt-1 xl:hidden" data-report-type-compact=(report.report_id) {
(type_and_category_lines(report))
}
}
}
}
pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
html! {
td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
(type_and_category_lines(report))
}
}
}
fn type_and_category_lines(report: &ReportEntry) -> Markup {
let reason_label = report
.reason
.as_deref()
@@ -219,17 +252,15 @@ pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
.zip(reason_label)
.is_some_and(|(category, label)| reason_repeats_category(category, label));
html! {
td class="px-4 py-3 text-sm text-neutral-900" {
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
@if let Some(category) = &report.category {
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
(report_category(category))
}
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
@if let Some(category) = &report.category {
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
(report_category(category))
}
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
div class="break-words text-neutral-500 text-xs" data-report-reason=(reason) {
(label)
}
}
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
div class="break-words text-neutral-500 text-xs" data-report-reason=(reason) {
(label)
}
}
}
@@ -270,7 +301,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
tr class="hover:bg-neutral-50 transition-colors" {
(reported_at_cell(base, report))
(type_and_category_cell(report))
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
@if let Some(webhook) = &webhook {
a href=(webhook.reports_href) class="hover:underline" {
(webhook_identity(webhook))
@@ -287,7 +318,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
span { (entity_display) }
}
}
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
td class="whitespace-nowrap px-2 py-3 text-sm text-neutral-900 xl:px-4" {
(format_status(report.status))
}
}
@@ -361,6 +392,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -476,12 +508,24 @@ mod tests {
let markup = report_row(&config, "/admin", "received", &report).into_string();
assert_eq!(
markup.matches("Harassment or bullying").count(),
1,
2,
"{markup}"
);
assert_eq!(
markup.matches(r#"data-report-reason="harassment""#).count(),
1,
2,
"{markup}"
);
assert!(
markup.contains(
r#"<div class="mt-1 xl:hidden" data-report-type-compact="1800000000000000007">"#
),
"{markup}"
);
assert!(
markup.contains(
r#"<td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell">"#
),
"{markup}"
);
assert!(