mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(admin): require a resolution and fix panel layout and copy (#3335)
This commit is contained in:
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() {
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] [&_.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!(
|
||||
|
||||
Reference in new issue
Block a user