Work on superadmin tooling

This commit is contained in:
Daniel Flanagan 2024-07-14 08:49:45 -05:00
parent 6b0e87e8f8
commit 9e1fcb2923
11 changed files with 171 additions and 42 deletions

1
Cargo.lock generated
View file

@ -1035,6 +1035,7 @@ dependencies = [
"maud", "maud",
"notify", "notify",
"pathdiff", "pathdiff",
"rand",
"redact", "redact",
"regex", "regex",
"serde", "serde",

View file

@ -30,6 +30,7 @@ futures = "0.3.30"
maud = "0.26.0" maud = "0.26.0"
notify = "6.1.1" notify = "6.1.1"
pathdiff = "0.2.1" pathdiff = "0.2.1"
rand = "0.8.5"
redact = { version = "0.1.10", features = ["serde"] } redact = { version = "0.1.10", features = ["serde"] }
regex = { version = "1.10.5" } regex = { version = "1.10.5" }
serde = "1.0.201" serde = "1.0.201"

View file

@ -1,5 +1,6 @@
pub mod prelude; pub mod prelude;
mod admin;
mod run; mod run;
use crate::{observe, prelude::*}; use crate::{observe, prelude::*};
@ -21,21 +22,28 @@ pub struct App {
enum Commands { enum Commands {
/// Run the web application server /// Run the web application server
Run(run::Run), Run(run::Run),
/// Perform administrator actions
Admin(admin::Admin),
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum ExecError { pub enum RunError {
#[error("run error: {0}")] #[error("run error: {0}")]
Run(#[from] run::RunError), Run(#[from] run::RunError),
#[error("admin error: {0}")]
Admin(#[from] admin::RunError),
#[error("{0}")] #[error("{0}")]
Eyre(#[from] color_eyre::Report), Eyre(#[from] color_eyre::Report),
} }
pub async fn run() -> Result<(), ExecError> { pub async fn run() -> Result<(), RunError> {
let cli = App::parse(); let cli = App::parse();
observe::setup_logging(&cli.log_env_filter)?; observe::setup_logging(&cli.log_env_filter)?;
match cli.command { match cli.command {
Commands::Run(args) => Ok(args.run().await?), Commands::Run(args) => Ok(args.run().await?),
Commands::Admin(args) => Ok(args.run().await?),
} }
} }

64
src/cli/admin.rs Normal file
View file

@ -0,0 +1,64 @@
use super::prelude::*;
#[derive(Args)]
pub struct Admin {
/// Register a user account
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
CreateAccount(CreateAccount),
}
#[derive(Error, Debug)]
pub enum RunError {
#[error("{0}")]
Eyre(#[from] color_eyre::Report),
#[error("create account error: {0}")]
CreateAccount(#[from] CreateAccountError),
}
impl Admin {
pub async fn run(&self) -> Result<(), RunError> {
match &self.command {
Commands::CreateAccount(args) => Ok(args.run().await?),
}
}
}
/// Create a user account
#[derive(Args)]
pub struct CreateAccount {
/// Whether or not the user is a site super admin
#[arg(short, long, default_value = "false")]
pub superadmin: bool,
/// The email address of the user account
#[arg(short = 'e', long)]
pub email_address: String,
/// The user's initial password - if none is set, a random one will be used and output
#[arg(short, long)]
pub initial_password: Option<String>,
}
#[derive(Error, Debug)]
pub enum CreateAccountError {}
impl CreateAccount {
pub async fn run(&self) -> Result<(), CreateAccountError> {
// self.email_address
let password = self.initial_password.unwrap_or_else(|| {
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(7)
.map(char::from)
.collect()
});
Ok(())
}
}

View file

@ -11,6 +11,7 @@ mod service;
mod state; mod state;
mod tailwind; mod tailwind;
mod user; mod user;
mod uuid;
mod webserver; mod webserver;
use crate::prelude::*; use crate::prelude::*;

View file

@ -17,18 +17,19 @@ pub fn head(page_title: &str) -> Markup {
(stylesheet("/static/style.css")); (stylesheet("/static/style.css"));
script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous" defer {} script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous" defer {}
} }
} }
pub fn foot() -> Markup { pub fn foot() -> Markup {
html! { html! {
footer class="p-2 border-t-2 border-surface0 flex overflow-x-scroll" { footer class="p-2 bg-mantle border-t-2 border-surface0 flex overflow-x-scroll mt-auto" {
section { section {
(PreEscaped("&copy; 2024 ")) (PreEscaped("&copy; 2024 "))
a .underline.text-mauve href="https://lyte.dev" { "lytedev" } a class="underline text-mauve" href="https://lyte.dev" { "lytedev" }
} }
section .ml-auto {("Made with ❤️")} section .ml-auto {("Made with ❤️")}
" "
a .underline.text-mauve href="/about" { "About" }
} }
} }
} }
@ -42,8 +43,8 @@ pub fn page(
Ok(Html( Ok(Html(
html! { html! {
(head(title)) (head(title))
body .bg-bg.text-text.min-h-lvh.flex.flex-col.font-sans { body hx-boost="true" class="bg-bg text-text min-h-lvh flex flex-col font-sans overflow-x-hidden" {
header class="drop-shadow border-b-2 border-surface0 bg-blue-500 flex overflow-x-scroll" { header class="drop-shadow border-b-2 border-surface0 bg-mantle flex overflow-x-scroll" {
a class="flex p-2 text-3xl font-mono text-mauve opacity-80 hover:bg-mauve hover:text-bg" href="/" { "lyrs" } a class="flex p-2 text-3xl font-mono text-mauve opacity-80 hover:bg-mauve hover:text-bg" href="/" { "lyrs" }
nav class="flex flex-1 justify-start" { nav class="flex flex-1 justify-start" {
@if let Some(user) = current_user { @if let Some(user) = current_user {
@ -55,10 +56,8 @@ pub fn page(
} }
} }
} }
main class="flex flex-col flex-1 relative overflow-x-scroll bg-mantle" {
(content) (content)
} }
}
(foot()) (foot())
}.into_string() }.into_string()
)) ))

View file

@ -93,6 +93,7 @@ pub async fn router(
.route("/", get(index)) .route("/", get(index))
.route("/about", get(about)) .route("/about", get(about))
.route("/users", get(users)) .route("/users", get(users))
// TODO: admin-only pages
.nest_service("/accounts", accounts_service) .nest_service("/accounts", accounts_service)
.nest_service("/static", static_file_service) .nest_service("/static", static_file_service)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
@ -109,15 +110,43 @@ pub async fn router(
} }
async fn index(auth_session: Option<AuthSession>) -> ReqResult<Html<String>> { async fn index(auth_session: Option<AuthSession>) -> ReqResult<Html<String>> {
page("index", html! { "Index" }, auth_session) page(
"index",
html! {
main class="p-2" {
h1 class="text-2xl" { "Index" }
p class="mt-2" {
"Here, we explain to you why you may like this web application."
}
}
},
auth_session,
)
} }
async fn about(auth_session: Option<AuthSession>) -> ReqResult<Html<String>> { async fn about(auth_session: Option<AuthSession>) -> ReqResult<Html<String>> {
page("about", html! { "About" }, auth_session) page(
"about",
html! {
main class="p-2" {
h1 class="text-2xl" { "About" }
p class="mt-2" {
"Here, we give a little history on why we made this."
}
}
},
auth_session,
)
} }
async fn dashboard(auth_session: Option<AuthSession>) -> ReqResult<Html<String>> { async fn dashboard(auth_session: Option<AuthSession>) -> ReqResult<Html<String>> {
page("dashboard", html! { "Dashboard" }, auth_session) page(
"dashboard",
html! {
div class="p-2" { "Dashboard" }
},
auth_session,
)
} }
async fn users(State(state): State<AppState>) -> ReqResult<String> { async fn users(State(state): State<AppState>) -> ReqResult<String> {

View file

@ -25,7 +25,10 @@ pub fn router(state: AppState) -> Result<Router, Infallible> {
.with_state(state)) .with_state(state))
} }
async fn login() -> ReqResult<Html<String>> { async fn login(auth_session: Option<AuthSession>) -> impl IntoResponse {
if auth_session.map(|s| s.user).flatten().is_some() {
return Redirect::to("/dashboard").into_response();
}
let form = html! { let form = html! {
(labelled_input("Username", html!{ (labelled_input("Username", html!{
input class="input" type="text" name="username" autocomplete="username" required; input class="input" type="text" name="username" autocomplete="username" required;
@ -41,10 +44,13 @@ async fn login() -> ReqResult<Html<String>> {
"." "."
} }
}; };
page("login", center_hero_form("Login", form, subaction), None) page("login", center_hero_form("Login", form, subaction), None).into_response()
} }
async fn register() -> ReqResult<Html<String>> { async fn register(auth_session: Option<AuthSession>) -> impl IntoResponse {
if auth_session.map(|s| s.user).flatten().is_some() {
return Redirect::to("/dashboard").into_response();
}
let form = html! { let form = html! {
(labelled_input("Username", html!{ (labelled_input("Username", html!{
input class="input" type="text" name="username" required; input class="input" type="text" name="username" required;
@ -60,7 +66,7 @@ async fn register() -> ReqResult<Html<String>> {
"." "."
} }
}; };
page("login", center_hero_form("Register", form, subaction), None) page("login", center_hero_form("Register", form, subaction), None).into_response()
} }
pub type AuthSession = axum_login::AuthSession<AppState>; pub type AuthSession = axum_login::AuthSession<AppState>;

View file

@ -1,27 +1,26 @@
@tailwind base; @tailwind base;
@layer base { @layer base {
/* main a[href] { */
/* @apply text-mauve underline; */
/* } */
main a[href] { /* input { */
@apply text-mauve underline; /* @apply opacity-80 hover:opacity-100 p-2 border-2 bg-bg border-surface2 rounded; */
} /* } */
input { /* button, */
@apply opacity-80 hover:opacity-100 p-2 border-2 bg-bg border-surface2 rounded; /* input[type=submit] { */
} /* @apply opacity-80 hover:opacity-100 p-2 border-0 rounded cursor-pointer text-bg; */
/* } */
button, /* .hero { */
input[type=submit] { /* @apply flex flex-col p-2 justify-center items-center relative; */
@apply opacity-80 hover:opacity-100 p-2 border-0 rounded cursor-pointer text-bg; /* } */
}
.hero { /* .card { */
@apply flex flex-col p-2 justify-center items-center relative; /* @apply flex flex-col drop-shadow-xl border-2 border-surface2 rounded p-2 gap-2; */
} /* } */
.card {
@apply flex flex-col drop-shadow-xl border-2 border-surface2 rounded p-2 gap-2;
}
} }
@tailwind components; @tailwind components;

View file

@ -1,10 +1,10 @@
use crate::prelude::*; use crate::{prelude::*, uuid};
use axum_login::AuthUser; use axum_login::AuthUser;
use chrono::Utc; use chrono::Utc;
use redact::{expose_secret, Secret}; use redact::{expose_secret, Secret};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use uuid::{NoContext, Uuid}; use uuid::Uuid;
pub const USER_TREE: &str = "user"; pub const USER_TREE: &str = "user";
@ -35,13 +35,8 @@ impl User {
pub fn try_new(username: &str, password: &str) -> Result<Self, argon2::password_hash::Error> { pub fn try_new(username: &str, password: &str) -> Result<Self, argon2::password_hash::Error> {
let now = Utc::now(); let now = Utc::now();
let ts = uuid::Timestamp::from_unix(
NoContext,
u64::from_ne_bytes(now.timestamp().to_ne_bytes()),
now.timestamp_subsec_micros(),
);
Ok(Self { Ok(Self {
id: Uuid::new_v7(ts), id: uuid::v7(now),
username: username.to_owned(), username: username.to_owned(),
registered_at: now, registered_at: now,
password_digest: Secret::new(crate::auth::password_digest(password)?.into()), password_digest: Secret::new(crate::auth::password_digest(password)?.into()),

26
src/uuid.rs Normal file
View file

@ -0,0 +1,26 @@
use chrono::{DateTime, TimeZone, Utc};
pub use uuid::Uuid;
use uuid::{NoContext, Timestamp};
fn now() -> Timestamp {
from_datetime(Utc::now())
}
fn from_datetime<T>(ts: DateTime<T>) -> Timestamp
where
T: TimeZone,
{
Timestamp::from_unix(
NoContext,
u64::from_ne_bytes(ts.timestamp().to_ne_bytes()),
ts.timestamp_subsec_micros(),
)
}
pub fn v7_now() -> Uuid {
Uuid::new_v7(now())
}
pub fn v7<T: TimeZone>(dt: DateTime<T>) -> Uuid {
Uuid::new_v7(from_datetime(dt))
}