diff --git a/assets/favicon.ico b/assets/favicon.ico index 2ba8527..f717780 100644 Binary files a/assets/favicon.ico and b/assets/favicon.ico differ diff --git a/src/backend/data.rs b/src/backend/data.rs index 280c64c..32672f6 100644 --- a/src/backend/data.rs +++ b/src/backend/data.rs @@ -37,6 +37,7 @@ pub struct User { pub login: String, pub password: String, pub full_name: Option, + #[validate(email(message = "Enter valid email address"))] pub email: Option, pub admin: bool, pub get_emails: bool, @@ -48,6 +49,59 @@ impl User { } } +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)] +pub struct UserProfile { + login: String, + full_name: String, + #[validate(email(message = "Enter valid email address"))] + email: String, + get_emails: Option +} + +impl UserProfile { + + pub fn login(&self) -> &str { + &self.login + } + + pub fn full_name(&self) -> &str { + &self.full_name + } + + pub fn email(&self) -> &str { + &self.email + } + + pub fn get_emails(&self) -> bool { + self.get_emails.is_some() + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)] +pub struct PwdChange { + login: String, + old_password: String, + #[validate(length(min = 1, message = "Enter new password"), + must_match(other = "password_ver", message = "Passwords doesn't match"))] + password: String, + password_ver: String +} + +impl PwdChange { + pub fn login(&self) -> &str { + &self.login + } + pub fn old_password(&self) -> &str { + &self.old_password + } + pub fn password(&self) -> &str { + &self.password + } + pub fn password_ver(&self) -> &str { + &self.password_ver + } +} + /*pub struct Property { id: u16, name: String, diff --git a/src/backend/mod.rs b/src/backend/mod.rs index a5e50de..56b20fb 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -21,6 +21,23 @@ macro_rules! perm_check { } } +#[macro_export] +macro_rules! user_check { + ($check:expr) => { + use crate::perm_check; + use crate::backend::user::logged_in_user; + + perm_check!(is_logged_in); + + if logged_in_user().await.unwrap_or(User::default()).login != $check { + let response = expect_context::(); + response.set_status(StatusCode::FORBIDDEN); + + return Ok(ApiResponse::Error("You can change your own profile only".to_string())) + } + } +} + cfg_if!{ if #[cfg(feature = "ssr")] { use sqlx::PgPool; diff --git a/src/backend/user.rs b/src/backend/user.rs index 61f552f..326fddd 100644 --- a/src/backend/user.rs +++ b/src/backend/user.rs @@ -1,11 +1,11 @@ use cfg_if::cfg_if; use leptos::*; +use crate::backend::data::{ApiResponse, PwdChange, User, UserProfile}; cfg_if! { if #[cfg(feature = "ssr")] { use sqlx::{query_as, Error, PgPool, query}; use actix_session::*; use leptos_actix::{extract, redirect}; - use crate::backend::data::User; pub async fn has_admin_user(pool: &PgPool) -> Result { let count: (i64,) = query_as(r#"SELECT COUNT(id) FROM "user" WHERE admin = $1"#) @@ -57,27 +57,32 @@ cfg_if! { if #[cfg(feature = "ssr")] { }} #[server(Login, "/api")] -pub async fn login(username: String, password: String) -> Result<(), ServerFnError> { +pub async fn login(username: String, password: String) -> Result, ServerFnError> { use crate::backend::AppData; use actix_session::*; use actix_web::web::Data; use leptos_actix::extract; + use actix_web::http::StatusCode; + use leptos_actix::ResponseOptions; let pool = extract(|data: Data| async move { data.db_pool().clone() }).await?; - let user = user_from_login(&pool, &username).await?; + let user = user_from_login(&pool, &username).await.unwrap_or(User::default()); - if pwhash::bcrypt::verify(password, &user.password) { + if !user.login.is_empty() && pwhash::bcrypt::verify(password, &user.password) { extract(|session: Session| async move { let _ = session.insert("user", user); }) .await?; redirect("/admin"); - return Ok(()); + return Ok(ApiResponse::Data(())); } - Err(ServerFnError::ServerError("Bad login".to_string())) + let response = expect_context::(); + response.set_status(StatusCode::UNAUTHORIZED); + + return Ok(ApiResponse::Error("Bad username or password".to_string())) } #[server] @@ -87,7 +92,6 @@ pub async fn logout() -> Result<(), ServerFnError> { }).await?; redirect("/login"); - Ok(()) } @@ -99,4 +103,63 @@ pub async fn auth_check() -> Result { #[server] pub async fn admin_check() -> Result { Ok(is_admin().await) +} + +#[server] +pub async fn get_user() -> Result, ServerFnError> { + Ok(logged_in_user().await) +} + +#[server] +pub async fn update_profile(user: UserProfile) -> Result, ServerFnError> { + use crate::backend::AppData; + use actix_web::web::Data; + use leptos_actix::extract; + use crate::user_check; + + user_check!(user.login()); + + let pool = extract(|data: Data| async move { data.db_pool().clone() }).await?; + sqlx::query(r#"UPDATE "user" SET full_name = $1, email = $2, get_emails = $3 WHERE login = $4"#) + .bind(user.full_name()) + .bind(user.email()) + .bind(user.get_emails()) + .bind(user.login()) + .execute(&pool) + .await?; + + let usr = user_from_login(&pool, user.login()).await?; + extract(|session: Session| async move { + let _ = session.insert("user", usr); + }).await?; + + Ok(ApiResponse::Data(())) +} + +#[server] +pub async fn change_pwd(new_pw: PwdChange) -> Result, ServerFnError> { + use crate::backend::AppData; + use actix_web::web::Data; + use leptos_actix::extract; + use crate::user_check; + + user_check!(new_pw.login()); + + let pool = extract(|data: Data| async move { data.db_pool().clone() }).await?; + let usr = user_from_login(&pool, new_pw.login()).await?; + + if !pwhash::bcrypt::verify(new_pw.old_password(), &usr.password) { + let response = expect_context::(); + response.set_status(StatusCode::UNAUTHORIZED); + + return Ok(ApiResponse::Error("Invalid old password".to_string())) + } + + sqlx::query(r#"UPDATE "user" SET password = $1 WHERE login = $2"#) + .bind(pwhash::bcrypt::hash(new_pw.password()).unwrap()) + .bind(new_pw.login()) + .execute(&pool) + .await?; + + Ok(ApiResponse::Data(())) } \ No newline at end of file diff --git a/src/components/admin_portal.rs b/src/components/admin_portal.rs index 494c1a5..6da1851 100644 --- a/src/components/admin_portal.rs +++ b/src/components/admin_portal.rs @@ -1,8 +1,18 @@ use leptos::*; +use crate::backend::data::User; +use crate::components::modal_box::DialogOpener; +use crate::components::user_menu::{MenuOpener, UserMenu}; use crate::locales::trl; +use crate::pages::change_pwd::ChangePassword; +use crate::pages::profile_edit::ProfileEdit; #[component] pub fn AdminPortal(children: Children) -> impl IntoView { + let user_menu = MenuOpener::new(); + let (user, set_user) = create_signal(User::default()); + let editor = DialogOpener::new(); + let pw_changer = DialogOpener::new(); + view! {
@@ -13,6 +23,7 @@ pub fn AdminPortal(children: Children) -> impl IntoView { +
@@ -73,53 +84,14 @@ pub fn AdminPortal(children: Children) -> impl IntoView { // // @@ -130,6 +102,8 @@ pub fn AdminPortal(children: Children) -> impl IntoView {
//
+ + {children()}
diff --git a/src/components/header.rs b/src/components/header.rs index d6676ac..86ccd1b 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -13,6 +13,7 @@ pub fn Header() -> impl IntoView { ("data-template", "vertical-menu-template-free"), ("data-assets-path", "/")]) /> + <Meta charset="utf-8"/> <Meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"/> diff --git a/src/components/mod.rs b/src/components/mod.rs index d9381a8..7f3f6f3 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -3,4 +3,5 @@ pub mod server_err; pub mod validation_err; pub mod header; pub mod admin_portal; +pub mod user_menu; diff --git a/src/components/server_err.rs b/src/components/server_err.rs index c4d9794..158b38d 100644 --- a/src/components/server_err.rs +++ b/src/components/server_err.rs @@ -1,6 +1,7 @@ use crate::components::modal_box::DialogOpener; use leptos::*; use crate::backend::data::ApiResponse; +use crate::locales::trl; #[component] pub fn ServerErr( @@ -9,7 +10,27 @@ pub fn ServerErr( ) -> impl IntoView { view! {{move || { if let Some(val) = result.get() { - if let Err(e) = val { + match val { + Ok(resp) => if let ApiResponse::Error(err) = resp { + view! { + <div class="alert alert-danger"> + {trl(&err)} + </div> + } + } + else { + opener.hide(); + view! {<div></div>} + } + Err(e) => { + view! { + <div class="alert alert-danger"> + "Server error: " {e.to_string()} + </div> + } + } + } + /*if let Err(e) = val { view! { <div class="alert alert-danger"> "Server error: " {e.to_string()} @@ -18,7 +39,7 @@ pub fn ServerErr( } else { opener.hide(); view! {<div></div>} - } + }*/ } else { view! {<div></div>} } diff --git a/src/components/user_menu.rs b/src/components/user_menu.rs new file mode 100644 index 0000000..c0780c9 --- /dev/null +++ b/src/components/user_menu.rs @@ -0,0 +1,92 @@ +use leptos::*; +use crate::backend::data::User; +use crate::backend::user::{get_user, logout}; +use crate::components::modal_box::DialogOpener; +use crate::locales::trl; + +#[derive(Copy, Clone)] +pub struct MenuOpener { + visible: ReadSignal<bool>, + set_visible: WriteSignal<bool>, +} + +impl MenuOpener { + pub fn new() -> Self { + let (visible, set_visible) = create_signal(false); + MenuOpener { + visible, + set_visible, + } + } + + pub fn visible(&self) -> bool { + self.visible.get() + } + + pub fn toggle(&self) { + let visible = self.visible.get(); + self.set_visible.update(|v| *v = !visible) + } +} + +#[component] +pub fn UserMenu( + opener: MenuOpener, + editor: DialogOpener, + pw_dialog: DialogOpener, + user_profile: WriteSignal<User>) -> impl IntoView { + let user = create_resource(move || opener.visible(), move |_| get_user()); + + view! { + <ul class={move || if opener.visible() {"dropdown-menu dropdown-menu-end show"} else + {"dropdown-menu dropdown-menu-end"}} + data-bs-popper="none"> + <li> + <a class="dropdown-item" href="#" on:click=move |_| {editor.show(); opener.toggle()}> + <div class="d-flex"> + <div class="flex-shrink-0 me-3"> + <i class="bx bxs-user-account" /> + </div> + <div class="flex-grow-1"> + <Suspense fallback=move || view! {<span>"Loading..."</span>}> + {move || { + user.get().map(|u| match u { + Ok(user) => { + let usr = user.unwrap_or(User::default()); + user_profile.update(|u| *u = usr.clone()); + view! { + <span class="fw-semibold d-block"> + {usr.full_name.unwrap_or("".to_string())} + </span> + //<small class="text-muted">"Admin"</small> + }}, + Err(_) => view! {<span>"Error loading user"</span>} + }) + }} + </Suspense> + </div> + </div> + </a> + </li> + <li> + <a class="dropdown-item" href="#" on:click=move |_| {pw_dialog.show(); opener.toggle()}> + <i class="bx bx-lock me-2"></i> + <span class="align-middle">{trl("Change password")}</span> + </a> + </li> + <li> + <div class="dropdown-divider"></div> + </li> + <li> + <a class="dropdown-item" href="/login" on:click=move |_| { + spawn_local(async move { + let _ = logout().await; + }); + }> + <i class="bx bx-power-off me-2"></i> + <span class="align-middle">{trl("Log Out")}</span> + </a> + </li> + </ul> + } +} \ No newline at end of file diff --git a/src/components/validation_err.rs b/src/components/validation_err.rs index 8e83b49..fc88d92 100644 --- a/src/components/validation_err.rs +++ b/src/components/validation_err.rs @@ -24,7 +24,7 @@ pub fn ValidationErr( } else { view! { <div class="alert alert-danger"> - "Validation error" + {trl("Validation error")} </div> } } diff --git a/src/locales/catalogues.rs b/src/locales/catalogues.rs index 549d56b..fdb57ac 100644 --- a/src/locales/catalogues.rs +++ b/src/locales/catalogues.rs @@ -14,6 +14,8 @@ lazy_static! { ("Save changes", "Uložit změny"), ("Company info", "Organizace"), ("Name cannot be empty", "Jméno nesmí být prázdné"), + ("Invalid old password", "Neplatné staré heslo"), + ("Please sign-in to your account", "Přihlaste se prosím k uživatelskému účtu"), ])), ("sk", HashMap::from( [ ("Dashboard", "Prehlad"), diff --git a/src/pages/change_pwd.rs b/src/pages/change_pwd.rs new file mode 100644 index 0000000..fef25d1 --- /dev/null +++ b/src/pages/change_pwd.rs @@ -0,0 +1,90 @@ +use leptos::*; +use leptos_router::*; +use crate::backend::data::{ApiResponse, User}; +use crate::backend::user::ChangePwd; +use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter}; +use crate::components::server_err::ServerErr; +use crate::components::validation_err::ValidationErr; +use crate::locales::trl; +use crate::validator::Validator; + +#[component] +pub fn change_password(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView { + let change_pwd = create_server_action::<ChangePwd>(); + let upd_val = change_pwd.value(); + let validator = Validator::new(); + let empty = create_rw_signal("".to_string()); + + view! { + {move || { + if let Some(res) = upd_val.get() { + if let Ok(r) = res { + if let ApiResponse::Data(_) = r { empty.update(|e| *e = "".to_string())} + } + } + view! { + <ActionForm + on:submit=move |ev| { + let act = ChangePwd::from_event(&ev); + if !act.is_err() { + validator.check(&act.unwrap().new_pw, &ev); + } + } + action=change_pwd> + <ModalDialog opener=opener title="Change password"> + <ModalBody> + <ServerErr result={upd_val} opener=opener/> + <ValidationErr validator=validator /> + <input type="hidden" value={move || user.get().login} name="new_pw[login]"/> + <div class="row"> + <div class="col mb-3"> + <label for="oldPw" class="form-label">"Old password"</label> + <input + type="password" + id="oldPw" + class="form-control" + name="new_pw[old_password]" + prop:value={move || empty.get()} + /> + </div> + </div> + <div class="row"> + <div class="col mb-3"> + <label for="newPw" class="form-label">"New password"</label> + <input + type="password" + id="newPw" + class="form-control" + name="new_pw[password]" + prop:value={move || empty.get()} + /> + </div> + </div> + <div class="row"> + <div class="col mb-3"> + <label for="verPw" class="form-label">"Verify password"</label> + <input + type="password" + id="verPw" + class="form-control" + name="new_pw[password_ver]" + prop:value={move || empty.get()} + /> + </div> + </div> + </ModalBody> + <ModalFooter> + <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" + on:click=move |_| {validator.reset(); opener.hide(); empty.update(|e| *e = "".to_string())}> + {trl("Close")} + </button> + <button type="submit" class="btn btn-primary"> + {trl("Save changes")} + </button> + </ModalFooter> + </ModalDialog> + </ActionForm> + } + }} + } +} \ No newline at end of file diff --git a/src/pages/company_info.rs b/src/pages/company_info.rs index c778adf..de41dde 100644 --- a/src/pages/company_info.rs +++ b/src/pages/company_info.rs @@ -8,7 +8,7 @@ use crate::pages::company_edit::CompanyEdit; #[component] pub fn CompanyInfo() -> impl IntoView { let editor = DialogOpener::new(); - let company = create_resource(move|| editor.visible(), move |_| { get_company() }); + let company = create_blocking_resource(move|| editor.visible(), move |_| { get_company() }); let (cmp, set_cmp) = create_signal(Company::default()); view! { diff --git a/src/pages/login.rs b/src/pages/login.rs index 8389713..fe5f5b9 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -2,49 +2,51 @@ use leptos::*; use leptos_meta::*; use leptos_router::ActionForm; use crate::backend::user::Login; +use crate::components::modal_box::DialogOpener; +use crate::components::server_err::ServerErr; +use crate::locales::trl; #[component] pub fn Login() -> impl IntoView { let login = create_server_action::<Login>(); - + let login_val = login.value(); view! { <Link rel="stylesheet" href="/vendor/css/pages/page-auth.css" /> <div class="authentication-wrapper authentication-basic container-p-y"> <div class="authentication-inner"> - <div class="card"> <div class="card-body"> //<!-- Logo --> <div class="app-brand justify-content-center"> <a href="index.html" class="app-brand-link gap-2"> <span class="app-brand-logo demo"> - + <img src="/rezervovator_l.svg" width="200"/> </span> //<span class="app-brand-text demo text-body fw-bolder">Sneat</span> </a> </div> //<!-- /Logo --> - <h4 class="mb-2">"Welcome to Rezervator 👋"</h4> - <p class="mb-4">"Please sign-in to your account and start the adventure"</p> + <p class="mb-4">{trl("Please sign-in to your account")}</p> <ActionForm action=login> + <ServerErr result=login_val opener=DialogOpener::new()/> <div class="mb-3"> - <label for="username" class="form-label">"Username"</label> + <label for="username" class="form-label">{trl("Username")}</label> <input type="text" class="form-control" id="username" name="username" - placeholder="Enter your username" + placeholder={trl("Enter your username")} autofocus /> </div> <div class="mb-3 form-password-toggle"> <div class="d-flex justify-content-between"> - <label class="form-label" for="password">"Password"</label> - <a href="auth-forgot-password-basic.html"> + <label class="form-label" for="password">{trl("Password")}</label> + /*<a href="auth-forgot-password-basic.html"> <small>"Forgot Password?"</small> - </a> + </a>*/ </div> <div class="input-group input-group-merge"> <input @@ -61,7 +63,7 @@ pub fn Login() -> impl IntoView { </div> <div class="mb-3"> - <button class="btn btn-primary d-grid w-100" type="submit">"Sign in"</button> + <button class="btn btn-primary d-grid w-100" type="submit">{trl("Sign in")}</button> </div> </ActionForm> diff --git a/src/pages/mod.rs b/src/pages/mod.rs index c352258..f919cd1 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -4,3 +4,5 @@ pub mod company_info; mod company_edit; pub mod login; pub mod public; +pub mod profile_edit; +pub mod change_pwd; diff --git a/src/pages/profile_edit.rs b/src/pages/profile_edit.rs new file mode 100644 index 0000000..ffb7413 --- /dev/null +++ b/src/pages/profile_edit.rs @@ -0,0 +1,83 @@ +use leptos::*; +use leptos_router::*; +use crate::backend::data::User; +use crate::backend::user::UpdateProfile; +use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter}; +use crate::components::server_err::ServerErr; +use crate::components::validation_err::ValidationErr; +use crate::locales::trl; +use crate::validator::Validator; + +#[component] +pub fn ProfileEdit(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView { + let update_user = create_server_action::<UpdateProfile>(); + let upd_val = update_user.value(); + let validator = Validator::new(); + + view! { + <ActionForm + on:submit=move |ev| { + let act = UpdateProfile::from_event(&ev); + if !act.is_err() { + validator.check(&act.unwrap().user, &ev); + } + } + action=update_user> + <ModalDialog opener=opener title="Edit profile"> + <ModalBody> + <ServerErr result={upd_val} opener=opener/> + <ValidationErr validator=validator /> + <input type="hidden" value={move || user.get().login} name="user[login]"/> + <div class="row"> + <div class="col mb-3"> + <label for="name" class="form-label">"Full name"</label> + <input + type="text" + id="name" + class="form-control" + placeholder="Enter Full name" + prop:value={move || user.get().full_name} + name="user[full_name]" + /> + </div> + </div> + <div class="row"> + <div class="col mb-3"> + <label for="email" class="form-label">"Email"</label> + <input + type="text" + id="name" + class="form-control" + placeholder="Enter email" + prop:value={move || user.get().email.unwrap_or("".to_string())} + name="user[email]" + /> + </div> + </div> + <div class="row"> + <div class="col mb-3"> + <input + class="form-check-input" + type="checkbox" + id="getMail" + prop:value={move || if user.get().get_emails {"true"} else {"false"}} + prop:checked={move || user.get().get_emails} + name="user[get_emails]" + /> + <label class="form-check-label" for="getMail">"Get emails"</label> + </div> + </div> + </ModalBody> + <ModalFooter> + <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" + on:click=move |_| {validator.reset(); opener.hide()}> + {trl("Close")} + </button> + <button type="submit" class="btn btn-primary"> + {trl("Save changes")} + </button> + </ModalFooter> + </ModalDialog> + </ActionForm> + } +} \ No newline at end of file