Compare commits

...

2 commits

Author SHA1 Message Date
Yash Karandikar b033e64507 Use get_user_by_id 2022-02-18 11:21:07 -06:00
Yash Karandikar a4d9ab162d Split components into separate files 2022-02-18 11:12:44 -06:00
6 changed files with 472 additions and 461 deletions

View 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,
})
}
}
}

View 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 { "<<<" } }
}
}
}
}

View file

@ -0,0 +1,3 @@
pub mod home;
pub mod issues;
pub mod users;

297
app/src/components/users.rs Normal file
View 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" }
}
}
}
}

View file

@ -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 {

View file

@ -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))