Work on superadmin tooling
This commit is contained in:
parent
6b0e87e8f8
commit
9e1fcb2923
11 changed files with 171 additions and 42 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -1035,6 +1035,7 @@ dependencies = [
|
||||||
"maud",
|
"maud",
|
||||||
"notify",
|
"notify",
|
||||||
"pathdiff",
|
"pathdiff",
|
||||||
|
"rand",
|
||||||
"redact",
|
"redact",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
12
src/cli.rs
12
src/cli.rs
|
@ -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
64
src/cli/admin.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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::*;
|
||||||
|
|
|
@ -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("© 2024 "))
|
(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 ❤️")}
|
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,9 +56,7 @@ pub fn page(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
main class="flex flex-col flex-1 relative overflow-x-scroll bg-mantle" {
|
(content)
|
||||||
(content)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(foot())
|
(foot())
|
||||||
}.into_string()
|
}.into_string()
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
11
src/user.rs
11
src/user.rs
|
@ -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
26
src/uuid.rs
Normal 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))
|
||||||
|
}
|
Loading…
Reference in a new issue