#![allow(non_snake_case)] use std::rc::Rc; use api::{ AddToHouseholdRequest, CreateHouseholdRequest, CreateHouseholdResponse, LoginRequest, LoginResponse, UserInfo, }; use dioxus::prelude::*; use dioxus_router::prelude::*; use gloo_storage::{errors::StorageError, LocalStorage, Storage}; use itertools::Itertools; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::bootstrap::{bs, FormModal, ModalToggleButton, Spinner}; mod bootstrap; mod ingredients; mod sidebar; mod recipe; mod full_context; pub use full_context::{use_full_context, use_trimmed_context}; use sidebar::RegaladeSidebar; const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") { None => "http://localhost:8085", Some(v) => v, }; const FRONTEND_ROOT: &str = match option_env!("REGALADE_FRONTEND_DOMAIN") { None => "http://localhost:8080", Some(v) => v, }; #[macro_export] macro_rules! api { ($($arg:tt)*) => {{ use $crate::API_ROUTE; &format!("{API_ROUTE}/api/{}", format_args!($($arg)*)) }}; } #[derive(Props)] pub struct ErrorProps<'a> { error: &'a Option, } pub fn ErrorView<'a>(cx: Scope<'a, ErrorProps<'a>>) -> Element { cx.props .error .as_ref() .and_then(|err| cx.render(rsx! { ErrorAlert { error: "{err}" } })) } #[inline_props] pub fn ErrorAlert<'a>(cx: Scope<'a>, error: &'a str) -> Element<'a> { cx.render(rsx! { div { class: "alert alert-danger", *error } }) } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct LoginInfo { token: String, name: String, } pub fn use_login(cx: &ScopeState) -> UseSharedState { use_shared_state::(cx) .expect("no login info in scope") .clone() } pub fn use_error(cx: &ScopeState) -> &UseState> { use_state(cx, || None) } #[derive(Clone)] pub struct Callback { pub cb: Rc, } impl Callback { pub fn call(&self) { (self.cb)() } } #[allow(clippy::vtable_address_comparisons)] impl PartialEq for Callback { fn eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.cb, &other.cb) } } impl From> for Callback { fn from(cb: Rc) -> Self { Self { cb } } } impl From for Callback where F: Fn() + 'static, { fn from(cb: F) -> Self { Self { cb: Rc::new(cb) } } } pub fn use_refresh(cx: &ScopeState) -> (u64, Callback) { let refresh = use_state(cx, || 0u64); let callback = { to_owned![refresh]; Callback::from(move || refresh.set(refresh.wrapping_add(1))) }; (**refresh, callback) } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HouseholdInfo { id: Uuid, name: String, } pub fn LoginRedirect(cx: Scope) -> Element { let navigator = use_navigator(cx); let token = match LocalStorage::get::("token") { Ok(v) => Some(v), Err(StorageError::KeyNotFound(_)) => { return None; } Err(e) => unreachable!("Could not get token: {e:?}"), }; use_shared_state_provider(cx, || token.clone()); cx.render(match token { Some(_) => rsx! { Outlet:: {} }, None => { navigator.push(Route::Login); rsx! {{}} } }) } async fn do_login(username: String, password: String) -> anyhow::Result<()> { let rsp = gloo_net::http::Request::post(api!("login")) .json(&LoginRequest { username: username.clone(), password, })? .send() .await?; if rsp.status() == 404 { anyhow::bail!("Account not foud") } else if !rsp.ok() { let body = rsp.text().await?; anyhow::bail!("Request failed: {body:?}") } let rsp: LoginResponse = rsp.json().await?; LocalStorage::set( "token", LoginInfo { token: rsp.token, name: username, }, )?; Ok(()) } async fn check_oidc() -> anyhow::Result { let rsp = gloo_net::http::Request::get(api!("login/has_oidc")) .send() .await?; Ok(rsp.status() == 200) } fn Openid(cx: Scope) -> Element { let has = use_future(cx, (), |()| check_oidc()); cx.render(match has.value().unwrap_or(&Ok(false)) { Ok(true) => { let route = api!("login/oidc").to_owned(); let ret = urlencoding::encode(&format!("{FRONTEND_ROOT}/login/oidc")).to_string(); rsx! { a { href: "{route}?return={ret}", class: "mt-1 w-100 btn btn-lg btn-primary", "Login with OpenID" } } } Ok(false) => rsx! {{}}, Err(e) => { log::error!("Could not check oidc status: {e:?}"); rsx! {{}} } }) } fn Login(cx: Scope) -> Element { let error = use_state(cx, || None::); let navigator = use_navigator(cx); let on_submit = move |e: Event| { to_owned![error, navigator]; cx.spawn(async move { match do_login( e.values["username"][0].to_string(), e.values["password"][0].to_string(), ) .await { Ok(_) => { error.set(None); navigator.push(Route::Index); } Err(e) => { error.set(Some(format!("Could not log in: {e}"))); } } }) }; cx.render(rsx! { link { href: "/login.css", rel: "stylesheet" } form { onsubmit: on_submit, prevent_default: "onsubmit", class: "form-signin w-100 m-auto text-center", h1 { class: "h3 mb-3", "Please log in" } ErrorView { error: error } div { class: "form-floating", input { name: "username", id: "floatingUser", class: "form-control", placeholder: "Username" } label { "for": "floatingUser", "Username" } } div { class: "form-floating", input { name: "password", id: "floatingPass", class: "form-control", placeholder: "Password", "type": "password" } label { "for": "floatingPass", "Password" } } button { class: "w-100 btn btn-lg btn-primary", "type": "submit", "Login" } Openid {} } }) } async fn do_new_household(token: String, name: String) -> anyhow::Result { let rsp = gloo_net::http::Request::post(api!("household")) .header("Authorization", &format!("Bearer {token}")) .json(&CreateHouseholdRequest { name })? .send() .await?; if !rsp.ok() { anyhow::bail!("Request failed: {rsp:?}") } let rsp: CreateHouseholdResponse = rsp.json().await?; Ok(rsp.id) } async fn do_resolve_user(token: String, username: String) -> anyhow::Result> { let rsp = gloo_net::http::Request::get(api!("search/user/{username}")) .header("Authorization", &format!("Bearer {token}")) .send() .await?; if rsp.status() == 404 { return Ok(None); } if !rsp.ok() { anyhow::bail!("Request failed: {rsp:?}") } let rsp: UserInfo = rsp.json().await?; Ok(Some(rsp.id)) } pub async fn do_add_user_to_household( token: String, household: Uuid, user: Uuid, ) -> anyhow::Result<()> { let rsp = gloo_net::http::Request::put(api!("household/{household}")) .header("Authorization", &format!("Bearer {token}")) .json(&AddToHouseholdRequest { user })? .send() .await?; if !rsp.ok() { anyhow::bail!("Request failed: {rsp:?}") } Ok(()) } fn CreateHousehold(cx: Scope) -> Element { let login = use_login(cx); let error = use_state(cx, || None::); let name = use_state(cx, String::new); let members = use_ref(cx, Vec::<(Uuid, String)>::new); let navigator = use_navigator(cx); let token = login.read().token.clone(); let on_submit = move |_| { to_owned![members, name, error, token, navigator]; cx.spawn(async move { match do_new_household(token.clone(), name.to_string()).await { Ok(id) => { let info = HouseholdInfo { id, name: name.to_string(), }; for (uid, user) in members.read().iter() { if let Err(e) = do_add_user_to_household(token.clone(), id, *uid).await { error.set(Some(format!( "Could not add user {user} (but household was created): {e:?}" ))); return; } } if let Err(e) = LocalStorage::set("household", info) { log::error!("Could not switch to new household: {e:?}"); }; let modal = bs::Modal::get_instance("#newHsModal"); modal.hide(); navigator.push(Route::Index); error.set(None); } Err(e) => { error.set(Some(format!("Could not create household: {e:?}"))); } } }) }; let new_member = use_state(cx, String::new); let token = login.read().token.clone(); let on_add_member = move |_| { to_owned![new_member, members, error, token]; cx.spawn(async move { match do_resolve_user(token, new_member.to_string()).await { Err(e) => { error.set(Some(format!("Could not add member: {e:?}"))); } Ok(None) => { error.set(Some(format!("User {new_member} does not exist"))); } Ok(Some(id)) => { members.with_mut(|m| { if !m.iter().any(|&(i, _)| i == id) { m.push((id, new_member.to_string())) } }); error.set(None); } } }); }; cx.render(rsx! { FormModal { id: "newHsModal", fade: true, centered: true, submit_label: "Create", title: "Create a Household", on_submit: on_submit, ErrorView { error: error } div { class: "form-floating", input { id: "newHsName", class: "form-control", placeholder: "Household name", oninput: move |ev| name.set(ev.value.clone()) } label { "for": "newHsName", "Household name" } } h2 { class: "fs-5 m-2", "Additional Members" } ul { class: "list-group list-group-flush", for (idx , (id , name)) in members.read().iter().enumerate() { li { key: "{id}", class: "list-group-item", "{name}" button { "type": "button", class: "btn btn-danger ms-2", onclick: move |_| { members .with_mut(|m| { m.remove(idx); }) }, "Remove" } } } } div { class: "d-flex flex-row", input { id: "newHsAddMember", class: "form-control me-2", oninput: move |ev| new_member.set(ev.value.clone()), placeholder: "Additional member" } button { "type": "button", class: "btn btn-primary", onclick: on_add_member, "Add" } } } }) } async fn fetch_households(token: String) -> anyhow::Result { let rsp = gloo_net::http::Request::get(api!("household")) .header("Authorization", &format!("Bearer {token}")) .send() .await?; if !rsp.ok() { anyhow::bail!("Request failed: {rsp:?}") } let rsp: api::Households = rsp.json().await?; Ok(rsp) } fn HouseholdListSelect(cx: Scope) -> Element { let login = use_login(cx); let households = use_future(cx, (), |_| fetch_households(login.read().token.clone())); let navigator = use_navigator(cx); cx.render(match households.value() { Some(Ok(response)) => { let households = response .households .iter() .sorted_by_key(|(_, i)| i.name.clone()) .map(|(id, info)| { let onclick = move |_| { if let Err(e) = LocalStorage::set( "household", HouseholdInfo { id: *id, name: info.name.clone(), }, ) { log::error!("Could not select household: {e:?}"); return; } navigator.push(Route::Index); }; rsx! {button { key: "{id}", class: "btn btn-secondary m-1", onclick: onclick, "{info.name}" }} }); rsx! {households} } Some(Err(e)) => { rsx! { div { class: "alert alert-danger", "Could not fetch households: {e:?}" } } } None => rsx! { Spinner {} }, }) } fn HouseholdSelection(cx: Scope) -> Element { cx.render(rsx! { link { href: "/household_selection.css", rel: "stylesheet" } div { class: "col-sm-3 m-auto p-2 text-center border rounded", h1 { class: "h3", "Available" } hr {} HouseholdListSelect {} hr {} ModalToggleButton { class: "btn btn-lg btn-primary", modal_id: "newHsModal", "New household" } CreateHousehold {} } }) } fn Index(cx: Scope) -> Element { cx.render(rsx! {"INDEX"}) } #[derive(Deserialize, PartialEq, Clone)] struct OidcQuery { token: String, username: String, } #[derive(PartialEq, Props)] struct OidcProps { token: String, username: String, } fn OidcRedirect(cx: Scope) -> Element { cx.render({ match LocalStorage::set( "token", LoginInfo { token: cx.props.token.clone(), name: cx.props.username.clone(), }, ) { Ok(_) => { gloo_utils::window().location().replace("/").unwrap(); rsx! {{}} } Err(_) => rsx! {"Could not store authentication, try again."}, } }) } use ingredients::Ingredients; use recipe::{RecipeCreator, RecipeList, RecipeView}; #[rustfmt::skip] #[derive(Clone, Routable)] enum Route { #[route("/login")] Login, #[route("/login/oidc?:token?:username")] OidcRedirect { token: String, username: String }, #[layout(LoginRedirect)] #[route("/household_selection")] HouseholdSelection, #[end_layout] #[layout(RegaladeSidebar)] #[route("/")] Index, #[route("/ingredients")] Ingredients, #[route("/recipe_creator")] RecipeCreator, #[nest("/recipe")] #[route("/")] RecipeList, #[route("/:id")] RecipeView {id: i64} } fn App(cx: Scope) -> Element { cx.render(rsx! { Router:: {} }) } fn main() { console_log::init_with_level(log::Level::Info).unwrap(); let html = gloo_utils::document_element(); html.set_attribute("data-bs-theme", "dark") .expect("could not set dark theme"); dioxus_web::launch(App) }