feat: added basic user management
This commit is contained in:
+306
-34
@@ -1,3 +1,4 @@
|
||||
use chrono::{DateTime, offset::Utc};
|
||||
use leptos::prelude::*;
|
||||
use serde::{Serialize, Deserialize};
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -5,63 +6,334 @@ use {
|
||||
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)]
|
||||
pub struct PubUser {
|
||||
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]
|
||||
async fn list_users() -> Result<Vec<PubUser>, ServerFnError> {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use futures::stream::StreamExt;
|
||||
|
||||
let pool = expect_context::<SqlitePool>();
|
||||
|
||||
let users = sqlx::query_as!(
|
||||
PubUser,
|
||||
"SELECT user_id, user_name FROM users"
|
||||
DbUser,
|
||||
"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)
|
||||
.await?;
|
||||
.fetch(&pool)
|
||||
.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]
|
||||
pub fn UserView() -> impl IntoView {
|
||||
let user_list = Resource::new(|| (), |_| async move { list_users().await });
|
||||
|
||||
view! {
|
||||
<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;
|
||||
let modal_ref = NodeRef::<leptos::html::Dialog>::new();
|
||||
|
||||
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>
|
||||
{users
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|user| view! {
|
||||
<li>{user.user_id}": "{user.user_name}</li>
|
||||
})
|
||||
.map(|(_, e)| view! { <li>{e.to_string()}</li> })
|
||||
.collect::<Vec<_>>()}
|
||||
</ul>
|
||||
})
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
{move || Suspend::new(async move {
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user