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 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,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 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 index;
|
||||||
|
pub mod login;
|
||||||
|
pub mod profile;
|
||||||
|
pub mod register;
|
||||||
pub mod transactions;
|
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/
|
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