Compare commits

...

2 commits

Author SHA1 Message Date
asonix b357f9c525 Style everything and make basic errors for login form 2023-04-15 15:19:03 -05:00
asonix ea262eb226 Permute bounded in multi-tag 2023-04-15 15:18:37 -05:00
6 changed files with 464 additions and 192 deletions

View file

@ -310,7 +310,7 @@ impl CollectionViewSchema for PostsWithTagsByMultipleTags {
tags.sort();
tags.reverse();
tags.permute()
tags.permute_bounded(6)
.map(|tags| {
document.header.emit_key_and_value(
TagSet {

View file

@ -30,6 +30,7 @@ impl FormState {
}
}
#[allow(non_snake_case)]
pub(crate) fn CreatePostForm(cx: Scope<CreatePostProps>) -> Element {
let form_state = use_ref(cx, || FormState::default());
@ -58,32 +59,37 @@ pub(crate) fn CreatePostForm(cx: Scope<CreatePostProps>) -> Element {
cx.render(rsx! {
form {
onsubmit: submit,
div {
label {
r#for: "title",
"Title:",
},
input {
oninput: move |evt| form_state.with_mut(|form| form.title = evt.value.clone()),
r#type: "text",
name: "title",
},
class: "post-form centered",
h2 {
"Create Post",
},
div {
label {
r#for: "body",
"Post:",
class: "form-body",
div {
label {
span { "Title" },
input {
oninput: move |evt| form_state.with_mut(|form| form.title = evt.value.clone()),
r#type: "text",
name: "title",
},
},
},
input {
oninput: move |evt| form_state.with_mut(|form| { form.body = evt.value.clone(); }),
r#type: "text",
name: "body",
div {
label {
span { "Post" },
textarea {
oninput: move |evt| form_state.with_mut(|form| { form.body = evt.value.clone(); }),
name: "body",
},
},
},
},
}
div {
class: "tags",
label {
r#for: "tags",
"Tags:",
span { "Tags" },
},
form_state.with(|form| {
form.tags.iter().map(|(index, tag)| {
@ -92,6 +98,7 @@ pub(crate) fn CreatePostForm(cx: Scope<CreatePostProps>) -> Element {
rsx! {
div {
class: "text-with-button",
input {
oninput: move |evt| form_state.with_mut(|form| {
if let Some(tag) = form.tags.get_mut(&index) {
@ -114,6 +121,7 @@ pub(crate) fn CreatePostForm(cx: Scope<CreatePostProps>) -> Element {
}).collect::<Vec<_>>()
}).into_iter(),
div {
class: "text-with-button",
input {
oninput: move |evt| form_state.with_mut(|form| {
form.new_tag = evt.value.clone();
@ -130,8 +138,9 @@ pub(crate) fn CreatePostForm(cx: Scope<CreatePostProps>) -> Element {
value: "Add",
},
},
},
}
div {
class: "form-footer",
input {
r#type: "submit",
value: "Create Post",

View file

@ -37,6 +37,7 @@ impl std::error::Error for HomeError {
}
}
#[allow(non_snake_case)]
pub(crate) fn Home<'a>(cx: Scope<'a, HomeProps<'a>>) -> Element<'a> {
let client = cx.props.client.clone();
@ -85,20 +86,24 @@ pub(crate) fn Home<'a>(cx: Scope<'a, HomeProps<'a>>) -> Element<'a> {
Some(Ok(posts)) => {
let rendered = posts.iter().map(|post| {
rsx! {
div {
h3 {
article {
class: "post centered",
h2 {
post.title.clone()
}
p {
post.body.clone()
}
if post.tags.is_empty() {
rsx! {"No tags"}
} else {
rsx! {
post.tags.iter().map(|tag| {
rsx! { span { tag.clone() } }
})
div {
class: "tags",
if post.tags.is_empty() {
rsx! {"No tags"}
} else {
rsx! {
post.tags.iter().map(|tag| {
rsx! { span { "#", tag.clone() } }
})
}
}
}
}

View file

@ -2,21 +2,48 @@ use api_types::{Auth, AuthResponse};
use dioxus::prelude::*;
use dioxus_router::use_router;
use reqwest::Client;
use tracing::Instrument;
#[derive(Debug)]
enum LoginError {
Request(reqwest::Error),
InvalidForm,
MissingUsername,
MissingPassword,
LoginFailed,
}
impl LoginError {
fn username_errors(&self) -> Option<String> {
if matches!(self, Self::MissingUsername) {
Some(self.to_string())
} else {
None
}
}
fn password_errors(&self) -> Option<String> {
if matches!(self, Self::MissingPassword) {
Some(self.to_string())
} else {
None
}
}
fn form_errors(&self) -> Option<String> {
if matches!(self, Self::Request(_) | Self::LoginFailed) {
Some(self.to_string())
} else {
None
}
}
}
impl std::fmt::Display for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Request(_) => write!(f, "Failed to request upstream"),
Self::InvalidForm => write!(f, "Login form is invalid"),
Self::LoginFailed => write!(f, "Login failed"),
Self::Request(_) => write!(f, "Failed to log in, try again or contact support"),
Self::MissingUsername => write!(f, "Username is required"),
Self::MissingPassword => write!(f, "Password is required"),
Self::LoginFailed => write!(f, "Username or password is incorrect"),
}
}
}
@ -42,86 +69,132 @@ pub(crate) struct LoginProps<'a> {
pub(crate) client: &'a Client,
}
#[tracing::instrument(skip_all)]
async fn on_submit(
client: Client,
username: Option<String>,
password: Option<String>,
) -> Result<String, LoginError> {
let username = username.ok_or(LoginError::MissingUsername)?;
let password = password.ok_or(LoginError::MissingPassword)?;
if username.is_empty() {
return Err(LoginError::MissingUsername);
}
if password.is_empty() {
return Err(LoginError::MissingPassword);
}
let auth = Auth { username, password };
tracing::info!("Authenticating user");
let response = client
.post("http://localhost:8006/sessions")
.json(&auth)
.send()
.await?;
if response.status().is_success() {
let json = response.json::<AuthResponse>().await?;
return Ok(json.token);
}
Err(LoginError::LoginFailed)
}
#[allow(non_snake_case)]
pub(crate) fn LoginForm<'a>(cx: Scope<'a, LoginProps<'a>>) -> Element<'a> {
let auth = use_state(cx, || Auth {
username: String::new(),
password: String::new(),
});
let router = use_router(cx).clone();
let client: Client = cx.props.client.clone();
let error: &UseState<Option<LoginError>> = use_state(cx, || None);
let router = use_router(cx);
let client = cx.props.client;
let session_token = cx.props.session_token;
let _future = use_future(cx, (auth, session_token), move |(auth, session_token)| {
async move {
if session_token.get().is_some() {
return Ok(());
}
let auth = auth.get();
if auth.username.is_empty() || auth.password.is_empty() {
return Err(LoginError::InvalidForm);
}
tracing::info!("Authenticating user");
let response = client
.post("http://localhost:8006/sessions")
.json(auth)
.send()
.await?;
if response.status().is_success() {
let json = response.json::<AuthResponse>().await?;
session_token.set(Some(json.token));
router.push_route("/home", None, None);
return Ok(());
}
Err(LoginError::LoginFailed)
}
.instrument(tracing::info_span!("Running login future"))
});
cx.render(rsx! {
form {
class: "login-form centered",
onsubmit: move |event| {
let Some(username) = event.data.values.get("username") else {
return;
};
let Some(password) = event.data.values.get("password") else {
return;
};
let username = event.data.values.get("username").cloned();
let password = event.data.values.get("password").cloned();
auth.set(Auth {
username: username.to_string(),
password: password.to_string(),
});
let error = error.clone();
let router = router.clone();
let client = client.clone();
let session_token = session_token.clone();
cx.spawn(async move {
match on_submit(client, username, password).await {
Ok(token) => {
error.set(None);
session_token.set(Some(token));
router.push_route("/home", None, None);
}
Err(e) => {
tracing::warn!("Setting error: {}", e);
error.set(Some(e));
}
}
})
},
div {
label {
r#for: "username",
"Username"
},
input {
r#type: "text",
name: "username",
},
h2 {
"Login",
},
div {
label {
r#for: "password",
"Password"
},
input {
r#type: "password",
name: "password",
},
if let Some(errors) = error.get().as_ref().and_then(|e| e.form_errors()) {
Some(rsx! {
div {
class: "errors",
errors,
}
})
} else {
None
}
input {
r#type: "submit",
"Login"
div {
class: "form-body",
div {
label {
span { "Username" },
input {
r#type: "text",
name: "username",
},
if let Some(errors) = error.get().as_ref().and_then(|e| e.username_errors()) {
Some(rsx! {
div {
class: "errors",
errors,
},
})
} else {
None
}
},
},
div {
label {
span { "Password" },
input {
r#type: "password",
name: "password",
},
if let Some(errors) = error.get().as_ref().and_then(|e| e.password_errors()) {
Some(rsx! {
div {
class: "errors",
errors,
},
})
} else {
None
}
},
}
}
div {
class: "form-footer",
input {
r#type: "submit",
value: "Login"
},
},
}
})

View file

@ -1,6 +1,5 @@
use std::{collections::BTreeMap, sync::Arc};
use std::sync::Arc;
use api_types::Post;
use axum::{extract::WebSocketUpgrade, response::Html, routing::get, Extension, Router};
use dioxus::prelude::*;
use dioxus_router::{Link, Redirect, Route, Router};
@ -63,9 +62,12 @@ fn application(cx: Scope<Arc<ApplicationState>>) -> Element {
}
},
}
} else {
rsx! {
Route { to: "/login", LoginForm { session_token: session_token, client: client } },
Route { to: "/register", RegistrationForm { session_token: session_token, client: client } },
}
}
Route { to: "/login", LoginForm { session_token: session_token, client: client } },
Route { to: "/register", RegistrationForm { session_token: session_token, client: client } },
Redirect { from: "", to: "/posts" },
}
})
@ -119,7 +121,160 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
r#"
<!DOCTYPE html>
<html>
<head> <title>Dioxus LiveView with Axum</title> </head>
<head>
<title>Dioxus LiveView with Axum</title>
<style>
* {{
box-sizing: border-box;
}}
body {{
margin: 0;
min-height: 100vh;
background-color: #333;
color: #f5f5f5;
font-family: sans;
}}
.errors {{
color: #ee4949;
font-weight: 500;
}}
nav {{
background-color: #fff;
border-bottom: 1px solid #e5e5e5;
display: flex;
margin-bottom: 32px;
}}
nav > div {{
padding: 16px;
}}
.centered {{
margin: 32px auto;
border: 1px solid #e5e5e5;
border-radius: 4px;
background-color: #fff;
color: #333;
}}
.centered > h2,
.centered > p {{
border-bottom: 1px solid #e5e5e5;
}}
.centered > h2,
.centered > p,
.centered > div {{
margin: 0;
padding: 16px 32px;
}}
.centered .form-footer {{
border-top: 1px solid #e5e5e5;
}}
form > .errors {{
padding: 16px 32px 0;
}}
.form-body > div {{
padding: 8px 0;
}}
label {{
display: block;
}}
label > .errors {{
padding-top: 4px;
}}
label > span {{
display: block;
padding-bottom: 4px;
font-weight: 500;
}}
label > textarea {{
min-height: 350px;
}}
.text-with-button {{
display: inline-flex;
padding: 16px;
width: 433px;
}}
.text-with-button input[type=text],
label > textarea,
label > input[type=text],
label > input[type=password] {{
display: block;
outline: none;
border: 1px solid #e5e5e5;
border-radius: 2px;
padding: 8px 16px;
line-height: 22px;
font-size: 16px;
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.1);
width: 100%;
}}
.text-with-button input[type=text]:hover,
label > textarea:hover,
label > input[type=text]:hover,
label > input[type=password]:hover {{
border-color: #ff84ca;
}}
.text-with-button input[type=text]:focus,
label > textarea:focus,
label > input[type=text]:focus,
label > input[type=password]:focus {{
border-color: #ff84ca;
box-shadow: inset 0 0 6px rgba(255, 132, 202, 0.4), 0 0 4px rgba(255, 132, 202, 0.2);
}}
.text-with-button input[type=button],
.form-footer input[type=submit] {{
border: 1px solid #ff84ca;
border-radius: 2px;
background-color: #ffc2e5;
padding: 8px 16px;
font-size: 16px;
line-height: 22px;
font-weight: 600;
color: #000;
}}
.text-with-button input[type=button]:hover,
.form-footer input[type=submit]:hover {{
background-color: #ffb2de;
}}
.text-with-button input[type=text] {{
border-radius: 2px 0 0 2px;
}}
.text-with-button input[type=button] {{
border-radius: 0 2px 2px 0;
border-left: none;
background-color: #f5f5f5;
border-color: #e5e5e5;
}}
.text-with-button input[type=button]:hover {{
background-color: #efefef;
}}
.login-form,
.registration-form {{
max-width: 400px;
}}
.post,
.post-form {{
max-width: 900px;
}}
.centered.post-form > div.tags {{
padding: 16px;
}}
.centered.post-form > div.tags > label {{
padding: 0 16px;
}}
.post .tags {{
padding: 16px;
}}
.post .tags > span {{
display: inline-block;
margin: 0 8px;
padding: 8px 16px;
border: 1px solid #ff84ca;
background-color: #ffc2e5;
color: #000;
border-radius: 2px;
font-weight: 500;
}}
</style>
</head>
<body> <div id="main"></div> </body>
{glue}
</html>

View file

@ -2,12 +2,13 @@ use api_types::{AuthResponse, NewUser};
use dioxus::prelude::*;
use dioxus_router::use_router;
use reqwest::Client;
use tracing::Instrument;
#[derive(Debug)]
enum RegistrationError {
Request(reqwest::Error),
InvalidForm,
MissingUsername,
MissingPassword,
PasswordMismatch,
RegistrationFailed,
LoginFailed,
}
@ -16,7 +17,9 @@ impl std::fmt::Display for RegistrationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Request(_) => write!(f, "Failed to request upstream"),
Self::InvalidForm => write!(f, "Registration form is invalid"),
Self::MissingUsername => write!(f, "Username is required"),
Self::MissingPassword => write!(f, "Password is required"),
Self::PasswordMismatch => write!(f, "Passwords do not match"),
Self::RegistrationFailed => write!(f, "Invalid credentials"),
Self::LoginFailed => write!(f, "Login failed"),
}
@ -44,101 +47,128 @@ pub(crate) struct RegistrationProps<'a> {
pub(crate) client: &'a Client,
}
#[tracing::instrument(skip_all)]
async fn on_submit(
client: Client,
username: Option<String>,
password: Option<String>,
password_confirmation: Option<String>,
) -> Result<String, RegistrationError> {
let username = username.ok_or(RegistrationError::MissingUsername)?.clone();
let password = password.ok_or(RegistrationError::MissingPassword)?.clone();
let password_confirmation = password_confirmation
.ok_or(RegistrationError::MissingPassword)?
.clone();
if username.is_empty() {
return Err(RegistrationError::MissingUsername);
}
if password.is_empty() {
return Err(RegistrationError::MissingPassword);
}
if password != password_confirmation {
return Err(RegistrationError::PasswordMismatch);
}
let new_user = NewUser { username, password };
tracing::info!("Creating user");
let response = client
.post("http://localhost:8006/users")
.json(&new_user)
.send()
.await?;
if !response.status().is_success() {
return Err(RegistrationError::RegistrationFailed);
}
tracing::info!("Authenticating user");
let response = client
.post("http://localhost:8006/sessions")
.json(&new_user)
.send()
.await?;
if response.status().is_success() {
let json = response.json::<AuthResponse>().await?;
return Ok(json.token);
}
Err(RegistrationError::LoginFailed)
}
#[allow(non_snake_case)]
pub(crate) fn RegistrationForm<'a>(cx: Scope<'a, RegistrationProps<'a>>) -> Element<'a> {
let new_user = use_state(cx, || NewUser {
username: String::new(),
password: String::new(),
});
let router = use_router(cx).clone();
let client: Client = cx.props.client.clone();
let router = use_router(cx);
let client: &Client = cx.props.client;
let session_token = cx.props.session_token;
let _future = use_future(
cx,
(new_user, session_token),
move |(new_user, session_token)| {
async move {
if session_token.get().is_some() {
return Ok(());
}
let new_user = new_user.get();
if new_user.username.is_empty() || new_user.password.is_empty() {
return Err(RegistrationError::InvalidForm);
}
tracing::info!("Creating user");
let response = client
.post("http://localhost:8006/users")
.json(new_user)
.send()
.await?;
if !response.status().is_success() {
return Err(RegistrationError::RegistrationFailed);
}
tracing::info!("Authenticating user");
let response = client
.post("http://localhost:8006/sessions")
.json(new_user)
.send()
.await?;
if response.status().is_success() {
let json = response.json::<AuthResponse>().await?;
session_token.set(Some(json.token));
router.push_route("/home", None, None);
return Ok(());
}
Err(RegistrationError::LoginFailed)
}
.instrument(tracing::info_span!("Running create user future"))
},
);
cx.render(rsx! {
form {
class: "registration-form centered",
onsubmit: move |event| {
let Some(username) = event.data.values.get("username") else {
return;
};
let Some(password) = event.data.values.get("password") else {
return;
};
let username = event.data.values.get("username").cloned();
let password = event.data.values.get("password").cloned();
let password_confirmation = event.data.values.get("password-confirmation").cloned();
new_user.set(NewUser {
username: username.to_string(),
password: password.to_string(),
let session_token = session_token.clone();
let client = client.clone();
let router = router.clone();
cx.spawn(async move {
match on_submit(client, username, password, password_confirmation).await {
Ok(token) => {
session_token.set(Some(token));
router.push_route("/home", None, None);
}
Err(_) => {
// TODO: Handle this error somehow
}
}
});
},
h2 {
"Create Account",
},
div {
label {
r#for: "username",
"Username"
class: "form-body",
div {
label {
span { "Username" },
input {
r#type: "text",
name: "username",
},
},
},
input {
r#type: "text",
name: "username",
div {
label {
span { "Password" },
input {
r#type: "password",
name: "password",
},
},
}
div {
label {
span { "Confirm Password" },
input {
r#type: "password",
name: "password-confirmation",
},
},
},
},
div {
label {
r#for: "password",
"Password"
},
class: "form-footer",
input {
r#type: "password",
name: "password",
r#type: "submit",
value: "Create Account"
},
}
input {
r#type: "submit",
"Create Account"
},
}
})