Compare commits
2 commits
630939707a
...
b033e64507
Author | SHA1 | Date | |
---|---|---|---|
Yash Karandikar | b033e64507 | ||
Yash Karandikar | a4d9ab162d |
37
app/src/components/home.rs
Normal file
37
app/src/components/home.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
use crate::Issue;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
#[component(HomeScreen<G>)]
|
||||
pub fn home_screen() -> View<G> {
|
||||
let issues: Signal<Vec<Issue>> = Signal::new(Vec::new());
|
||||
if G::IS_BROWSER {
|
||||
wasm_bindgen_futures::spawn_local(cloned!(issues => async move {
|
||||
let resp = reqwasm::http::Request::get("/api/issues").send().await.unwrap().text().await.unwrap();
|
||||
let parsed: Vec<Issue> = serde_json::from_str(&resp).unwrap_or_default();
|
||||
issues.set(parsed);
|
||||
}));
|
||||
}
|
||||
view! {
|
||||
div(class="text-align-center") {
|
||||
Keyed(KeyedProps {
|
||||
iterable: issues.handle(),
|
||||
template: |i| view! {
|
||||
div(class="card") {
|
||||
h3(class="text-align-center", style="margin-bottom: 0") { (i.title) }
|
||||
div(style="justify-content: space-between; display: flex") {
|
||||
div { (i.author) }
|
||||
div { (i.repo) }
|
||||
}
|
||||
br {}
|
||||
br {}
|
||||
p(class="text-align-center") { (i.desc) }
|
||||
div(style="display: flex; justify-content: right") {
|
||||
a(href=format!("/issue/{}", i.id)) { button { ">>>" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
key: |i| i.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
124
app/src/components/issues.rs
Normal file
124
app/src/components/issues.rs
Normal file
|
@ -0,0 +1,124 @@
|
|||
use crate::{local_storage, setLocation, Issue, PartialIssue, User};
|
||||
use sycamore::prelude::*;
|
||||
|
||||
#[component(IssueCreate<G>)]
|
||||
pub fn issue_create() -> View<G> {
|
||||
let repo = Signal::new(String::new());
|
||||
let title = Signal::new(String::new());
|
||||
let desc = Signal::new(String::new());
|
||||
|
||||
let error = Signal::new(false);
|
||||
|
||||
let current_user = Signal::new(User::default());
|
||||
|
||||
if G::IS_BROWSER {
|
||||
if let Some(user) = local_storage::getItem("token") {
|
||||
let decoded = base64::decode(user).unwrap();
|
||||
let json = unsafe { String::from_utf8_unchecked(decoded) };
|
||||
current_user.set(serde_json::from_str(&json).unwrap());
|
||||
} else {
|
||||
setLocation("/login");
|
||||
}
|
||||
}
|
||||
|
||||
let on_click = cloned!((repo, title, desc, error) => move |_| {
|
||||
let repo = (*repo.get()).trim().to_string();
|
||||
let author = current_user.get().id;
|
||||
let title = (*title.get()).trim().to_string();
|
||||
let desc = (*desc.get()).trim().to_string();
|
||||
|
||||
if repo == "" || title == "" || desc == "" {
|
||||
error.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let issue = PartialIssue {
|
||||
repo,
|
||||
author,
|
||||
title,
|
||||
desc
|
||||
};
|
||||
|
||||
let sered = serde_json::to_string(&issue).unwrap();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let resp = reqwasm::http::Request::post("/api/add_issue")
|
||||
.body(sered)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let formatted = format!("/issue/{resp}");
|
||||
setLocation(&formatted);
|
||||
});
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
a(href="/") { button { "<<<" } }
|
||||
label { "repo:"
|
||||
// TODO: Fetch a list of repos from gitea or something
|
||||
input(placeholder="karx/bugspray", bind:value=repo)
|
||||
}
|
||||
label {
|
||||
"Title:"
|
||||
input(bind:value=title)
|
||||
}
|
||||
label {
|
||||
"Description:"
|
||||
textarea(bind:value=desc)
|
||||
}
|
||||
(if *error.clone().get() {
|
||||
view! {
|
||||
p(style="color: red") { "Invalid form input. Make sure none of the inputs are empty." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
div(style="justify-content: right") {
|
||||
button(on:click=on_click) { "Submit" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(IssueDetail<G>)]
|
||||
pub fn issue_detail(index: i64) -> View<G> {
|
||||
let issue: Signal<Issue> = Signal::new(Issue::default());
|
||||
if G::IS_BROWSER {
|
||||
wasm_bindgen_futures::spawn_local(cloned!(issue => async move {
|
||||
let resp = reqwasm::http::Request::get(&format!("/api/issues/{}", index)).send().await.unwrap();
|
||||
if resp.status() == 404 {
|
||||
setLocation("/not_found"); // lmao
|
||||
}
|
||||
let text = resp.text().await.unwrap();
|
||||
let parsed: Issue = serde_json::from_str(&text).unwrap();
|
||||
issue.set(parsed);
|
||||
}));
|
||||
}
|
||||
|
||||
let title = create_memo(cloned!(issue => move || issue.get().title.clone()));
|
||||
let author = create_memo(cloned!(issue => move || issue.get().author.clone()));
|
||||
let repo = create_memo(cloned!(issue => move || issue.get().repo.clone()));
|
||||
let desc = create_memo(cloned!(issue => move || issue.get().desc.clone()));
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
h3(class="text-align-center", style="margin-bottom: 0") { (title.get()) }
|
||||
div(style="justify-content: space-between; display: flex") {
|
||||
div { (author.get()) }
|
||||
div { (repo.get()) }
|
||||
}
|
||||
br {}
|
||||
br {}
|
||||
p(class="text-align-center") { (desc.get()) }
|
||||
div(style="display: flex; justify-content: left") {
|
||||
a(href="/") { button { "<<<" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
3
app/src/components/mod.rs
Normal file
3
app/src/components/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod home;
|
||||
pub mod issues;
|
||||
pub mod users;
|
297
app/src/components/users.rs
Normal file
297
app/src/components/users.rs
Normal file
|
@ -0,0 +1,297 @@
|
|||
use crate::{local_storage, log, setLocation, PartialUser, User};
|
||||
use base64::encode;
|
||||
use fancy_regex::Regex;
|
||||
use pbkdf2::password_hash::PasswordVerifier;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
#[component(Register<G>)]
|
||||
pub fn register() -> View<G> {
|
||||
let username = Signal::new(String::new());
|
||||
let password_raw = Signal::new(String::new());
|
||||
let password_confirm = Signal::new(String::new());
|
||||
|
||||
let error_blank = Signal::new(false);
|
||||
let error_passmatch = Signal::new(false);
|
||||
|
||||
let error_username_format = Signal::new(false);
|
||||
let error_format = Signal::new(false);
|
||||
|
||||
let error_unique_username = Signal::new(false);
|
||||
|
||||
let whitespace_re = Regex::new(r"\s").unwrap();
|
||||
let password_re = Regex::new(
|
||||
r"^.*(?=.{8})(?=.+[a-z])(?=.*[A-Z])(?=.+\d)(?=.*[`~!@#$%^&*()_+\-=\[\]{}\\|:;/?<>,.]).*$",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let on_click = cloned!((error_blank, error_passmatch, username, password_raw, password_confirm, error_format, error_username_format, error_unique_username) => move |_| {
|
||||
use pbkdf2::{
|
||||
password_hash::{
|
||||
PasswordHasher, SaltString,
|
||||
},
|
||||
Pbkdf2
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
|
||||
let username = username.get().trim().to_string();
|
||||
let password_raw = password_raw.get().trim().to_string();
|
||||
let password_confirm = password_confirm.get().trim().to_string();
|
||||
|
||||
error_blank.set(username == "" || password_raw == "" || password_confirm == "");
|
||||
error_passmatch.set(password_raw != password_confirm);
|
||||
error_format.set(!password_re.is_match(&password_raw).unwrap());
|
||||
error_username_format.set(whitespace_re.is_match(&username).unwrap()); // error is true if username contains whitespace
|
||||
|
||||
wasm_bindgen_futures::spawn_local(cloned!((error_unique_username, username) => async move {
|
||||
let resp = reqwasm::http::Request::get(&format!("/api/user/by_username/{username}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let desered: User = serde_json::from_str(&resp).unwrap();
|
||||
|
||||
error_unique_username.set(desered.id != 0);
|
||||
}));
|
||||
|
||||
if *error_unique_username.get() || *error_blank.get() || *error_passmatch.get() || *error_format.get() || *error_username_format.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password_hash = Pbkdf2.hash_password(password_raw.as_bytes(), &salt).unwrap().to_string();
|
||||
|
||||
log(&password_hash);
|
||||
|
||||
let user = PartialUser {
|
||||
username,
|
||||
password_hash
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&user).unwrap();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
reqwasm::http::Request::post("/api/register")
|
||||
.body(serialized)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
setLocation("/login");
|
||||
});
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
label { "Username:"
|
||||
input(bind:value=username)
|
||||
}
|
||||
label { "Password"
|
||||
input(bind:value=password_raw, type="password")
|
||||
}
|
||||
label { "Confirm password:"
|
||||
input(bind:value=password_confirm, type="password")
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_unique_username.get() {
|
||||
view! {
|
||||
p(style="color: red") { "That username is not available." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_username_format.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Username cannot contain any spaces." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_format.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Invalid password. Make sure it contains lowercase and uppercase letters, at least one digit, and at least one special character." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_passmatch.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Passwords do not match" }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_blank.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Invalid form input. Make sure none of the fields are empty." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div(style="justify-content: right") {
|
||||
button(on:click=on_click) { "Register" }
|
||||
}
|
||||
|
||||
small {
|
||||
a(href="/login") { "Already have an account?" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(Login<G>)]
|
||||
pub fn login() -> View<G> {
|
||||
let username = Signal::new(String::new());
|
||||
let password_raw = Signal::new(String::new());
|
||||
|
||||
let error_blank = Signal::new(false);
|
||||
let error_wrong_username = Signal::new(false);
|
||||
let error_wrong_password = Signal::new(false);
|
||||
|
||||
let text = Signal::new(String::from("Log in"));
|
||||
|
||||
let on_click = cloned!((username, password_raw, error_blank, error_wrong_password, error_wrong_username, text) => move |_| {
|
||||
let username = username.get().trim().to_string();
|
||||
let password_raw = password_raw.get().trim().to_string();
|
||||
|
||||
error_blank.set(username == "" || password_raw == "");
|
||||
|
||||
wasm_bindgen_futures::spawn_local(cloned!((error_wrong_username, error_wrong_password, text) => async move {
|
||||
use pbkdf2::{
|
||||
password_hash::PasswordHash,
|
||||
Pbkdf2,
|
||||
};
|
||||
|
||||
text.set(String::from("Logging in..."));
|
||||
|
||||
let resp = reqwasm::http::Request
|
||||
::get(&format!("/api/user/by_username/{username}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user: User = serde_json::from_str(&resp).unwrap();
|
||||
|
||||
error_wrong_username.set(user.id == 0);
|
||||
|
||||
if *error_wrong_username.get() {
|
||||
text.set(String::from("Log in"));
|
||||
return;
|
||||
}
|
||||
|
||||
let hashed = PasswordHash::new(&user.password_hash).unwrap();
|
||||
|
||||
error_wrong_password.set(Pbkdf2.verify_password(password_raw.as_bytes(), &hashed).is_err());
|
||||
|
||||
if *error_wrong_password.get() {
|
||||
text.set(String::from("Log in"));
|
||||
return;
|
||||
}
|
||||
|
||||
let encoded = encode(serde_json::to_string(&user).unwrap().as_bytes());
|
||||
|
||||
local_storage::setItem("token", &encoded);
|
||||
log(&encoded);
|
||||
|
||||
setLocation("/");
|
||||
|
||||
text.set(String::from("Log in"));
|
||||
}));
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
label { "Username:"
|
||||
input(bind:value=username)
|
||||
}
|
||||
label { "Password:"
|
||||
input(bind:value=password_raw, type="password")
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_blank.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Invalid form input. Make sure none of the fields are blank." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_wrong_username.get() {
|
||||
view! {
|
||||
p(style="color: red") { "No such user exists!" }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_wrong_password.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Incorrect password." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div(style="justify-content: right") {
|
||||
button(on:click=on_click) { (text.get()) }
|
||||
}
|
||||
|
||||
small {
|
||||
a(href="/register") { "Don't have an account?" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(Logout<G>)]
|
||||
pub fn logout() -> View<G> {
|
||||
let on_confirm = |_| {
|
||||
local_storage::removeItem("token");
|
||||
setLocation("/");
|
||||
};
|
||||
|
||||
let on_cancel = |_| {
|
||||
setLocation("/");
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
div(class="text-align-center") {
|
||||
"Really log out?"
|
||||
br {}
|
||||
button(on:click=on_confirm) { "Yes, log out" }
|
||||
button(on:click=on_cancel) { "Cancel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
467
app/src/lib.rs
467
app/src/lib.rs
|
@ -1,8 +1,6 @@
|
|||
mod components;
|
||||
mod local_storage;
|
||||
|
||||
use fancy_regex::Regex;
|
||||
use pbkdf2::password_hash::PasswordVerifier;
|
||||
use reqwasm::http::Request;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sycamore::prelude::*;
|
||||
use sycamore_router::{
|
||||
|
@ -10,8 +8,6 @@ use sycamore_router::{
|
|||
};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use base64::encode;
|
||||
|
||||
macro_rules! wasm_import {
|
||||
($($tt:tt)*) => {
|
||||
#[wasm_bindgen]
|
||||
|
@ -111,7 +107,7 @@ fn switch<G: Html>(route: ReadSignal<AppRoutes>) -> View<G> {
|
|||
a(href="/logout") { "Log out" }
|
||||
}
|
||||
})
|
||||
HomeScreen()
|
||||
components::home::HomeScreen()
|
||||
},
|
||||
AppRoutes::IssueDetail(index) => view! {
|
||||
h1(class="text-align-center") { "Bugspray" }
|
||||
|
@ -128,24 +124,24 @@ fn switch<G: Html>(route: ReadSignal<AppRoutes>) -> View<G> {
|
|||
a(href="/logout") { "Log out" }
|
||||
}
|
||||
})
|
||||
IssueDetail(*index)
|
||||
components::issues::IssueDetail(*index)
|
||||
},
|
||||
AppRoutes::IssueCreate => view! {
|
||||
h1(class="text-align-center") { "Bugspray" }
|
||||
IssueCreate()
|
||||
components::issues::IssueCreate()
|
||||
},
|
||||
|
||||
AppRoutes::Register => view! {
|
||||
h1(class="text-align-center") { "Bugspray" }
|
||||
Register()
|
||||
components::users::Register()
|
||||
},
|
||||
AppRoutes::Login => view! {
|
||||
h1(class="text-align-center") { "Bugspray" }
|
||||
Login()
|
||||
components::users::Login()
|
||||
},
|
||||
AppRoutes::Logout => view! {
|
||||
h1(class="text-align-center") { "Bugspray" }
|
||||
Logout()
|
||||
components::users::Logout()
|
||||
},
|
||||
|
||||
_ => view! {
|
||||
|
@ -171,455 +167,6 @@ fn switch<G: Html>(route: ReadSignal<AppRoutes>) -> View<G> {
|
|||
}
|
||||
}
|
||||
|
||||
#[component(HomeScreen<G>)]
|
||||
pub fn home_screen() -> View<G> {
|
||||
let issues: Signal<Vec<Issue>> = Signal::new(Vec::new());
|
||||
if G::IS_BROWSER {
|
||||
wasm_bindgen_futures::spawn_local(cloned!(issues => async move {
|
||||
let resp = reqwasm::http::Request::get("/api/issues").send().await.unwrap().text().await.unwrap();
|
||||
let parsed: Vec<Issue> = serde_json::from_str(&resp).unwrap_or_default();
|
||||
issues.set(parsed);
|
||||
}));
|
||||
}
|
||||
view! {
|
||||
div(class="text-align-center") {
|
||||
Keyed(KeyedProps {
|
||||
iterable: issues.handle(),
|
||||
template: |i| view! {
|
||||
div(class="card") {
|
||||
h3(class="text-align-center", style="margin-bottom: 0") { (i.title) }
|
||||
div(style="justify-content: space-between; display: flex") {
|
||||
div { (i.author) }
|
||||
div { (i.repo) }
|
||||
}
|
||||
br {}
|
||||
br {}
|
||||
p(class="text-align-center") { (i.desc) }
|
||||
div(style="display: flex; justify-content: right") {
|
||||
a(href=format!("/issue/{}", i.id)) { button { ">>>" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
key: |i| i.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(IssueCreate<G>)]
|
||||
pub fn issue_create() -> View<G> {
|
||||
let repo = Signal::new(String::new());
|
||||
let title = Signal::new(String::new());
|
||||
let desc = Signal::new(String::new());
|
||||
|
||||
let error = Signal::new(false);
|
||||
|
||||
let current_user = Signal::new(User::default());
|
||||
|
||||
if G::IS_BROWSER {
|
||||
if let Some(user) = local_storage::getItem("token") {
|
||||
let decoded = base64::decode(user).unwrap();
|
||||
let json = unsafe { String::from_utf8_unchecked(decoded) };
|
||||
current_user.set(serde_json::from_str(&json).unwrap());
|
||||
} else {
|
||||
setLocation("/login");
|
||||
}
|
||||
}
|
||||
|
||||
let on_click = cloned!((repo, title, desc, error) => move |_| {
|
||||
let repo = (*repo.get()).trim().to_string();
|
||||
let author = current_user.get().id;
|
||||
let title = (*title.get()).trim().to_string();
|
||||
let desc = (*desc.get()).trim().to_string();
|
||||
|
||||
if repo == "" || title == "" || desc == "" {
|
||||
error.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let issue = PartialIssue {
|
||||
repo,
|
||||
author,
|
||||
title,
|
||||
desc
|
||||
};
|
||||
|
||||
let sered = serde_json::to_string(&issue).unwrap();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let resp = reqwasm::http::Request::post("/api/add_issue")
|
||||
.body(sered)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let formatted = format!("/issue/{resp}");
|
||||
setLocation(&formatted);
|
||||
});
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
a(href="/") { button { "<<<" } }
|
||||
label { "repo:"
|
||||
// TODO: Fetch a list of repos from gitea or something
|
||||
input(placeholder="karx/bugspray", bind:value=repo)
|
||||
}
|
||||
label {
|
||||
"Title:"
|
||||
input(bind:value=title)
|
||||
}
|
||||
label {
|
||||
"Description:"
|
||||
textarea(bind:value=desc)
|
||||
}
|
||||
(if *error.clone().get() {
|
||||
view! {
|
||||
p(style="color: red") { "Invalid form input. Make sure none of the inputs are empty." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
div(style="justify-content: right") {
|
||||
button(on:click=on_click) { "Submit" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(Register<G>)]
|
||||
pub fn register() -> View<G> {
|
||||
let username = Signal::new(String::new());
|
||||
let password_raw = Signal::new(String::new());
|
||||
let password_confirm = Signal::new(String::new());
|
||||
|
||||
let error_blank = Signal::new(false);
|
||||
let error_passmatch = Signal::new(false);
|
||||
|
||||
let error_username_format = Signal::new(false);
|
||||
let error_format = Signal::new(false);
|
||||
|
||||
let error_unique_username = Signal::new(false);
|
||||
|
||||
let whitespace_re = Regex::new(r"\s").unwrap();
|
||||
let password_re = Regex::new(
|
||||
r"^.*(?=.{8})(?=.+[a-z])(?=.*[A-Z])(?=.+\d)(?=.*[`~!@#$%^&*()_+\-=\[\]{}\\|:;/?<>,.]).*$",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let on_click = cloned!((error_blank, error_passmatch, username, password_raw, password_confirm, error_format, error_username_format, error_unique_username) => move |_| {
|
||||
use pbkdf2::{
|
||||
password_hash::{
|
||||
PasswordHasher, SaltString,
|
||||
},
|
||||
Pbkdf2
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
|
||||
let username = username.get().trim().to_string();
|
||||
let password_raw = password_raw.get().trim().to_string();
|
||||
let password_confirm = password_confirm.get().trim().to_string();
|
||||
|
||||
error_blank.set(username == "" || password_raw == "" || password_confirm == "");
|
||||
error_passmatch.set(password_raw != password_confirm);
|
||||
error_format.set(!password_re.is_match(&password_raw).unwrap());
|
||||
error_username_format.set(whitespace_re.is_match(&username).unwrap()); // error is true if username contains whitespace
|
||||
|
||||
wasm_bindgen_futures::spawn_local(cloned!((error_unique_username, username) => async move {
|
||||
let resp = reqwasm::http::Request::get(&format!("/api/user/by_username/{username}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let desered: User = serde_json::from_str(&resp).unwrap();
|
||||
|
||||
error_unique_username.set(desered.id != 0);
|
||||
}));
|
||||
|
||||
if *error_unique_username.get() || *error_blank.get() || *error_passmatch.get() || *error_format.get() || *error_username_format.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password_hash = Pbkdf2.hash_password(password_raw.as_bytes(), &salt).unwrap().to_string();
|
||||
|
||||
log(&password_hash);
|
||||
|
||||
let user = PartialUser {
|
||||
username,
|
||||
password_hash
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&user).unwrap();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
reqwasm::http::Request::post("/api/register")
|
||||
.body(serialized)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
setLocation("/login");
|
||||
});
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
label { "Username:"
|
||||
input(bind:value=username)
|
||||
}
|
||||
label { "Password"
|
||||
input(bind:value=password_raw, type="password")
|
||||
}
|
||||
label { "Confirm password:"
|
||||
input(bind:value=password_confirm, type="password")
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_unique_username.get() {
|
||||
view! {
|
||||
p(style="color: red") { "That username is not available." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_username_format.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Username cannot contain any spaces." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_format.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Invalid password. Make sure it contains lowercase and uppercase letters, at least one digit, and at least one special character." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_passmatch.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Passwords do not match" }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_blank.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Invalid form input. Make sure none of the fields are empty." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div(style="justify-content: right") {
|
||||
button(on:click=on_click) { "Register" }
|
||||
}
|
||||
|
||||
small {
|
||||
a(href="/login") { "Already have an account?" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(Login<G>)]
|
||||
pub fn login() -> View<G> {
|
||||
let username = Signal::new(String::new());
|
||||
let password_raw = Signal::new(String::new());
|
||||
|
||||
let error_blank = Signal::new(false);
|
||||
let error_wrong_username = Signal::new(false);
|
||||
let error_wrong_password = Signal::new(false);
|
||||
|
||||
let text = Signal::new(String::from("Log in"));
|
||||
|
||||
let on_click = cloned!((username, password_raw, error_blank, error_wrong_password, error_wrong_username, text) => move |_| {
|
||||
let username = username.get().trim().to_string();
|
||||
let password_raw = password_raw.get().trim().to_string();
|
||||
|
||||
error_blank.set(username == "" || password_raw == "");
|
||||
|
||||
wasm_bindgen_futures::spawn_local(cloned!((error_wrong_username, error_wrong_password, text) => async move {
|
||||
use pbkdf2::{
|
||||
password_hash::PasswordHash,
|
||||
Pbkdf2,
|
||||
};
|
||||
|
||||
text.set(String::from("Logging in..."));
|
||||
|
||||
let resp = Request
|
||||
::get(&format!("/api/user/by_username/{username}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user: User = serde_json::from_str(&resp).unwrap();
|
||||
|
||||
error_wrong_username.set(user.id == 0);
|
||||
|
||||
if *error_wrong_username.get() {
|
||||
text.set(String::from("Log in"));
|
||||
return;
|
||||
}
|
||||
|
||||
let hashed = PasswordHash::new(&user.password_hash).unwrap();
|
||||
|
||||
error_wrong_password.set(Pbkdf2.verify_password(password_raw.as_bytes(), &hashed).is_err());
|
||||
|
||||
if *error_wrong_password.get() {
|
||||
text.set(String::from("Log in"));
|
||||
return;
|
||||
}
|
||||
|
||||
let encoded = encode(serde_json::to_string(&user).unwrap().as_bytes());
|
||||
|
||||
local_storage::setItem("token", &encoded);
|
||||
log(&encoded);
|
||||
|
||||
setLocation("/");
|
||||
|
||||
text.set(String::from("Log in"));
|
||||
}));
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
label { "Username:"
|
||||
input(bind:value=username)
|
||||
}
|
||||
label { "Password:"
|
||||
input(bind:value=password_raw, type="password")
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_blank.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Invalid form input. Make sure none of the fields are blank." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_wrong_username.get() {
|
||||
view! {
|
||||
p(style="color: red") { "No such user exists!" }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div {
|
||||
(if *error_wrong_password.get() {
|
||||
view! {
|
||||
p(style="color: red") { "Incorrect password." }
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
|
||||
div(style="justify-content: right") {
|
||||
button(on:click=on_click) { (text.get()) }
|
||||
}
|
||||
|
||||
small {
|
||||
a(href="/register") { "Don't have an account?" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(Logout<G>)]
|
||||
pub fn logout() -> View<G> {
|
||||
let on_confirm = |_| {
|
||||
local_storage::removeItem("token");
|
||||
setLocation("/");
|
||||
};
|
||||
|
||||
let on_cancel = |_| {
|
||||
setLocation("/");
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
div(class="text-align-center") {
|
||||
"Really log out?"
|
||||
br {}
|
||||
button(on:click=on_confirm) { "Yes, log out" }
|
||||
button(on:click=on_cancel) { "Cancel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(IssueDetail<G>)]
|
||||
pub fn issue_detail(index: i64) -> View<G> {
|
||||
let issue: Signal<Issue> = Signal::new(Issue::default());
|
||||
if G::IS_BROWSER {
|
||||
wasm_bindgen_futures::spawn_local(cloned!(issue => async move {
|
||||
let resp = reqwasm::http::Request::get(&format!("/api/issues/{}", index)).send().await.unwrap();
|
||||
if resp.status() == 404 {
|
||||
setLocation("/not_found"); // lmao
|
||||
}
|
||||
let text = resp.text().await.unwrap();
|
||||
let parsed: Issue = serde_json::from_str(&text).unwrap();
|
||||
issue.set(parsed);
|
||||
}));
|
||||
}
|
||||
|
||||
let title = create_memo(cloned!(issue => move || issue.get().title.clone()));
|
||||
let author = create_memo(cloned!(issue => move || issue.get().author.clone()));
|
||||
let repo = create_memo(cloned!(issue => move || issue.get().repo.clone()));
|
||||
let desc = create_memo(cloned!(issue => move || issue.get().desc.clone()));
|
||||
|
||||
view! {
|
||||
div(class="card") {
|
||||
h3(class="text-align-center", style="margin-bottom: 0") { (title.get()) }
|
||||
div(style="justify-content: space-between; display: flex") {
|
||||
div { (author.get()) }
|
||||
div { (repo.get()) }
|
||||
}
|
||||
br {}
|
||||
br {}
|
||||
p(class="text-align-center") { (desc.get()) }
|
||||
div(style="display: flex; justify-content: left") {
|
||||
a(href="/") { button { "<<<" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(App<G>)]
|
||||
pub fn app(path: Option<String>) -> View<G> {
|
||||
match path {
|
||||
|
|
|
@ -63,7 +63,10 @@ async fn main() {
|
|||
api::get_issue,
|
||||
api::add_issue,
|
||||
api::register,
|
||||
api::get_user_by_username
|
||||
|
||||
|
||||
api::get_user_by_username,
|
||||
api::get_user_by_id,
|
||||
],
|
||||
)
|
||||
.mount("/", FileServer::from("app/dist").rank(1))
|
||||
|
|
Loading…
Reference in a new issue