feat: added basic user management
This commit is contained in:
parent
dee6c62bf2
commit
bee66a8d6c
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
12
sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json
generated
Normal file
12
sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM users WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6"
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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!");
|
||||||
|
|
||||||
|
|||||||
@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
37
sparse-server/src/error.rs
Normal file
37
sparse-server/src/error.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")]
|
||||||
|
|||||||
@ -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")]
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
sparse-server/style/_main.scss
Normal file
3
sparse-server/style/_main.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
main.main {
|
||||||
|
|
||||||
|
}
|
||||||
24
sparse-server/style/_users.scss
Normal file
24
sparse-server/style/_users.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user