Compare commits
8 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
13687748e0 | 2 years ago |
|
|
38b3797304 | 2 years ago |
|
|
68e77ea0fa | 2 years ago |
|
|
30de1a9b53 | 2 years ago |
|
|
792b0d96ce | 2 years ago |
|
|
d7655c3ebe | 3 years ago |
|
|
0ba58d90e3 | 3 years ago |
|
|
569e3753f0 | 3 years ago |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,184 @@
|
|||||||
|
use crate::api::{
|
||||||
|
types::{
|
||||||
|
company::Company,
|
||||||
|
paginated_response::PaginatedResponse,
|
||||||
|
transaction::UserTransaction,
|
||||||
|
user::{FollowCompany, UserProfile},
|
||||||
|
},
|
||||||
|
FastInsidersApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(client)]
|
||||||
|
use super::user::set_token_cookie;
|
||||||
|
|
||||||
|
#[cfg(client)]
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(client)]
|
||||||
|
async fn post_auth_route<T>(route: &str, body: T) -> Result<Response, ()>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
{
|
||||||
|
let token = wasm_cookies::get_raw("token").unwrap_or_default();
|
||||||
|
let resp = reqwasm::http::Request::post(route)
|
||||||
|
.header("Authorization", &format!("Bearer {}", token))
|
||||||
|
.header("Content-type", "application/json")
|
||||||
|
.body(serde_json::to_string(&body).unwrap())
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow_company(&self, company_id: i32) -> Result<(), ()> {
|
||||||
|
let route = &format!("{}/auth/follow_company", self.url);
|
||||||
|
|
||||||
|
#[cfg(client)]
|
||||||
|
let res = {
|
||||||
|
let body = FollowCompany { company_id };
|
||||||
|
let resp = post_auth_route(route, body).await?;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(engine)]
|
||||||
|
return Err(());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfollow_company(&self, company_id: i32) -> Result<(), ()> {
|
||||||
|
let route = &format!("{}/auth/unfollow_company", self.url);
|
||||||
|
|
||||||
|
#[cfg(client)]
|
||||||
|
let res = {
|
||||||
|
let body = FollowCompany { company_id };
|
||||||
|
let resp = post_auth_route(route, body).await?;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(engine)]
|
||||||
|
return Err(());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_followed_companies(
|
||||||
|
&self,
|
||||||
|
page: i64,
|
||||||
|
size: i64,
|
||||||
|
) -> Result<PaginatedResponse<Company>, ()> {
|
||||||
|
let route = &format!(
|
||||||
|
"{}/auth/get_followed_companies?page={}&size={}",
|
||||||
|
self.url, page, size
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(client)]
|
||||||
|
let res = {
|
||||||
|
let resp = get_auth_route(route).await?;
|
||||||
|
resp.json::<PaginatedResponse<Company>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(engine)]
|
||||||
|
let res = reqwest::get(route)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?
|
||||||
|
.json::<PaginatedResponse<Company>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_transactions(
|
||||||
|
&self,
|
||||||
|
page: i64,
|
||||||
|
size: i64,
|
||||||
|
) -> Result<PaginatedResponse<UserTransaction>, ()> {
|
||||||
|
let route = &format!(
|
||||||
|
"{}/auth/user_transactions?page={}&size={}",
|
||||||
|
self.url, page, size
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(client)]
|
||||||
|
let res = {
|
||||||
|
let resp = get_auth_route(route).await?;
|
||||||
|
resp.json::<PaginatedResponse<UserTransaction>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(engine)]
|
||||||
|
let res = reqwest::get(route)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?
|
||||||
|
.json::<PaginatedResponse<UserTransaction>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,2 +1,4 @@
|
|||||||
|
pub mod authenticated;
|
||||||
pub mod company;
|
pub mod company;
|
||||||
pub mod transaction;
|
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 company;
|
||||||
pub mod paginated_response;
|
pub mod paginated_response;
|
||||||
pub mod transaction;
|
pub mod transaction;
|
||||||
|
pub mod user;
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct UserProfile {
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FollowCompany {
|
||||||
|
pub company_id: i32,
|
||||||
|
}
|
||||||
@ -1 +1,3 @@
|
|||||||
pub mod dark_mode_btn;
|
pub mod dark_mode_btn;
|
||||||
|
pub mod user_header;
|
||||||
|
pub mod user_icon;
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
use lazy_static::lazy_static;
|
||||||
|
use perseus::prelude::*;
|
||||||
|
use sycamore::{prelude::*, rt::Event};
|
||||||
|
|
||||||
|
use crate::global_state::AppStateRx;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref USER_HEADER: Capsule<PerseusNodeType, ()> = get_capsule();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_header<G: Html>(cx: Scope, _props: ()) -> View<G> {
|
||||||
|
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
(if *global_state.logged_in.get() {
|
||||||
|
view! { cx,
|
||||||
|
div (class="px-6 text-left border-l border-slate-700 dark:border-slate-300") {
|
||||||
|
a (id="header-followed-companies", href="/user_transactions", class="hover:underline") {
|
||||||
|
"Followed companies"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view!{cx, }
|
||||||
|
})
|
||||||
|
div (class="grow") {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback<G: Html>(cx: Scope, _props: ()) -> View<G> {
|
||||||
|
view! { cx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_capsule<G: Html>() -> Capsule<G, ()> {
|
||||||
|
Capsule::build(Template::build("user_header"))
|
||||||
|
.fallback(fallback)
|
||||||
|
.view(user_header)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
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 {
|
||||||
|
// Since logged in is set to false by default (on the first page load) we have to check
|
||||||
|
if *global_state.logged_in.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
navigate("/")
|
||||||
|
};
|
||||||
|
|
||||||
|
#[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,6 @@
|
|||||||
pub mod index;
|
pub mod index;
|
||||||
|
pub mod login;
|
||||||
|
pub mod profile;
|
||||||
|
pub mod register;
|
||||||
pub mod transactions;
|
pub mod transactions;
|
||||||
|
pub mod user_transactions;
|
||||||
|
|||||||
@ -0,0 +1,157 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use perseus::prelude::*;
|
||||||
|
use sycamore::{prelude::*, rt::Event};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
base_async_select::{AsyncSelectRx, BaseAsyncSelect},
|
||||||
|
base_button::{BaseButton, BaseButtonStateRx},
|
||||||
|
base_table::BaseTable,
|
||||||
|
loading::Loading,
|
||||||
|
main_content_container::MainContentContainer,
|
||||||
|
paginated_data_table::{PaginatedTable, PaginatedTableStateRx},
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let async_select_route_ref = create_ref(cx, |n, l| api_scope_ref.get_company_by_name(n, l));
|
||||||
|
let async_select_prop: AsyncSelectRx<_, _, _> = AsyncSelectRx {
|
||||||
|
route: async_select_route_ref,
|
||||||
|
selected_item: create_signal(cx, None),
|
||||||
|
clear: create_signal(cx, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let table_route_ref = create_ref(cx, move |_, p, s| {
|
||||||
|
api_scope_ref.get_followed_companies(p, s)
|
||||||
|
});
|
||||||
|
let paginated_table_state: PaginatedTableStateRx<_, _, _> = PaginatedTableStateRx {
|
||||||
|
record_label: "Companies".to_owned(),
|
||||||
|
route: table_route_ref,
|
||||||
|
filter: None,
|
||||||
|
table_class: create_ref(cx, "w-full".to_string()),
|
||||||
|
refresh: create_signal(cx, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let follow_button = BaseButtonStateRx {
|
||||||
|
label: create_signal(cx, "Follow".to_string()),
|
||||||
|
disabled: create_memo(cx, move || async_select_prop.selected_item.get().is_none()),
|
||||||
|
clicked: create_signal(cx, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
create_effect(cx, move || {
|
||||||
|
if *follow_button.clicked.get() && async_select_prop.selected_item.get_untracked().is_some()
|
||||||
|
{
|
||||||
|
follow_button.clicked.set(false);
|
||||||
|
if let Some(company) = (*async_select_prop.selected_item.get_untracked()).clone() {
|
||||||
|
spawn_local_scoped(cx, async move {
|
||||||
|
api_scope_ref.follow_company(company.id).await.unwrap();
|
||||||
|
paginated_table_state.refresh.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async_select_prop.clear.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
h1(class="text-lg text-center") {
|
||||||
|
"Profile page"
|
||||||
|
}
|
||||||
|
div(class="m-auto w-full max-w-2xl") {
|
||||||
|
(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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
h2(class="mt-3 font-bold") {
|
||||||
|
"Follow companies"
|
||||||
|
}
|
||||||
|
BaseAsyncSelect(async_select_prop)
|
||||||
|
BaseButton(follow_button)
|
||||||
|
PaginatedTable(paginated_table_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
use perseus::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
base_async_select::{AsyncSelectRx, BaseAsyncSelect},
|
||||||
|
base_button::{BaseButton, BaseButtonStateRx},
|
||||||
|
main_content_container::MainContentContainer,
|
||||||
|
paginated_data_table::{PaginatedTable, PaginatedTableStateRx},
|
||||||
|
the_header::TheHeader,
|
||||||
|
},
|
||||||
|
global_state::AppStateRx,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
|
||||||
|
#[rx(alias = "TransactionsPageStateRx")]
|
||||||
|
pub struct TransactionsPageState {
|
||||||
|
pub company_slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_transactions_page<'a, G: Html>(cx: Scope) -> 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);
|
||||||
|
|
||||||
|
let expand = create_signal(cx, false);
|
||||||
|
let filter_expand = BaseButtonStateRx {
|
||||||
|
label: create_signal(cx, "Filters".to_string()),
|
||||||
|
disabled: create_signal(cx, false),
|
||||||
|
clicked: create_signal(cx, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
create_effect(cx, move || {
|
||||||
|
if *filter_expand.clicked.get() {
|
||||||
|
filter_expand.clicked.set(false);
|
||||||
|
expand.set(!*expand.get());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let route_ref = create_ref(cx, move |_, p, s| api_scope_ref.get_user_transactions(p, s));
|
||||||
|
let paginated_table_state: PaginatedTableStateRx<_, _, _> = PaginatedTableStateRx {
|
||||||
|
record_label: "transactions".to_owned(),
|
||||||
|
route: route_ref,
|
||||||
|
filter: None,
|
||||||
|
table_class: create_ref(cx, "".to_string()),
|
||||||
|
refresh: create_signal(cx, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dark_mode_class = create_memo(cx, || {
|
||||||
|
if *global_state.dark_mode.get() {
|
||||||
|
"dark"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
a (class="hover:underline", href="/user_transactions") {
|
||||||
|
h1 (
|
||||||
|
class="text-lg text-center"
|
||||||
|
) {
|
||||||
|
"Latest transactions from your followed companies"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PaginatedTable(paginated_table_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_template<G: Html>() -> Template<G> {
|
||||||
|
Template::build("user_transactions")
|
||||||
|
.head(head)
|
||||||
|
.view(user_transactions_page)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[engine_only_fn]
|
||||||
|
fn head(cx: Scope) -> View<SsrNode> {
|
||||||
|
view! {cx,
|
||||||
|
title { "Fast Insiders" }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -1 +1,2 @@
|
|||||||
target/
|
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,43 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(User::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(User::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(User::Email).string().unique_key())
|
||||||
|
.col(ColumnDef::new(User::Name).string().not_null().unique_key())
|
||||||
|
.col(ColumnDef::new(User::Password).string().not_null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(User::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Iden)]
|
||||||
|
pub enum User {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
Email,
|
||||||
|
Name,
|
||||||
|
Password,
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
use crate::m20230112_115856_create_company_table as company;
|
||||||
|
use crate::m20230604_113236_user_table as user;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(UserCompany::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(ColumnDef::new(UserCompany::UserId).integer().not_null())
|
||||||
|
.col(ColumnDef::new(UserCompany::CompanyId).integer().not_null())
|
||||||
|
.primary_key(
|
||||||
|
Index::create()
|
||||||
|
.col(UserCompany::CompanyId)
|
||||||
|
.col(UserCompany::UserId),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("FK_user")
|
||||||
|
.from(UserCompany::Table, UserCompany::UserId)
|
||||||
|
.to(user::User::Table, user::User::Id)
|
||||||
|
.on_update(ForeignKeyAction::Cascade)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("FK_company")
|
||||||
|
.from(UserCompany::Table, UserCompany::CompanyId)
|
||||||
|
.to(company::Company::Table, company::Company::Id)
|
||||||
|
.on_update(ForeignKeyAction::Cascade)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(UserCompany::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Iden)]
|
||||||
|
enum UserCompany {
|
||||||
|
Table,
|
||||||
|
UserId,
|
||||||
|
CompanyId,
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,9 @@
|
|||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.0
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
|
||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
pub mod company;
|
pub mod company;
|
||||||
pub mod in_process_transaction;
|
pub mod in_process_transaction;
|
||||||
pub mod transaction;
|
pub mod transaction;
|
||||||
|
pub mod user;
|
||||||
|
pub mod user_company;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.0
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
|
||||||
|
|
||||||
pub use super::company::Entity as Company;
|
pub use super::company::Entity as Company;
|
||||||
pub use super::in_process_transaction::Entity as InProcessTransaction;
|
pub use super::in_process_transaction::Entity as InProcessTransaction;
|
||||||
pub use super::transaction::Entity as Transaction;
|
pub use super::transaction::Entity as Transaction;
|
||||||
|
pub use super::user::Entity as User;
|
||||||
|
pub use super::user_company::Entity as UserCompany;
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub name: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::user_company::Entity")]
|
||||||
|
UserCompany,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user_company::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::UserCompany.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::company::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
super::user_company::Relation::Company.def()
|
||||||
|
}
|
||||||
|
fn via() -> Option<RelationDef> {
|
||||||
|
Some(super::user_company::Relation::User.def().rev())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user_company")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i32,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub company_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::company::Entity",
|
||||||
|
from = "Column::CompanyId",
|
||||||
|
to = "super::company::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Company,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::company::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Company.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
pub mod company;
|
pub mod company;
|
||||||
pub mod in_process_transaction;
|
pub mod in_process_transaction;
|
||||||
pub mod transaction;
|
pub mod transaction;
|
||||||
|
pub mod user;
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
use crate::model::user::ActiveModel;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue, ConnectionTrait, DbErr, DeriveIntoActiveModel, EntityTrait,
|
||||||
|
IntoActiveModel,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::model;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, DeriveIntoActiveModel, Serialize, Deserialize)]
|
||||||
|
pub struct NewUser {
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewUser {
|
||||||
|
pub async fn create<C>(&self, db: &C) -> Result<model::user::Model, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
let res = self.clone().into_active_model().insert(db).await?;
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow_company<C>(db: &C, user_id: i32, company_id: i32) -> Result<(), DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
let relation = model::user_company::ActiveModel {
|
||||||
|
user_id: ActiveValue::Set(user_id),
|
||||||
|
company_id: ActiveValue::Set(company_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
relation.insert(db).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfollow_company<C>(db: &C, user_id: i32, company_id: i32) -> Result<(), DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
model::user_company::Entity::delete_by_id((user_id, company_id))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
Extension, Json,
|
||||||
|
};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use sea_orm::{
|
||||||
|
sea_query::Expr, ColumnTrait, EntityTrait, FromQueryResult, ItemsAndPagesNumber, JoinType,
|
||||||
|
ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::paginate::PaginatedResponse,
|
||||||
|
error::AppError,
|
||||||
|
model::{
|
||||||
|
self,
|
||||||
|
prelude::{Company, Transaction, User},
|
||||||
|
},
|
||||||
|
repo::user::{follow_company, unfollow_company},
|
||||||
|
AppState, UserJWTClaim,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::Pagination;
|
||||||
|
|
||||||
|
#[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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct FollowCompany {
|
||||||
|
company_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow_company_route(
|
||||||
|
Extension(token_data): Extension<UserJWTClaim>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<FollowCompany>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
follow_company(db, token_data.user_id, payload.company_id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfollow_company_route(
|
||||||
|
Extension(token_data): Extension<UserJWTClaim>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<FollowCompany>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
unfollow_company(db, token_data.user_id, payload.company_id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_followed_companies(
|
||||||
|
Extension(token_data): Extension<UserJWTClaim>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(Pagination { page, size }): Query<Pagination>,
|
||||||
|
) -> Result<Json<PaginatedResponse<model::company::Model>>, AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let s = size.unwrap_or(10);
|
||||||
|
let p = page.unwrap_or_default();
|
||||||
|
|
||||||
|
let user = User::find_by_id(token_data.user_id)
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound("User not found".to_string()))?;
|
||||||
|
|
||||||
|
// Eventually switch these 2 by using cloumn_as to make a count column
|
||||||
|
let (count_res, companies) = tokio::join!(user.find_related(Company).count(db), async {
|
||||||
|
user.find_related(Company)
|
||||||
|
.order_by(model::company::Column::Name, sea_orm::Order::Asc)
|
||||||
|
.offset(p * s)
|
||||||
|
.limit(s)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = count_res?;
|
||||||
|
|
||||||
|
let res = PaginatedResponse {
|
||||||
|
count,
|
||||||
|
num_pages: count / s + 1,
|
||||||
|
list: companies?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(res))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, FromQueryResult)]
|
||||||
|
pub struct UserTransaction {
|
||||||
|
pub company_name: String,
|
||||||
|
pub company_slug: String,
|
||||||
|
pub date_published: NaiveDate,
|
||||||
|
pub date_executed: NaiveDate,
|
||||||
|
pub person: String,
|
||||||
|
pub exchange: String,
|
||||||
|
pub nature: String,
|
||||||
|
pub isin: Option<String>,
|
||||||
|
pub instrument: String,
|
||||||
|
pub volume: i32,
|
||||||
|
pub unit_price: f32,
|
||||||
|
pub total: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_followed_companies_transactions(
|
||||||
|
Extension(token_data): Extension<UserJWTClaim>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(Pagination { page, size }): Query<Pagination>,
|
||||||
|
) -> Result<Json<PaginatedResponse<UserTransaction>>, AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let s = size.unwrap_or(20).min(50);
|
||||||
|
|
||||||
|
let query = Transaction::find()
|
||||||
|
.select_only()
|
||||||
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
model::transaction::Relation::Company.def(),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
model::company::Relation::UserCompany.def(),
|
||||||
|
)
|
||||||
|
.column_as(model::company::Column::Name, "company_name")
|
||||||
|
.column_as(model::company::Column::Slug, "company_slug")
|
||||||
|
.column(model::transaction::Column::DatePublished)
|
||||||
|
.column(model::transaction::Column::DateExecuted)
|
||||||
|
.column(model::transaction::Column::Person)
|
||||||
|
.column(model::transaction::Column::Exchange)
|
||||||
|
.column(model::transaction::Column::Nature)
|
||||||
|
.column(model::transaction::Column::Isin)
|
||||||
|
.column(model::transaction::Column::Instrument)
|
||||||
|
.column(model::transaction::Column::Volume)
|
||||||
|
.column(model::transaction::Column::UnitPrice)
|
||||||
|
.column_as(
|
||||||
|
Expr::col(model::transaction::Column::UnitPrice)
|
||||||
|
.mul(Expr::col(model::transaction::Column::Volume)),
|
||||||
|
"total",
|
||||||
|
)
|
||||||
|
.filter(model::user_company::Column::UserId.eq(token_data.user_id))
|
||||||
|
.order_by_desc(model::transaction::Column::DatePublished)
|
||||||
|
.into_model::<UserTransaction>()
|
||||||
|
.paginate(db, s);
|
||||||
|
|
||||||
|
let ItemsAndPagesNumber {
|
||||||
|
number_of_pages: num_pages,
|
||||||
|
number_of_items: count,
|
||||||
|
} = query.num_items_and_pages().await?;
|
||||||
|
|
||||||
|
let p = page.unwrap_or(0).min(num_pages);
|
||||||
|
let list = query.fetch_page(p).await?;
|
||||||
|
|
||||||
|
let res = PaginatedResponse {
|
||||||
|
count,
|
||||||
|
num_pages,
|
||||||
|
list,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(res))
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use rand::RngCore;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
use crate::UserJWTClaim;
|
||||||
|
use crate::{error::AppError, model, repo::user::NewUser, AppState};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserLoginBody {
|
||||||
|
pub name: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<UserLoginBody>,
|
||||||
|
) -> Result<Json<LoginResponse>, AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
let user_opt = model::user::Entity::find()
|
||||||
|
.filter(model::user::Column::Name.eq(payload.name))
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if user_opt.is_none() {
|
||||||
|
// To prevent timing attacks, we use the same verify function on a known password
|
||||||
|
argon2::verify_encoded("$argon2i$v=19$m=4096,t=3,p=1$CXr/AgSDawghR+GmOhM0wQ$4k2TCyoqkh/YaK9mh6uEa0eRZ/CIx3bfzJs5UnCcKjw", b"1234").unwrap();
|
||||||
|
return Err(AppError::NotFound(
|
||||||
|
"User does not exist. Consider registering.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = user_opt.unwrap();
|
||||||
|
|
||||||
|
let valid =
|
||||||
|
argon2::verify_encoded(&user.password, payload.password.as_bytes()).map_err(|e| {
|
||||||
|
error!("Error verifying the password for user {}: {}", user.name, e);
|
||||||
|
AppError::InternalServerError(
|
||||||
|
"There was an error verifying authentication.".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a JWT and store it as a same site cookie
|
||||||
|
let claim = UserJWTClaim {
|
||||||
|
user_id: user.id,
|
||||||
|
username: user.name.clone(),
|
||||||
|
exp: OffsetDateTime::now_utc() + time::Duration::days(5),
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = state
|
||||||
|
.jwt_secret_manager
|
||||||
|
.encode_new(&claim)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Failed to encode a new JWT for user {}: {}", user.name, e);
|
||||||
|
AppError::InternalServerError(
|
||||||
|
"There was an error while encoding the authorization token".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(LoginResponse { token }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserRegisterBody {
|
||||||
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<UserRegisterBody>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
let mut filter = model::user::Column::Name.eq(&payload.name);
|
||||||
|
let mut email = Some(payload.email.to_string());
|
||||||
|
if !payload.email.is_empty() {
|
||||||
|
filter = filter.or(model::user::Column::Email.eq(&payload.email));
|
||||||
|
} else {
|
||||||
|
email = None;
|
||||||
|
}
|
||||||
|
let user_opt = model::user::Entity::find().filter(filter).one(db).await?;
|
||||||
|
|
||||||
|
if user_opt.is_some() {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"The username or email is already in use.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = generate_salt();
|
||||||
|
let pass_hash =
|
||||||
|
argon2::hash_encoded(payload.password.as_ref(), &salt, &argon2::Config::default())
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Failed to hash a password: {}", e);
|
||||||
|
AppError::InternalServerError(
|
||||||
|
"There was an error in the registration process".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let new_user = NewUser {
|
||||||
|
email,
|
||||||
|
name: payload.name,
|
||||||
|
password: pass_hash,
|
||||||
|
};
|
||||||
|
|
||||||
|
new_user.create(db).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_salt() -> Vec<u8> {
|
||||||
|
let mut salt = [0u8; 16]; // 16 bytes salt length (adjust as needed)
|
||||||
|
rand::thread_rng().fill_bytes(&mut salt);
|
||||||
|
|
||||||
|
salt.into()
|
||||||
|
}
|
||||||
Loading…
Reference in new issue