2025-02-17 01:36:01 -05:00

407 lines
12 KiB
Rust

use chrono::{offset::Utc, DateTime};
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use {crate::db::user, leptos::server_fn::error::NoCustomError, sqlx::SqlitePool};
pub 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, Debug, Serialize, Deserialize, Eq)]
pub struct User {
pub user_id: i64,
pub user_name: String,
}
impl std::cmp::PartialEq for User {
fn eq(&self, other: &Self) -> bool {
self.user_id == other.user_id
}
}
#[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,
last_active: Option<DateTime<Utc>>,
}
#[server(prefix = "/api/users", endpoint = "delete")]
async fn delete_user(user_id: i64) -> Result<(), ServerFnError> {
let user = user::get_auth_session().await?;
if user.is_none() {
return Err(ServerFnError::<NoCustomError>::ServerError(
"You are not signed in!".to_owned(),
));
}
let pool = crate::db::get_db()?;
sqlx::query!("DELETE FROM users WHERE user_id = ?", user_id)
.execute(&pool)
.await?;
Ok(())
}
#[server(prefix = "/api/users", endpoint = "reset")]
async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnError> {
let user = user::get_auth_session().await?;
if user.is_none() {
return Err(ServerFnError::<NoCustomError>::ServerError(
"You are not signed in!".to_owned(),
));
}
let pool = crate::db::get_db()?;
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};
#[cfg_attr(feature = "ssr", allow(unused_variables))]
let UseIntervalReturn { counter, .. } = use_interval(1000);
#[cfg_attr(feature = "ssr", allow(unused_variables))]
let (time_ago, set_time_ago) = signal(
user.last_active
.map(|active| format_delta(Utc::now() - active)),
);
#[cfg(feature = "hydrate")]
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(prefix = "/api/users", endpoint = "list")]
async fn list_users() -> Result<Vec<PubUser>, ServerFnError> {
let user = user::get_auth_session().await?;
if user.is_none() {
return Err(ServerFnError::<NoCustomError>::ServerError(
"You are not signed in!".to_owned(),
));
}
use futures::stream::StreamExt;
let pool = crate::db::get_db()?;
let users = sqlx::query_as!(DbUser, "SELECT user_id, user_name, last_active FROM users")
.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;
let users: Result<Vec<_>, _> = users.into_iter().collect();
Ok(users?)
}
#[server(prefix = "/api/users", endpoint = "add")]
async fn add_user(name: String, password: String) -> Result<(), ServerFnError> {
let user = user::get_auth_session().await?;
if user.is_none() {
return Err(ServerFnError::<NoCustomError>::ServerError(
"You are not signed in!".to_owned(),
));
}
let pool = crate::db::get_db()?;
crate::db::user::create_user(&pool, name, password).await?;
Ok(())
}
#[component]
pub fn UserView() -> impl IntoView {
#[cfg(feature = "hydrate")]
Effect::new(move || {
let user = expect_context::<ReadSignal<Option<crate::users::User>>>();
if user.get().is_none() {
let navigate = leptos_router::hooks::use_navigate();
navigate("/login?next=beacons", Default::default());
}
});
let user_list = Resource::new(|| (), |_| async move { list_users().await });
let modal_ref = NodeRef::<leptos::html::Dialog>::new();
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>
{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! {
<ul>
{users
.into_iter()
.map(|user| view! {
<RenderUser
refresh_user_list
user
/>
})
.collect::<Vec<_>>()}
</ul>
})
})}
</ErrorBoundary>
</Suspense>
</main>
}
}