Files
fluxer/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs
T

591 lines
23 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
acl, admin_flags,
api::types::{AdminUser, LimitConfigResponse, ListUserChangeLogResponse},
config::AdminConfig,
templates::components::{
badge::{BadgeVariant, badge},
form::{checkbox, csrf_input, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
utils::{
bigint::format_discriminator,
timestamps::{format_admin_timestamp, snowflake_creation_date},
user_tag::user_tag,
},
};
use maud::{Markup, html};
pub fn overview_tab(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
csrf_token: &str,
change_log: Option<&ListUserChangeLogResponse>,
) -> Markup {
render_overview_tab(
config, user, admin_acls, csrf_token, change_log, None, false, false,
)
}
#[allow(clippy::too_many_arguments)]
pub fn overview_tab_with_limit_config(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
csrf_token: &str,
change_log: Option<&ListUserChangeLogResponse>,
limit_config: Option<&LimitConfigResponse>,
username_sign_in: bool,
) -> Markup {
render_overview_tab(
config,
user,
admin_acls,
csrf_token,
change_log,
limit_config,
true,
username_sign_in,
)
}
#[allow(clippy::too_many_arguments)]
fn render_overview_tab(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
csrf_token: &str,
change_log: Option<&ListUserChangeLogResponse>,
limit_config: Option<&LimitConfigResponse>,
show_traits: bool,
username_sign_in: bool,
) -> Markup {
html! {
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 " (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: " (format_admin_timestamp(pending)) }
@if let Some(code) = user.deletion_reason_code {
div class="mt-1" { "Reason code: " (code) }
}
@if let Some(reason) = &user.deletion_public_reason {
div class="mt-1" { "Public reason: " (reason) }
}
@if let Some(reason) = &user.deletion_audit_log_reason {
div class="mt-1" { "Private reason: " (reason) }
}
div class="mt-1" {
"Scheduled by "
@match user.deletion_scheduled_by.as_deref() {
Some(id) if id == user.id => { "the user" }
Some(id) => {
a href={(config.base_path) "/users/" (id)} class="underline" { (id) }
}
None => { "an unrecorded source" }
}
@if let Some(at) = user.deletion_scheduled_at.as_deref() {
" on " (format_admin_timestamp(at))
}
}
}
}
}
@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 " (format_admin_timestamp(pending))
}
@if acl::has_permission(admin_acls, acl::USER_CANCEL_BULK_MESSAGE_DELETION) {
form method="post"
action={(config.base_path) "/users/" (user.id) "?action=cancel_bulk_message_deletion&tab=overview"} {
(csrf_input(csrf_token))
button type="submit"
class="mt-3 rounded bg-neutral-900 px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-neutral-800" {
"Cancel Bulk Message Deletion"
}
}
}
}
}
(card_with_header("Account Information", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("User ID", html! {
span class="font-mono text-xs" { (user.id) }
}))
(detail_row("Created", html! {
(snowflake_creation_date(&user.id))
}))
(detail_row("Username", html! {
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}))
(detail_row("Display Name", html! {
@if let Some(ref name) = user.global_name {
(name)
} @else {
span class="text-neutral-400" { "Not set" }
}
}))
@if acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in {
(detail_row("Email", html! {
@if let Some(ref email) = user.email {
(email)
@if user.email_verified { span class="ml-1 text-green-600" { "verified" } }
@else { span class="ml-1 text-red-600" { "unverified" } }
@if user.email_bounced { span class="ml-1 text-orange-600" { "(bounced)" } }
} @else {
span class="text-neutral-400" { "Not set" }
}
}))
}
@if acl::has_permission(admin_acls, acl::USER_VIEW_DOB) {
(detail_row("Date of Birth", html! {
(user.date_of_birth.as_deref().unwrap_or("Not set"))
}))
}
(detail_row("Locale", html! {
(user.locale.as_deref().unwrap_or("Not set"))
}))
@if let Some(ref bio) = user.bio {
(detail_row("Bio", html! { (bio) }))
}
@if let Some(ref pronouns) = user.pronouns {
(detail_row("Pronouns", html! { (pronouns) }))
}
@if user.bot {
(detail_row("Type", html! { (badge("Bot", BadgeVariant::Info)) }))
}
@if user.system {
(detail_row("Type", html! { (badge("System", BadgeVariant::Warning)) }))
}
(detail_row("Authenticators", html! {
@if user.authenticator_types.is_empty() {
span class="text-neutral-400" { "None" }
} @else {
(user.authenticator_types.iter().map(|t| match t {
0 => "TOTP",
2 => "WebAuthn",
_ => "Unknown",
}).collect::<Vec<_>>().join(", "))
}
}))
(detail_row("Premium", html! {
@match user.premium_type {
Some(1) => {
"Subscription"
@if let Some(ref value) = user.premium_since {
span class="ml-1 text-neutral-500" { " since " (value) }
}
@if let Some(ref value) = user.premium_until {
span class="ml-1 text-neutral-500" { " until " (value) }
}
}
Some(2) => {
"Lifetime"
@if let Some(value) = user.premium_lifetime_sequence {
span class="ml-1 text-neutral-500" { " #" (value) }
}
}
_ => { span class="text-neutral-400" { "None" } }
}
}))
@if let Some(ref value) = user.premium_grace_ends_at {
(detail_row("Grace Period Ends", html! { (format_admin_timestamp(value)) }))
}
(detail_row("Last Active", html! {
(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! {
@if let Some(ip) = &user.last_active_ip {
span class="font-mono text-xs" { (ip) }
a href={(config.base_path) "/users?ip=" (urlencoding::encode(ip))}
class="ml-2 text-xs text-blue-600 hover:underline" {
"Find related"
}
@if let Some(reverse) = &user.last_active_ip_reverse {
span class="ml-2 text-neutral-500" { "(" (reverse) ")" }
}
} @else {
span class="text-neutral-400" { "Not recorded" }
}
}))
(detail_row("Location", html! {
(user.last_active_location.as_deref().unwrap_or("Unknown"))
}))
}
}
}))
(flags_card(config, user, admin_acls, csrf_token))
(acls_card(config, user, admin_acls, csrf_token))
@if show_traits {
(traits_card(config, user, admin_acls, csrf_token, limit_config))
}
@if acl::has_permission(admin_acls, acl::USER_VIEW_CONTACT_LOG) {
(card_with_header("Contact Change Log", html! {
@if let Some(log) = change_log {
@if log.entries.is_empty() {
p class="text-sm text-neutral-500" { "No contact changes recorded." }
} @else {
dl class="divide-y divide-neutral-100" {
@for entry in &log.entries {
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" { (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"))
" \u{2192} "
(entry.new_value.as_deref().unwrap_or("null"))
}
@if let Some(ref reason) = entry.reason {
dd class="text-xs text-neutral-500" { (reason) }
}
}
}
}
}
} @else {
p class="text-sm text-red-700" { "Failed to load change log." }
}
}))
}
}
}
}
fn flags_card(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
csrf_token: &str,
) -> Markup {
let can_update_flags = acl::has_permission(admin_acls, acl::USER_UPDATE_FLAGS);
html! {
div class="space-y-6" {
(u64_flag_form(
config,
&user.id,
"User Flags",
"update_flags",
"flags[]",
user.flags,
admin_flags::USER_FLAGS,
csrf_token,
can_update_flags,
Some(acl::USER_UPDATE_FLAGS),
))
(i32_flag_form(
config,
&user.id,
"Premium Flags",
"update_premium_flags",
"flags[]",
user.premium_flags,
admin_flags::PREMIUM_FLAGS,
csrf_token,
can_update_flags,
Some(acl::USER_UPDATE_FLAGS),
))
}
}
}
#[allow(clippy::too_many_arguments)]
fn u64_flag_form(
config: &AdminConfig,
user_id: &str,
title: &str,
action: &str,
input_name: &str,
value: u64,
flags: &[admin_flags::U64Flag],
csrf_token: &str,
can_edit: bool,
required_acl: Option<&str>,
) -> Markup {
flag_form_shell(
config,
user_id,
title,
action,
value.to_string(),
csrf_token,
can_edit,
required_acl,
html! {
@for flag in flags {
(flag_checkbox(
input_name,
flag.value.to_string(),
flag.name,
value & flag.value != 0,
can_edit,
))
}
},
)
}
#[allow(clippy::too_many_arguments)]
fn i32_flag_form(
config: &AdminConfig,
user_id: &str,
title: &str,
action: &str,
input_name: &str,
value: i32,
flags: &[admin_flags::I32Flag],
csrf_token: &str,
can_edit: bool,
required_acl: Option<&str>,
) -> Markup {
flag_form_shell(
config,
user_id,
title,
action,
value.to_string(),
csrf_token,
can_edit,
required_acl,
html! {
@for flag in flags {
(flag_checkbox(
input_name,
flag.value.to_string(),
flag.name,
value & flag.value != 0,
can_edit,
))
}
},
)
}
#[allow(clippy::too_many_arguments)]
fn flag_form_shell(
config: &AdminConfig,
user_id: &str,
title: &str,
action: &str,
_raw_value: String,
csrf_token: &str,
can_edit: bool,
required_acl: Option<&str>,
checkboxes: Markup,
) -> Markup {
card_with_header(
title,
html! {
form method="post" action={(config.base_path) "/users/" (user_id) "?action=" (action) "&tab=overview"} class="space-y-4" {
(csrf_input(csrf_token))
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
(checkboxes)
}
@if can_edit {
(form_actions(html! {
(submit_button("Save"))
}))
} @else {
@if let Some(required) = required_acl {
p class="text-xs text-neutral-500" {
"Read-only \u{2014} " (required) " permission required."
}
}
}
}
},
)
}
fn flag_checkbox(
input_name: &str,
value: String,
label: &str,
checked: bool,
can_edit: bool,
) -> Markup {
checkbox(input_name, &value, label, checked, can_edit)
}
fn acls_card(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
csrf_token: &str,
) -> Markup {
let can_edit = acl::has_permission(admin_acls, acl::ACL_SET_USER);
card_with_header(
"ACLs",
html! {
@if can_edit {
form method="post" action={(config.base_path) "/users/" (user.id) "?action=update_acls&tab=overview"} {
(csrf_input(csrf_token))
div class="space-y-4" {
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
@for item in acl::ALL_ACLS {
@let checked = user.acls.iter().any(|value| value == item);
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
}
}
@for item in &user.acls {
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
input type="hidden" name="acls[]" value=(item);
}
}
(form_actions(html! {
(submit_button("Save ACLs"))
}))
}
}
} @else {
@if user.acls.is_empty() {
p class="text-sm text-neutral-500" { "No ACLs assigned." }
} @else {
div class="flex flex-wrap gap-2" {
@for item in &user.acls {
(badge(item, BadgeVariant::Default))
}
}
}
p class="mt-4 text-xs text-neutral-500" {
"Read-only \u{2014} acl:set:user permission required."
}
}
},
)
}
fn traits_card(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
csrf_token: &str,
limit_config: Option<&LimitConfigResponse>,
) -> Markup {
let trait_definitions = parse_trait_definitions(limit_config);
let custom_traits = custom_traits(user, &trait_definitions);
let can_edit = acl::has_permission(admin_acls, acl::USER_UPDATE_TRAITS);
card_with_header(
"Traits",
html! {
div class="space-y-4" {
@if limit_config.is_some() {
@if can_edit {
(traits_form(config, user, csrf_token, &trait_definitions, &custom_traits))
} @else {
(assigned_traits(&user.traits))
p class="mt-2 text-xs text-neutral-500" { "Read-only \u{2014} trait update permission required." }
}
} @else {
(assigned_traits(&user.traits))
p class="text-sm text-red-700" { "Failed to load limit configuration." }
}
@if trait_definitions.is_empty() {
p class="text-neutral-500 text-xs" {
"No trait definitions declared. "
a href={(config.base_path) "/instance-config"} class="text-blue-600 underline" {
"Open Instance Configuration"
}
}
}
}
},
)
}
fn assigned_traits(traits: &[String]) -> Markup {
html! {
@if traits.is_empty() {
p class="text-neutral-500 text-sm" { "No traits assigned." }
} @else {
div class="flex flex-wrap gap-2" {
@for trait_name in traits {
(badge(trait_name, BadgeVariant::Default))
}
}
}
}
}
fn traits_form(
config: &AdminConfig,
user: &AdminUser,
csrf_token: &str,
trait_definitions: &[&str],
custom_traits: &[&str],
) -> Markup {
html! {
form method="post" action={(config.base_path) "/users/" (user.id) "?action=update_traits&tab=overview"} {
(csrf_input(csrf_token))
div class="space-y-4" {
@if !trait_definitions.is_empty() {
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
@for definition in trait_definitions {
(flag_checkbox(
"traits[]",
definition.to_string(),
definition,
user.traits.iter().any(|t| t == definition),
true,
))
}
}
}
div class="flex flex-col gap-1" {
label for="custom-traits" class="text-neutral-500 text-xs" {
"Custom traits (comma or newline separated)"
}
textarea id="custom-traits" name="traits" rows="2"
placeholder="beta-tester, experimental"
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" {
(custom_traits.join("\n"))
}
p class="text-neutral-500 text-xs" {
"The premium trait follows each member's subscription or gift and cannot be \
assigned here."
}
}
(form_actions(html! {
(submit_button("Save Traits"))
}))
}
}
}
}
const DERIVED_TRAITS: [&str; 1] = ["premium"];
fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&str> {
limit_config
.map(|response| {
response
.limit_config
.trait_definitions
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.filter(|value| !DERIVED_TRAITS.contains(value))
.collect()
})
.unwrap_or_default()
}
fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a str> {
user.traits
.iter()
.map(String::as_str)
.filter(|trait_name| !trait_definitions.contains(trait_name))
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
.collect()
}