use askama::Template;
use axum::Extension;
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::Event;
use axum::response::{IntoResponse, Response};
use koan_core::auth::Role;
use koan_core::db::queries::auth::{self as users, UserRow};
use koan_core::invite::{self, AccountError, Invite};
use super::pages::respond;
use super::{UiState, events, open, patch};
use crate::auth::AuthUser;
use crate::html::{Html, NOTHING, Render};
use crate::share::blocking;
pub(super) const FORM: &str = "mb-2 flex max-w-form gap-2";
pub(super) const ROLES: [(Role, &str); 3] = [
(Role::Readonly, "Listen only"),
(Role::User, "Listen and edit"),
(Role::Admin, "Admin"),
];
#[derive(Template)]
#[template(path = "users/list.html")]
struct UserList<'a> {
rows: &'a [UserRow],
me: i64,
}
fn user_list(rows: &[UserRow], me: i64) -> Html {
UserList { rows, me }.html()
}
enum Shown<'a> {
Html(&'a Html),
Message(&'a str),
Error(&'a str),
}
#[derive(Template)]
#[template(path = "users/result.html")]
struct UserResult<'a> {
shown: Shown<'a>,
}
fn show(shown: Shown) -> Event {
patch(&UserResult { shown }.html(), None)
}
fn result(html: &Html) -> Event {
show(Shown::Html(html))
}
fn failure(text: &str) -> Event {
show(Shown::Error(text))
}
fn invite_qr(link: &str) -> Option<Html> {
use qrcode::render::svg;
let code = match qrcode::QrCode::new(link) {
Ok(code) => code,
Err(e) => {
log::warn!("web UI: invite link too long for a QR code: {e}");
return None;
}
};
let svg = code
.render::<svg::Color>()
.module_dimensions(4, 4)
.dark_color(svg::Color("#000"))
.light_color(svg::Color("#fff"))
.build();
svg.find("<svg").map(|at| Html::trusted(&svg[at..]))
}
#[derive(Template)]
#[template(path = "users/invite.html")]
struct InvitePanel<'a> {
invite: &'a Invite,
link: String,
qr: Option<Html>,
email: Html,
}
fn invite_panel(invite: &Invite) -> Html {
let link = invite.link();
InvitePanel {
invite,
qr: invite_qr(&link),
email: Html::trusted(invite.email_html()),
link,
}
.html()
}
fn token_invite(
s: &UiState,
server: &str,
id: i64,
username: &str,
password: Option<&str>,
) -> Result<Invite, AccountError> {
let keys = crate::auth::signing_keys().map_err(|e| AccountError::Other(Box::new(e)))?;
let db = open(&s.pool).ok_or_else(|| AccountError::Other("no database".into()))?;
let token = invite::mint_token(&db.conn, &keys.0, id)?;
Ok(Invite::with_token(server, username, &token, password))
}
fn forbidden() -> Response {
(StatusCode::FORBIDDEN, "admins only").into_response()
}
fn usable(s: &UiState, user: &AuthUser) -> bool {
s.auth_enabled && user.role == Role::Admin
}
fn list(s: &UiState) -> Vec<UserRow> {
open(&s.pool)
.and_then(|db| users::list_users(&db.conn).ok())
.unwrap_or_default()
}
#[derive(Template)]
#[template(path = "pages/users.html")]
struct UsersPage {
list: Option<Html>,
}
pub(super) async fn page(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
headers: HeaderMap,
) -> Response {
let list = if s.auth_enabled {
if user.role != Role::Admin {
return forbidden();
}
let st = s.clone();
let rows = blocking(move || Some(list(&st))).await.unwrap_or_default();
Some(user_list(&rows, user.user_id))
} else {
None
};
let inner = UsersPage { list }.html();
respond(&s, &headers, &user, "Users", &inner)
}
pub(super) async fn create(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
body: Bytes,
) -> Response {
if !usable(&s, &user) {
return forbidden();
}
let signals = serde_json::from_slice::<serde_json::Value>(&body).unwrap_or_default();
let field = |k: &str| {
signals
.get(k)
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim()
.to_owned()
};
let username = field("newuser");
let role: Role = field("newrole").parse().unwrap_or(Role::Readonly);
let Some(server) = s.server_url.clone() else {
return events(vec![failure(
"The server's address is unknown: set server.url.",
)]);
};
let me = user.user_id;
let made = blocking(move || {
let db = open(&s.pool)?;
let made = invite::create_account(&db.conn, &username, role).and_then(|made| {
token_invite(&s, &server, made.id, username.trim(), Some(&made.password))
});
Some((made, list(&s)))
})
.await;
let Some((made, rows)) = made else {
return events(vec![failure("The account could not be made.")]);
};
match made {
Ok(i) => events(vec![
result(&invite_panel(&i)),
patch(&user_list(&rows, me), None),
Event::default()
.event("datastar-patch-signals")
.data("signals {\"newuser\":\"\"}"),
]),
Err(e) => events(vec![failure(&e.to_string())]),
}
}
#[derive(serde::Deserialize)]
pub(super) struct InviteQuery {
#[serde(default)]
reset: bool,
}
pub(super) async fn invite(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<i64>,
Query(q): Query<InviteQuery>,
) -> Response {
if !usable(&s, &user) {
return forbidden();
}
let Some(server) = s.server_url.clone() else {
return events(vec![failure(
"The server's address is unknown: set server.url.",
)]);
};
let made = blocking(move || {
let db = open(&s.pool)?;
let row = users::get_user_by_id(&db.conn, id).ok()??;
let password = if q.reset {
match invite::set_password(&db.conn, &row.username, None) {
Ok(p) => {
crate::clients::registry().disconnect(&row.username);
Some(p)
}
Err(e) => return Some(Err(e)),
}
} else {
None
};
Some(token_invite(
&s,
&server,
row.id,
&row.username,
password.as_deref(),
))
})
.await;
match made {
Some(Ok(i)) => events(vec![result(&invite_panel(&i))]),
Some(Err(e)) => events(vec![failure(&e.to_string())]),
None => events(vec![failure("No such account.")]),
}
}
fn clear_password_signal() -> Event {
Event::default()
.event("datastar-patch-signals")
.data("signals {\"setpassword\":\"\"}")
}
#[derive(Template)]
#[template(path = "users/password.html")]
struct PasswordForm<'a> {
id: i64,
name: &'a str,
}
pub(super) async fn password_form(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Response {
if !usable(&s, &user) {
return forbidden();
}
let row = blocking(move || users::get_user_by_id(&open(&s.pool)?.conn, id).ok()?).await;
let Some(row) = row else {
return events(vec![failure("No such account.")]);
};
let form = PasswordForm {
id,
name: &row.username,
};
events(vec![clear_password_signal(), result(&form.html())])
}
pub(super) async fn set_password(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<i64>,
body: Bytes,
) -> Response {
if !usable(&s, &user) {
return forbidden();
}
let password = serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("setpassword")?.as_str().map(str::to_owned))
.unwrap_or_default();
let done = blocking(move || {
let db = open(&s.pool)?;
let row = users::get_user_by_id(&db.conn, id).ok()??;
let done = invite::set_password(&db.conn, &row.username, Some(&password));
if done.is_ok() {
crate::clients::registry().disconnect(&row.username);
}
Some((row, done))
})
.await;
match done {
Some((row, Ok(_))) => events(vec![
show(Shown::Message(&format!(
"{}'s password is changed, and their devices are signed out.",
row.username
))),
clear_password_signal(),
]),
Some((_, Err(e))) => events(vec![failure(&e.to_string())]),
None => events(vec![failure("No such account.")]),
}
}
#[derive(serde::Deserialize)]
pub(super) struct RoleQuery {
role: String,
}
pub(super) async fn set_role(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<i64>,
Query(q): Query<RoleQuery>,
) -> Response {
if !usable(&s, &user) {
return forbidden();
}
let Ok(role) = q.role.parse::<Role>() else {
return events(vec![failure("Unknown access level.")]);
};
let me = user.user_id;
let done = blocking(move || {
let db = open(&s.pool)?;
let row = users::get_user_by_id(&db.conn, id).ok()??;
let done = invite::set_role(&db.conn, &row.username, role);
Some((done, list(&s)))
})
.await;
match done {
Some((Ok(()), rows)) => events(vec![patch(&user_list(&rows, me), None), result(&NOTHING)]),
Some((Err(e), rows)) => events(vec![
patch(&user_list(&rows, me), None),
failure(&e.to_string()),
]),
None => events(vec![failure("No such account.")]),
}
}
pub(super) async fn delete(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Response {
if !usable(&s, &user) {
return forbidden();
}
if id == user.user_id {
return events(vec![failure("You cannot delete your own account here.")]);
}
let me = user.user_id;
let done = blocking(move || {
let db = open(&s.pool)?;
let row = users::get_user_by_id(&db.conn, id).ok()??;
let done = invite::delete_account(&db.conn, &row.username);
if done.is_ok() {
crate::clients::registry().disconnect(&row.username);
}
Some((done, list(&s)))
})
.await;
match done {
Some((Ok(()), rows)) => events(vec![patch(&user_list(&rows, me), None), result(&NOTHING)]),
Some((Err(e), _)) => events(vec![failure(&e.to_string())]),
None => events(vec![failure("No such account.")]),
}
}