feat: implemented signup and account verification

This commit is contained in:
antifallobst 2023-10-06 20:48:56 +02:00
parent 6c915ef56d
commit 35d0ca8945
Signed by: antifallobst
GPG Key ID: 2B4F402172791BAF
6 changed files with 283 additions and 29 deletions

View File

@ -5,5 +5,6 @@
<title>Nerdcult</title>
<link data-trunk rel="css" href="style/topbar.css">
<link data-trunk rel="css" href="style/home.css">
<link data-trunk rel="css" href="style/components.css">
</head>
</html>

View File

@ -1,6 +1,34 @@
use serde::Deserialize;
use std::fmt::{Display, Formatter};
#[derive(Deserialize)]
pub struct AccountAuthenticateResponse {
pub token: String,
}
#[derive(Deserialize)]
#[serde(tag = "conflict", rename_all = "snake_case")]
pub enum AccountRegisterConflictResponse {
Username,
Email,
}
impl Display for AccountRegisterConflictResponse {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::Username => "username",
Self::Email => "email",
}
)
}
}
#[derive(Deserialize)]
#[serde(tag = "problem", rename_all = "snake_case")]
pub enum AccountRegisterCriteriaProblemResponse {
Email,
Password,
}

View File

@ -39,7 +39,9 @@ pub struct Session {
impl Default for Session {
fn default() -> Self {
Self {
base_url: option_env!("NC_API_URL").unwrap_or("https://api.nerdcult.net").to_string(),
base_url: option_env!("NC_API_URL")
.unwrap_or("https://api.nerdcult.net")
.to_string(),
auth_token: get_auth_cookie(),
}
}
@ -92,6 +94,89 @@ impl Session {
);
}
pub fn register(
&self,
username: String,
password: String,
email: String,
callback: Callback<Result<()>>,
) {
let url = format!("{}/account/register", &self.base_url);
let body = json!({
"username": username,
"password": password,
"email": email
});
let call = async move {
let request = Request::post(&url)
.mode(RequestMode::Cors)
.credentials(RequestCredentials::Omit)
.json(&body)?;
let response = request.send().await?;
match response.status() {
200 => Ok(()),
400 => Err(Error::msg(format!("Bad request"))),
403 => Err(Error::msg(format!("You're not allowed to do this!"))),
409 => {
let conflict = response
.json::<data::AccountRegisterConflictResponse>()
.await?;
Err(Error::msg(format!(
"The requested {} is already taken.",
conflict
)))
}
422 => {
let problem = response
.json::<data::AccountRegisterCriteriaProblemResponse>()
.await?;
match problem {
data::AccountRegisterCriteriaProblemResponse::Email => {
Err(Error::msg(format!("Not a valid email!")))
}
data::AccountRegisterCriteriaProblemResponse::Password => Err(Error::msg(
format!("The password does not meet the criteria!"),
)),
}
}
e => Err(Error::msg(format!("Unknown response: {e}"))),
}
};
wasm_bindgen_futures::spawn_local(async move { callback.emit(call.await) });
}
pub fn verify(&self, token: String, callback: Callback<Result<()>>) {
let url = format!("{}/account/verify", &self.base_url);
let body = json!({
"token": token
});
let call = async move {
let request = Request::post(&url)
.mode(RequestMode::Cors)
.credentials(RequestCredentials::Omit)
.json(&body)?;
let response = request.send().await?;
match response.status() {
200 => Ok(()),
400 => Err(Error::msg(format!("Bad request"))),
403 => Err(Error::msg(format!("You're not allowed to do this!"))),
404 => Err(Error::msg(format!("Unknown Token!"))),
e => Err(Error::msg(format!("Unknown response: {e}"))),
}
};
wasm_bindgen_futures::spawn_local(async move { callback.emit(call.await) });
}
pub fn set_token(&mut self, token: String) {
self.auth_token = Some(token);
}

View File

@ -20,7 +20,6 @@ pub struct State {
sign_up: bool,
sign_in_status: AttrValue,
sign_up_status: AttrValue,
}
impl Default for State {
@ -30,7 +29,6 @@ impl Default for State {
sign_up: false,
sign_in_status: AttrValue::default(),
sign_up_status: AttrValue::default(),
}
}
}
@ -74,6 +72,11 @@ pub fn TopBar() -> Html {
Callback::from(move |_| state.dispatch(Action::ToggleSignUp))
};
let sign_up_close = {
let state = state.clone();
Callback::from(move |_| state.dispatch(Action::ToggleSignUp))
};
let sign_in_status = {
let state = state.clone();
Callback::from(move |status: Result<()>| match status {
@ -101,7 +104,7 @@ pub fn TopBar() -> Html {
}
if state.sign_up {
<SignUp/>
<SignUp close={sign_up_close}/>
}
</nav>
}

View File

@ -1,18 +1,45 @@
use crate::backend::Session;
use anyhow::Result;
use std::ops::Deref;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::prelude::*;
#[derive(PartialEq, Clone, Debug)]
enum View {
SignUp,
Verify,
}
impl Default for View {
fn default() -> Self {
Self::SignUp
}
}
#[derive(PartialEq, Clone, Default, Debug)]
struct Data {
pub view: View,
pub status: String,
pub submitted: bool,
pub username: String,
pub email: String,
pub password: String,
pub password_reenter: String,
pub token: String,
}
#[derive(Properties, PartialEq)]
pub struct Props {
pub close: Callback<()>,
}
#[function_component]
pub fn SignUp() -> Html {
pub fn SignUp(props: &Props) -> Html {
let state = use_state(|| Data::default());
let session = use_context::<Session>().unwrap_or_default();
let cloned_state = state.clone();
let on_username_changed = Callback::from(move |event: Event| {
@ -54,35 +81,130 @@ pub fn SignUp() -> Html {
});
let cloned_state = state.clone();
let onsubmit = Callback::from(move |event: SubmitEvent| {
event.prevent_default();
let on_password_reenter_changed = Callback::from(move |event: Event| {
let password_reenter = event
.target()
.expect("WTF")
.unchecked_into::<HtmlInputElement>()
.value();
let data = cloned_state.deref().clone();
gloo::console::log!("username: ", data.username);
gloo::console::log!("email: ", data.email);
gloo::console::log!("password: ", data.password);
let mut data = cloned_state.deref().clone();
data.password_reenter = password_reenter;
cloned_state.set(data);
});
html! {
<form class="auth-container" onsubmit={onsubmit}>
<div class="auth-field">
<label for="username">{"Username"}</label><br/>
<input class="auth-input" type="text" name="username" onchange={on_username_changed}/><br/>
</div>
let cloned_state = state.clone();
let on_token_changed = Callback::from(move |event: Event| {
let token = event
.target()
.expect("WTF")
.unchecked_into::<HtmlInputElement>()
.value();
<div class="auth-field">
<label for="password">{"Password"}</label><br/>
<input class="auth-input" type="password" name="password" onchange={on_password_changed}/><br/>
</div>
let mut data = cloned_state.deref().clone();
data.token = token;
cloned_state.set(data);
});
<div class="auth-field">
<label for="email">{"Email"}</label><br/>
<input class="auth-input" type="text" name="email" onchange={on_email_changed}/><br/>
</div>
let cloned_state = state.clone();
let callback = props.close.clone();
let on_api_response = Callback::from(move |response: Result<()>| {
let mut state = cloned_state.deref().clone();
state.submitted = false;
<div class="center-x">
<button class="auth-submit" type="submit">{"Sign In"}</button>
</div>
</form>
match state.view {
View::SignUp => match response {
Ok(_) => state.view = View::Verify,
Err(e) => state.status = e.to_string(),
},
View::Verify => match response {
Ok(_) => callback.emit(()),
Err(e) => state.status = e.to_string(),
},
}
cloned_state.set(state);
});
let cloned_state = state.clone();
let onsubmit = Callback::from(move |event: SubmitEvent| {
event.prevent_default();
let mut state = cloned_state.deref().clone();
state.status = String::default();
state.submitted = true;
match state.view {
View::SignUp => session.register(
state.username.clone(),
state.password.clone(),
state.email.clone(),
on_api_response.clone(),
),
View::Verify => session.verify(state.token.clone(), on_api_response.clone()),
}
cloned_state.set(state);
});
match state.view {
View::SignUp => html! {
<form class="auth-container" onsubmit={onsubmit}>
<div class="auth-field">
<label for="username">{"Username"}</label><br/>
<input class="auth-input" type="text" name="username" onchange={on_username_changed}/><br/>
</div>
<div class="auth-field">
<label for="email">{"Email"}</label><br/>
<input class="auth-input" type="text" name="email" onchange={on_email_changed}/><br/>
</div>
<div class="auth-field">
<label for="password">{"Password"}</label><br/>
<input class="auth-input" type="password" name="password" onchange={on_password_changed}/><br/>
</div>
<div class="auth-field">
<label for="password-reenter">{"Re Enter Password"}</label><br/>
<input class="auth-input" type="password" name="password-reenter" onchange={on_password_reenter_changed}/><br/>
</div>
if !state.submitted {
<div class="center-x">
<button class="auth-submit" type="submit">{"Sign Up"}</button>
</div>
} else {
<div class="center-x">
<div class="spinner" />
</div>
}
<div class="center-x">
<p class="auth-status">{&state.clone().deref().status}</p>
</div>
</form>
},
View::Verify => html! {
<form class="auth-container" onsubmit={onsubmit}>
<div class="auth-field">
<label for="token">{"Verification Token"}</label><br/>
<input class="auth-input" type="text" name="token" onchange={on_token_changed}/><br/>
</div>
if !state.submitted {
<div class="center-x">
<button class="auth-submit" type="submit">{"Verify"}</button>
</div>
} else {
<div class="center-x">
<div class="spinner" />
</div>
}
<div class="center-x">
<p class="auth-status">{&state.clone().deref().status}</p>
</div>
</form>
},
}
}

15
style/components.css Normal file
View File

@ -0,0 +1,15 @@
.spinner {
display: inline-block;
top: 50%;
border: 8px solid #00000000;
border-top: 8px solid #690455;
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 1.5s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}