auto use pregenerated passwords (down to just 1 field now)

This commit is contained in:
emilis 2026-01-03 03:08:03 +00:00
parent 68c0d0e4b3
commit 3083fb17d9
No known key found for this signature in database
11 changed files with 221 additions and 179 deletions

4
Cargo.lock generated
View File

@ -694,9 +694,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -1786,12 +1788,14 @@ dependencies = [
"chrono-humanize",
"ciborium",
"futures",
"getrandom 0.3.4",
"gloo 0.10.0",
"instant",
"log",
"once_cell",
"plan-proto",
"postcard",
"rand 0.9.2",
"serde",
"thiserror 2.0.17",
"wasm-bindgen",

View File

@ -24,6 +24,8 @@ web-sys = { version = "0.3.77", features = [
"ReadableStreamDefaultReader",
"HtmlDialogElement",
"DomRect",
"ShareData",
"Clipboard",
] }
log = "0.4"
yew = { version = "0.21", features = ["csr"] }
@ -42,3 +44,5 @@ thiserror = { version = "2" }
plan-proto = { path = "../plan-proto", features = ["client"] }
ciborium = { version = "0.2" }
chrono-humanize = { version = "0.2.3", features = ["wasmbind"] }
rand = { version = "0.9" }
getrandom = { version = "0.3", features = ["wasm_js"] }

View File

@ -356,9 +356,6 @@ dialog::backdrop {
.dialog-box {
font-size: 2rem;
border: 1px solid white;
display: flex;
flex-direction: column;
@ -372,6 +369,8 @@ dialog::backdrop {
gap: 5px;
color: white;
font-size: 1.5em;
.options {
margin-top: 30px;
display: flex;
@ -382,7 +381,6 @@ dialog::backdrop {
&>button {
min-width: 4cm;
font-size: 1em;
}
$close_color: rgba(255, 0, 0, 1);
@ -432,11 +430,13 @@ dialog::backdrop {
flex-direction: column;
align-items: center;
font-size: 1.5rem;
gap: 10px;
input {
background-color: black;
border: 1px solid white;
color: white;
font-size: 1em;
}
.submit {
@ -458,6 +458,15 @@ dialog::backdrop {
}
}
.login-fields {
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: center;
gap: 10px;
width: 100%;
}
.fields {
display: flex;
flex-direction: row;
@ -547,20 +556,27 @@ dialog::backdrop {
.splash {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
user-select: none;
gap: 5vw;
width: 100%;
.options {
.section {
flex-grow: 1;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1cm;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
min-height: 50vh;
}
& button {
font-size: 2rem;
}
.instruction {
margin: 0;
font-style: italic;
color: rgba(255, 255, 255, 0.7);
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.3);
}
}
@ -727,3 +743,20 @@ select {
}
}
}
.notification {
border: 1px solid white;
padding: 20px;
// font-size: 2em;
color: white;
background-color: black;
}
dialog:has(.notification) {
background-color: rgba(0, 0, 0, 0);
border: none;
}
.share-button {
font-size: 1.5em;
}

View File

@ -1,54 +0,0 @@
use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct DialogProps {
#[prop_or_default]
pub children: Html,
pub options: Box<[String]>,
#[prop_or_default]
pub cancel_callback: Option<Callback<()>>,
pub callback: Callback<String>,
}
#[function_component]
pub fn Dialog(
DialogProps {
children,
options,
cancel_callback,
callback,
}: &DialogProps,
) -> Html {
let options = options
.iter()
.map(|opt| {
let callback = callback.clone();
let option = opt.clone();
let cb = Callback::from(move |_| {
callback.emit(option.clone());
});
html! {
<button onclick={cb}>{opt.clone()}</button>
}
})
.collect::<Html>();
let backdrop_click = cancel_callback.clone().map(|cancel_callback| {
Callback::from(move |_| {
cancel_callback.emit(());
})
});
html! {
<div class="click-backdrop" onclick={backdrop_click}>
<div class="dialog">
<div class="dialog-box">
<div class="message">
{children.clone()}
</div>
<div class="options">
{options}
</div>
</div>
</div>
</div>
}
}

View File

@ -1,7 +1,10 @@
use plan_proto::token::Token;
use yew::prelude::*;
use crate::{components::dialog::Dialog, storage::StorageKey};
use crate::{
components::dialog_modal::{DialogModal, DialogMode},
storage::StorageKey,
};
#[function_component]
pub fn Nav() -> Html {
@ -23,51 +26,30 @@ pub fn Nav() -> Html {
</>
}
});
let dialog = use_state(|| false);
let logged_in_buttons = token.is_some().then(|| {
let confirm_signout = {
let dialog = dialog.clone();
Callback::from(move |_| {
dialog.set(true);
})
};
let dialog = dialog.then(|| {
let cancel_signout = {
let dialog = dialog.clone();
Callback::from(move |_| {
dialog.set(false);
})
};
let callback = Callback::from(move |option: String| match option.as_str() {
"yes" => {
Token::delete();
let _ = gloo::utils::window().location().reload();
}
"no" => {
dialog.set(false);
}
_ => {}
let signout = {
let callback = Callback::from(move |_| {
Token::delete();
let _ = gloo::utils::window().location().reload();
});
let options: Box<[String]> = Box::new([String::from("yes"), String::from("no")]);
html! {
<Dialog
options={options}
cancel_callback={Some(cancel_signout)}
callback={callback}
<DialogModal
id="signout-dialog"
mode={DialogMode::ConfirmOrClose(callback)}
close_backdrop=true
button_content={html!{"sign out"}}
>
<p>{"really sign out?"}</p>
</Dialog>
</DialogModal>
}
});
};
html! {
<>
<div class="right-side">
<a href="/user/settings"><button>{"⚙️"}</button></a>
<button onclick={confirm_signout} >{"sign out"}</button>
{signout}
</div>
<a href="/plans/new"><button>{"new plan"}</button></a>
{dialog}
</>
}
});

View File

@ -8,7 +8,6 @@ mod pages {
pub mod user_settings_page;
}
mod components {
pub mod dialog;
pub mod dialog_modal;
pub mod error;
pub mod half_hour_range_select;

View File

@ -1,7 +1,11 @@
use core::{num::NonZeroU8, time::Duration};
use std::rc::Rc;
use crate::{SERVER_URL, storage::StorageKey};
use crate::{
SERVER_URL,
pages::{signin::Signin, signup::Signup},
storage::StorageKey,
};
use futures::{FutureExt, StreamExt, lock::Mutex, select};
use gloo::net::http::Request;
use plan_proto::{
@ -169,10 +173,15 @@ pub fn MainPage() -> Html {
pub fn SignedOutMainPage() -> Html {
html! {
<div class="splash">
<h1>{"plan"}</h1>
<div class="options">
<a href="/signin"><button>{"sign in"}</button></a>
<a href="/signup"><button>{"sign up"}</button></a>
<div class="section">
<h1 class="instruction">{"create new account"}</h1>
<h2 class="instruction">{"with just a username"}</h2>
<Signup />
</div>
<div class="section">
<h1 class="instruction">{"or sign in with an account"}</h1>
<h3 class="instruction">{"that has a password set"}</h3>
<Signin />
</div>
</div>
}

View File

@ -1,27 +1,28 @@
use core::{cell::RefCell, ops::Not, sync::atomic::AtomicBool, time::Duration};
use core::{cell::RefCell, sync::atomic::AtomicBool, time::Duration};
use std::rc::Rc;
use chrono::TimeDelta;
use chrono_humanize::{HumanTime, Humanize, Tense};
use chrono_humanize::{HumanTime, Tense};
use core::ops::Deref;
use futures::{FutureExt, SinkExt, StreamExt, channel::mpsc::Receiver, stream::Fuse};
use futures::{SinkExt, StreamExt, stream::Fuse};
use gloo::net::websocket::{Message, futures::WebSocket};
use plan_proto::{
error::ServerError,
message::{ClientMessage, ServerMessage},
plan::{Plan, PlanId, UpdateTiles},
plan::{Plan, PlanId},
token::{Token, TokenLogin},
};
use serde::Serialize;
use thiserror::Error;
use wasm_bindgen::JsError;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::{HtmlDialogElement, ShareData, js_sys::Reflect};
use yew::{platform::pinned::mpsc::UnboundedReceiver, prelude::*};
use crate::{
WS_URL,
components::{error::ErrorDisplay, planview::PlanView},
pages::{mainpage::SignedOutMainPage, not_found::NotFound},
request::RequestError,
storage::{LastPlanVisitedLoggedOut, StorageKey},
};
@ -167,41 +168,6 @@ impl Session {
let ws = WebSocket::open(url)
.map_err(|err| ConnectionError::SocketOpeningError(err.to_string()))?;
Ok(ws)
// struct WsConnectFuture(RefCell<Option<WebSocket>>);
// let ws_fut = WsConnectFuture(RefCell::new(Some(ws)));
// impl Future for WsConnectFuture {
// type Output = Result<WebSocket, ConnectionError>;
// fn poll(
// self: std::pin::Pin<&mut Self>,
// cx: &mut std::task::Context<'_>,
// ) -> std::task::Poll<Self::Output> {
// let ws = match self.0.try_borrow_mut() {
// Ok(ws) => ws,
// Err(_) => {
// log::warn!("ref mut");
// cx.waker().wake_by_ref();
// return std::task::Poll::Pending;
// }
// };
// match ws.as_ref().map(|ws| ws.state()) {
// Some(gloo::net::websocket::State::Connecting) => {
// cx.waker().wake_by_ref();
// std::task::Poll::Pending
// }
// Some(gloo::net::websocket::State::Open) => {
// std::task::Poll::Ready(Ok(self.0.take().unwrap()))
// }
// Some(gloo::net::websocket::State::Closing)
// | Some(gloo::net::websocket::State::Closed) => {
// std::task::Poll::Ready(Err(ConnectionError::SocketClosed))
// }
// None => std::task::Poll::Pending,
// }
// }
// }
// ws_fut.await
}
#[allow(clippy::await_holding_refcell_ref)]
@ -314,6 +280,70 @@ impl Session {
#[function_component]
pub fn PlanPage(PlanPageProps { id }: &PlanPageProps) -> Html {
const SHARE_SUCCESS_ID: &str = "share-link-copied";
let share_button = {
let share_cb = {
Callback::from(move |_| {
if let Some(href) = gloo::utils::document()
.location()
.and_then(|a| a.href().ok())
{
let share_data = ShareData::new();
share_data.set_url(&href);
yew::platform::spawn_local(async move {
let nav = gloo::utils::window().navigator();
let can_share = Reflect::get(&nav, &JsValue::from_str("share"))
.map(|v| !v.is_undefined())
.unwrap_or_default();
if can_share && nav.can_share_with_data(&share_data) {
if let Err(err) =
Into::<JsFuture>::into(nav.share_with_data(&share_data)).await
{
log::error!("share url [{href}]: {err:?}");
}
return;
}
// not supported on this platform;
// copy url to clipboard and show the copied link modal
if let Err(err) = Into::<JsFuture>::into(
gloo::utils::window()
.navigator()
.clipboard()
.write_text(&href),
)
.await
{
log::error!("error writing to clipboard: {err:?}");
return;
}
let Some(dialog) = gloo::utils::document()
.get_element_by_id(SHARE_SUCCESS_ID)
.and_then(|d| d.dyn_into::<HtmlDialogElement>().ok())
else {
return;
};
if let Err(err) = dialog.show_modal() {
log::error!("show success modal: {err:?}");
return;
}
gloo::timers::future::sleep(Duration::from_secs(1)).await;
dialog.close();
});
}
})
};
html! {
<>
<button onclick={share_cb} class="share-button">{"share"}</button>
<dialog id={SHARE_SUCCESS_ID}>
<div class="notification">
<p>{"link copied to clipboard"}</p>
</div>
</dialog>
</>
}
};
let plan_state = use_state_eq(|| PlanState::Loading);
let token = match use_context::<Token>() {
Some(token) => token,
@ -330,9 +360,7 @@ pub fn PlanPage(PlanPageProps { id }: &PlanPageProps) -> Html {
let error_obj = html! {
<ErrorDisplay state={error_state.clone()}/>
};
let on_error = use_callback(error_state.setter(), |err: Option<String>, err_state| {
err_state.set(err);
});
let (send, recv) = yew::platform::pinned::mpsc::unbounded();
let send = use_state(|| send);
let recv = use_mut_ref(|| recv);
@ -381,6 +409,7 @@ pub fn PlanPage(PlanPageProps { id }: &PlanPageProps) -> Html {
html! {
<div class="plan">
{share_button}
{content}
{error_obj}
</div>

View File

@ -10,6 +10,7 @@ use crate::{
state_input::{InputType, StateInput},
},
error::JsError,
storage::{LoginState, StorageKey},
};
#[function_component]
@ -19,8 +20,17 @@ pub fn Signin() -> Html {
let error_obj = html! {
<ErrorDisplay state={error_state.clone()}/>
};
let username_state = use_state(String::new);
let password_state = use_state(String::new);
let (u, p) = if let Ok(LoginState::Passwordless { username, password }) =
LoginState::load_from_storage()
{
(username.to_string(), password.to_string())
} else {
(String::new(), String::new())
};
let username_state = use_state(|| u);
let password_state = use_state(|| p);
let (submit_username, submit_password) = (username_state.clone(), password_state.clone());
let on_submit = {
@ -96,8 +106,7 @@ pub fn Signin() -> Html {
};
html! {
<div class="signin" onkeyup={on_key_up}>
<h1>{"signin"}</h1>
<div class="fields">
<div class="login-fields">
<label>{"username"}</label>
<StateInput name="username" id={Some("username".to_string())} state={username_state}/>
<label>{"password"}</label>

View File

@ -1,8 +1,12 @@
use gloo::net::http::Request;
use plan_proto::{
error::ServerError, limited::ClampedString, message::ServerMessage, token::Token,
user::UserLogin,
error::ServerError,
limited::ClampedString,
message::ServerMessage,
token::Token,
user::{Password, UserLogin},
};
use rand::distr::SampleString;
use wasm_bindgen::{JsCast, convert::OptionIntoWasmAbi};
use web_sys::{
HtmlElement, ReadableStreamDefaultReader,
@ -18,7 +22,7 @@ use crate::{
},
error::JsError,
login,
storage::StorageKey,
storage::{LoginState, StorageKey},
};
#[function_component]
@ -29,14 +33,23 @@ pub fn Signup() -> Html {
<ErrorDisplay state={error_state.clone()}/>
};
let username_state = use_state(String::new);
let password_state = use_state(String::new);
let (submit_username, submit_password) = (username_state.clone(), password_state.clone());
let on_submit = {
let username_state = username_state.clone();
let err_set = error_set.clone();
Callback::from(move |_| {
let password = match Password::new(
rand::distr::Alphanumeric.sample_string(&mut rand::rng(), Password::MAX_LEN),
) {
Ok(password) => password,
Err(err) => {
err_set.set(Some(format!("generated password not in range: {err:?}",)));
return;
}
};
let user = UserLogin {
username: match ClampedString::new((*submit_username).clone()) {
username: match ClampedString::new((*username_state).clone()) {
Ok(username) => username,
Err(range) => {
err_set.set(Some(format!(
@ -46,16 +59,7 @@ pub fn Signup() -> Html {
return;
}
},
password: match ClampedString::new((*submit_password).clone()) {
Ok(pass) => pass,
Err(range) => {
err_set.set(Some(format!(
"password length must be between {} characters",
range.bounds_string_human()
)));
return;
}
},
password,
};
let mut user_serialized = vec![];
if let Err(err) = ciborium::into_writer(&user, &mut user_serialized) {
@ -72,6 +76,17 @@ pub fn Signup() -> Html {
match req.send().await {
Ok(resp) => {
if resp.ok() {
// Save the created credentials
let login_state = LoginState::Passwordless {
username: user.username,
password: user.password,
};
if let Err(err) = login_state.save_to_storage() {
error_set.set(Some(format!(
"failed saving generated credentials: {err}"
)));
return;
}
crate::login::login(
user_serialized.into_boxed_slice(),
error_set,
@ -136,12 +151,9 @@ pub fn Signup() -> Html {
};
html! {
<div class="signup" onkeyup={on_key_up}>
<h1>{"signup"}</h1>
<div class="fields">
<label>{"username"}</label>
<div class="login-fields">
<label for="username">{"username"}</label>
<StateInput name="username" id={Some("username".to_string())} state={username_state}/>
<label>{"password"}</label>
<StateInput name="password" id={Some("password".to_string())} state={password_state} input_type={InputType::Password}/>
</div>
<button class="submit" onclick={on_submit}>{"submit"}</button>
{error_obj}

View File

@ -1,9 +1,11 @@
use gloo::storage::{LocalStorage, Storage, errors::StorageError};
use plan_proto::{plan::PlanId, token::Token, user::UserLogin};
use plan_proto::{
plan::PlanId,
token::Token,
user::{Password, Username},
};
use serde::{Deserialize, Serialize};
use crate::error::JsError;
pub trait StorageKey: for<'a> Deserialize<'a> + Serialize {
const KEY: &str;
@ -28,3 +30,16 @@ pub struct LastPlanVisitedLoggedOut(pub PlanId);
impl StorageKey for LastPlanVisitedLoggedOut {
const KEY: &str = "last-plan-visited-logged-out";
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum LoginState {
#[default]
NoLogin,
Passwordless {
username: Username,
password: Password,
},
}
impl StorageKey for LoginState {
const KEY: &str = "login-state";
}