diff --git a/Cargo.lock b/Cargo.lock index b45e9f9..5314535 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1035,6 +1035,7 @@ dependencies = [ "maud", "notify", "pathdiff", + "rand", "redact", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7612472..fa01c29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ futures = "0.3.30" maud = "0.26.0" notify = "6.1.1" pathdiff = "0.2.1" +rand = "0.8.5" redact = { version = "0.1.10", features = ["serde"] } regex = { version = "1.10.5" } serde = "1.0.201" diff --git a/src/cli.rs b/src/cli.rs index 2d322dd..18133e4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,6 @@ pub mod prelude; +mod admin; mod run; use crate::{observe, prelude::*}; @@ -21,21 +22,28 @@ pub struct App { enum Commands { /// Run the web application server Run(run::Run), + + /// Perform administrator actions + Admin(admin::Admin), } #[derive(Error, Debug)] -pub enum ExecError { +pub enum RunError { #[error("run error: {0}")] Run(#[from] run::RunError), + #[error("admin error: {0}")] + Admin(#[from] admin::RunError), + #[error("{0}")] Eyre(#[from] color_eyre::Report), } -pub async fn run() -> Result<(), ExecError> { +pub async fn run() -> Result<(), RunError> { let cli = App::parse(); observe::setup_logging(&cli.log_env_filter)?; match cli.command { Commands::Run(args) => Ok(args.run().await?), + Commands::Admin(args) => Ok(args.run().await?), } } diff --git a/src/cli/admin.rs b/src/cli/admin.rs new file mode 100644 index 0000000..78c7fb2 --- /dev/null +++ b/src/cli/admin.rs @@ -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, +} + +#[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(()) + } +} diff --git a/src/main.rs b/src/main.rs index e1e3493..5472777 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod service; mod state; mod tailwind; mod user; +mod uuid; mod webserver; use crate::prelude::*; diff --git a/src/partials.rs b/src/partials.rs index 15bb7af..71cf37e 100644 --- a/src/partials.rs +++ b/src/partials.rs @@ -17,18 +17,19 @@ pub fn head(page_title: &str) -> Markup { (stylesheet("/static/style.css")); script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous" defer {} - } } pub fn foot() -> Markup { 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 { (PreEscaped("© 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 ❤️")} + " " + a .underline.text-mauve href="/about" { "About" } } } } @@ -42,8 +43,8 @@ pub fn page( Ok(Html( html! { (head(title)) - body .bg-bg.text-text.min-h-lvh.flex.flex-col.font-sans { - header class="drop-shadow border-b-2 border-surface0 bg-blue-500 flex overflow-x-scroll" { + 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-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" } nav class="flex flex-1 justify-start" { @if let Some(user) = current_user { @@ -55,9 +56,7 @@ pub fn page( } } } - main class="flex flex-col flex-1 relative overflow-x-scroll bg-mantle" { - (content) - } + (content) } (foot()) }.into_string() diff --git a/src/router.rs b/src/router.rs index 21d7fec..4b765a7 100644 --- a/src/router.rs +++ b/src/router.rs @@ -93,6 +93,7 @@ pub async fn router( .route("/", get(index)) .route("/about", get(about)) .route("/users", get(users)) + // TODO: admin-only pages .nest_service("/accounts", accounts_service) .nest_service("/static", static_file_service) .layer(TraceLayer::new_for_http()) @@ -109,15 +110,43 @@ pub async fn router( } async fn index(auth_session: Option) -> ReqResult> { - 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) -> ReqResult> { - 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) -> ReqResult> { - page("dashboard", html! { "Dashboard" }, auth_session) + page( + "dashboard", + html! { + div class="p-2" { "Dashboard" } + }, + auth_session, + ) } async fn users(State(state): State) -> ReqResult { diff --git a/src/service/accounts.rs b/src/service/accounts.rs index bcd0e63..bcb7a9a 100644 --- a/src/service/accounts.rs +++ b/src/service/accounts.rs @@ -25,7 +25,10 @@ pub fn router(state: AppState) -> Result { .with_state(state)) } -async fn login() -> ReqResult> { +async fn login(auth_session: Option) -> impl IntoResponse { + if auth_session.map(|s| s.user).flatten().is_some() { + return Redirect::to("/dashboard").into_response(); + } let form = html! { (labelled_input("Username", html!{ input class="input" type="text" name="username" autocomplete="username" required; @@ -41,10 +44,13 @@ async fn login() -> ReqResult> { "." } }; - page("login", center_hero_form("Login", form, subaction), None) + page("login", center_hero_form("Login", form, subaction), None).into_response() } -async fn register() -> ReqResult> { +async fn register(auth_session: Option) -> impl IntoResponse { + if auth_session.map(|s| s.user).flatten().is_some() { + return Redirect::to("/dashboard").into_response(); + } let form = html! { (labelled_input("Username", html!{ input class="input" type="text" name="username" required; @@ -60,7 +66,7 @@ async fn register() -> ReqResult> { "." } }; - 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; diff --git a/src/style.css b/src/style.css index 07be6c3..334b4eb 100644 --- a/src/style.css +++ b/src/style.css @@ -1,27 +1,26 @@ @tailwind base; @layer base { + /* main a[href] { */ + /* @apply text-mauve underline; */ + /* } */ - main a[href] { - @apply text-mauve underline; - } + /* input { */ + /* @apply opacity-80 hover:opacity-100 p-2 border-2 bg-bg border-surface2 rounded; */ + /* } */ - input { - @apply opacity-80 hover:opacity-100 p-2 border-2 bg-bg border-surface2 rounded; - } + /* button, */ + /* input[type=submit] { */ + /* @apply opacity-80 hover:opacity-100 p-2 border-0 rounded cursor-pointer text-bg; */ + /* } */ - button, - input[type=submit] { - @apply opacity-80 hover:opacity-100 p-2 border-0 rounded cursor-pointer text-bg; - } + /* .hero { */ + /* @apply flex flex-col p-2 justify-center items-center relative; */ + /* } */ - .hero { - @apply flex flex-col p-2 justify-center items-center relative; - } - - .card { - @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; diff --git a/src/user.rs b/src/user.rs index 3175935..23e2071 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,10 +1,10 @@ -use crate::prelude::*; +use crate::{prelude::*, uuid}; use axum_login::AuthUser; use chrono::Utc; use redact::{expose_secret, Secret}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use uuid::{NoContext, Uuid}; +use uuid::Uuid; pub const USER_TREE: &str = "user"; @@ -35,13 +35,8 @@ impl User { pub fn try_new(username: &str, password: &str) -> Result { 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 { - id: Uuid::new_v7(ts), + id: uuid::v7(now), username: username.to_owned(), registered_at: now, password_digest: Secret::new(crate::auth::password_digest(password)?.into()), diff --git a/src/uuid.rs b/src/uuid.rs new file mode 100644 index 0000000..5a87ee0 --- /dev/null +++ b/src/uuid.rs @@ -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(ts: DateTime) -> 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(dt: DateTime) -> Uuid { + Uuid::new_v7(from_datetime(dt)) +}