// 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::>().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() }