540871a8cdc2c7df89bd3c0f5eb39387359db062 / frogue / src / views / admin.rs
Raw
// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Admin interface views using maud templates.
use maud::{Markup, html};
use crate::auth::AdminUser;
use crate::entities::{instance_blocks, reports, users};
use crate::views::components::{
ActionButton, ActionMethod, ActionVariant, Complaint, FrogueUsername, TextField,
};
use crate::views::notes::Note;
use crate::views::{Page, ViewCtx};
/// A reported object: either a local note rendered inline or an external link.
pub enum ReportedObject {
/// Local note: render inline using the existing note component.
LocalNote(Box<Note>),
/// External URL: render as a plain link.
ExternalLink(String),
}
/// Convert an actor URL to @user@domain format for display.
/// Returns None if the URL cannot be parsed.
fn url_to_handle(url: &str) -> Option<String> {
let parsed = url::Url::parse(url).ok()?;
let domain = parsed.host_str()?;
// Try to extract username from path
// Common patterns: /users/username, /@username, or just /username
let path = parsed.path().trim_start_matches('/');
let username = if let Some(rest) = path.strip_prefix("users/") {
rest.split('/').next()?
} else if let Some(rest) = path.strip_prefix('@') {
rest.split('/').next()?
} else {
path.split('/').next()?
};
if username.is_empty() {
None
} else {
Some(format!("@{}@{}", username, domain))
}
}
/// Admin home page
pub fn home(admin: &AdminUser, pending_reports: u64, error: Option<&str>) -> Page<Markup> {
let ctx = ViewCtx::admin(admin.id, admin.name.clone(), pending_reports);
Page {
title: "Admin Dashboard".to_string(),
ctx,
extra_head: None,
body: html! {
h1 { "Admin Dashboard" }
(Complaint(error))
nav {
ul {
li { a href="/admin/users" { "User Management" } }
li { a href="/admin/reports" { "Report Queue" } }
li { a href="/admin/federation" { "Federation Controls" } }
}
}
},
}
}
/// Users list page
pub fn users_list(
admin: &AdminUser,
users: &[users::Model],
repo_counts: &std::collections::HashMap<i64, usize>,
note_counts: &std::collections::HashMap<i64, usize>,
pending_reports: u64,
error: Option<&str>,
) -> Page<Markup> {
let ctx = ViewCtx::admin(admin.id, admin.name.clone(), pending_reports);
Page {
title: "User Management".to_string(),
ctx,
extra_head: None,
body: html! {
h1 { "User Management" }
(Complaint(error))
table {
thead {
tr {
th { "ID" }
th { "Name" }
th { "Email" }
th { "Repos" }
th { "Notes" }
th { "Created At" }
th { "Actions" }
}
}
tbody {
@for user in users {
tr {
td { (user.id) }
td { (FrogueUsername(&user.name)) }
td { (user.email) }
td { (repo_counts.get(&user.id).copied().unwrap_or(0)) }
td { (note_counts.get(&user.id).copied().unwrap_or(0)) }
td { (user.created_at.format("%Y-%m-%d %H:%M")) }
td {
@if user.id == 1 {
span { "Admin (protected)" }
} @else {
(ActionButton {
action: format!("/admin/users/{}/delete", user.id),
label: "Delete",
variant: ActionVariant::Danger,
hidden: &[],
confirm: Some("This will permanently delete the user and all their data."),
method: ActionMethod::Post,
})
}
}
}
}
}
}
},
}
}
/// Reports list page
pub fn reports_list(
admin: &AdminUser,
reports: &[reports::Model],
users: &[users::Model],
remote_actors: &[crate::entities::ap_remote_actors::Model],
current_filter: Option<&str>,
pending_reports: u64,
error: Option<&str>,
) -> Page<Markup> {
let ctx = ViewCtx::admin(admin.id, admin.name.clone(), pending_reports);
// Build user lookup for resolving local reporters/targets
let user_by_id: std::collections::HashMap<i64, &users::Model> =
users.iter().map(|u| (u.id, u)).collect();
// Build remote actor lookup for resolving remote reporters/targets
let remote_actor_by_id: std::collections::HashMap<
i64,
&crate::entities::ap_remote_actors::Model,
> = remote_actors.iter().map(|a| (a.id, a)).collect();
Page {
title: "Report Queue".to_string(),
ctx,
extra_head: None,
body: html! {
h1 { "Report Queue" }
(Complaint(error))
// State filter form
form method="get" action="/admin/reports" style="margin-bottom: 1em;" {
label for="state" { "Filter by state: " }
select name="state" id="state" {
option value="" selected[current_filter.is_none()] { "All" }
option value="open" selected[current_filter == Some("open")] { "Open" }
option value="resolved" selected[current_filter == Some("resolved")] { "Resolved" }
option value="dismissed" selected[current_filter == Some("dismissed")] { "Dismissed" }
}
button type="submit" { "Filter" }
}
@if reports.is_empty() {
p { "No reports found." }
} @else {
table {
thead {
tr {
th { "UUID" }
th { "Reporter" }
th { "Target" }
th { "Reason" }
th { "Objects" }
th { "State" }
th { "Forwarded" }
th { "Inbound" }
th { "Created At" }
th { "Actions" }
}
}
tbody {
@for report in reports {
tr {
td { (report.uuid) }
td {
@if let Some(user_id) = report.reporter_local_user_id {
@if let Some(user) = user_by_id.get(&user_id) {
(FrogueUsername(&user.name))
} @else {
"Unknown local user"
}
} @else if let Some(url) = &report.reporter_remote_actor_url {
@if let Some(handle) = url_to_handle(url) {
a href=(url) { (handle) }
} @else {
a href=(url) { (url) }
}
" "
span class="remote-badge" { "remote" }
}
}
td {
@if let Some(user_id) = report.target_local_user_id {
@if let Some(user) = user_by_id.get(&user_id) {
(FrogueUsername(&user.name))
} @else {
"Unknown local user"
}
} @else if let Some(actor_id) = report.target_remote_actor_id {
@if let Some(actor) = remote_actor_by_id.get(&actor_id) {
@if let Some(handle) = url_to_handle(&actor.actor_url) {
a href=(actor.actor_url) { (handle) }
} @else {
a href=(actor.actor_url) { (actor.actor_url) }
}
} @else {
(format!("Remote actor #{}", actor_id))
}
} @else {
"Unknown"
}
}
td { (report.comment.as_deref().unwrap_or("No reason provided")) }
td {
// Count objects in the JSON array
@if let Ok(objects) = serde_json::from_str::<Vec<String>>(&report.object_urls) {
(objects.len())
} @else {
"0"
}
}
td { (report.state) }
td {
@if report.forwarded != 0 {
"✓"
} @else {
"—"
}
}
td {
@if report.inbound != 0 {
"✓"
} @else {
"—"
}
}
td { (report.created_at.format("%Y-%m-%d %H:%M")) }
td {
a href=(format!("/admin/reports/{}", report.uuid)) { "View" }
" "
(ActionButton {
action: format!("/admin/reports/{}/resolve", report.uuid),
label: "Resolve",
variant: ActionVariant::Solid,
hidden: &[],
confirm: None,
method: ActionMethod::Post,
})
(ActionButton {
action: format!("/admin/reports/{}/dismiss", report.uuid),
label: "Dismiss",
variant: ActionVariant::Quiet,
hidden: &[],
confirm: None,
method: ActionMethod::Post,
})
}
}
}
}
}
}
},
}
}
/// Report detail page
#[allow(clippy::too_many_arguments)]
pub fn report_detail(
admin: &AdminUser,
report: &reports::Model,
reporter_user: Option<&users::Model>,
reporter_remote_url: Option<&str>,
target_user: Option<&users::Model>,
target_remote: Option<&crate::entities::ap_remote_actors::Model>,
reported_objects: &[ReportedObject],
pending_reports: u64,
error: Option<&str>,
) -> Page<Markup> {
let ctx = ViewCtx::admin(admin.id, admin.name.clone(), pending_reports);
Page {
title: format!("Report {}", report.uuid),
ctx,
extra_head: None,
body: html! {
h1 { "Report Details" }
(Complaint(error))
p {
a href="/admin/reports" { "← Back to Report Queue" }
}
section {
h2 { "Report Information" }
dl {
dt { "UUID" }
dd { code { (report.uuid) } }
dt { "State" }
dd { (report.state) }
dt { "Created At" }
dd { (report.created_at.format("%Y-%m-%d %H:%M:%S UTC")) }
dt { "Forwarded" }
dd { (report.forwarded) }
dt { "Inbound" }
dd { (report.inbound) }
@if let Some(comment) = &report.comment {
dt { "Comment" }
dd { (comment) }
}
}
}
section {
h2 { "Reporter" }
@if let Some(user) = reporter_user {
dl {
dt { "Local User" }
dd {
a href=(format!("/@{}", user.name)) { (FrogueUsername(&user.name)) }
}
}
} @else if let Some(url) = reporter_remote_url {
dl {
dt { "Remote Actor" }
dd {
a href=(url) { (url) }
}
}
div class="moderation-shortcuts" {
h3 { "Moderation Actions" }
form method="post" action="/admin/federation/block-remote-user" style="display:inline" {
input type="hidden" name="actor_url" value=(url);
input type="hidden" name="reason" value=(format!("Reported content: {}", report.uuid));
button type="submit" { "Block Remote User" }
}
" "
form method="post" action="/admin/federation/block-domain" style="display:inline" {
@if let Some(domain) = url.split("://").nth(1).and_then(|s| s.split('/').next()) {
input type="hidden" name="domain" value=(domain);
input type="hidden" name="reason" value=(format!("Reporter domain for report: {}", report.uuid));
button type="submit" { "Defederate Domain" }
}
}
}
} @else {
p { "Unknown reporter" }
}
}
section {
h2 { "Target" }
@if let Some(user) = target_user {
dl {
dt { "Local User" }
dd {
a href=(format!("/@{}", user.name)) { (FrogueUsername(&user.name)) }
}
}
} @else if let Some(actor) = target_remote {
dl {
dt { "Remote Actor" }
dd {
a href=(actor.actor_url) { (actor.actor_url) }
}
}
div class="moderation-shortcuts" {
h3 { "Moderation Actions" }
form method="post" action="/admin/federation/block-remote-user" style="display:inline" {
input type="hidden" name="actor_url" value=(actor.actor_url);
input type="hidden" name="reason" value=(format!("Reported content: {}", report.uuid));
button type="submit" { "Block Remote User" }
}
" "
form method="post" action="/admin/federation/block-domain" style="display:inline" {
@if let Some(domain) = actor.actor_url.split("://").nth(1).and_then(|s| s.split('/').next()) {
input type="hidden" name="domain" value=(domain);
input type="hidden" name="reason" value=(format!("Target domain for report: {}", report.uuid));
button type="submit" { "Defederate Domain" }
}
}
}
} @else {
p { "Unknown target" }
}
}
@if !reported_objects.is_empty() {
section {
h2 { "Reported Objects" }
@for obj in reported_objects {
@match obj {
ReportedObject::LocalNote(note) => {
div class="reported-object reported-object--local-note" {
(&**note)
}
}
ReportedObject::ExternalLink(url) => {
div class="reported-object reported-object--external" {
a href=(url) target="_blank" rel="noopener noreferrer" { (url) }
}
}
}
}
}
}
section {
h2 { "Actions" }
div class="report-actions" {
@if report.state == "open" {
(ActionButton {
action: format!("/admin/reports/{}/resolve", report.uuid),
label: "Resolve",
variant: ActionVariant::Solid,
hidden: &[],
confirm: None,
method: ActionMethod::Post,
})
(ActionButton {
action: format!("/admin/reports/{}/dismiss", report.uuid),
label: "Dismiss",
variant: ActionVariant::Quiet,
hidden: &[],
confirm: None,
method: ActionMethod::Post,
})
} @else {
p { (format!("Report is {}", report.state)) }
}
}
}
},
}
}
/// Federation controls page
pub fn federation(
admin: &AdminUser,
blocks: &[instance_blocks::Model],
admin_blocks: &[crate::entities::admin_remote_blocks::Model],
remote_actors: &[crate::entities::ap_remote_actors::Model],
users: &[crate::entities::users::Model],
pending_reports: u64,
error: Option<&str>,
) -> Page<Markup> {
let ctx = ViewCtx::admin(admin.id, admin.name.clone(), pending_reports);
// Build lookup for remote actor URLs
let remote_actor_by_id: std::collections::HashMap<
i64,
&crate::entities::ap_remote_actors::Model,
> = remote_actors.iter().map(|a| (a.id, a)).collect();
// Build lookup for admin user names (blocked_by)
let user_by_id: std::collections::HashMap<i64, &crate::entities::users::Model> =
users.iter().map(|u| (u.id, u)).collect();
Page {
title: "Federation Controls".to_string(),
ctx,
extra_head: None,
body: html! {
h1 { "Federation Controls" }
(Complaint(error))
section {
h2 { "Block Domain" }
form method="post" action="/admin/federation/block-domain" {
(TextField {
label: Some("Domain"),
name: "domain",
input_type: "text",
value: None,
required: true,
maxlength: 253,
})
(TextField {
label: Some("Reason"),
name: "reason",
input_type: "text",
value: None,
required: false,
maxlength: 500,
})
(ActionButton {
action: "/admin/federation/block-domain".to_string(),
label: "Block Domain",
variant: ActionVariant::Danger,
hidden: &[],
confirm: None,
method: ActionMethod::Post,
})
}
}
/*
section {
h2 { "Unblock Domain" }
form method="post" action="/admin/federation/unblock-domain" {
(TextField {
label: Some("Domain"),
name: "domain",
input_type: "text",
value: None,
required: true,
maxlength: 253,
})
(ActionButton {
action: "/admin/federation/unblock-domain".to_string(),
label: "Unblock Domain",
variant: ActionVariant::Solid,
hidden: &[],
confirm: None,
method: ActionMethod::Post,
})
}
}
*/
section {
h2 { "Block Remote User" }
form method="post" action="/admin/federation/block-remote-user" {
(TextField {
label: Some("Actor URL"),
name: "actor_url",
input_type: "url",
value: None,
required: true,
maxlength: 2000,
})
(TextField {
label: Some("Reason"),
name: "reason",
input_type: "text",
value: None,
required: false,
maxlength: 500,
})
(ActionButton {
action: "/admin/federation/block-remote-user".to_string(),
label: "Block Remote User",
variant: ActionVariant::Danger,
hidden: &[],
confirm: Some("This will remove follow relationships and deliver a Block activity."),
method: ActionMethod::Post,
})
}
}
/*
section {
h2 { "Unblock Remote User" }
form method="post" action="/admin/federation/unblock-remote-user" {
(TextField {
label: Some("Remote Actor ID"),
name: "remote_actor_id",
input_type: "number",
value: None,
required: true,
maxlength: 20,
})
(ActionButton {
action: "/admin/federation/unblock-remote-user".to_string(),
label: "Unblock Remote User",
variant: ActionVariant::Solid,
hidden: &[],
confirm: None,
method: ActionMethod::Post,
})
}
}
*/
@if !blocks.is_empty() {
section {
h2 { "Blocked Domains" }
table {
thead {
tr {
th { "Domain" }
th { "Reason" }
th { "Blocked By" }
th { "Blocked At" }
th { "Actions" }
}
}
tbody {
@for block in blocks {
tr {
td { (block.domain) }
td { (block.reason.as_deref().unwrap_or("-")) }
td {
@if let Some(user) = user_by_id.get(&block.blocked_by) {
(user.name)
} @else {
"Unknown"
}
}
td { (block.created_at.format("%Y-%m-%d %H:%M")) }
td {
form method="post" action="/admin/federation/unblock-domain" {
input type="hidden" name="domain" value=(block.domain);
(ActionButton {
action: "/admin/federation/unblock-domain".to_string(),
label: "Unblock",
variant: ActionVariant::Quiet,
hidden: &[("domain", &block.domain)],
confirm: None,
method: ActionMethod::Post,
})
}
}
}
}
}
}
}
}
@if !admin_blocks.is_empty() {
section {
h2 { "Blocked Remote Users" }
table {
thead {
tr {
th { "Actor URL" }
th { "Name" }
th { "Reason" }
th { "Blocked At" }
th { "Actions" }
}
}
tbody {
@for block in admin_blocks {
tr {
td {
@if let Some(actor) = remote_actor_by_id.get(&block.remote_actor_id) {
a href=(actor.actor_url) { (actor.actor_url) }
} @else {
(format!("Remote actor #{}", block.remote_actor_id))
}
}
td {
@if let Some(actor) = remote_actor_by_id.get(&block.remote_actor_id) {
@if let Some(name) = &actor.name {
(name)
} @else if let Some(username) = &actor.preferred_username {
(username)
} @else {
"-"
}
} @else {
"-"
}
}
td { (block.reason.as_deref().unwrap_or("-")) }
td { (block.created_at.format("%Y-%m-%d %H:%M")) }
td {
form method="post" action="/admin/federation/unblock-remote-user" {
input type="hidden" name="remote_actor_id" value=(block.remote_actor_id);
(ActionButton {
action: "/admin/federation/unblock-remote-user".to_string(),
label: "Unblock",
variant: ActionVariant::Quiet,
hidden: &[("remote_actor_id", &block.remote_actor_id.to_string())],
confirm: None,
method: ActionMethod::Post,
})
}
}
}
}
}
}
}
}
},
}
}