From 6534ee4300064748f8239741fdb36c3770ae022f Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 9 Oct 2026 01:40:34 +0200 Subject: [PATCH] feat(admin): require a resolution and fix panel layout and copy (#3335) --- fluxer_admin/src/admin_flags.rs | 34 ++ fluxer_admin/src/api/reports.rs | 5 +- fluxer_admin/src/config/mod.rs | 313 +++++++++++++++++- fluxer_admin/src/middleware/csrf.rs | 1 + fluxer_admin/src/routes/bans_actions.rs | 9 +- fluxer_admin/src/routes/mod.rs | 5 +- fluxer_admin/src/routes/reports.rs | 60 ++-- fluxer_admin/src/routes/user_actions.rs | 12 +- fluxer_admin/src/styles/app.css | 6 + .../src/templates/components/archive.rs | 3 +- .../components/audit_logs_for_target.rs | 3 +- fluxer_admin/src/templates/components/form.rs | 2 +- .../components/user_profile_badges.rs | 10 +- .../src/templates/pages/admin_api_keys.rs | 5 +- fluxer_admin/src/templates/pages/archives.rs | 3 +- .../src/templates/pages/audit_logs.rs | 8 +- .../src/templates/pages/audit_logs_table.rs | 3 +- fluxer_admin/src/templates/pages/bans.rs | 3 +- .../src/templates/pages/bulk_actions.rs | 4 + fluxer_admin/src/templates/pages/gateway.rs | 3 +- .../pages/guild_detail_tabs/members.rs | 6 +- .../pages/guild_detail_tabs/reports.rs | 27 +- .../src/templates/pages/guilds_list.rs | 3 +- .../src/templates/pages/instance_config.rs | 4 +- .../src/templates/pages/job_detail.rs | 10 +- fluxer_admin/src/templates/pages/jobs_list.rs | 7 +- .../src/templates/pages/jobs_list_helpers.rs | 47 ++- .../src/templates/pages/report_detail.rs | 102 +++++- .../src/templates/pages/reports_list.rs | 158 ++++++++- .../src/templates/pages/search_index.rs | 3 +- .../src/templates/pages/url_domain_bans.rs | 3 +- .../pages/user_detail_tabs/account.rs | 10 +- .../pages/user_detail_tabs/group_dm.rs | 3 +- .../pages/user_detail_tabs/guilds.rs | 3 +- .../pages/user_detail_tabs/moderation.rs | 15 +- .../pages/user_detail_tabs/overview.rs | 32 +- .../pages/user_detail_tabs/relationships.rs | 3 +- .../pages/user_detail_tabs/reports.rs | 78 ++++- fluxer_admin/src/utils/mod.rs | 1 + fluxer_admin/src/utils/plural.rs | 27 ++ fluxer_admin/src/utils/timestamps.rs | 122 +++---- fluxer_admin/tests/csrf_legacy_cookie.rs | 1 + fluxer_admin/tests/htmx_acceptance.rs | 117 ++++++- fluxer_admin/tests/notification_writes.rs | 12 +- fluxer_admin/tests/parity.rs | 2 + fluxer_admin/tests/parity/rust_server.rs | 1 + fluxer_admin/tests/password_reset_link.rs | 1 + fluxer_admin/tests/username_tags.rs | 1 + .../tests/voice_restriction_writes.rs | 1 + fluxer_api/src/api/admin/models/GuildTypes.ts | 6 + .../services/guild/AdminGuildLookupService.ts | 6 + .../api/admin/tests/AdminGuildRoutes.test.ts | 37 +++ .../audit_coverage/ReportAdminAuditCases.ts | 30 ++ .../activity/tests/ModerationEvents.test.ts | 16 + .../src/content/docs/admin-api/guilds.mdx | 51 ++- .../src/content/docs/admin-api/reports.mdx | 2 + .../src/content/docs/http-api/users.mdx | 1 + 57 files changed, 1186 insertions(+), 255 deletions(-) create mode 100644 fluxer_admin/src/utils/plural.rs diff --git a/fluxer_admin/src/admin_flags.rs b/fluxer_admin/src/admin_flags.rs index 7754e466f..2e2558611 100644 --- a/fluxer_admin/src/admin_flags.rs +++ b/fluxer_admin/src/admin_flags.rs @@ -17,6 +17,7 @@ pub mod user_flag_bits { pub const FRIENDLY_BOT: u64 = 1 << 4; pub const FRIENDLY_BOT_MANUAL_APPROVAL: u64 = 1 << 5; pub const SPAMMER: u64 = 1 << 6; + pub const PROFILE_HIDDEN: u64 = 1 << 7; pub const HIGH_GLOBAL_RATE_LIMIT: u64 = 1 << 33; pub const DELETED: u64 = 1 << 34; pub const SELF_DELETED: u64 = 1 << 36; @@ -58,6 +59,10 @@ pub const USER_FLAGS: &[U64Flag] = &[ name: "SPAMMER", value: user_flag_bits::SPAMMER, }, + U64Flag { + name: "PROFILE_HIDDEN", + value: user_flag_bits::PROFILE_HIDDEN, + }, U64Flag { name: "HIGH_GLOBAL_RATE_LIMIT", value: user_flag_bits::HIGH_GLOBAL_RATE_LIMIT, @@ -154,3 +159,32 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[ value: 1 << 8, }, ]; + +#[cfg(test)] +mod tests { + use super::USER_FLAGS; + + #[test] + fn user_flags_cover_every_flag_in_the_admin_spec() { + let spec: serde_json::Value = + serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec"); + let values = spec["components"]["schemas"]["UserFlags"]["x-bitflagValues"] + .as_array() + .expect("UserFlags bitflag values"); + assert!(!values.is_empty()); + for entry in values { + let name = entry["name"].as_str().expect("flag name"); + let value: u64 = entry["value"] + .as_str() + .expect("flag value") + .parse() + .expect("numeric flag value"); + assert!( + USER_FLAGS + .iter() + .any(|flag| flag.name == name && flag.value == value), + "{name} ({value}) is missing from USER_FLAGS" + ); + } + } +} diff --git a/fluxer_admin/src/api/reports.rs b/fluxer_admin/src/api/reports.rs index 68c0a5d17..42a99c9fb 100644 --- a/fluxer_admin/src/api/reports.rs +++ b/fluxer_admin/src/api/reports.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::api::generated::snowflake; +use crate::api::generated::{snowflake, types::UpdateReportRequestResolution}; use super::client::{AdminApiClient, ApiError, ApiResult}; use super::types::{ @@ -41,11 +41,12 @@ impl AdminApiClient { pub async fn resolve_report( &self, report_id: &str, + resolution: UpdateReportRequestResolution, public_comment: Option<&str>, notify_reporter: bool, audit_log_reason: Option<&str>, ) -> ApiResult { - let mut body = serde_json::json!({"status": "resolved"}); + let mut body = serde_json::json!({"status": "resolved", "resolution": resolution}); if let Some(public_comment) = public_comment { body["public_comment"] = serde_json::Value::from(public_comment); } diff --git a/fluxer_admin/src/config/mod.rs b/fluxer_admin/src/config/mod.rs index df47aeeaf..0c0cb9107 100644 --- a/fluxer_admin/src/config/mod.rs +++ b/fluxer_admin/src/config/mod.rs @@ -1,11 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use fluxer_common::config::{ - normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env, + env_value, normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env, read_first_env, trim_trailing_slash, }; const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789"; +const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrobjects.com"; #[derive(Clone, Debug)] pub struct AdminConfig { @@ -17,6 +18,7 @@ pub struct AdminConfig { pub api_endpoint: String, pub media_endpoint: String, pub static_cdn_endpoint: String, + pub reports_bucket_origin: String, pub admin_endpoint: String, pub web_app_endpoint: String, pub oauth_client_id: String, @@ -76,6 +78,7 @@ impl AdminConfig { static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash( &read_env("FLUXER_STATIC_CDN_ENDPOINT", ""), )), + reports_bucket_origin: reports_bucket_origin_from_env(), admin_endpoint, web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env( @@ -120,6 +123,72 @@ impl AdminConfig { } } +fn reports_bucket_origin_from_env() -> String { + let public_endpoint = env_value("FLUXER_S3_PUBLIC_ENDPOINT") + .map(|value| normalize_public_endpoint_from_env(value.trim())); + let endpoint = env_value("FLUXER_S3_ENDPOINT"); + presign_endpoint( + public_endpoint.as_deref(), + endpoint.as_deref(), + &read_env("FLUXER_S3_BUCKET_UPLOADS", "fluxer-uploads"), + ) + .and_then(|endpoint| { + bucket_origin( + &endpoint, + read_bool_env("FLUXER_S3_FORCE_PATH_STYLE", false), + &read_env("FLUXER_S3_BUCKET_REPORTS", "fluxer-reports"), + ) + }) + .unwrap_or_else(|| DEFAULT_REPORTS_BUCKET_ORIGIN.to_owned()) +} + +fn presign_endpoint( + public_endpoint: Option<&str>, + endpoint: Option<&str>, + uploads_bucket: &str, +) -> Option { + let Some(public_endpoint) = public_endpoint else { + return url::Url::parse(endpoint?.trim()).ok(); + }; + let mut parsed = url::Url::parse(public_endpoint).ok()?; + let host = parsed.host_str()?.to_owned(); + if let Some(shared_host) = host.strip_prefix(&format!("{uploads_bucket}.")) { + parsed.set_host(Some(shared_host)).ok()?; + } + Some(parsed) +} + +fn bucket_origin(endpoint: &url::Url, force_path_style: bool, bucket: &str) -> Option { + if !matches!(endpoint.scheme(), "http" | "https") { + return None; + } + let path_style = force_path_style + || !matches!(endpoint.host(), Some(url::Host::Domain(_))) + || !is_virtual_hostable_bucket(bucket, endpoint.scheme() == "http"); + if path_style { + return Some(endpoint.origin().ascii_serialization()); + } + let mut virtual_host = endpoint.clone(); + virtual_host + .set_host(Some(&format!("{bucket}.{}", endpoint.host_str()?))) + .ok()?; + Some(virtual_host.origin().ascii_serialization()) +} + +fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool { + if allow_dots && bucket.contains('.') { + return bucket + .split('.') + .all(|label| is_virtual_hostable_bucket(label, false)); + } + (3..=63).contains(&bucket.len()) + && bucket + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !bucket.starts_with('-') + && !bucket.ends_with('-') +} + impl RuntimeEnv { pub(crate) fn from_env_value(value: &str) -> Self { match value { @@ -138,7 +207,7 @@ mod tests { static ENV_LOCK: Mutex<()> = Mutex::new(()); - const MANAGED_ENV: [&str; 10] = [ + const MANAGED_ENV: [&str; 15] = [ "FLUXER_ENV", "FLUXER_ADMIN_HOST", "FLUXER_ADMIN_PORT", @@ -149,6 +218,11 @@ mod tests { "FLUXER_MEDIA_ENDPOINT", "FLUXER_STATIC_CDN_ENDPOINT", "FLUXER_BASE_DOMAIN", + "FLUXER_S3_ENDPOINT", + "FLUXER_S3_PUBLIC_ENDPOINT", + "FLUXER_S3_FORCE_PATH_STYLE", + "FLUXER_S3_BUCKET_UPLOADS", + "FLUXER_S3_BUCKET_REPORTS", ]; fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig { @@ -238,6 +312,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(), @@ -266,6 +341,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(), @@ -368,4 +444,237 @@ mod tests { format!("{api_admin_endpoint}/oauth2_callback") ); } + + fn origin_for(endpoint: &str, force_path_style: bool, bucket: &str) -> Option { + bucket_origin( + &url::Url::parse(endpoint).expect("valid endpoint"), + force_path_style, + bucket, + ) + } + + #[test] + fn bucket_origin_matches_the_addressing_of_presigned_urls() { + let cases = [ + ( + "https://ewr1.vultrobjects.com", + false, + "fluxer-reports", + "https://fluxer-reports.ewr1.vultrobjects.com", + ), + ( + "https://ewr1.vultrobjects.com/", + true, + "fluxer-reports", + "https://ewr1.vultrobjects.com", + ), + ( + "http://seaweedfs:8333", + true, + "fluxer-reports", + "http://seaweedfs:8333", + ), + ( + "http://seaweedfs:8333", + false, + "fluxer-reports", + "http://fluxer-reports.seaweedfs:8333", + ), + ( + "http://127.0.0.1:8333", + false, + "fluxer-reports", + "http://127.0.0.1:8333", + ), + ( + "http://[::1]:8333", + false, + "fluxer-reports", + "http://[::1]:8333", + ), + ( + "https://s3.example.com:9000", + false, + "fluxer-reports", + "https://fluxer-reports.s3.example.com:9000", + ), + ( + "https://s3.example.com:443/base/path", + false, + "fluxer-reports", + "https://fluxer-reports.s3.example.com", + ), + ( + "https://S3.Example.com", + false, + "fluxer-reports", + "https://fluxer-reports.s3.example.com", + ), + ( + "https://s3.example.com", + false, + "reports.example", + "https://s3.example.com", + ), + ( + "http://s3.example.com", + false, + "reports.example", + "http://reports.example.s3.example.com", + ), + ( + "http://s3.example.com", + false, + "a.example", + "http://s3.example.com", + ), + ( + "https://s3.example.com", + false, + "Reports", + "https://s3.example.com", + ), + ( + "https://s3.example.com", + false, + "ab", + "https://s3.example.com", + ), + ( + "https://s3.example.com", + false, + "reports_bucket", + "https://s3.example.com", + ), + ( + "https://s3.example.com", + false, + "-reports", + "https://s3.example.com", + ), + ( + "https://s3.example.com", + false, + "192.168.1.1", + "https://s3.example.com", + ), + ]; + for (endpoint, force_path_style, bucket, expected) in cases { + assert_eq!( + origin_for(endpoint, force_path_style, bucket).as_deref(), + Some(expected), + "{endpoint} {force_path_style} {bucket}" + ); + } + assert_eq!(origin_for("ftp://s3.example.com", true, "reports"), None); + } + + #[test] + fn presign_endpoint_prefers_the_public_endpoint_and_drops_the_uploads_bucket_host() { + let host = |public: Option<&str>, endpoint: Option<&str>| { + presign_endpoint(public, endpoint, "fluxer-uploads").map(|url| url.to_string()) + }; + assert_eq!( + host( + Some("https://fluxer-uploads.ewr1.vultrobjects.com"), + Some("https://internal.example") + ) + .as_deref(), + Some("https://ewr1.vultrobjects.com/") + ); + assert_eq!( + host( + Some("https://cdn.example.com"), + Some("http://seaweedfs:8333") + ) + .as_deref(), + Some("https://cdn.example.com/") + ); + assert_eq!( + host(None, Some("http://seaweedfs:8333")).as_deref(), + Some("http://seaweedfs:8333/") + ); + assert_eq!(host(Some("not a url"), Some("http://seaweedfs:8333")), None); + assert_eq!(host(None, None), None); + } + + #[test] + fn the_reports_bucket_origin_defaults_to_the_hosted_bucket() { + let config = config_from_env(&[]); + assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN); + + let config = config_from_env(&[("FLUXER_S3_ENDPOINT", "not a url")]); + assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN); + } + + #[test] + fn the_reports_bucket_origin_follows_the_object_store_settings() { + let hosted = config_from_env(&[ + ("FLUXER_S3_ENDPOINT", "https://ewr1.vultrobjects.com"), + ( + "FLUXER_S3_PUBLIC_ENDPOINT", + "https://fluxer-uploads.ewr1.vultrobjects.com", + ), + ]); + assert_eq!(hosted.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN); + + let bundled = config_from_env(&[ + ("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"), + ( + "FLUXER_S3_PUBLIC_ENDPOINT", + "https://objects.fluxer.example", + ), + ("FLUXER_S3_FORCE_PATH_STYLE", "true"), + ]); + assert_eq!( + bundled.reports_bucket_origin, + "https://objects.fluxer.example" + ); + + let internal_only = config_from_env(&[ + ("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"), + ("FLUXER_S3_FORCE_PATH_STYLE", "true"), + ]); + assert_eq!(internal_only.reports_bucket_origin, "http://seaweedfs:8333"); + + let outside = config_from_env(&[ + ( + "FLUXER_S3_ENDPOINT", + "https://s3.eu-central-1.amazonaws.com", + ), + ("FLUXER_S3_FORCE_PATH_STYLE", "false"), + ("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"), + ("FLUXER_S3_BUCKET_REPORTS", "example-reports"), + ]); + assert_eq!( + outside.reports_bucket_origin, + "https://example-reports.s3.eu-central-1.amazonaws.com" + ); + + let renamed_uploads = config_from_env(&[ + ("FLUXER_S3_ENDPOINT", "https://s3.example.com"), + ( + "FLUXER_S3_PUBLIC_ENDPOINT", + "https://example-uploads.s3.example.com", + ), + ("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"), + ("FLUXER_S3_BUCKET_REPORTS", "example-reports"), + ]); + assert_eq!( + renamed_uploads.reports_bucket_origin, + "https://example-reports.s3.example.com" + ); + } + + #[test] + fn a_non_default_public_port_reaches_the_reports_bucket_origin() { + let config = config_from_env(&[ + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "19080"), + ("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"), + ("FLUXER_S3_PUBLIC_ENDPOINT", "http://fluxer.example"), + ("FLUXER_S3_FORCE_PATH_STYLE", "true"), + ]); + assert_eq!(config.reports_bucket_origin, "http://fluxer.example:19080"); + } } diff --git a/fluxer_admin/src/middleware/csrf.rs b/fluxer_admin/src/middleware/csrf.rs index fc938cdfb..33ea89f06 100644 --- a/fluxer_admin/src/middleware/csrf.rs +++ b/fluxer_admin/src/middleware/csrf.rs @@ -213,6 +213,7 @@ mod tests { api_endpoint: String::new(), media_endpoint: String::new(), static_cdn_endpoint: String::new(), + reports_bucket_origin: String::new(), admin_endpoint: admin_endpoint.to_owned(), web_app_endpoint: String::new(), oauth_client_id: String::new(), diff --git a/fluxer_admin/src/routes/bans_actions.rs b/fluxer_admin/src/routes/bans_actions.rs index 60ed37eb5..018c1ba79 100644 --- a/fluxer_admin/src/routes/bans_actions.rs +++ b/fluxer_admin/src/routes/bans_actions.rs @@ -4,6 +4,7 @@ use crate::api::client::{AdminApiClient, ApiResult}; use crate::api::types::{FlashLevel, FlashMessage}; use crate::middleware::auth::AuthContext; use crate::templates; +use crate::utils::timestamps::format_admin_timestamp; use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; @@ -201,7 +202,13 @@ async fn execute_check( }; match result { Ok(r) if r.banned => match r.expires_at { - Some(expires_at) => ("info", format!("{value} is banned until {expires_at}")), + Some(expires_at) => ( + "info", + format!( + "{value} is banned until {}", + format_admin_timestamp(&expires_at) + ), + ), None => ("info", format!("{value} is banned")), }, Ok(_) => ("info", format!("{value} is NOT banned")), diff --git a/fluxer_admin/src/routes/mod.rs b/fluxer_admin/src/routes/mod.rs index 9aa4e13c3..120601669 100644 --- a/fluxer_admin/src/routes/mod.rs +++ b/fluxer_admin/src/routes/mod.rs @@ -150,7 +150,10 @@ fn build_admin_csp(config: &AdminConfig) -> String { "default-src 'self'".to_owned(), "script-src 'self' 'unsafe-inline'".to_owned(), "style-src 'self' 'unsafe-inline'".to_owned(), - format!("img-src 'self' data: blob: {static_cdn} {media} https://fluxer-reports.ewr1.vultrobjects.com"), + format!( + "img-src 'self' data: blob: {static_cdn} {media} {}", + config.reports_bucket_origin + ), "font-src 'self'".to_owned(), "connect-src 'self'".to_owned(), "object-src 'none'".to_owned(), diff --git a/fluxer_admin/src/routes/reports.rs b/fluxer_admin/src/routes/reports.rs index fb5ac4b06..82c2127c2 100644 --- a/fluxer_admin/src/routes/reports.rs +++ b/fluxer_admin/src/routes/reports.rs @@ -22,7 +22,6 @@ use axum::{ Form, Router, extract::{FromRequest, Path, Query, Request, State}, http::HeaderMap, - http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, routing::get, }; @@ -69,6 +68,8 @@ struct ResolveForm { #[serde(default)] resolution: Option, #[serde(default)] + public_comment: Option, + #[serde(default)] notify_reporter: Option, #[serde(default)] notify_reporter_present: Option, @@ -287,53 +288,52 @@ async fn report_resolve( ) -> Response { let config = state.config(); let base = &config.base_path; - let is_background = request - .uri() - .query() - .is_some_and(|query| query.split('&').any(|part| part == "background=1")); + let back = format!("{base}/reports/{report_id}"); let form: ResolveForm = match Form::from_request(request, &state).await { Ok(Form(f)) => f, Err(error) => { tracing::warn!(%error, report_id, "failed to parse report resolve form"); return flash::redirect_with_flash( - &format!("{base}/reports/{report_id}"), + &back, FlashData::error("Invalid form data"), config.secure_cookies(), ); } }; + let Some((resolution, label)) = + templates::pages::report_detail::resolution_choice(form.resolution.as_deref()) + else { + return flash::redirect_with_flash( + &back, + FlashData::error(RESOLUTION_REQUIRED), + config.secure_cookies(), + ); + }; let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); - let public_comment = clean_string(form.resolution.as_deref().unwrap_or("")); + let public_comment = clean_string(form.public_comment.as_deref().unwrap_or("")); let notify_reporter = form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true"); - let result = client - .resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None) - .await; - match result { - Ok(_) => { - if is_background { - return StatusCode::NO_CONTENT.into_response(); - } - flash::redirect_with_flash( - &format!("{base}/reports/{report_id}"), - FlashData::success("Report resolved"), - config.secure_cookies(), - ) - } + let flash = match client + .resolve_report( + &report_id, + resolution, + public_comment.as_deref(), + notify_reporter, + None, + ) + .await + { + Ok(_) => FlashData::success(format!("Report resolved: {label}")), Err(error) => { tracing::warn!(%error, report_id, "admin API request failed: resolve report"); - if is_background { - return StatusCode::BAD_GATEWAY.into_response(); - } - flash::redirect_with_flash( - &format!("{base}/reports/{report_id}"), - FlashData::error("Failed to resolve report"), - config.secure_cookies(), - ) + FlashData::error("Failed to resolve report") } - } + }; + flash::redirect_with_flash(&back, flash, config.secure_cookies()) } +const RESOLUTION_REQUIRED: &str = "Choose a resolution"; + async fn report_legal_hold( State(state): State, auth: axum::Extension, diff --git a/fluxer_admin/src/routes/user_actions.rs b/fluxer_admin/src/routes/user_actions.rs index 76a96a021..05a4a8298 100644 --- a/fluxer_admin/src/routes/user_actions.rs +++ b/fluxer_admin/src/routes/user_actions.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::utils::plural::count_noun; use crate::{ admin_flags, api::client::{AdminApiClient, ApiError}, @@ -311,7 +312,7 @@ pub async fn dispatch( }; if !form.bool_value("confirm") { return DispatchOutcome::error( - "Confirm whose deletion you are cancelling before submitting", + "Confirm whose deletion you are canceling before submitting", ); } let Some(private_reason) = get("private_reason") else { @@ -322,7 +323,7 @@ pub async fn dispatch( .cancel_deletion(user_id, &expected, notify_user, Some(&private_reason)) .await { - Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"), + Ok(_) => DispatchOutcome::success("User deletion canceled successfully"), Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error( "The pending deletion changed since this page loaded. Reload and review.", ), @@ -435,8 +436,9 @@ pub async fn dispatch( match client.delete_all_user_messages(user_id, dry_run).await { Ok(response) if dry_run => DispatchOutcome { flash: FlashData::success(format!( - "Dry run found {} messages across {} channels. Confirm to delete them permanently.", - response.message_count, response.channel_count + "Dry run found {} across {}. Confirm to delete them permanently.", + count_noun(response.message_count, "message", "messages"), + count_noun(response.channel_count, "channel", "channels") )), redirect_params: vec![ ("delete_all_messages_dry_run".to_owned(), "true".to_owned()), @@ -470,7 +472,7 @@ pub async fn dispatch( } "cancel_bulk_message_deletion" => DispatchOutcome::from_result( client.cancel_bulk_message_deletion(user_id).await, - "Bulk message deletion cancelled successfully", + "Bulk message deletion canceled successfully", "Failed to cancel bulk message deletion", ), "message_shred" => { diff --git a/fluxer_admin/src/styles/app.css b/fluxer_admin/src/styles/app.css index 32e655d50..0c35d0c56 100644 --- a/fluxer_admin/src/styles/app.css +++ b/fluxer_admin/src/styles/app.css @@ -35,6 +35,12 @@ h1, h2, h3, h4, h5, h6 { input, select, textarea { font-size: 16px; } + + select, + input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="color"], [type="file"]) { + height: auto; + min-height: 2.5rem; + } } a:focus-visible, diff --git a/fluxer_admin/src/templates/components/archive.rs b/fluxer_admin/src/templates/components/archive.rs index 55c0b3a55..b1c9e96e0 100644 --- a/fluxer_admin/src/templates/components/archive.rs +++ b/fluxer_admin/src/templates/components/archive.rs @@ -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)) diff --git a/fluxer_admin/src/templates/components/audit_logs_for_target.rs b/fluxer_admin/src/templates/components/audit_logs_for_target.rs index 30d3adc52..e165986de 100644 --- a/fluxer_admin/src/templates/components/audit_logs_for_target.rs +++ b/fluxer_admin/src/templates/components/audit_logs_for_target.rs @@ -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)))) diff --git a/fluxer_admin/src/templates/components/form.rs b/fluxer_admin/src/templates/components/form.rs index 2a2af5941..1f199888f 100644 --- a/fluxer_admin/src/templates/components/form.rs +++ b/fluxer_admin/src/templates/components/form.rs @@ -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) } } } } diff --git a/fluxer_admin/src/templates/components/user_profile_badges.rs b/fluxer_admin/src/templates/components/user_profile_badges.rs index 2333dd3db..2bcf312c4 100644 --- a/fluxer_admin/src/templates/components/user_profile_badges.rs +++ b/fluxer_admin/src/templates/components/user_profile_badges.rs @@ -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(), } }) diff --git a/fluxer_admin/src/templates/pages/admin_api_keys.rs b/fluxer_admin/src/templates/pages/admin_api_keys.rs index 9aad1319d..471184436 100644 --- a/fluxer_admin/src/templates/pages/admin_api_keys.rs +++ b/fluxer_admin/src/templates/pages/admin_api_keys.rs @@ -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() { diff --git a/fluxer_admin/src/templates/pages/archives.rs b/fluxer_admin/src/templates/pages/archives.rs index 3eaf67430..a801d98e6 100644 --- a/fluxer_admin/src/templates/pages/archives.rs +++ b/fluxer_admin/src/templates/pages/archives.rs @@ -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" { diff --git a/fluxer_admin/src/templates/pages/audit_logs.rs b/fluxer_admin/src/templates/pages/audit_logs.rs index c9bdc09d1..52b3b4e47 100644 --- a/fluxer_admin/src/templates/pages/audit_logs.rs +++ b/fluxer_admin/src/templates/pages/audit_logs.rs @@ -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) } diff --git a/fluxer_admin/src/templates/pages/audit_logs_table.rs b/fluxer_admin/src/templates/pages/audit_logs_table.rs index d81a11447..31bbedc2c 100644 --- a/fluxer_admin/src/templates/pages/audit_logs_table.rs +++ b/fluxer_admin/src/templates/pages/audit_logs_table.rs @@ -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)))) diff --git a/fluxer_admin/src/templates/pages/bans.rs b/fluxer_admin/src/templates/pages/bans.rs index 61f747315..3f59482c0 100644 --- a/fluxer_admin/src/templates/pages/bans.rs +++ b/fluxer_admin/src/templates/pages/bans.rs @@ -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(), ) } diff --git a/fluxer_admin/src/templates/pages/bulk_actions.rs b/fluxer_admin/src/templates/pages/bulk_actions.rs index 218a74008..67e9c3978 100644 --- a/fluxer_admin/src/templates/pages/bulk_actions.rs +++ b/fluxer_admin/src/templates/pages/bulk_actions.rs @@ -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, diff --git a/fluxer_admin/src/templates/pages/gateway.rs b/fluxer_admin/src/templates/pages/gateway.rs index f029fa918..6d6805faf 100644 --- a/fluxer_admin/src/templates/pages/gateway.rs +++ b/fluxer_admin/src/templates/pages/gateway.rs @@ -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" { diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/members.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/members.rs index 2e354103a..993e2ea75 100644 --- a/fluxer_admin/src/templates/pages/guild_detail_tabs/members.rs +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/members.rs @@ -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")) } } } diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/reports.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/reports.rs index 01dfe0d7e..127946e7c 100644 --- a/fluxer_admin/src/templates/pages/guild_detail_tabs/reports.rs +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/reports.rs @@ -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#"Pending"#), + "{markup}" + ); + assert!( + markup.contains(r#"
Pending
"#), "{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}"); } } diff --git a/fluxer_admin/src/templates/pages/guilds_list.rs b/fluxer_admin/src/templates/pages/guilds_list.rs index 2a2403b5c..ef46c6f05 100644 --- a/fluxer_admin/src/templates/pages/guilds_list.rs +++ b/fluxer_admin/src/templates/pages/guilds_list.rs @@ -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()) ")" } diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index ada7e9c17..de4930e7f 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -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)) diff --git a/fluxer_admin/src/templates/pages/job_detail.rs b/fluxer_admin/src/templates/pages/job_detail.rs index 604290c07..a8ce7dad1 100644 --- a/fluxer_admin/src/templates/pages/job_detail.rs +++ b/fluxer_admin/src/templates/pages/job_detail.rs @@ -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 })) diff --git a/fluxer_admin/src/templates/pages/jobs_list.rs b/fluxer_admin/src/templates/pages/jobs_list.rs index 06bfde907..b5630df3c 100644 --- a/fluxer_admin/src/templates/pages/jobs_list.rs +++ b/fluxer_admin/src/templates/pages/jobs_list.rs @@ -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 { diff --git a/fluxer_admin/src/templates/pages/jobs_list_helpers.rs b/fluxer_admin/src/templates/pages/jobs_list_helpers.rs index 96f5521ec..294dfe4e5 100644 --- a/fluxer_admin/src/templates/pages/jobs_list_helpers.rs +++ b/fluxer_admin/src/templates/pages/jobs_list_helpers.rs @@ -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<")); + } +} diff --git a/fluxer_admin/src/templates/pages/report_detail.rs b/fluxer_admin/src/templates/pages/report_detail.rs index cff3257b0..071296194 100644 --- a/fluxer_admin/src/templates/pages/report_detail.rs +++ b/fluxer_admin/src/templates/pages/report_detail.rs @@ -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#"