598 lines
16 KiB
Rust
598 lines
16 KiB
Rust
#![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<String>,
|
|
}
|
|
|
|
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<LoginInfo> {
|
|
use_shared_state::<LoginInfo>(cx)
|
|
.expect("no login info in scope")
|
|
.clone()
|
|
}
|
|
|
|
pub fn use_error(cx: &ScopeState) -> &UseState<Option<String>> {
|
|
use_state(cx, || None)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Callback {
|
|
pub cb: Rc<dyn Fn()>,
|
|
}
|
|
|
|
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<Rc<dyn Fn()>> for Callback {
|
|
fn from(cb: Rc<dyn Fn()>) -> Self {
|
|
Self { cb }
|
|
}
|
|
}
|
|
|
|
impl<F> From<F> 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::<LoginInfo>("token") {
|
|
Ok(v) => Some(v),
|
|
Err(StorageError::KeyNotFound(_)) => {
|
|
navigator.push(Route::Login);
|
|
None
|
|
}
|
|
Err(e) => unreachable!("Could not get token: {e:?}"),
|
|
};
|
|
|
|
use_shared_state_provider(cx, || token.clone());
|
|
|
|
cx.render(match token {
|
|
Some(info) => rsx! {
|
|
LoginRedirectInner {info: info},
|
|
},
|
|
None => {
|
|
rsx! {{}}
|
|
}
|
|
})
|
|
}
|
|
|
|
#[derive(Props, PartialEq)]
|
|
struct LoginRedirectInnerProps {
|
|
info: LoginInfo,
|
|
}
|
|
|
|
fn LoginRedirectInner(cx: Scope<LoginRedirectInnerProps>) -> Element {
|
|
use_shared_state_provider(cx, || cx.props.info.clone());
|
|
|
|
cx.render(rsx! {
|
|
Outlet::<Route> {}
|
|
})
|
|
}
|
|
|
|
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<bool> {
|
|
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::<String>);
|
|
let navigator = use_navigator(cx);
|
|
|
|
let on_submit = move |e: Event<FormData>| {
|
|
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,
|
|
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<Uuid> {
|
|
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<Option<Uuid>> {
|
|
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::<String>);
|
|
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<api::Households> {
|
|
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<OidcProps>) -> 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}
|
|
}
|
|
|
|
pub trait AppContext {}
|
|
|
|
pub struct AppProps<'a, C> {
|
|
pub context: &'a C,
|
|
}
|
|
pub fn App<'a, C: AppContext>(cx: Scope<'a, AppProps<'a, C>>) -> Element {
|
|
cx.render(rsx! {
|
|
Router::<Route> {}
|
|
})
|
|
}
|