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",
|
||||
"notify",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"redact",
|
||||
"regex",
|
||||
"serde",
|
||||
|
|
|
@ -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"
|
||||
|
|
12
src/cli.rs
12
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?),
|
||||
}
|
||||
}
|
||||
|
|
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 tailwind;
|
||||
mod user;
|
||||
mod uuid;
|
||||
mod webserver;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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<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>> {
|
||||
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>> {
|
||||
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> {
|
||||
|
|
|
@ -25,7 +25,10 @@ pub fn router(state: AppState) -> Result<Router, Infallible> {
|
|||
.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! {
|
||||
(labelled_input("Username", html!{
|
||||
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! {
|
||||
(labelled_input("Username", html!{
|
||||
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>;
|
||||
|
|
|
@ -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;
|
||||
|
|
11
src/user.rs
11
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<Self, argon2::password_hash::Error> {
|
||||
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()),
|
||||
|
|
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