feat: added basic user management

This commit is contained in:
Andrew Rioux 2025-01-26 17:33:26 -05:00
parent dee6c62bf2
commit bee66a8d6c
Signed by: andrew.rioux
GPG Key ID: 9B8BAC47C17ABB94
17 changed files with 587 additions and 120 deletions

1
Cargo.lock generated
View File

@ -369,6 +369,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]

View File

@ -70,6 +70,7 @@
wasm-bindgen-cli wasm-bindgen-cli
dart-sass dart-sass
binaryen binaryen
sqlx-cli
]; ];
freebsd = buildTools.linux freebsd = buildTools.linux
++ [ pkgsCross.x86_64-freebsd.buildPackages.clang ]; ++ [ pkgsCross.x86_64-freebsd.buildPackages.clang ];
@ -89,9 +90,6 @@
# Cargo lint tools # Cargo lint tools
taplo taplo
cargo-deny cargo-deny
# Web server tools
sqlx-cli
]; ];
craneLib = (crane.mkLib pkgs).overrideToolchain (p: craneLib = (crane.mkLib pkgs).overrideToolchain (p:

View File

@ -92,6 +92,8 @@ let
(craneLib.fileset.commonCargoSources ./sparse-server) (craneLib.fileset.commonCargoSources ./sparse-server)
./sparse-server/style ./sparse-server/style
./sparse-server/public ./sparse-server/public
./sparse-server/.sqlx
./sparse-server/migrations
]; ];
}; };

View File

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT user_id, user_name FROM users", "query": "SELECT user_id, user_name, (SELECT MAX(expires) FROM sessions s WHERE s.user_id = u.user_id) as last_active FROM users u",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -12,6 +12,11 @@
"name": "user_name", "name": "user_name",
"ordinal": 1, "ordinal": 1,
"type_info": "Text" "type_info": "Text"
},
{
"name": "last_active",
"ordinal": 2,
"type_info": "Integer"
} }
], ],
"parameters": { "parameters": {
@ -19,8 +24,9 @@
}, },
"nullable": [ "nullable": [
false, false,
false false,
true
] ]
}, },
"hash": "7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09" "hash": "ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c"
} }

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM users WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6"
}

View File

@ -28,10 +28,10 @@ tokio-stream = { version = "0.1", optional = true }
futures-util = { version = "0.3", optional = true } futures-util = { version = "0.3", optional = true }
tracing = { version = "0.1", optional = true } tracing = { version = "0.1", optional = true }
web-sys = { version = "0.3", features = ["WebSocket"] } web-sys = { version = "0.3", features = ["WebSocket"] }
leptos-use = { version = "0.15", default-features = false, features = ["use_websocket"] } leptos-use = { version = "0.15", default-features = false, features = ["use_websocket", "use_interval"] }
codee = "0.2" codee = "0.2"
sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite"], optional = true } sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite"], optional = true }
chrono = "0.4" chrono = { version = "0.4", features = ["serde"] }
rpassword = { version = "7.3", optional = true } rpassword = { version = "7.3", optional = true }
pbkdf2 = { version = "0.12", features = ["simple", "sha2"], optional = true } pbkdf2 = { version = "0.12", features = ["simple", "sha2"], optional = true }
sha2 = { version = "0.10", optional = true } sha2 = { version = "0.10", optional = true }
@ -39,7 +39,7 @@ hex = { version = "0.4", optional = true }
serde = "1.0" serde = "1.0"
[features] [features]
hydrate = ["leptos/hydrate"] hydrate = ["leptos/hydrate", "chrono/wasmbind"]
ssr = [ ssr = [
"dep:axum", "dep:axum",
"dep:tokio", "dep:tokio",

View File

@ -1,7 +1,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::{ use leptos_router::{
components::{Route, Router, Routes}, components::{A, Route, Router, Routes},
StaticSegment, StaticSegment,
}; };
@ -50,12 +50,18 @@ pub fn App() -> impl IntoView {
// content for this welcome page // content for this welcome page
<Router> <Router>
<main> <nav>
<Routes fallback=|| "Page not found.".into_view()> <h1>"Sparse control"</h1>
<Route path=StaticSegment("") view=HomePage/> <A href="/">"Home"</A>
<Route path=StaticSegment("/users") view=crate::users::UserView/> <A href="/beacons">"Beacon management"</A>
</Routes> <A href="/users">"Users"</A>
</main> </nav>
<aside class="beacons">
</aside>
<Routes fallback=|| "Page not found.".into_view()>
<Route path=StaticSegment("") view=HomePage/>
<Route path=StaticSegment("/users") view=crate::users::UserView/>
</Routes>
</Router> </Router>
} }
} }
@ -104,41 +110,43 @@ fn HomePage() -> impl IntoView {
}; };
view! { view! {
<h1>"Welcome to Leptos!"</h1> <main class="main">
<button on:click=on_click>"Click Me: " {count}</button> <h1>"Welcome to Leptos!"</h1>
<Suspense <button on:click=on_click>"Click Me: " {count}</button>
fallback=move || view! { <p>"Loading..."</p> } <Suspense
> fallback=move || view! { <p>"Loading..."</p> }
<h2>"Loaded time:"</h2> >
{move || { <h2>"Loaded time:"</h2>
loaded_time.get() {move || {
.map(|time| match time { loaded_time.get()
Ok(t) => view! { <p>{format!("{}", t)}</p>}, .map(|time| match time {
Err(_) => view! { <p>{"Error!".to_string()}</p> } Ok(t) => view! { <p>{format!("{}", t)}</p>},
}) Err(_) => view! { <p>{"Error!".to_string()}</p> }
}} })
<h2>"Requested time:"</h2> }}
<button on:click=request_time_callback>"Request new time"</button> <h2>"Requested time:"</h2>
{move || pending.get().then_some("Loading...")} <button on:click=request_time_callback>"Request new time"</button>
{move || match requested_time.get() { {move || pending.get().then_some("Loading...")}
Some(t) => { {move || match requested_time.get() {
leptos::logging::log!("updating time display"); Some(t) => {
view! { <p>{t}</p> } leptos::logging::log!("updating time display");
}, view! { <p>{t}</p> }
None => view! { <p>{"N/A".to_string()}</p> } },
}} None => view! { <p>{"N/A".to_string()}</p> }
</Suspense> }}
<h2>"Messages"</h2> </Suspense>
<input bind:value=text_input /> <h2>"Messages"</h2>
<input <input bind:value=text_input />
on:click=send_message <input
type="button" on:click=send_message
value="Send message" type="button"
/> value="Send message"
{move || messages />
.get() {move || messages
.iter() .get()
.map(|message| view! { <p>{message.clone()}</p> }) .iter()
.collect::<Vec<_>>()} .map(|message| view! { <p>{message.clone()}</p> })
.collect::<Vec<_>>()}
</main>
} }
} }

View File

@ -24,30 +24,23 @@ async fn list_users(db: SqlitePool) -> anyhow::Result<ExitCode> {
Ok(ExitCode::SUCCESS) Ok(ExitCode::SUCCESS)
} }
async fn create_user(db: SqlitePool, name: String) -> anyhow::Result<ExitCode> { fn get_password() -> anyhow::Result<String> {
let mut tx = db.begin().await?; let password1 = rpassword::prompt_password("Enter new password: ")?;
let password2 = rpassword::prompt_password("Enter password again: ")?;
let previous_user_check = sqlx::query_scalar!( if password1 != password2 {
"SELECT COUNT(*) FROM users WHERE user_name = ?", Err(anyhow::anyhow!("Passwords do not match!"))?
name
)
.fetch_one(&mut *tx)
.await?;
if previous_user_check > 0 {
eprintln!("Error! User already exists!");
return Ok(ExitCode::FAILURE);
} }
let new_id = query!( Ok(password1)
r#"INSERT INTO users (user_name, password_salt, password_hash) VALUES (?, "", "")"#, }
name
)
.execute(&mut *tx)
.await?
.last_insert_rowid();
reset_password(&mut *tx, new_id as i16).await?; async fn create_user(db: SqlitePool, name: String) -> anyhow::Result<ExitCode> {
let password = get_password()?;
let mut tx = db.begin().await?;
crate::db::user::create_user(&mut tx, name, password).await?;
tx.commit().await?; tx.commit().await?;
@ -58,16 +51,11 @@ async fn create_user(db: SqlitePool, name: String) -> anyhow::Result<ExitCode> {
async fn reset_password<'a, E>(db: E, id: i16) -> anyhow::Result<ExitCode> async fn reset_password<'a, E>(db: E, id: i16) -> anyhow::Result<ExitCode>
where where
E: sqlx::Executor<'a, Database = sqlx::Sqlite> E: sqlx::SqliteExecutor<'a>
{ {
let password1 = rpassword::prompt_password("Enter new password: ")?; let password = get_password()?;
let password2 = rpassword::prompt_password("Enter password again: ")?;
if password1 != password2 { crate::db::user::reset_password(db, id, password).await?;
Err(anyhow::anyhow!("Passwords do not match!"))?
}
crate::db::user::reset_password(db, id, password1).await?;
println!("Password set successfully!"); println!("Password set successfully!");

View File

@ -1,12 +1,13 @@
use sqlx::{sqlite::SqlitePool, Database};
use pbkdf2::{pbkdf2_hmac_array, password_hash::{rand_core::OsRng, SaltString}}; use pbkdf2::{pbkdf2_hmac_array, password_hash::{rand_core::OsRng, SaltString}};
use sha2::Sha256; use sha2::Sha256;
use crate::error::Error;
const PASSWORD_ITERATIONS: u32 = 100_000; const PASSWORD_ITERATIONS: u32 = 100_000;
pub async fn reset_password<'a, E>(pool: E, id: i16, password: String) -> anyhow::Result<()> pub async fn reset_password<'a, E>(pool: E, id: i16, password: String) -> Result<(), crate::error::Error>
where where
E: sqlx::Executor<'a, Database = sqlx::Sqlite> E: sqlx::SqliteExecutor<'a>
{ {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
@ -30,3 +31,37 @@ where
Ok(()) Ok(())
} }
pub async fn create_user<'a, E>(acq: E, name: String, password: String) -> Result<(), crate::error::Error>
where
E: sqlx::Acquire<'a, Database = sqlx::Sqlite>
{
let mut tx = acq.begin().await?;
let previous_user_check = sqlx::query_scalar!(
"SELECT COUNT(*) FROM users WHERE user_name = ?",
name
)
.fetch_one(&mut *tx)
.await?;
if previous_user_check > 0 {
return Err(Error::UserCreate("User already exists".to_string()));
}
tracing::info!("Creating new user {}", name);
let new_id = sqlx::query!(
r#"INSERT INTO users (user_name, password_salt, password_hash) VALUES (?, "", "")"#,
name
)
.execute(&mut *tx)
.await?
.last_insert_rowid();
reset_password(&mut *tx, new_id as i16, password).await?;
tx.commit().await?;
Ok(())
}

View File

@ -0,0 +1,37 @@
#[derive(Debug)]
pub enum Error {
UserCreate(String),
#[cfg(feature = "ssr")]
Sqlx(sqlx::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::UserCreate(err) => {
write!(f, "user create error: {err}")
}
#[cfg(feature = "ssr")]
Error::Sqlx(err) => {
write!(f, "sqlx error: {err:?}")
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
#[cfg(feature = "ssr")]
Error::Sqlx(err) => Some(err),
_ => None,
}
}
}
#[cfg(feature = "ssr")]
impl From<sqlx::Error> for Error {
fn from(err: sqlx::Error) -> Self {
Self::Sqlx(err)
}
}

View File

@ -2,6 +2,8 @@ pub mod app;
pub mod users; pub mod users;
pub mod error;
pub mod db; pub mod db;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]

View File

@ -17,6 +17,8 @@ mod webserver;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub mod users; pub mod users;
pub mod error;
pub mod db; pub mod db;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]

View File

@ -1,3 +1,4 @@
use chrono::{DateTime, offset::Utc};
use leptos::prelude::*; use leptos::prelude::*;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@ -5,63 +6,334 @@ use {
sqlx::SqlitePool sqlx::SqlitePool
}; };
fn format_delta(time: chrono::TimeDelta) -> String {
let seconds = time.num_seconds();
match seconds {
0..=59 => format!("{} second{} ago", seconds, if seconds == 1 {""} else {"s"}),
60..=3599 => {
let minutes = seconds / 60;
format!("{} minute{} ago", minutes, if minutes == 1 {""} else {"s"})
}
3600..=86399 => {
let hours = seconds / 3600;
format!("{} hours{} ago", hours, if hours == 1 {""} else {"s"})
}
_ => {
let days = seconds / 86400;
format!("{} day{} ago", days, if days == 1 {""} else {"s"})
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct DbUser {
user_id: i64,
user_name: String,
last_active: Option<i64>
}
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct PubUser { pub struct PubUser {
user_id: i64, user_id: i64,
user_name: String user_name: String,
last_active: Option<DateTime<Utc>>
}
#[server]
async fn delete_user(user_id: i64) -> Result<(), ServerFnError> {
let pool = expect_context::<SqlitePool>();
sqlx::query!(
"DELETE FROM users WHERE user_id = ?",
user_id
)
.execute(&pool)
.await?;
Ok(())
}
#[server]
async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnError> {
let pool = expect_context::<SqlitePool>();
crate::db::user::reset_password(&pool, user_id as i16, password).await?;
Ok(())
}
#[component]
pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl IntoView {
use leptos_use::{use_interval, UseIntervalReturn};
let UseIntervalReturn { counter, .. } = use_interval(1000);
let (time_ago, set_time_ago) = signal(user.last_active.map(|active| format_delta(Utc::now() - active)));
Effect::watch(
move || counter.get(),
move |_, _, _| {
set_time_ago(user.last_active.map(|active| format_delta(Utc::now() - active)));
},
false
);
let dialog_ref = NodeRef::<leptos::html::Dialog>::new();
let new_password = RwSignal::new("".to_owned());
let error_dialog_ref = NodeRef::<leptos::html::Dialog>::new();
let (error_msg, set_error_msg) = signal(None::<String>);
let clear_error = move |_| {
set_error_msg(None);
};
let handle_delete = Action::new(move |&id: &i64| async move {
match delete_user(id).await {
Ok(()) => {
refresh_user_list.dispatch(());
}
Err(e) => {
set_error_msg(Some(e.to_string()));
}
}
});
let handle_reset = Action::new(move |info: &(i64, String)| {
let (id, password) = info.clone();
async move {
match reset_password(id, password).await {
Ok(()) => {}
Err(e) => {
set_error_msg(Some(e.to_string()));
}
}
}
});
view! {
<dialog
node_ref=error_dialog_ref
on:close=clear_error
>
{error_msg}
</dialog>
<li>
{user.user_id}
": "
{user.user_name}
{move || time_ago.get().map(|active| view! {
<span>
"(last activity: "
{active}
")"
</span>
})}
<button
on:click=move |_| {
handle_delete.dispatch(user.user_id);
}
>
"Delete"
</button>
<button
on:click=move |_| {
if let Some(dialog) = dialog_ref.get() {
let _ = dialog.show_modal();
}
}
>
"Reset password"
</button>
<dialog
node_ref=dialog_ref
>
<form
on:submit=move |ev| {
let _ = ev.prevent_default();
handle_reset.dispatch((user.user_id, new_password.get()));
new_password.set("".to_owned());
if let Some(dialog) = dialog_ref.get() {
let _ = dialog.close();
}
}
>
<fieldset>
<legend>"Reset password"</legend>
<label>
"New password"
</label>
<input
bind:value=new_password
type="password"
/>
<div>
</div>
<input
value="Submit"
type="submit"
/>
</fieldset>
</form>
</dialog>
</li>
}
} }
#[server] #[server]
async fn list_users() -> Result<Vec<PubUser>, ServerFnError> { async fn list_users() -> Result<Vec<PubUser>, ServerFnError> {
use leptos::server_fn::error::NoCustomError; use futures::stream::StreamExt;
let pool = expect_context::<SqlitePool>(); let pool = expect_context::<SqlitePool>();
let users = sqlx::query_as!( let users = sqlx::query_as!(
PubUser, DbUser,
"SELECT user_id, user_name FROM users" "SELECT user_id, user_name, (SELECT MAX(expires) FROM sessions s WHERE s.user_id = u.user_id) as last_active FROM users u"
) )
.fetch_all(&pool) .fetch(&pool)
.await?; .map(|user| user.map(|u| PubUser {
user_id: u.user_id,
user_name: u.user_name,
last_active: u.last_active.map(|ts| DateTime::from_timestamp(ts, 0)).flatten()
}))
.collect::<Vec<Result<_, _>>>()
.await;
Ok(users) let users: Result<Vec<_>, _> = users.into_iter().collect();
Ok(users?)
}
#[server]
async fn add_user(name: String, password: String) -> Result<(), ServerFnError> {
let pool = expect_context::<SqlitePool>();
crate::db::user::create_user(&pool, name, password).await?;
Ok(())
} }
#[component] #[component]
pub fn UserView() -> impl IntoView { pub fn UserView() -> impl IntoView {
let user_list = Resource::new(|| (), |_| async move { list_users().await }); let user_list = Resource::new(|| (), |_| async move { list_users().await });
view! { let modal_ref = NodeRef::<leptos::html::Dialog>::new();
<h1>"User list"</h1>
<Suspense
fallback=move || view! { <p>"Loading..."</p> }
>
<ErrorBoundary
fallback=|errors| view! {
<p>"Errors loading users!"</p>
<ul>
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li> })
.collect::<Vec<_>>()}
</ul>
}
>
{move || Suspend::new(async move {
let users_res = user_list.await;
users_res.map(|users| view! { let new_user_name = RwSignal::new("".to_owned());
let new_user_pass = RwSignal::new("".to_owned());
let (user_add_error, set_user_add_error) = signal(None::<String>);
let add_user_action = Action::new(move |_: &()| async move {
if let Some(modal) = modal_ref.get() {
let _ = modal.close();
}
match add_user(new_user_name.get(), new_user_pass.get()).await {
Ok(()) => {
user_list.refetch();
}
Err(e) => {
set_user_add_error(Some(format!("Error adding user! {e:?}")));
}
};
new_user_name.set("".to_owned());
new_user_pass.set("".to_owned());
});
let refresh_user_list = Action::new(move |_: &()| async move {
user_list.refetch();
});
view! {
<main class="users">
<input
type="button"
value="Add user"
on:click=move |_| {
if let Some(modal) = modal_ref.get() {
let _ = modal.show_modal();
}
}
/>
<dialog node_ref=modal_ref>
<form
on:submit=move |ev| {
let _ = ev.prevent_default();
add_user_action.dispatch(());
}
>
<fieldset>
<legend>"Add user"</legend>
<label>
"New user name"
</label>
<input
bind:value=new_user_name
/>
<label>
"New password"
</label>
<input
bind:value=new_user_pass
type="password"
/>
<div>
</div>
<input
value="Submit"
type="submit"
/>
</fieldset>
</form>
</dialog>
{move || user_add_error.get().map(|err| view! {
<p>
"Error adding user! "
{err}
</p>
})}
<h2>"User list"</h2>
<Suspense
fallback=move || view! { <p>"Loading..."</p> }
>
<ErrorBoundary
fallback=|errors| view! {
<p>"Errors loading users!"</p>
<ul> <ul>
{users {move || errors.get()
.into_iter() .into_iter()
.map(|user| view! { .map(|(_, e)| view! { <li>{e.to_string()}</li> })
<li>{user.user_id}": "{user.user_name}</li>
})
.collect::<Vec<_>>()} .collect::<Vec<_>>()}
</ul> </ul>
}) }
})} >
</ErrorBoundary> {move || Suspend::new(async move {
</Suspense> let users_res = user_list.await;
users_res.map(|users| view! {
<ul>
{users
.into_iter()
.map(|user| view! {
<RenderUser
refresh_user_list
user
/>
})
.collect::<Vec<_>>()}
</ul>
})
})}
</ErrorBoundary>
</Suspense>
</main>
} }
} }

View File

@ -9,6 +9,7 @@ use sparse_server::app::*;
pub async fn websocket(ws: axum::extract::ws::WebSocketUpgrade) -> axum::response::Response { pub async fn websocket(ws: axum::extract::ws::WebSocketUpgrade) -> axum::response::Response {
tracing::info!("Handling websocket request to /ws");
ws.on_upgrade(handle_websocket) ws.on_upgrade(handle_websocket)
} }

View File

@ -0,0 +1,3 @@
main.main {
}

View File

@ -0,0 +1,24 @@
main.users {
dialog::backdrop {
background-color: #0008;
}
form fieldset {
display: grid;
grid-template-columns: 1fr 1fr;
* {
display: inline-block;
margin: 10px;
}
}
ul {
list-style-type: none;
}
li button {
margin-left: 10px;
margin-right: 0px;
}
}

View File

@ -1,4 +1,80 @@
body { @use '_users';
@use '_main';
html, body {
font-family: sans-serif; font-family: sans-serif;
text-align: center; background-color: #201f30;
}
color: white;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
body {
display: grid;
grid-template-columns: 350px 1fr;
grid-template-rows: 79px 1fr;
grid-template-areas:
"nav nav"
"beacons main";
}
nav {
background-color: #11111c;
grid-area: nav;
border-bottom: 1px solid #2e2e59;
h1 {
color: white;
text-decoration: none;
display: inline-block;
padding: 15px;
margin: 10px 5px;
}
a, a:visited {
color: white;
text-decoration: none;
display: inline-block;
padding: 10px;
margin: 10px 5px;
}
a:hover {
text-decoration: underline;
}
}
aside.beacons {
grid-area: beacons;
background-color: #11111c;
overflow-y: auto;
overflow-x: hidden;
}
main {
grid-area: main;
padding: 20px;
overflow: auto;
}
input[type="submit"],
input[type="button"],
button {
background-color: #fff;
border: 1px solid transparent;
cursor: pointer;
margin: 5px;
padding: 2px 4px;
border-radius: 2px;
box-shadow: #fff7 0 1px 0 inset;
&:hover {
background-color: #ccc;
}
}