parent
d7655c3ebe
commit
792b0d96ce
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,68 @@
|
||||
use crate::api::{types::user::UserProfile, FastInsidersApi};
|
||||
|
||||
#[cfg(client)]
|
||||
use super::user::set_token_cookie;
|
||||
|
||||
#[cfg(engine)]
|
||||
type Response = reqwest::Response;
|
||||
|
||||
#[cfg(client)]
|
||||
type Response = reqwasm::http::Response;
|
||||
|
||||
#[cfg(client)]
|
||||
fn update_token(resp: &Response) {
|
||||
if let Some(token) = resp.headers().get("x-new-token") {
|
||||
set_token_cookie(&token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
async fn get_auth_route(route: &str) -> Result<Response, ()> {
|
||||
let token = wasm_cookies::get_raw("token").unwrap_or_default();
|
||||
let resp = reqwasm::http::Request::get(route)
|
||||
.header("Authorization", &format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
update_token(&resp);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
impl FastInsidersApi {
|
||||
/// This is only a route to verify that we are authenticated
|
||||
pub async fn is_authenticated(&self) -> Result<Response, ()> {
|
||||
let route = &format!("{}/auth/is_authenticated", self.url);
|
||||
|
||||
#[cfg(client)]
|
||||
let resp = get_auth_route(route).await?;
|
||||
|
||||
#[cfg(engine)]
|
||||
let resp = reqwest::Client::new()
|
||||
.get(route)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn get_profile(&self) -> Result<UserProfile, ()> {
|
||||
let route = &format!("{}/auth/profile", self.url);
|
||||
|
||||
#[cfg(client)]
|
||||
let res = {
|
||||
let resp = get_auth_route(route).await?;
|
||||
resp.json::<UserProfile>().await.map_err(|_| ())?
|
||||
};
|
||||
|
||||
#[cfg(engine)]
|
||||
let res = reqwest::get(route)
|
||||
.await
|
||||
.map_err(|_| ())?
|
||||
.json::<UserProfile>()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
pub mod authenticated;
|
||||
pub mod company;
|
||||
pub mod transaction;
|
||||
pub mod user;
|
||||
|
||||
@ -0,0 +1,163 @@
|
||||
use std::{error::Error, fmt::Display, time::Duration};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::FastInsidersApi;
|
||||
|
||||
#[cfg(client)]
|
||||
#[derive(Serialize)]
|
||||
pub struct UserLoginBody {
|
||||
pub name: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
#[derive(Serialize)]
|
||||
pub struct UserRegisterBody {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
type Response = reqwasm::http::Response;
|
||||
|
||||
#[cfg(client)]
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub enum LoginError {
|
||||
Unknown,
|
||||
InvalidCredentials,
|
||||
InternalServer(String),
|
||||
Server(String),
|
||||
UserNotFound(String),
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
impl Display for LoginError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unknown => {
|
||||
write!(f, "There was an unknown error while trying to log you in.").to_owned()
|
||||
}
|
||||
Self::InternalServer(e) => write!(f, "Internal server error: {}", e),
|
||||
Self::Server(e) => write!(f, "There was an error completing the request {}", e),
|
||||
Self::InvalidCredentials => write!(f, "Invalid username or password"),
|
||||
Self::UserNotFound(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
impl Error for LoginError {}
|
||||
|
||||
#[cfg(client)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub enum RegisterError {
|
||||
Unknown,
|
||||
InternalServer(String),
|
||||
Server(String),
|
||||
Conflict(String),
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
impl Display for RegisterError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unknown => {
|
||||
write!(f, "There was an unknown error while trying to log you in.").to_owned()
|
||||
}
|
||||
Self::InternalServer(e) => write!(f, "Internal server error: {}", e),
|
||||
Self::Server(e) => write!(f, "There was an error completing the request {}", e),
|
||||
Self::Conflict(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
impl Error for RegisterError {}
|
||||
|
||||
#[cfg(client)]
|
||||
pub fn set_token_cookie(token: &str) {
|
||||
use wasm_cookies::CookieOptions;
|
||||
let cookie_options = CookieOptions::default()
|
||||
.secure()
|
||||
.with_same_site(wasm_cookies::SameSite::Strict)
|
||||
.expires_after(Duration::from_secs(60 * 60 * 24 * 5)); // 5 days
|
||||
|
||||
wasm_cookies::set("token", token, &cookie_options);
|
||||
}
|
||||
|
||||
#[cfg(client)]
|
||||
impl FastInsidersApi {
|
||||
pub async fn login(&self, body: &UserLoginBody) -> Result<(), LoginError> {
|
||||
let route = &format!("{}/user/login", self.url);
|
||||
|
||||
let resp = reqwasm::http::Request::post(route)
|
||||
.header("Content-type", "application/json")
|
||||
.body(serde_json::to_string(&body).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LoginError::Server(e.to_string()))?;
|
||||
|
||||
if resp.status() == 200 {
|
||||
if let Ok(data) = resp.json::<LoginResponse>().await {
|
||||
set_token_cookie(&data.token);
|
||||
return Ok(());
|
||||
} else {
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
|
||||
match resp.status() {
|
||||
401 => return Err(LoginError::InvalidCredentials),
|
||||
500 => {
|
||||
let error = resp.json::<ErrorBody>().await.unwrap().error;
|
||||
return Err(LoginError::InternalServer(error));
|
||||
}
|
||||
404 => {
|
||||
let error = resp.json::<ErrorBody>().await.unwrap().error;
|
||||
return Err(LoginError::UserNotFound(error));
|
||||
}
|
||||
_ => return Err(LoginError::Unknown),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register(&self, body: &UserRegisterBody) -> Result<(), RegisterError> {
|
||||
let route = &format!("{}/user/register", self.url);
|
||||
|
||||
use wasm_cookies::CookieOptions;
|
||||
let resp = reqwasm::http::Request::post(route)
|
||||
.header("Content-type", "application/json")
|
||||
.body(serde_json::to_string(&body).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| RegisterError::Server(e.to_string()))?;
|
||||
|
||||
if resp.status() == 200 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if resp.status() == 500 {
|
||||
let error = resp.json::<ErrorBody>().await.unwrap().error;
|
||||
return Err(RegisterError::InternalServer(error));
|
||||
}
|
||||
|
||||
if resp.status() == 409 {
|
||||
let error = resp.json::<ErrorBody>().await.unwrap().error;
|
||||
return Err(RegisterError::Conflict(error));
|
||||
}
|
||||
Err(RegisterError::Unknown)
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod company;
|
||||
pub mod paginated_response;
|
||||
pub mod transaction;
|
||||
pub mod user;
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
pub email: Option<String>,
|
||||
pub name: String,
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
pub mod dark_mode_btn;
|
||||
pub mod user_icon;
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
use lazy_static::lazy_static;
|
||||
use perseus::prelude::*;
|
||||
use sycamore::{prelude::*, rt::Event};
|
||||
|
||||
use crate::global_state::AppStateRx;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref USER_ICON: Capsule<PerseusNodeType, ()> = get_capsule();
|
||||
}
|
||||
|
||||
fn user_icon<G: Html>(cx: Scope, _props: ()) -> View<G> {
|
||||
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
|
||||
|
||||
let api = global_state.api.get();
|
||||
let api_scope_ref = create_ref(cx, api);
|
||||
|
||||
#[cfg(client)]
|
||||
spawn_local_scoped(cx, async move {
|
||||
let status = api_scope_ref.is_authenticated().await.unwrap().status();
|
||||
if status == 200 {
|
||||
global_state.logged_in.set(true);
|
||||
} else {
|
||||
global_state.logged_in.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(client)]
|
||||
let logout = move |e: Event| {
|
||||
wasm_cookies::delete("token");
|
||||
global_state.logged_in.set(false);
|
||||
};
|
||||
|
||||
#[cfg(engine)]
|
||||
let logout = move |_| {};
|
||||
|
||||
view! { cx,
|
||||
(if *global_state.logged_in.get() {
|
||||
view! { cx,
|
||||
div(on:click=|_| navigate("/profile"), class="mx-1 hover:cursor-pointer", title="Login") {
|
||||
// user profile icon
|
||||
svg(xmlns="http://www.w3.org/2000/svg", fill="none", viewBox="0 0 24 24", stroke-width="1.5", stroke="currentColor", class="w-6 h-6") {
|
||||
path(stroke-linecap="round", stroke-linejoin="round", d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z") {}
|
||||
}
|
||||
}
|
||||
|
||||
div(on:click=logout, class="mx-1 hover:cursor-pointer", title="Login") {
|
||||
// logout icon
|
||||
svg(xmlns="http://www.w3.org/2000/svg", width="24", height="24", viewBox="0 0 24 24", fill="none", stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round", class="feather feather-log-out") {
|
||||
path(d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"){}
|
||||
polyline(points="16 17 21 12 16 7") {}
|
||||
line(x1="21", y1="12", x2="9", y2="12") {}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
view! { cx,
|
||||
div(on:click=|_| navigate("/login"), class="mx-1 hover:cursor-pointer", title="Login") {
|
||||
// login icon
|
||||
svg(xmlns="http://www.w3.org/2000/svg", width="24", height="24", viewBox="0 0 24 24", fill="none", stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round", class="feather feather-log-in") {
|
||||
path(d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4") {}
|
||||
polyline(points="10 17 15 12 10 7") {}
|
||||
line(x1="15", y1="12", x2="3", y2="12"){}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback<G: Html>(cx: Scope, _props: ()) -> View<G> {
|
||||
view! { cx,
|
||||
div(on:click=|_| navigate("/login"), class="mx-1 hover:cursor-pointer", title="Login") {
|
||||
// login icon
|
||||
svg(xmlns="http://www.w3.org/2000/svg", width="24", height="24", viewBox="0 0 24 24", fill="none", stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round", class="feather feather-log-in") {
|
||||
path(d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4") {}
|
||||
polyline(points="10 17 15 12 10 7") {}
|
||||
line(x1="15", y1="12", x2="3", y2="12"){}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_capsule<G: Html>() -> Capsule<G, ()> {
|
||||
Capsule::build(Template::build("dark_mode_btn"))
|
||||
.fallback(fallback)
|
||||
.view(user_icon)
|
||||
.build()
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
use perseus::prelude::*;
|
||||
use sycamore::{prelude::*, rt::Event};
|
||||
|
||||
use crate::{
|
||||
components::{main_content_container::MainContentContainer, the_header::TheHeader},
|
||||
global_state::AppStateRx,
|
||||
};
|
||||
|
||||
fn login_page<G: Html>(cx: BoundedScope) -> View<G> {
|
||||
let reactor = Reactor::<G>::from_cx(cx);
|
||||
let global_state = reactor.get_global_state::<AppStateRx>(cx);
|
||||
|
||||
let api = global_state.api.get();
|
||||
let api_scope_ref = create_ref(cx, api);
|
||||
|
||||
let dark_mode_class = create_memo(cx, || {
|
||||
if *global_state.dark_mode.get() {
|
||||
"dark"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
let username = create_signal(cx, "".to_string());
|
||||
let password = create_signal(cx, "".to_string());
|
||||
|
||||
let error_msg = create_signal(cx, "".to_string());
|
||||
|
||||
create_effect(cx, move || {
|
||||
if *global_state.logged_in.get() {
|
||||
navigate("/");
|
||||
}
|
||||
});
|
||||
|
||||
let submit_disabled = create_memo(cx, move || {
|
||||
username.get().is_empty() || password.get().is_empty()
|
||||
});
|
||||
|
||||
#[cfg(client)]
|
||||
let submit_login = move |e: Event| {
|
||||
use crate::api::routes::user::UserLoginBody;
|
||||
e.prevent_default();
|
||||
let user_info = UserLoginBody {
|
||||
name: username.get().to_string(),
|
||||
password: password.get().to_string(),
|
||||
};
|
||||
spawn_local_scoped(cx, async move {
|
||||
match api_scope_ref.login(&user_info).await {
|
||||
Ok(()) => {
|
||||
global_state.logged_in.set(true);
|
||||
}
|
||||
Err(e) => error_msg.set(e.to_string()),
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
#[cfg(engine)]
|
||||
let submit_login = move |_| {};
|
||||
|
||||
view! {cx,
|
||||
main (class=format!("{} flex flex-1", dark_mode_class)) {
|
||||
div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") {
|
||||
TheHeader()
|
||||
MainContentContainer(useless_prop=1) {
|
||||
form(class="flex flex-col justify-center items-center") {
|
||||
label(for="username") { "Username:" }
|
||||
input(id="username", bind:value=username, type="text", class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800") {}
|
||||
label(for="password") { "Password:" }
|
||||
input(id="password", bind:value=password, type="password", class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800") {}
|
||||
input(on:click=submit_login,
|
||||
value="Login",
|
||||
type="submit",
|
||||
class="p-2 m-2 rounded-md hover:cursor-pointer disabled:cursor-not-allowed bg-slate-300 dark:hover:bg-slate-900 dark:bg-slate-800 hover:bg-slate-400",
|
||||
disabled=*submit_disabled.get()
|
||||
) {}
|
||||
p (class="text-red-700 dark:text-rose-500") {
|
||||
(error_msg.get())
|
||||
}
|
||||
}
|
||||
a (class="hover:underline", href="/register") {
|
||||
"Don't have an account? Register here."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_template<G: Html>() -> Template<G> {
|
||||
Template::build("login").head(head).view(login_page).build()
|
||||
}
|
||||
|
||||
#[engine_only_fn]
|
||||
fn head(cx: Scope) -> View<SsrNode> {
|
||||
view! {cx,
|
||||
title { "Fast Insiders" }
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,5 @@
|
||||
pub mod index;
|
||||
pub mod login;
|
||||
pub mod profile;
|
||||
pub mod register;
|
||||
pub mod transactions;
|
||||
|
||||
@ -0,0 +1,105 @@
|
||||
use perseus::prelude::*;
|
||||
use sycamore::{prelude::*, rt::Event};
|
||||
|
||||
use crate::{
|
||||
components::{
|
||||
loading::Loading, main_content_container::MainContentContainer, the_header::TheHeader,
|
||||
},
|
||||
global_state::AppStateRx,
|
||||
};
|
||||
|
||||
fn profile_page<G: Html>(cx: BoundedScope) -> View<G> {
|
||||
let reactor = Reactor::<G>::from_cx(cx);
|
||||
let global_state = reactor.get_global_state::<AppStateRx>(cx);
|
||||
|
||||
let api = global_state.api.get();
|
||||
let api_scope_ref = create_ref(cx, api);
|
||||
|
||||
let dark_mode_class = create_memo(cx, || {
|
||||
if *global_state.dark_mode.get() {
|
||||
"dark"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
let username = create_signal(cx, "".to_string());
|
||||
let email = create_signal(cx, "".to_string());
|
||||
|
||||
let loading: &Signal<bool> = create_signal(cx, true);
|
||||
|
||||
#[cfg(client)]
|
||||
spawn_local_scoped(cx, async move {
|
||||
let resp = api_scope_ref.get_profile().await.unwrap();
|
||||
username.set(resp.name);
|
||||
if let Some(em) = resp.email {
|
||||
email.set(em);
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
|
||||
let displayed_email = create_memo(cx, move || {
|
||||
if email.get().is_empty() {
|
||||
"No email set".to_string()
|
||||
} else {
|
||||
(*email.get()).clone()
|
||||
}
|
||||
});
|
||||
|
||||
view! {cx,
|
||||
main (class=format!("{} flex flex-1", dark_mode_class)) {
|
||||
div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") {
|
||||
TheHeader()
|
||||
MainContentContainer(useless_prop=1) {
|
||||
div(class="items-center m-auto w-2/5 min-w-70") {
|
||||
h1(class="text-lg text-center") {
|
||||
"Profile page"
|
||||
}
|
||||
(if !*loading.get() {
|
||||
view! {cx,
|
||||
p() {
|
||||
b() {
|
||||
"Username: "
|
||||
}
|
||||
input(
|
||||
class="p-2 w-full rounded-md bg-slate-300 dark:bg-slate-800",
|
||||
disabled=true,
|
||||
value=username.get(),
|
||||
)
|
||||
}
|
||||
p() {
|
||||
b() {
|
||||
"Email: "
|
||||
}
|
||||
input(
|
||||
class="p-2 w-full rounded-md bg-slate-300 dark:bg-slate-800",
|
||||
disabled=true,
|
||||
value=displayed_email.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {cx,
|
||||
Loading()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_template<G: Html>() -> Template<G> {
|
||||
Template::build("profile")
|
||||
.head(head)
|
||||
.view(profile_page)
|
||||
.build()
|
||||
}
|
||||
|
||||
#[engine_only_fn]
|
||||
fn head(cx: Scope) -> View<SsrNode> {
|
||||
view! {cx,
|
||||
title { "Fast Insiders - User Profile" }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
use perseus::prelude::*;
|
||||
use sycamore::{prelude::*, rt::Event};
|
||||
|
||||
use crate::{
|
||||
components::{main_content_container::MainContentContainer, the_header::TheHeader},
|
||||
global_state::AppStateRx,
|
||||
};
|
||||
|
||||
fn register_page<G: Html>(cx: BoundedScope) -> View<G> {
|
||||
let reactor = Reactor::<G>::from_cx(cx);
|
||||
let global_state = reactor.get_global_state::<AppStateRx>(cx);
|
||||
|
||||
let api = global_state.api.get();
|
||||
let api_scope_ref = create_ref(cx, api);
|
||||
|
||||
let dark_mode_class = create_memo(cx, || {
|
||||
if *global_state.dark_mode.get() {
|
||||
"dark"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
let username = create_signal(cx, "".to_string());
|
||||
let email = create_signal(cx, "".to_string());
|
||||
let password = create_signal(cx, "".to_string());
|
||||
let confirm_password = create_signal(cx, "".to_string());
|
||||
|
||||
let error_msg = create_signal(cx, "".to_string());
|
||||
|
||||
create_effect(cx, move || {
|
||||
if *global_state.logged_in.get() {
|
||||
navigate("/");
|
||||
}
|
||||
});
|
||||
|
||||
let passwords_match = move |e: Event| {
|
||||
if confirm_password.get() != password.get() {
|
||||
error_msg.set("Passwords do not match".to_string());
|
||||
} else {
|
||||
error_msg.set("".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let submit_disabled = create_memo(cx, move || {
|
||||
username.get().is_empty()
|
||||
|| password.get().is_empty()
|
||||
|| password.get() != confirm_password.get()
|
||||
});
|
||||
|
||||
#[cfg(client)]
|
||||
let submit_register = move |e: Event| {
|
||||
use crate::api::routes::user::UserRegisterBody;
|
||||
e.prevent_default();
|
||||
let user_info = UserRegisterBody {
|
||||
name: username.get().to_string(),
|
||||
email: email.get().to_string(),
|
||||
password: password.get().to_string(),
|
||||
};
|
||||
spawn_local_scoped(cx, async move {
|
||||
match api_scope_ref.register(&user_info).await {
|
||||
Ok(()) => navigate("/login"),
|
||||
Err(e) => error_msg.set(e.to_string()),
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
#[cfg(engine)]
|
||||
let submit_register = move |_| {};
|
||||
|
||||
view! {cx,
|
||||
main (class=format!("{} flex flex-1", dark_mode_class)) {
|
||||
div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") {
|
||||
TheHeader()
|
||||
MainContentContainer(useless_prop=1) {
|
||||
form(class="flex flex-col justify-center items-center") {
|
||||
label(for="username") { "Username:" }
|
||||
input(id="username",
|
||||
bind:value=username,
|
||||
type="text",
|
||||
class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800"
|
||||
) {}
|
||||
label(for="password") { "Password:" }
|
||||
input(id="password",
|
||||
bind:value=password,
|
||||
type="password",
|
||||
class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800"
|
||||
) {}
|
||||
label(for="confirm-password") { "Confirm Password:" }
|
||||
input(id="confirm-password",
|
||||
bind:value=confirm_password,
|
||||
on:blur=passwords_match,
|
||||
type="password",
|
||||
class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800"
|
||||
) {}
|
||||
label(for="Email") { "Email:" }
|
||||
input(id="email",
|
||||
bind:value=email,
|
||||
type="text",
|
||||
class="p-2 mx-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800"
|
||||
) {}
|
||||
p (class="mt-0 italic") {
|
||||
"Set an email if you want to be able to reset your password"
|
||||
}
|
||||
input(on:click=submit_register,
|
||||
value="Register",
|
||||
type="submit",
|
||||
class="p-2 m-2 rounded-md hover:cursor-pointer disabled:cursor-not-allowed bg-slate-300 dark:hover:bg-slate-900 dark:bg-slate-800 hover:bg-slate-400",
|
||||
disabled=*submit_disabled.get()
|
||||
) {}
|
||||
p (class="text-red-700 dark:text-rose-500") {
|
||||
(error_msg.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_template<G: Html>() -> Template<G> {
|
||||
Template::build("register")
|
||||
.head(head)
|
||||
.view(register_page)
|
||||
.build()
|
||||
}
|
||||
|
||||
#[engine_only_fn]
|
||||
fn head(cx: Scope) -> View<SsrNode> {
|
||||
view! {cx,
|
||||
title { "Fast Insiders - Register" }
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1 +1,2 @@
|
||||
target/
|
||||
keys/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,21 @@
|
||||
//! Custom serialization of OffsetDateTime to conform with the JWT spec (RFC 7519 section 2, "Numeric Date")
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Serializes an OffsetDateTime to a Unix timestamp (milliseconds since 1970/1/1T00:00:00T)
|
||||
pub fn serialize<S>(date: &OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let timestamp = date.unix_timestamp();
|
||||
serializer.serialize_i64(timestamp)
|
||||
}
|
||||
|
||||
/// Attempts to deserialize an i64 and use as a Unix timestamp
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
OffsetDateTime::from_unix_timestamp(i64::deserialize(deserializer)?)
|
||||
.map_err(|_| serde::de::Error::custom("invalid Unix timestamp value"))
|
||||
}
|
||||
@ -0,0 +1,322 @@
|
||||
use futures::StreamExt;
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use rsa::{
|
||||
pkcs8::{der::zeroize::Zeroizing, EncodePrivateKey, EncodePublicKey, LineEnding},
|
||||
RsaPrivateKey, RsaPublicKey,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
use tokio::{fs, io};
|
||||
|
||||
pub mod jwt_numeric_date;
|
||||
|
||||
const RSA_BITS: usize = 4096;
|
||||
const KEYS_DIR: &str = "./keys";
|
||||
const CUR_PRIV: &str = "cur_priv.pem";
|
||||
const CUR_PUB: &str = "cur_pub.pem";
|
||||
const PRE_PRIV: &str = "pre_priv.pem";
|
||||
const PRE_PUB: &str = "pre_pub.pem";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JWTSecretManager {
|
||||
current: RSAKeyPair,
|
||||
previous: Option<RSAKeyPair>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JWTSecretManagerError {
|
||||
#[error("Failed to generatie new key pair: {0}")]
|
||||
KeyGeneration(rsa::Error),
|
||||
#[error("Failed to save pem files to file system: {0}")]
|
||||
KeySave(io::Error),
|
||||
#[error("Failed to create the directory to store keys: {0}")]
|
||||
KeysDir(io::Error),
|
||||
#[error("Failed to read existing keys from file system: {0}")]
|
||||
KeysRead(io::Error),
|
||||
#[error("Failed to encode a new JWT: {0}")]
|
||||
EncodeFailed(jsonwebtoken::errors::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ValidationOutcome<T>
|
||||
where
|
||||
T: Serialize,
|
||||
for<'de> T: Deserialize<'de>,
|
||||
{
|
||||
Ok(T),
|
||||
Outdated(T, String),
|
||||
Error(jsonwebtoken::errors::Error),
|
||||
Unauthorized,
|
||||
}
|
||||
|
||||
impl<T> From<jsonwebtoken::errors::Error> for ValidationOutcome<T>
|
||||
where
|
||||
T: Serialize,
|
||||
for<'de> T: Deserialize<'de>,
|
||||
{
|
||||
fn from(value: jsonwebtoken::errors::Error) -> Self {
|
||||
ValidationOutcome::Error(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl JWTSecretManager {
|
||||
pub async fn init() -> Result<Self, JWTSecretManagerError> {
|
||||
// Check if we have any keys
|
||||
let keys_dir = Path::new(KEYS_DIR);
|
||||
if !keys_dir.exists() {
|
||||
fs::create_dir(KEYS_DIR)
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeysDir)?;
|
||||
}
|
||||
|
||||
let current = if !keys_dir.join(CUR_PUB).exists() || !keys_dir.join(CUR_PRIV).exists() {
|
||||
let keys = RSAKeyPair::generate_new()
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeyGeneration)?;
|
||||
|
||||
keys.save_pem_files(keys_dir, CUR_PRIV, CUR_PUB)
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeySave)?;
|
||||
|
||||
keys
|
||||
} else {
|
||||
let public = fs::read_to_string(keys_dir.join(CUR_PUB))
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeysRead)?;
|
||||
let private = fs::read_to_string(keys_dir.join(CUR_PRIV))
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeysRead)?;
|
||||
|
||||
RSAKeyPair {
|
||||
private: Zeroizing::new(private),
|
||||
public,
|
||||
}
|
||||
};
|
||||
|
||||
let previous = if !keys_dir.join(PRE_PUB).exists() || !keys_dir.join(PRE_PRIV).exists() {
|
||||
None
|
||||
} else {
|
||||
let public = fs::read_to_string(keys_dir.join(PRE_PUB))
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeysRead)?;
|
||||
let private = fs::read_to_string(keys_dir.join(PRE_PRIV))
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeysRead)?;
|
||||
|
||||
Some(RSAKeyPair {
|
||||
private: Zeroizing::new(private),
|
||||
public,
|
||||
})
|
||||
};
|
||||
|
||||
Ok(JWTSecretManager { current, previous })
|
||||
}
|
||||
|
||||
pub async fn rotate(&mut self) -> Result<(), JWTSecretManagerError> {
|
||||
self.previous = Some(self.current.clone());
|
||||
|
||||
self.current
|
||||
.save_pem_files(KEYS_DIR, PRE_PRIV, PRE_PUB)
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeySave)?;
|
||||
|
||||
self.current = RSAKeyPair::generate_new()
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeyGeneration)?;
|
||||
|
||||
self.current
|
||||
.save_pem_files(KEYS_DIR, CUR_PRIV, CUR_PUB)
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::KeySave)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn decode<T>(&self, token: &str) -> ValidationOutcome<T>
|
||||
where
|
||||
T: Serialize + Clone,
|
||||
for<'de> T: Deserialize<'de>,
|
||||
{
|
||||
if let Ok(claim) = self.current.verify_jwt(token).await {
|
||||
return ValidationOutcome::Ok(claim);
|
||||
}
|
||||
|
||||
match &self.previous {
|
||||
Some(k) => {
|
||||
if let Ok(claim) = k.verify_jwt::<T>(token).await {
|
||||
let new_token = match self.current.encode(&claim).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => return ValidationOutcome::Error(e),
|
||||
};
|
||||
return ValidationOutcome::Outdated(claim, new_token);
|
||||
}
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
|
||||
ValidationOutcome::Unauthorized
|
||||
}
|
||||
|
||||
pub async fn encode_new<T>(&self, claim: &T) -> Result<String, JWTSecretManagerError>
|
||||
where
|
||||
T: Serialize + Clone,
|
||||
for<'de> T: Deserialize<'de>,
|
||||
{
|
||||
self.current
|
||||
.encode(&claim)
|
||||
.await
|
||||
.map_err(JWTSecretManagerError::EncodeFailed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RSAKeyPair {
|
||||
private: Zeroizing<String>,
|
||||
public: String,
|
||||
}
|
||||
|
||||
impl RSAKeyPair {
|
||||
async fn generate_new() -> Result<RSAKeyPair, rsa::Error> {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
let private_key = RsaPrivateKey::new(&mut rng, RSA_BITS).expect("failed to generate a key");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
|
||||
let private = private_key.to_pkcs8_pem(LineEnding::LF)?;
|
||||
let public = public_key.to_public_key_pem(LineEnding::LF).unwrap(); // This is infaillible?
|
||||
|
||||
Ok(RSAKeyPair { private, public })
|
||||
}
|
||||
|
||||
async fn save_pem_files(
|
||||
&self,
|
||||
path: impl AsRef<Path>,
|
||||
private_name: &str,
|
||||
public_name: &str,
|
||||
) -> io::Result<()> {
|
||||
// There's probably a better looking way to do this
|
||||
let futures = vec![
|
||||
fs::write(path.as_ref().join(public_name), &self.public),
|
||||
fs::write(path.as_ref().join(private_name), &self.private),
|
||||
];
|
||||
|
||||
let stream = futures::stream::iter(futures).buffer_unordered(2);
|
||||
let results = stream.collect::<Vec<_>>().await;
|
||||
|
||||
for res in results {
|
||||
res?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_jwt<T>(&self, token: &str) -> Result<T, jsonwebtoken::errors::Error>
|
||||
where
|
||||
for<'de> T: Deserialize<'de>,
|
||||
{
|
||||
match decode::<T>(
|
||||
token,
|
||||
&DecodingKey::from_rsa_pem(self.public.as_bytes())?,
|
||||
&Validation::new(Algorithm::RS256),
|
||||
) {
|
||||
Ok(t) => Ok(t.claims),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode<T>(&self, claim: &T) -> Result<String, jsonwebtoken::errors::Error>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let secret = &EncodingKey::from_rsa_pem(self.private.as_bytes())?;
|
||||
encode(&Header::new(Algorithm::RS256), claim, secret)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use time::Duration;
|
||||
|
||||
use cookie::time::OffsetDateTime;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::crypto::RSAKeyPair;
|
||||
use crate::crypto::ValidationOutcome;
|
||||
|
||||
use super::jwt_numeric_date;
|
||||
use super::JWTSecretManager;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||
struct Claim {
|
||||
data: Vec<u8>,
|
||||
#[serde(with = "jwt_numeric_date")]
|
||||
exp: OffsetDateTime,
|
||||
}
|
||||
|
||||
fn create_random_claim_data() -> Vec<u8> {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
bytes.into()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_encode_and_decode_jwt_using_rsa() {
|
||||
let rsa_keys = RSAKeyPair::generate_new()
|
||||
.await
|
||||
.expect("It should be possible to generate a new RSA key pair");
|
||||
|
||||
let claim = Claim {
|
||||
data: create_random_claim_data(),
|
||||
exp: OffsetDateTime::now_utc() + Duration::days(1),
|
||||
};
|
||||
|
||||
let token = rsa_keys
|
||||
.encode(&claim)
|
||||
.await
|
||||
.expect("It should be possible to encode a claim");
|
||||
|
||||
let decoded_claim = rsa_keys
|
||||
.verify_jwt::<Claim>(&token)
|
||||
.await
|
||||
.expect("It should be possible to verify a token");
|
||||
|
||||
assert_eq!(decoded_claim.data, claim.data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jwt_secret_manager_can_encode_and_decode() {
|
||||
let jwt_secret_manager = JWTSecretManager::init()
|
||||
.await
|
||||
.expect("JWTSecretManager should be able to init");
|
||||
|
||||
let claim = Claim {
|
||||
data: create_random_claim_data(),
|
||||
exp: OffsetDateTime::now_utc() + Duration::days(1),
|
||||
};
|
||||
|
||||
let token = jwt_secret_manager
|
||||
.encode_new(&claim)
|
||||
.await
|
||||
.expect("It should be possible to encode a claim into a JWT");
|
||||
|
||||
match jwt_secret_manager.decode::<Claim>(&token).await {
|
||||
ValidationOutcome::Ok(c) => assert_eq!(c.data, claim.data),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jwt_secret_manager_can_rotate() {
|
||||
let mut jwt_secret_manager = JWTSecretManager::init()
|
||||
.await
|
||||
.expect("JWTSecretManager should be able to init");
|
||||
|
||||
jwt_secret_manager
|
||||
.rotate()
|
||||
.await
|
||||
.expect("JWTSecretManager should be able to rotate rsa keys");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
use axum::{extract::State, Extension, Json};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{error::AppError, model, AppState, UserJWTClaim};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
email: Option<String>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl From<model::user::Model> for UserProfile {
|
||||
fn from(value: model::user::Model) -> Self {
|
||||
UserProfile {
|
||||
email: value.email,
|
||||
name: value.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_authenticated() -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
Extension(token_data): Extension<UserJWTClaim>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<UserProfile>, AppError> {
|
||||
let db = &state.db;
|
||||
let user_opt = model::user::Entity::find()
|
||||
.filter(model::user::Column::Id.eq(token_data.user_id))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
return Ok(Json(UserProfile {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}));
|
||||
}
|
||||
|
||||
Err(AppError::NotFound(
|
||||
"Authenticated user does not exist".to_string(),
|
||||
))
|
||||
}
|
||||
Loading…
Reference in new issue