aboutsummaryrefslogtreecommitdiff
path: root/server/src/ui/admin
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/ui/admin')
-rw-r--r--server/src/ui/admin/log.rs258
-rw-r--r--server/src/ui/admin/mod.rs288
-rw-r--r--server/src/ui/admin/user.rs176
3 files changed, 722 insertions, 0 deletions
diff --git a/server/src/ui/admin/log.rs b/server/src/ui/admin/log.rs
new file mode 100644
index 0000000..dff6d1b
--- /dev/null
+++ b/server/src/ui/admin/log.rs
@@ -0,0 +1,258 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2025 metamuffin <metamuffin.org>
+*/
+use crate::{
+ logic::session::AdminSession,
+ ui::{
+ error::MyResult,
+ layout::{DynLayoutPage, LayoutPage},
+ },
+ uri,
+};
+use chrono::{DateTime, Utc};
+use log::Level;
+use markup::Render;
+use rocket::get;
+use rocket_ws::{Message, Stream, WebSocket};
+use serde_json::json;
+use std::{
+ collections::VecDeque,
+ fmt::Write,
+ sync::{Arc, LazyLock, RwLock},
+};
+use tokio::sync::broadcast;
+
+const MAX_LOG_LEN: usize = 4096;
+
+static LOGGER: LazyLock<Log> = LazyLock::new(Log::default);
+
+pub fn enable_logging() {
+ log::set_logger(&*LOGGER).unwrap();
+ log::set_max_level(log::LevelFilter::Debug);
+}
+
+type LogBuffer = VecDeque<Arc<LogLine>>;
+
+pub struct Log {
+ inner: env_logger::Logger,
+ stream: (
+ broadcast::Sender<Arc<LogLine>>,
+ broadcast::Sender<Arc<LogLine>>,
+ ),
+ log: RwLock<(LogBuffer, LogBuffer)>,
+}
+
+pub struct LogLine {
+ time: DateTime<Utc>,
+ module: Option<&'static str>,
+ level: Level,
+ message: String,
+}
+
+#[get("/admin/log?<warnonly>", rank = 2)]
+pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult<DynLayoutPage<'a>> {
+ Ok(LayoutPage {
+ title: "Log".into(),
+ class: Some("admin_log"),
+ content: markup::new! {
+ h1 { "Server Log" }
+ a[href=uri!(r_admin_log(!warnonly))] { @if warnonly { "Show everything" } else { "Show only warnings" }}
+ code.log[id="log"] {
+ @let g = LOGGER.log.read().unwrap();
+ table { @for e in if warnonly { g.1.iter() } else { g.0.iter() } {
+ tr[class=format!("level-{:?}", e.level).to_ascii_lowercase()] {
+ td.time { @e.time.to_rfc3339() }
+ td.loglevel { @format_level(e.level) }
+ td.module { @e.module }
+ td { @markup::raw(vt100_to_html(&e.message)) }
+ }
+ }}
+ }
+ },
+ })
+}
+
+#[get("/admin/log?stream&<warnonly>", rank = 1)]
+pub fn r_admin_log_stream(
+ _session: AdminSession,
+ ws: WebSocket,
+ warnonly: bool,
+) -> Stream!['static] {
+ let mut stream = if warnonly {
+ LOGGER.stream.1.subscribe()
+ } else {
+ LOGGER.stream.0.subscribe()
+ };
+ Stream! { ws =>
+ let _ = ws;
+ while let Ok(line) = stream.recv().await {
+ yield Message::Text(json!({
+ "time": line.time,
+ "level_class": format!("level-{:?}", line.level).to_ascii_lowercase(),
+ "level_html": format_level_string(line.level),
+ "module": line.module,
+ "message": vt100_to_html(&line.message),
+ }).to_string());
+ }
+ }
+}
+
+impl Default for Log {
+ fn default() -> Self {
+ Self {
+ inner: env_logger::builder()
+ .filter_level(log::LevelFilter::Warn)
+ .parse_env("LOG")
+ .build(),
+ stream: (
+ tokio::sync::broadcast::channel(1024).0,
+ tokio::sync::broadcast::channel(1024).0,
+ ),
+ log: Default::default(),
+ }
+ }
+}
+impl Log {
+ fn should_log(&self, metadata: &log::Metadata) -> bool {
+ let level = metadata.level();
+ level
+ <= match metadata.target() {
+ x if x.starts_with("jelly") => Level::Debug,
+ x if x.starts_with("rocket::") => Level::Info,
+ _ => Level::Warn,
+ }
+ }
+ fn do_log(&self, record: &log::Record) {
+ let time = Utc::now();
+ let line = Arc::new(LogLine {
+ time,
+ module: record.module_path_static(),
+ level: record.level(),
+ message: record.args().to_string(),
+ });
+ let mut w = self.log.write().unwrap();
+ w.0.push_back(line.clone());
+ let _ = self.stream.0.send(line.clone());
+ while w.0.len() > MAX_LOG_LEN {
+ w.0.pop_front();
+ }
+ if record.level() <= Level::Warn {
+ let _ = self.stream.1.send(line.clone());
+ w.1.push_back(line);
+ while w.1.len() > MAX_LOG_LEN {
+ w.1.pop_front();
+ }
+ }
+ }
+}
+
+impl log::Log for Log {
+ fn enabled(&self, metadata: &log::Metadata) -> bool {
+ self.inner.enabled(metadata) || self.should_log(metadata)
+ }
+ fn log(&self, record: &log::Record) {
+ match (record.module_path_static(), record.line()) {
+ // TODO is there a better way to ignore those?
+ (Some("rocket::rocket"), Some(670)) => return,
+ (Some("rocket::server"), Some(401)) => return,
+ _ => {}
+ }
+ if self.inner.enabled(record.metadata()) {
+ self.inner.log(record);
+ }
+ if self.should_log(record.metadata()) {
+ self.do_log(record)
+ }
+ }
+ fn flush(&self) {
+ self.inner.flush();
+ }
+}
+
+fn vt100_to_html(s: &str) -> String {
+ let mut out = HtmlOut::default();
+ let mut st = vte::Parser::new();
+ st.advance(&mut out, s.as_bytes());
+ out.s
+}
+
+fn format_level(level: Level) -> impl markup::Render {
+ let (s, c) = match level {
+ Level::Debug => ("DEBUG", "blue"),
+ Level::Error => ("ERROR", "red"),
+ Level::Warn => ("WARN", "yellow"),
+ Level::Info => ("INFO", "green"),
+ Level::Trace => ("TRACE", "lightblue"),
+ };
+ markup::new! { span[style=format!("color:{c}")] {@s} }
+}
+fn format_level_string(level: Level) -> String {
+ let mut w = String::new();
+ format_level(level).render(&mut w).unwrap();
+ w
+}
+
+#[derive(Default)]
+pub struct HtmlOut {
+ s: String,
+ color: bool,
+}
+impl HtmlOut {
+ pub fn set_color(&mut self, [r, g, b]: [u8; 3]) {
+ self.reset_color();
+ self.color = true;
+ write!(self.s, "<span style=color:#{:02x}{:02x}{:02x}>", r, g, b).unwrap()
+ }
+ pub fn reset_color(&mut self) {
+ if self.color {
+ write!(self.s, "</span>").unwrap();
+ self.color = false;
+ }
+ }
+}
+impl vte::Perform for HtmlOut {
+ fn print(&mut self, c: char) {
+ match c {
+ 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c),
+ x => write!(self.s, "&#{};", x as u32).unwrap(),
+ }
+ }
+ fn execute(&mut self, _byte: u8) {}
+ fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {}
+ fn put(&mut self, _byte: u8) {}
+ fn unhook(&mut self) {}
+ fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {}
+ fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {}
+ fn csi_dispatch(
+ &mut self,
+ params: &vte::Params,
+ _intermediates: &[u8],
+ _ignore: bool,
+ action: char,
+ ) {
+ let mut k = params.iter();
+ #[allow(clippy::single_match)]
+ match action {
+ 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) {
+ c @ (30..=37 | 40..=47) => {
+ let c = if *c >= 40 { *c - 10 } else { *c };
+ self.set_color(match c {
+ 30 => [0, 0, 0],
+ 31 => [255, 0, 0],
+ 32 => [0, 255, 0],
+ 33 => [255, 255, 0],
+ 34 => [0, 0, 255],
+ 35 => [255, 0, 255],
+ 36 => [0, 255, 255],
+ 37 => [255, 255, 255],
+ _ => unreachable!(),
+ });
+ }
+ _ => (),
+ },
+ _ => (),
+ }
+ }
+}
diff --git a/server/src/ui/admin/mod.rs b/server/src/ui/admin/mod.rs
new file mode 100644
index 0000000..de06610
--- /dev/null
+++ b/server/src/ui/admin/mod.rs
@@ -0,0 +1,288 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2025 metamuffin <metamuffin.org>
+*/
+pub mod log;
+pub mod user;
+
+use super::assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED};
+use crate::{
+ database::Database,
+ logic::session::AdminSession,
+ ui::{
+ admin::log::rocket_uri_macro_r_admin_log,
+ error::MyResult,
+ layout::{DynLayoutPage, FlashDisplay, LayoutPage},
+ },
+ uri,
+};
+use anyhow::{anyhow, Context};
+use jellybase::{assetfed::AssetInner, federation::Federation, CONF};
+use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS};
+use markup::DynRender;
+use rand::Rng;
+use rocket::{form::Form, get, post, FromForm, State};
+use std::time::Instant;
+use tokio::{sync::Semaphore, task::spawn_blocking};
+use user::rocket_uri_macro_r_admin_users;
+
+#[get("/admin/dashboard")]
+pub async fn r_admin_dashboard(
+ _session: AdminSession,
+ database: &State<Database>,
+) -> MyResult<DynLayoutPage<'static>> {
+ admin_dashboard(database, None).await
+}
+
+pub async fn admin_dashboard<'a>(
+ database: &Database,
+ flash: Option<MyResult<String>>,
+) -> MyResult<DynLayoutPage<'a>> {
+ let invites = database.list_invites()?;
+ let flash = flash.map(|f| f.map_err(|e| format!("{e:?}")));
+
+ let last_import_err = IMPORT_ERRORS.read().await.to_owned();
+
+ let database = database.to_owned();
+ Ok(LayoutPage {
+ title: "Admin Dashboard".to_string(),
+ content: markup::new! {
+ h1 { "Admin Panel" }
+ @FlashDisplay { flash: flash.clone() }
+ @if !last_import_err.is_empty() {
+ section.message.error {
+ details {
+ summary { p.error { @format!("The last import resulted in {} errors:", last_import_err.len()) } }
+ ol { @for e in &last_import_err {
+ li.error { pre.error { @e } }
+ }}
+ }
+ }
+ }
+ ul {
+ li{a[href=uri!(r_admin_log(true))] { "Server Log (Warnings only)" }}
+ li{a[href=uri!(r_admin_log(false))] { "Server Log (Full) " }}
+ }
+ h2 { "Library" }
+ @if is_importing() {
+ section.message { p.warn { "An import is currently running." } }
+ }
+ @if is_transcoding() {
+ section.message { p.warn { "Currently transcoding posters." } }
+ }
+ form[method="POST", action=uri!(r_admin_import(true))] {
+ input[type="submit", disabled=is_importing(), value="Start incremental import"];
+ }
+ form[method="POST", action=uri!(r_admin_import(false))] {
+ input[type="submit", disabled=is_importing(), value="Start full import"];
+ }
+ form[method="POST", action=uri!(r_admin_transcode_posters())] {
+ input[type="submit", disabled=is_transcoding(), value="Transcode all posters with low resolution"];
+ }
+ form[method="POST", action=uri!(r_admin_update_search())] {
+ input[type="submit", value="Regenerate full-text search index"];
+ }
+ form[method="POST", action=uri!(r_admin_delete_cache())] {
+ input.danger[type="submit", value="Delete Cache"];
+ }
+ h2 { "Users" }
+ p { a[href=uri!(r_admin_users())] "Manage Users" }
+ h2 { "Invitations" }
+ form[method="POST", action=uri!(r_admin_invite())] {
+ input[type="submit", value="Generate new invite code"];
+ }
+ ul { @for t in &invites {
+ li {
+ form[method="POST", action=uri!(r_admin_remove_invite())] {
+ span { @t }
+ input[type="text", name="invite", value=&t, hidden];
+ input[type="submit", value="Invalidate"];
+ }
+ }
+ }}
+
+ h2 { "Database" }
+ @match db_stats(&database) {
+ Ok(s) => { @s }
+ Err(e) => { pre.error { @format!("{e:?}") } }
+ }
+ },
+ ..Default::default()
+ })
+}
+
+#[post("/admin/generate_invite")]
+pub async fn r_admin_invite(
+ _session: AdminSession,
+ database: &State<Database>,
+) -> MyResult<DynLayoutPage<'static>> {
+ let i = format!("{}", rand::rng().random::<u128>());
+ database.create_invite(&i)?;
+ admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await
+}
+
+#[derive(FromForm)]
+pub struct DeleteInvite {
+ invite: String,
+}
+
+#[post("/admin/remove_invite", data = "<form>")]
+pub async fn r_admin_remove_invite(
+ session: AdminSession,
+ database: &State<Database>,
+ form: Form<DeleteInvite>,
+) -> MyResult<DynLayoutPage<'static>> {
+ drop(session);
+ if !database.delete_invite(&form.invite)? {
+ Err(anyhow!("invite does not exist"))?;
+ };
+ admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await
+}
+
+#[post("/admin/import?<incremental>")]
+pub async fn r_admin_import(
+ session: AdminSession,
+ database: &State<Database>,
+ _federation: &State<Federation>,
+ incremental: bool,
+) -> MyResult<DynLayoutPage<'static>> {
+ drop(session);
+ let t = Instant::now();
+ if !incremental {
+ database.clear_nodes()?;
+ }
+ let r = import_wrap((*database).clone(), incremental).await;
+ let flash = r
+ .map_err(|e| e.into())
+ .map(|_| format!("Import successful; took {:?}", t.elapsed()));
+ admin_dashboard(database, Some(flash)).await
+}
+
+#[post("/admin/update_search")]
+pub async fn r_admin_update_search(
+ _session: AdminSession,
+ database: &State<Database>,
+) -> MyResult<DynLayoutPage<'static>> {
+ let db2 = (*database).clone();
+ let r = spawn_blocking(move || db2.search_create_index())
+ .await
+ .unwrap();
+ admin_dashboard(
+ database,
+ Some(
+ r.map_err(|e| e.into())
+ .map(|_| "Search index updated".to_string()),
+ ),
+ )
+ .await
+}
+
+#[post("/admin/delete_cache")]
+pub async fn r_admin_delete_cache(
+ session: AdminSession,
+ database: &State<Database>,
+) -> MyResult<DynLayoutPage<'static>> {
+ drop(session);
+ let t = Instant::now();
+ let r = tokio::fs::remove_dir_all(&CONF.cache_path).await;
+ tokio::fs::create_dir(&CONF.cache_path).await?;
+ admin_dashboard(
+ database,
+ Some(
+ r.map_err(|e| e.into())
+ .map(|_| format!("Cache deleted; took {:?}", t.elapsed())),
+ ),
+ )
+ .await
+}
+
+static SEM_TRANSCODING: Semaphore = Semaphore::const_new(1);
+fn is_transcoding() -> bool {
+ SEM_TRANSCODING.available_permits() == 0
+}
+
+#[post("/admin/transcode_posters")]
+pub async fn r_admin_transcode_posters(
+ session: AdminSession,
+ database: &State<Database>,
+) -> MyResult<DynLayoutPage<'static>> {
+ drop(session);
+ let _permit = SEM_TRANSCODING
+ .try_acquire()
+ .context("transcoding in progress")?;
+
+ let t = Instant::now();
+
+ {
+ let nodes = database.list_nodes_with_udata("")?;
+ for (node, _) in nodes {
+ if let Some(poster) = &node.poster {
+ let asset = AssetInner::deser(&poster.0)?;
+ if asset.is_federated() {
+ continue;
+ }
+ let source = resolve_asset(asset).await.context("resolving asset")?;
+ jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, 1024)
+ .await
+ .context("transcoding asset")?;
+ }
+ }
+ }
+ drop(_permit);
+
+ admin_dashboard(
+ database,
+ Some(Ok(format!(
+ "All posters pre-transcoded; took {:?}",
+ t.elapsed()
+ ))),
+ )
+ .await
+}
+
+fn db_stats(_db: &Database) -> anyhow::Result<DynRender> {
+ // TODO
+ // let txn = db.inner.begin_read()?;
+ // let stats = [
+ // ("node", txn.open_table(T_NODE)?.stats()?),
+ // ("user", txn.open_table(T_USER_NODE)?.stats()?),
+ // ("user-node", txn.open_table(T_USER_NODE)?.stats()?),
+ // ("invite", txn.open_table(T_INVITE)?.stats()?),
+ // ];
+
+ // let cache_stats = db.node_index.reader.searcher().doc_store_cache_stats();
+ // let ft_total_docs = db.node_index.reader.searcher().total_num_docs()?;
+
+ Ok(markup::new! {
+ // h3 { "Key-Value-Store Statistics" }
+ // table.border {
+ // tbody {
+ // tr {
+ // th { "table name" }
+ // th { "tree height" }
+ // th { "stored bytes" }
+ // th { "metadata bytes" }
+ // th { "fragmented bytes" }
+ // th { "branch pages" }
+ // th { "leaf pages" }
+ // }
+ // @for (name, stats) in &stats { tr {
+ // td { @name }
+ // td { @stats.tree_height() }
+ // td { @format_size(stats.stored_bytes(), DECIMAL) }
+ // td { @format_size(stats.metadata_bytes(), DECIMAL) }
+ // td { @format_size(stats.fragmented_bytes(), DECIMAL) }
+ // td { @stats.branch_pages() }
+ // td { @stats.leaf_pages() }
+ // }}
+ // }
+ // }
+ // h3 { "Search Engine Statistics" }
+ // ul {
+ // li { "Total documents: " @ft_total_docs }
+ // li { "Cache misses: " @cache_stats.cache_misses }
+ // li { "Cache hits: " @cache_stats.cache_hits }
+ // }
+ })
+}
diff --git a/server/src/ui/admin/user.rs b/server/src/ui/admin/user.rs
new file mode 100644
index 0000000..c5239f7
--- /dev/null
+++ b/server/src/ui/admin/user.rs
@@ -0,0 +1,176 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2025 metamuffin <metamuffin.org>
+*/
+use crate::{
+ database::Database,
+ logic::session::AdminSession,
+ ui::{
+ error::MyResult,
+ layout::{DynLayoutPage, FlashDisplay, LayoutPage},
+ },
+ uri,
+};
+use anyhow::{anyhow, Context};
+use jellycommon::user::{PermissionSet, UserPermission};
+use rocket::{form::Form, get, post, FromForm, FromFormField, State};
+
+#[get("/admin/users")]
+pub fn r_admin_users(
+ _session: AdminSession,
+ database: &State<Database>,
+) -> MyResult<DynLayoutPage<'static>> {
+ user_management(database, None)
+}
+
+fn user_management<'a>(
+ database: &Database,
+ flash: Option<MyResult<String>>,
+) -> MyResult<DynLayoutPage<'a>> {
+ // TODO this doesnt scale, pagination!
+ let users = database.list_users()?;
+ let flash = flash.map(|f| f.map_err(|e| format!("{e:?}")));
+
+ Ok(LayoutPage {
+ title: "User management".to_string(),
+ content: markup::new! {
+ h1 { "User Management" }
+ @FlashDisplay { flash: flash.clone() }
+ h2 { "All Users" }
+ ul { @for u in &users {
+ li {
+ a[href=uri!(r_admin_user(&u.name))] { @format!("{:?}", u.display_name) " (" @u.name ")" }
+ }
+ }}
+ },
+ ..Default::default()
+ })
+}
+
+#[get("/admin/user/<name>")]
+pub fn r_admin_user<'a>(
+ _session: AdminSession,
+ database: &State<Database>,
+ name: &'a str,
+) -> MyResult<DynLayoutPage<'a>> {
+ manage_single_user(database, None, name.to_string())
+}
+
+fn manage_single_user<'a>(
+ database: &Database,
+ flash: Option<MyResult<String>>,
+ name: String,
+) -> MyResult<DynLayoutPage<'a>> {
+ let user = database
+ .get_user(&name)?
+ .ok_or(anyhow!("user does not exist"))?;
+ let flash = flash.map(|f| f.map_err(|e| format!("{e:?}")));
+
+ Ok(LayoutPage {
+ title: "User management".to_string(),
+ content: markup::new! {
+ h1 { @format!("{:?}", user.display_name) " (" @user.name ")" }
+ a[href=uri!(r_admin_users())] "Back to the User List"
+ @FlashDisplay { flash: flash.clone() }
+ form[method="POST", action=uri!(r_admin_remove_user())] {
+ input[type="text", name="name", value=&user.name, hidden];
+ input.danger[type="submit", value="Remove user(!)"];
+ }
+
+ h2 { "Permissions" }
+ @PermissionDisplay { perms: &user.permissions }
+
+ form[method="POST", action=uri!(r_admin_user_permission())] {
+ input[type="text", name="name", value=&user.name, hidden];
+ fieldset.perms {
+ legend { "Permission" }
+ @for p in UserPermission::ALL_ENUMERABLE {
+ label {
+ input[type="radio", name="permission", value=serde_json::to_string(p).unwrap()];
+ @format!("{p}")
+ } br;
+ }
+ }
+ fieldset.perms {
+ legend { "Permission" }
+ label { input[type="radio", name="action", value="unset"]; "Unset" } br;
+ label { input[type="radio", name="action", value="grant"]; "Grant" } br;
+ label { input[type="radio", name="action", value="revoke"]; "Revoke" } br;
+ }
+ input[type="submit", value="Update"];
+ }
+
+ },
+ ..Default::default()
+ })
+}
+
+markup::define! {
+ PermissionDisplay<'a>(perms: &'a PermissionSet) {
+ ul { @for (perm,grant) in &perms.0 {
+ @if *grant {
+ li[class="perm-grant"] { @format!("Allow {}", perm) }
+ } else {
+ li[class="perm-revoke"] { @format!("Deny {}", perm) }
+ }
+ }}
+ }
+}
+
+#[derive(FromForm)]
+pub struct DeleteUser {
+ name: String,
+}
+#[derive(FromForm)]
+pub struct UserPermissionForm {
+ name: String,
+ permission: String,
+ action: GrantState,
+}
+
+#[derive(FromFormField)]
+pub enum GrantState {
+ Grant,
+ Revoke,
+ Unset,
+}
+
+#[post("/admin/update_user_permission", data = "<form>")]
+pub fn r_admin_user_permission(
+ session: AdminSession,
+ database: &State<Database>,
+ form: Form<UserPermissionForm>,
+) -> MyResult<DynLayoutPage<'static>> {
+ drop(session);
+ let perm = serde_json::from_str::<UserPermission>(&form.permission)
+ .context("parsing provided permission")?;
+
+ database.update_user(&form.name, |user| {
+ match form.action {
+ GrantState::Grant => drop(user.permissions.0.insert(perm.clone(), true)),
+ GrantState::Revoke => drop(user.permissions.0.insert(perm.clone(), false)),
+ GrantState::Unset => drop(user.permissions.0.remove(&perm)),
+ }
+ Ok(())
+ })?;
+
+ manage_single_user(
+ database,
+ Some(Ok("Permissions update".into())),
+ form.name.clone(),
+ )
+}
+
+#[post("/admin/remove_user", data = "<form>")]
+pub fn r_admin_remove_user(
+ session: AdminSession,
+ database: &State<Database>,
+ form: Form<DeleteUser>,
+) -> MyResult<DynLayoutPage<'static>> {
+ drop(session);
+ if !database.delete_user(&form.name)? {
+ Err(anyhow!("user did not exist"))?;
+ }
+ user_management(database, Some(Ok("User removed".into())))
+}