From 1e59b87b0fb17b701afeb0f474d1bd93447376f6 Mon Sep 17 00:00:00 2001 From: Nghia Date: Sun, 12 Jan 2025 11:51:19 +0100 Subject: [PATCH] frontend: add applications shell (#657) * add applications shell * add login redirect * add authorization header * add user info handler * add user info shell * refactor root and shell * prevent client is none --- Cargo.lock | 37 +++ nghe-api/src/user/info.rs | 15 ++ nghe-api/src/user/mod.rs | 1 + nghe-api/src/user/role.rs | 1 + nghe-backend/src/orm/users.rs | 17 +- nghe-backend/src/route/user/create.rs | 4 +- nghe-backend/src/route/user/info.rs | 37 +++ nghe-backend/src/route/user/mod.rs | 2 + nghe-backend/src/test/mock_impl/user.rs | 4 +- nghe-frontend/Cargo.toml | 7 + nghe-frontend/src/client.rs | 61 ++++- .../src/components/authentication/login.rs | 24 +- .../src/components/authentication/setup.rs | 2 +- nghe-frontend/src/components/body.rs | 47 ++-- nghe-frontend/src/components/init.rs | 11 + nghe-frontend/src/components/mod.rs | 3 + nghe-frontend/src/components/root/mod.rs | 30 +++ nghe-frontend/src/components/root/shell.rs | 234 ++++++++++++++++++ nghe-frontend/src/lib.rs | 2 + 19 files changed, 496 insertions(+), 43 deletions(-) create mode 100644 nghe-api/src/user/info.rs create mode 100644 nghe-backend/src/route/user/info.rs create mode 100644 nghe-frontend/src/components/init.rs create mode 100644 nghe-frontend/src/components/root/mod.rs create mode 100644 nghe-frontend/src/components/root/shell.rs diff --git a/Cargo.lock b/Cargo.lock index 460b09059..92de328c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1231,6 +1231,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "default-struct-builder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deluxe" version = "0.5.0" @@ -2639,6 +2651,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "leptos-use" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af542655ab0c5e93238774c5a60f4d5541ad2c61bb6552d520d29eebcb60351" +dependencies = [ + "cfg-if", + "chrono", + "codee", + "default-struct-builder", + "js-sys", + "lazy_static", + "leptos", + "paste", + "send_wrapper", + "thiserror 2.0.11", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "leptos_config" version = "0.7.3" @@ -3160,11 +3193,15 @@ name = "nghe_frontend" version = "0.9.10" dependencies = [ "anyhow", + "codee", + "concat-string", "console_error_panic_hook", "gloo-net", "leptos", + "leptos-use", "leptos_router", "nghe_api", + "uuid", "wasm-bindgen", "web-sys", ] diff --git a/nghe-api/src/user/info.rs b/nghe-api/src/user/info.rs new file mode 100644 index 000000000..bce0e8101 --- /dev/null +++ b/nghe-api/src/user/info.rs @@ -0,0 +1,15 @@ +use nghe_proc_macro::api_derive; + +use super::Role; + +#[api_derive(fake = true)] +#[endpoint(path = "userInfo", internal = true)] +pub struct Request; + +#[api_derive] +#[derive(Clone)] +pub struct Response { + pub username: String, + pub email: String, + pub role: Role, +} diff --git a/nghe-api/src/user/mod.rs b/nghe-api/src/user/mod.rs index 502d92e51..bd72bf320 100644 --- a/nghe-api/src/user/mod.rs +++ b/nghe-api/src/user/mod.rs @@ -1,4 +1,5 @@ pub mod create; +pub mod info; mod role; pub mod setup; diff --git a/nghe-api/src/user/role.rs b/nghe-api/src/user/role.rs index 4871e610e..0ae667b06 100644 --- a/nghe-api/src/user/role.rs +++ b/nghe-api/src/user/role.rs @@ -1,6 +1,7 @@ use nghe_proc_macro::api_derive; #[api_derive(fake = true)] +#[derive(Clone, Copy)] pub struct Role { pub admin: bool, pub stream: bool, diff --git a/nghe-backend/src/orm/users.rs b/nghe-backend/src/orm/users.rs index ed46539e6..5a6fa0790 100644 --- a/nghe-backend/src/orm/users.rs +++ b/nghe-backend/src/orm/users.rs @@ -36,16 +36,27 @@ pub struct UsernameAuthentication<'a> { pub password: Cow<'a, [u8]>, } -#[derive(Debug, Queryable, Selectable, Insertable)] +#[derive(Debug, Queryable, Selectable, Insertable, o2o)] #[diesel(table_name = users, check_for_backend(super::Type))] -pub struct Data<'a> { +#[owned_into(nghe_api::user::info::Response)] +pub struct Info<'a> { + #[into(~.into_owned())] pub username: Cow<'a, str>, - pub password: Cow<'a, [u8]>, + #[into(~.into_owned())] pub email: Cow<'a, str>, #[diesel(embed)] + #[into(~.into())] pub role: Role, } +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = users, check_for_backend(super::Type))] +pub struct Data<'a> { + #[diesel(embed)] + pub info: Info<'a>, + pub password: Cow<'a, [u8]>, +} + #[derive(Debug, Queryable, Selectable, Identifiable)] #[diesel(table_name = users, check_for_backend(super::Type))] pub struct User<'a> { diff --git a/nghe-backend/src/route/user/create.rs b/nghe-backend/src/route/user/create.rs index d4b4fbdda..b34e03ce0 100644 --- a/nghe-backend/src/route/user/create.rs +++ b/nghe-backend/src/route/user/create.rs @@ -14,10 +14,8 @@ pub async fn handler(database: &Database, request: Request) -> Result Result { + users::table + .filter(users::id.eq(user_id)) + .select(users::Info::as_select()) + .first(&mut database.get().await?) + .await + .map(users::Info::into) + .map_err(Error::from) +} + +#[cfg(test)] +#[coverage(off)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::test::{Mock, mock}; + + #[rstest] + #[tokio::test] + async fn test_handler(#[future(awt)] mock: Mock) { + let user = mock.user(0).await; + let user_info = handler(mock.database(), user.id()).await.unwrap(); + assert_eq!(user.username(), user_info.username); + } +} diff --git a/nghe-backend/src/route/user/mod.rs b/nghe-backend/src/route/user/mod.rs index 6f060ce67..d548c2381 100644 --- a/nghe-backend/src/route/user/mod.rs +++ b/nghe-backend/src/route/user/mod.rs @@ -1,9 +1,11 @@ pub mod create; +mod info; mod setup; nghe_proc_macro::build_router! { modules = [ create(internal = true), + info(internal = true), setup(internal = true), ], } diff --git a/nghe-backend/src/test/mock_impl/user.rs b/nghe-backend/src/test/mock_impl/user.rs index 95b06e1fa..2be64a8bf 100644 --- a/nghe-backend/src/test/mock_impl/user.rs +++ b/nghe-backend/src/test/mock_impl/user.rs @@ -32,8 +32,8 @@ impl<'a> Mock<'a> { self.user.id } - fn username(&self) -> String { - self.user.data.username.to_string() + pub fn username(&self) -> String { + self.user.data.info.username.to_string() } fn password(&self) -> String { diff --git a/nghe-frontend/Cargo.toml b/nghe-frontend/Cargo.toml index 9a3e47daf..db0bf3138 100644 --- a/nghe-frontend/Cargo.toml +++ b/nghe-frontend/Cargo.toml @@ -7,7 +7,11 @@ edition = { workspace = true } workspace = true [dependencies] +concat-string = { workspace = true } +uuid = { workspace = true } + anyhow = { version = "1.0.95" } +codee = { version = "0.2.0" } console_error_panic_hook = { version = "0.1.7" } gloo-net = { version = "0.6.0", default-features = false, features = [ "http", @@ -15,6 +19,9 @@ gloo-net = { version = "0.6.0", default-features = false, features = [ ] } leptos = { version = "0.7.2", features = ["csr", "nightly"] } leptos_router = { version = "0.7.2", features = ["nightly"] } +leptos-use = { version = "0.15.3", default-features = false, features = [ + "storage", +] } wasm-bindgen = { version = "0.2.99" } nghe_api = { path = "../nghe-api" } diff --git a/nghe-frontend/src/client.rs b/nghe-frontend/src/client.rs index ca976edcb..5973fc030 100644 --- a/nghe-frontend/src/client.rs +++ b/nghe-frontend/src/client.rs @@ -1,16 +1,71 @@ use anyhow::Error; +use codee::string::{FromToStringCodec, OptionCodec}; +use concat_string::concat_string; use gloo_net::http; +use leptos::prelude::*; +use leptos_router::NavigateOptions; +use leptos_router::hooks::use_navigate; +use leptos_use::storage::use_local_storage; use nghe_api::common::{JsonEndpoint, JsonURL}; +use uuid::Uuid; -pub struct Client; +#[derive(Clone)] +pub struct Client { + authorization: String, +} impl Client { - pub async fn json(request: &R) -> Result { - let response = http::Request::post(::URL_JSON).json(request)?.send().await?; + const API_KEY_STORAGE_KEY: &'static str = "api-key"; + + pub const EXPECT_MSG: &'static str = "use_client_redirect should prevent this"; + + pub fn new(api_key: Uuid) -> Self { + Self { authorization: concat_string!("Bearer ", api_key.to_string()) } + } + + pub fn use_api_key() -> (Signal>, WriteSignal>) { + let (read, write, _) = use_local_storage::, OptionCodec>( + Self::API_KEY_STORAGE_KEY, + ); + (read, write) + } + + pub fn use_client() -> Signal> { + let (read_api_key, _) = Self::use_api_key(); + Signal::derive(move || read_api_key.with(|api_key| api_key.map(Client::new))) + } + + pub fn use_client_redirect() -> (Signal>, Effect) { + let client = Self::use_client(); + let effect = Effect::new(move || { + if client.with(Option::is_none) { + use_navigate()("/login", NavigateOptions::default()); + } + }); + (client, effect) + } + + async fn json_impl( + request: &R, + authorization: Option<&str>, + ) -> Result { + let response = http::Request::post(::URL_JSON) + .header("Authorization", authorization.unwrap_or_default()) + .json(request)? + .send() + .await?; if response.ok() { Ok(response.json().await?) } else { anyhow::bail!("{}", response.text().await?) } } + + pub async fn json_no_auth(request: &R) -> Result { + Self::json_impl(request, None).await + } + + pub async fn json(&self, request: &R) -> Result { + Self::json_impl(request, Some(&self.authorization)).await + } } diff --git a/nghe-frontend/src/components/authentication/login.rs b/nghe-frontend/src/components/authentication/login.rs index 4f5bd7e77..7676c9436 100644 --- a/nghe-frontend/src/components/authentication/login.rs +++ b/nghe-frontend/src/components/authentication/login.rs @@ -7,6 +7,15 @@ use crate::client::Client; use crate::components::form; pub fn Login() -> impl IntoView { + let navigate = leptos_router::hooks::use_navigate(); + + let (read_api_key, set_api_key) = Client::use_api_key(); + Effect::new(move || { + if read_api_key.with(Option::is_some) { + navigate("/", NavigateOptions::default()); + } + }); + let username = RwSignal::new(String::default()); let password = RwSignal::new(String::default()); let client = RwSignal::new(nghe_api::constant::SERVER_NAME.into()); @@ -15,19 +24,18 @@ pub fn Login() -> impl IntoView { let (password_error, set_password_error) = signal(Option::default()); let (client_error, set_client_error) = signal(Option::default()); - let login_action = Action::<_, _, SyncStorage>::new_unsync(|request: &Request| { + let login_action = Action::<_, _, SyncStorage>::new_unsync(move |request: &Request| { let request = request.clone(); async move { - Client::json(&request).await.map_err(|error| error.to_string())?; + let api_key = Client::json_no_auth(&request) + .await + .map_err(|error| error.to_string())? + .api_key + .api_key; + set_api_key(Some(api_key)); Ok::<_, String>(()) } }); - Effect::new(move || { - if login_action.value().with(|result| result.as_ref().is_some_and(Result::is_ok)) { - let navigate = leptos_router::hooks::use_navigate(); - navigate("/", NavigateOptions::default()); - } - }); html::section().class("bg-gray-50 dark:bg-gray-900").child( html::div() diff --git a/nghe-frontend/src/components/authentication/setup.rs b/nghe-frontend/src/components/authentication/setup.rs index c2d05bebc..2dec01c75 100644 --- a/nghe-frontend/src/components/authentication/setup.rs +++ b/nghe-frontend/src/components/authentication/setup.rs @@ -18,7 +18,7 @@ pub fn Setup() -> impl IntoView { let setup_action = Action::<_, _, SyncStorage>::new_unsync(|request: &Request| { let request = request.clone(); async move { - Client::json(&request).await.map_err(|error| error.to_string())?; + Client::json_no_auth(&request).await.map_err(|error| error.to_string())?; Ok::<_, String>(()) } }); diff --git a/nghe-frontend/src/components/body.rs b/nghe-frontend/src/components/body.rs index 662ea7cc3..e4bd20d1b 100644 --- a/nghe-frontend/src/components/body.rs +++ b/nghe-frontend/src/components/body.rs @@ -2,35 +2,36 @@ use leptos::prelude::*; use leptos_router::components::{Route, Router, Routes}; use leptos_router::path; -use super::authentication; +use super::{Root, authentication}; -#[component] pub fn Body() -> impl IntoView { Router( component_props_builder(&Router) .base("/frontend") .children(ToChildren::to_children(move || { - Routes( - component_props_builder(&Routes) - .fallback(|| "Not found") - .children(ToChildren::to_children(move || { - ( - Route( - component_props_builder(&Route) - .path(path!("/setup")) - .view(authentication::Setup) - .build(), - ), - Route( - component_props_builder(&Route) - .path(path!("/login")) - .view(authentication::Login) - .build(), - ), - ) - })) - .build(), - ) + Root(move || { + Routes( + component_props_builder(&Routes) + .fallback(|| "Not found") + .children(ToChildren::to_children(move || { + ( + Route( + component_props_builder(&Route) + .path(path!("/setup")) + .view(authentication::Setup) + .build(), + ), + Route( + component_props_builder(&Route) + .path(path!("/login")) + .view(authentication::Login) + .build(), + ), + ) + })) + .build(), + ) + }) })) .build(), ) diff --git a/nghe-frontend/src/components/init.rs b/nghe-frontend/src/components/init.rs new file mode 100644 index 000000000..d5511162d --- /dev/null +++ b/nghe-frontend/src/components/init.rs @@ -0,0 +1,11 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(inline_js = "export function initializeFlowbite() { initFlowbite(); }")] +extern "C" { + fn initializeFlowbite(); +} + +pub fn flowbite() { + initializeFlowbite(); + leptos::logging::debug_warn!("initializeFlowbite called"); +} diff --git a/nghe-frontend/src/components/mod.rs b/nghe-frontend/src/components/mod.rs index 6df75bb7d..a5ca3679d 100644 --- a/nghe-frontend/src/components/mod.rs +++ b/nghe-frontend/src/components/mod.rs @@ -3,5 +3,8 @@ mod authentication; mod body; mod form; +mod init; +mod root; pub use body::Body; +pub use root::Root; diff --git a/nghe-frontend/src/components/root/mod.rs b/nghe-frontend/src/components/root/mod.rs new file mode 100644 index 000000000..516d10283 --- /dev/null +++ b/nghe-frontend/src/components/root/mod.rs @@ -0,0 +1,30 @@ +mod shell; + +use leptos::prelude::*; + +use crate::client::Client; +use crate::components::init; + +pub fn Root( + child: impl Fn() -> IV + Copy + Send + Sync + 'static, +) -> impl IntoView { + let location = leptos_router::hooks::use_location(); + Effect::new(move |_| { + location.pathname.track(); + init::flowbite(); + }); + + let client = Client::use_client(); + Show( + component_props_builder(&Show) + .when(move || { + client.with(Option::is_some) + && location.pathname.with(|pathname| { + pathname != "/frontend/setup" && pathname != "/frontend/login" + }) + }) + .children(ToChildren::to_children(move || shell::Shell(child))) + .fallback(child) + .build(), + ) +} diff --git a/nghe-frontend/src/components/root/shell.rs b/nghe-frontend/src/components/root/shell.rs new file mode 100644 index 000000000..307af01ac --- /dev/null +++ b/nghe-frontend/src/components/root/shell.rs @@ -0,0 +1,234 @@ +use leptos::prelude::*; +use leptos::{html, svg}; +use nghe_api::user::info::Request; + +use crate::client::Client; + +pub fn Shell( + child: impl Fn() -> IV + Copy + Send + Sync + 'static, +) -> impl IntoView { + let (client, _redirect) = Client::use_client_redirect(); + let user_info = LocalResource::new(move || async move { + let client = client().expect(Client::EXPECT_MSG); + client.json(&Request).await.unwrap() + }); + + Suspense( + component_props_builder(&Suspense) + .fallback(move || "Loading") + .children(ToChildren::to_children(move || { + Suspend::new(async move { + let user_info = user_info.await; + html::div().class("antialiased bg-gray-50 dark:bg-gray-900").child(( + html::nav() + .class( + "bg-white border-b border-gray-200 px-4 py-2.5 dark:bg-gray-800 \ + dark:border-gray-700 fixed left-0 right-0 top-0 z-50", + ) + .child( + html::div() + .class("flex flex-wrap justify-between items-center") + .child(( + html::div().class("flex justify-start items-center").child( + ( + html::button() + .attr("data-drawer-target", "drawer-navigation") + .attr("data-drawer-toggle", "drawer-navigation") + .aria_controls("drawer-navigation") + .class( + "p-2 mr-2 text-gray-600 rounded-lg \ + cursor-pointer md:hidden \ + hover:text-gray-900 hover:bg-gray-100 \ + focus:bg-gray-100 dark:focus:bg-gray-700 \ + focus:ring-2 focus:ring-gray-100 \ + dark:focus:ring-gray-700 \ + dark:text-gray-400 \ + dark:hover:bg-gray-700 \ + dark:hover:text-white", + ) + .child(( + svg::svg() + .aria_hidden("true") + .class("w-6 h-6") + .attr("fill", "currentColor") + .attr("viewBox", "0 0 20 20") + .attr( + "xmlns", + "http://www.w3.org/2000/svg", + ) + .child( + svg::path() + .attr("fill-rule", "evenodd") + .attr("clip-rule", "evenodd") + .attr( + "d", + "M3 5a1 1 0 011-1h12a1 1 \ + 0 110 2H4a1 1 0 \ + 01-1-1zM3 10a1 1 0 \ + 011-1h6a1 1 0 110 2H4a1 \ + 1 0 01-1-1zM3 15a1 1 0 \ + 011-1h12a1 1 0 110 2H4a1 \ + 1 0 01-1-1z", + ), + ), + svg::svg() + .aria_hidden("true") + .class("w-6 h-6") + .attr("fill", "currentColor") + .attr("viewBox", "0 0 20 20") + .attr( + "xmlns", + "http://www.w3.org/2000/svg", + ) + .child( + svg::path() + .attr("fill-rule", "evenodd") + .attr("clip-rule", "evenodd") + .attr( + "d", + "M4.293 4.293a1 1 0 \ + 011.414 0L10 \ + 8.586l4.293-4.293a1 1 0 \ + 111.414 1.414L11.414 \ + 10l4.293 4.293a1 1 0 \ + 01-1.414 1.414L10 \ + 11.414l-4.293 4.293a1 1 \ + 0 01-1.414-1.414L8.586 \ + 10 4.293 5.707a1 1 0 \ + 010-1.414z", + ), + ), + html::span() + .class("sr-only") + .child("Toggle sidebar"), + )), + html::a() + .href("/frontend") + .class("flex items-center justify-between mr-4") + .child( + html::span() + .class( + "self-center text-2xl \ + font-semibold whitespace-nowrap \ + dark:text-white", + ) + .child("Nghe"), + ), + ), + ), + html::div().class("flex items-center lg:order-2").child(( + html::button() + .r#type("button") + .aria_controls("drawer-navigation") + .attr("data-dropdown-toggle", "drawer-navigation") + .class( + "p-2 mr-1 text-gray-500 rounded-lg md:hidden \ + hover:text-gray-900 hover:bg-gray-100 \ + dark:text-gray-400 dark:hover:text-white \ + dark:hover:bg-gray-700 focus:ring-4 \ + focus:ring-gray-300 dark:focus:ring-gray-600", + ) + .child(( + svg::svg() + .aria_hidden("true") + .class("w-6 h-6") + .attr("fill", "currentColor") + .attr("viewBox", "0 0 20 20") + .attr("xmlns", "http://www.w3.org/2000/svg") + .child( + svg::path() + .attr("fill-rule", "evenodd") + .attr("clip-rule", "evenodd") + .attr( + "d", + "M8 4a4 4 0 100 8 4 4 0 \ + 000-8zM2 8a6 6 0 1110.89 \ + 3.476l4.817 4.817a1 1 0 \ + 01-1.414 1.414l-4.816-4.\ + 816A6 6 0 012 8z", + ), + ), + html::span() + .class("sr-only") + .child("Toggle search"), + )), + html::button() + .id("user-menu-button") + .r#type("button") + .aria_expanded("false") + .attr("data-dropdown-toggle", "dropdown") + .class( + "flex mx-3 text-sm bg-gray-800 rounded-full \ + md:mr-0 focus:ring-4 focus:ring-gray-300 \ + dark:focus:ring-gray-600", + ) + .child(( + html::img() + .class("w-8 h-8 rounded-full") + .alt("user photo"), + html::span() + .class("sr-only") + .child("Open user menu"), + )), + html::div() + .id("dropdown") + .class( + "hidden z-50 my-4 w-56 text-base list-none \ + bg-white rounded divide-y divide-gray-100 \ + shadow dark:bg-gray-700 dark:divide-gray-600 \ + rounded-xl", + ) + .child(( + html::div().class("py-3 px-4").child(( + html::span() + .class( + "block text-sm font-semibold \ + text-gray-900 dark:text-white", + ) + .child(user_info.username), + html::span() + .class( + "block text-sm text-gray-900 \ + truncate dark:text-white", + ) + .child(user_info.email), + )), + html::ul() + .aria_labelledby("dropdown") + .class( + "py-1 text-gray-700 dark:text-gray-300", + ) + .child( + html::li().child( + html::span() + .class( + "block py-2 px-4 text-sm \ + hover:bg-gray-100 \ + dark:hover:bg-gray-600 \ + dark:hover:text-white", + ) + .child("Sign out"), + ), + ), + )), + )), + )), + ), + html::aside() + .id("drawer-navigation") + .aria_label("Sidenav") + .class( + "fixed top-0 left-0 z-40 w-64 h-screen pt-14 transition-transform \ + -translate-x-full bg-white border-r border-gray-200 \ + md:translate-x-0 dark:bg-gray-800 dark:border-gray-700", + ) + .child(html::div().class( + "overflow-y-auto py-5 px-3 h-full bg-white dark:bg-gray-800", + )), + html::main().class("p-4 md:ml-64 h-auto pt-20").child(child()), + )) + }) + })) + .build(), + ) +} diff --git a/nghe-frontend/src/lib.rs b/nghe-frontend/src/lib.rs index 7a5deb812..4de638880 100644 --- a/nghe-frontend/src/lib.rs +++ b/nghe-frontend/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::too_many_lines)] + mod client; mod components;