Initial Commit
This commit is contained in:
45
src/lib/config.rs
Normal file
45
src/lib/config.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use std::env;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
const DATABASE_URL_KEY: &str = "DATABASE_URL";
|
||||
const SMTP_HOST: &str = "SMTP_HOST";
|
||||
const SMTP_PORT: &str = "SMTP_PORT";
|
||||
const SMTP_USERNAME: &str = "SMTP_USERNAME";
|
||||
const SMTP_PASSWORD: &str = "SMTP_PASSWORD";
|
||||
const SMTP_SENDER: &str = "SMTP_SENDER";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
|
||||
pub smtp_host: String,
|
||||
pub smtp_port: u16,
|
||||
pub smtp_username: String,
|
||||
pub smtp_password: String,
|
||||
pub smtp_sender: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Config> {
|
||||
let database_url = load_env(DATABASE_URL_KEY)?;
|
||||
let smtp_host = load_env(SMTP_HOST)?;
|
||||
let smtp_port = load_env(SMTP_PORT)?.parse()?;
|
||||
let smtp_username = load_env(SMTP_USERNAME)?;
|
||||
let smtp_password = load_env(SMTP_PASSWORD)?;
|
||||
let smtp_sender = load_env(SMTP_SENDER)?;
|
||||
|
||||
Ok(Config {
|
||||
database_url,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
smtp_username,
|
||||
smtp_password,
|
||||
smtp_sender,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_env(key: &str) -> anyhow::Result<String> {
|
||||
env::var(key).with_context(|| format!("failed to load environment variable {}", key))
|
||||
}
|
38
src/lib/domain/api/mod.rs
Normal file
38
src/lib/domain/api/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
pub mod models;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod ports;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod service;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub use service::Service;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod prelude {
|
||||
use super::super::super::outbound::dangerous_lettre::DangerousLettre;
|
||||
use super::super::super::outbound::postgres::Postgres;
|
||||
|
||||
// This is too damn tightly coupled to pgsql and lettre now
|
||||
// But so far, this is the only thing that actually works
|
||||
pub type AppService = std::sync::Arc<Service<Postgres, DangerousLettre>>;
|
||||
|
||||
pub use super::models::user::*;
|
||||
pub use super::ports::*;
|
||||
pub use super::service::*;
|
||||
|
||||
pub use crate::domain::leptos::flashbag::Alert;
|
||||
pub use crate::domain::leptos::flashbag::Flash;
|
||||
pub use crate::domain::leptos::flashbag::FlashBag;
|
||||
pub use crate::domain::leptos::flashbag::FlashMessage;
|
||||
|
||||
pub use axum_session::SessionAnySession as Session;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub mod prelude {
|
||||
pub use super::models::user::*;
|
||||
pub use crate::domain::leptos::flashbag::Alert;
|
||||
pub use crate::domain::leptos::flashbag::Flash;
|
||||
}
|
1
src/lib/domain/api/models/mod.rs
Normal file
1
src/lib/domain/api/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod user;
|
453
src/lib/domain/api/models/user.rs
Normal file
453
src/lib/domain/api/models/user.rs
Normal file
@@ -0,0 +1,453 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
use anyhow::anyhow;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, SaltString},
|
||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
};
|
||||
|
||||
use derive_more::{derive::Display, From};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use serde::{ser::SerializeStruct, Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// A uniquely identifiable blog of blog blog.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
|
||||
pub struct User {
|
||||
id: uuid::Uuid,
|
||||
email: EmailAddress,
|
||||
#[cfg(feature = "ssr")]
|
||||
password: Password,
|
||||
verified: Verified,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(
|
||||
id: uuid::Uuid,
|
||||
email: EmailAddress,
|
||||
#[cfg(feature = "ssr")] password: Password,
|
||||
verified: Verified,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
email,
|
||||
#[cfg(feature = "ssr")]
|
||||
password,
|
||||
verified,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &uuid::Uuid {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &EmailAddress {
|
||||
&self.email
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn password(&self) -> &Password {
|
||||
&self.password
|
||||
}
|
||||
|
||||
pub fn verified(&self) -> &Verified {
|
||||
&self.verified
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for User {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("User", 3)?;
|
||||
state.serialize_field("id", &self.id())?;
|
||||
state.serialize_field("email", &self.email())?;
|
||||
state.serialize_field("verified", &self.verified())?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
|
||||
#[serde(from = "String")]
|
||||
pub struct ActivationToken(String);
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<String> for ActivationToken {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<&str> for ActivationToken {
|
||||
fn from(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl ActivationToken {
|
||||
pub fn new() -> Self {
|
||||
let token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(11)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
Self(token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
|
||||
#[serde(from = "String")]
|
||||
pub struct PasswordResetToken(String);
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<String> for PasswordResetToken {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<&str> for PasswordResetToken {
|
||||
fn from(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl PasswordResetToken {
|
||||
pub fn new() -> Self {
|
||||
let token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(24)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
Self(token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ResetPasswordError {
|
||||
#[error("The reset token is no longer valid.")]
|
||||
NotFound,
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
// to be extended as new error scenarios are introduced
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(from = "bool")]
|
||||
pub struct Verified(bool);
|
||||
|
||||
impl From<bool> for Verified {
|
||||
fn from(value: bool) -> Self {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Verified {
|
||||
pub fn new(value: bool) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub fn is_verified(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Verified> for bool {
|
||||
fn from(value: Verified) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String")]
|
||||
/// A valid email address.
|
||||
pub struct EmailAddress(String);
|
||||
|
||||
impl TryFrom<String> for EmailAddress {
|
||||
type Error = EmailAddressError;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(&value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for EmailAddress {
|
||||
type Error = EmailAddressError;
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error)]
|
||||
#[error("{invalid_email} is not a valid email address")]
|
||||
pub struct EmailAddressError {
|
||||
pub invalid_email: String,
|
||||
}
|
||||
|
||||
impl From<EmailAddressError> for String {
|
||||
fn from(value: EmailAddressError) -> Self {
|
||||
format!("{}", value)
|
||||
}
|
||||
}
|
||||
|
||||
impl EmailAddress {
|
||||
pub fn new(raw: &str) -> Result<Self, EmailAddressError> {
|
||||
let trimmed = raw.trim();
|
||||
Self::validate_email_address(trimmed)?;
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn validate_email_address(email: &str) -> Result<(), EmailAddressError> {
|
||||
if !email.contains('@') {
|
||||
return Err(EmailAddressError {
|
||||
invalid_email: email.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if email.contains(' ') {
|
||||
return Err(EmailAddressError {
|
||||
invalid_email: email.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if email.len() < 3 {
|
||||
// a@b is technically valid, a@ or @b is not
|
||||
return Err(EmailAddressError {
|
||||
invalid_email: email.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
use validator::ValidateEmail;
|
||||
if !email.validate_email() {
|
||||
return Err(EmailAddressError {
|
||||
invalid_email: email.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
|
||||
#[serde(try_from = "String")]
|
||||
/// A valid password.
|
||||
pub struct Password {
|
||||
hashed_password: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl TryFrom<String> for Password {
|
||||
type Error = PasswordError;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(&value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl TryFrom<&str> for Password {
|
||||
type Error = PasswordError;
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl Password {
|
||||
pub fn new(password: &str) -> Result<Self, PasswordError> {
|
||||
// password restrictions
|
||||
if password.len() < 6 {
|
||||
return Err(PasswordError::TooShort);
|
||||
}
|
||||
|
||||
// Do we need to protect people from being stupid?
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hashed_password = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| PasswordError::Unknown(anyhow!(e)))?
|
||||
.to_string();
|
||||
|
||||
Ok(Self { hashed_password })
|
||||
}
|
||||
|
||||
pub fn new_hashed(hashed_password: &str) -> Self {
|
||||
Self {
|
||||
hashed_password: hashed_password.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify(&self, password: &str) -> Result<(), anyhow::Error> {
|
||||
let hash = PasswordHash::new(&self.hashed_password).map_err(|e| anyhow!(e))?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &hash)
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PasswordError {
|
||||
#[error("Password must be at least 6 characters long.")]
|
||||
TooShort,
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
// to be extended as new error scenarios are introduced
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// The fields required by the domain to create a [User].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, From)]
|
||||
pub struct CreateUserRequest {
|
||||
email: EmailAddress,
|
||||
password: Password,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl CreateUserRequest {
|
||||
pub fn new(email: EmailAddress, password: Password) -> Self {
|
||||
Self { email, password }
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &EmailAddress {
|
||||
&self.email
|
||||
}
|
||||
|
||||
pub fn password(&self) -> &Password {
|
||||
&self.password
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CreateUserError {
|
||||
#[error("User with email {email} already exists")]
|
||||
Duplicate { email: EmailAddress },
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
// to be extended as new error scenarios are introduced
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// The fields required by the domain to create a [User].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, From)]
|
||||
pub struct UserLoginRequest {
|
||||
email: EmailAddress,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl UserLoginRequest {
|
||||
pub fn new(email: EmailAddress, password: String) -> Self {
|
||||
Self { email, password }
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &EmailAddress {
|
||||
&self.email
|
||||
}
|
||||
|
||||
pub fn password(&self) -> &str {
|
||||
&self.password
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub struct UpdateUserRequest {
|
||||
email: Option<EmailAddress>,
|
||||
password: Option<Password>,
|
||||
verified: Option<Verified>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl UpdateUserRequest {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
email: None,
|
||||
password: None,
|
||||
verified: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn email(&self) -> Option<&EmailAddress> {
|
||||
self.email.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_email(mut self, email: EmailAddress) -> Self {
|
||||
self.email = Some(email);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_password(mut self, password: Password) -> Self {
|
||||
self.password = Some(password);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn password(&self) -> Option<&Password> {
|
||||
self.password.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_verified(mut self, verified: Verified) -> Self {
|
||||
self.verified = Some(verified);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn verified(&self) -> Option<&Verified> {
|
||||
self.verified.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UpdateUserError {
|
||||
#[error("User with email {email} already exists")]
|
||||
Duplicate { email: EmailAddress },
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
// to be extended as new error scenarios are introduced
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DeleteUserError {
|
||||
#[error("User with id {id} not found")]
|
||||
NotFound { id: uuid::Uuid },
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
// to be extended as new error scenarios are introduced
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ActivateUserError {
|
||||
#[error("Token {token} is invalid")]
|
||||
NotFound { token: ActivationToken },
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
// to be extended as new error scenarios are introduced
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UserLoginError {
|
||||
#[error("User with email {email} not found")]
|
||||
NotFound { email: EmailAddress },
|
||||
#[error("User with email {email} not verified")]
|
||||
NotActive { email: EmailAddress },
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
// to be extended as new error scenarios are introduced
|
||||
}
|
133
src/lib/domain/api/ports.rs
Normal file
133
src/lib/domain/api/ports.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
Module `ports` specifies the API by which external modules interact with the user domain.
|
||||
|
||||
All traits are bounded by `Send + Sync + 'static`, since their implementations must be shareable
|
||||
between request-handling threads.
|
||||
|
||||
Trait methods are explicitly asynchronous, including `Send` bounds on response types,
|
||||
since the application is expected to always run in a multithreaded environment.
|
||||
*/
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use super::models::user::*;
|
||||
|
||||
/// `ApiService` is the public API for the user domain.
|
||||
///
|
||||
/// External modules must conform to this contract – the domain is not concerned with the
|
||||
/// implementation details or underlying technology of any external code.
|
||||
pub trait ApiService: Clone + Send + Sync + 'static {
|
||||
/// Asynchronously create a new [User].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [CreateUserError::Duplicate] if an [User] with the same [EmailAddress] already exists.
|
||||
fn create_user(
|
||||
&self,
|
||||
req: CreateUserRequest,
|
||||
) -> impl Future<Output = Result<User, CreateUserError>> + Send;
|
||||
|
||||
// fn activate_user
|
||||
|
||||
fn get_user_session(
|
||||
&self,
|
||||
session: &axum_session::SessionAnySession, // TODO: Get rid of this and make cleaner
|
||||
) -> impl Future<Output = Option<User>> + Send;
|
||||
|
||||
fn activate_user_account(
|
||||
&self,
|
||||
token: ActivationToken,
|
||||
) -> impl Future<Output = Result<User, ActivateUserError>> + Send;
|
||||
|
||||
fn user_login(
|
||||
&self,
|
||||
req: UserLoginRequest,
|
||||
) -> impl Future<Output = Result<User, UserLoginError>> + Send;
|
||||
|
||||
fn forgot_password(&self, email: &EmailAddress) -> impl Future<Output = ()> + Send;
|
||||
|
||||
fn reset_password(
|
||||
&self,
|
||||
token: &PasswordResetToken,
|
||||
password: &Password,
|
||||
) -> impl Future<Output = Result<User, ResetPasswordError>> + Send;
|
||||
|
||||
fn find_user_by_password_reset_token(
|
||||
&self,
|
||||
token: &PasswordResetToken,
|
||||
) -> impl Future<Output = Option<User>> + Send;
|
||||
|
||||
// These shouldnt be here, _why_ are they here, and implement that here instead
|
||||
// fn find_user_by_email(self, email: EmailAddress) -> impl Future<Output = Option<User>> + Send;
|
||||
// fn find_user_by_id(&self, user_id: uuid::Uuid) -> impl Future<Output = Option<User>> + Send;
|
||||
}
|
||||
|
||||
pub trait UserRepository: Clone + Send + Sync + 'static {
|
||||
// Create
|
||||
fn create_user(
|
||||
&self,
|
||||
req: CreateUserRequest,
|
||||
) -> impl Future<Output = Result<User, CreateUserError>> + Send;
|
||||
|
||||
fn create_activation_token(
|
||||
&self,
|
||||
ent: &User,
|
||||
) -> impl Future<Output = Result<ActivationToken, anyhow::Error>> + Send;
|
||||
|
||||
fn create_password_reset_token(
|
||||
&self,
|
||||
ent: &User,
|
||||
) -> impl Future<Output = Result<PasswordResetToken, anyhow::Error>> + Send;
|
||||
|
||||
// Read
|
||||
fn all_users(&self) -> impl Future<Output = Vec<User>> + Send;
|
||||
|
||||
fn find_user_by_id(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
|
||||
|
||||
fn find_user_by_email(
|
||||
&self,
|
||||
email: &EmailAddress,
|
||||
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
|
||||
|
||||
fn find_user_by_activation_token(
|
||||
&self,
|
||||
token: &ActivationToken,
|
||||
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
|
||||
|
||||
fn find_user_by_password_reset_token(
|
||||
&self,
|
||||
token: &PasswordResetToken,
|
||||
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
|
||||
|
||||
// // Update
|
||||
fn update_user(
|
||||
&self,
|
||||
ent: &User,
|
||||
req: UpdateUserRequest,
|
||||
) -> impl Future<Output = Result<(User, User), UpdateUserError>> + Send;
|
||||
|
||||
// Delete
|
||||
// fn delete_user(&self, ent: User) -> impl Future<Output = Result<User, DeleteUserError>> + Send;
|
||||
fn delete_activation_token_for_user(
|
||||
&self,
|
||||
ent: &User,
|
||||
) -> impl Future<Output = Result<(), anyhow::Error>> + Send;
|
||||
|
||||
fn delete_password_reset_tokens_for_user(
|
||||
&self,
|
||||
ent: &User,
|
||||
) -> impl Future<Output = Result<(), anyhow::Error>> + Send;
|
||||
}
|
||||
|
||||
pub trait UserNotifier: Clone + Send + Sync + 'static {
|
||||
fn user_created(&self, user: &User, token: &ActivationToken)
|
||||
-> impl Future<Output = ()> + Send;
|
||||
fn forgot_password(
|
||||
&self,
|
||||
user: &User,
|
||||
token: &PasswordResetToken,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
193
src/lib/domain/api/service.rs
Normal file
193
src/lib/domain/api/service.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
/*!
|
||||
Module `service` provides the canonical implementation of the [ApiService] port. All
|
||||
user-domain logic is defined here.
|
||||
*/
|
||||
use axum_session::SessionAnySession;
|
||||
|
||||
use super::models::user::*;
|
||||
|
||||
use super::ports::{ApiService, UserNotifier, UserRepository};
|
||||
|
||||
pub trait Repository = UserRepository;
|
||||
pub trait Email = UserNotifier;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Service<R, N>
|
||||
where
|
||||
R: Repository,
|
||||
N: Email,
|
||||
{
|
||||
repo: R,
|
||||
notifier: N,
|
||||
}
|
||||
|
||||
impl<R, N> Service<R, N>
|
||||
where
|
||||
R: Repository,
|
||||
N: Email,
|
||||
{
|
||||
pub fn new(repo: R, notifier: N) -> Self {
|
||||
Self { repo, notifier }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R, N> ApiService for Service<R, N>
|
||||
where
|
||||
R: UserRepository,
|
||||
N: Email,
|
||||
{
|
||||
async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError> {
|
||||
let result = self.repo.create_user(req).await;
|
||||
|
||||
if result.is_err() {
|
||||
// something went wrong, log the error
|
||||
// but keep passing on the result to the requester (http server)
|
||||
return result;
|
||||
}
|
||||
|
||||
let user = result.as_ref().unwrap();
|
||||
|
||||
let token = self.repo.create_activation_token(user).await?;
|
||||
|
||||
// generate a activation token and send an email
|
||||
self.notifier.user_created(user, &token).await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn get_user_session(&self, session: &SessionAnySession) -> Option<User> {
|
||||
let Some(user_id) = session.get("user") else {
|
||||
return None;
|
||||
};
|
||||
|
||||
self.repo.find_user_by_id(user_id).await.unwrap_or(None)
|
||||
}
|
||||
|
||||
async fn activate_user_account(
|
||||
&self,
|
||||
token: ActivationToken,
|
||||
) -> Result<User, ActivateUserError> {
|
||||
let user = match self.repo.find_user_by_activation_token(&token).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return Err(ActivateUserError::Unknown(e.into())),
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
return Err(ActivateUserError::NotFound { token });
|
||||
};
|
||||
|
||||
let req = UpdateUserRequest::new().set_verified(Verified::new(true));
|
||||
|
||||
let update = self.repo.update_user(&user, req).await;
|
||||
|
||||
if let Err(e) = update {
|
||||
return Err(ActivateUserError::Unknown(e.into()));
|
||||
}
|
||||
|
||||
self.repo.delete_activation_token_for_user(&user).await?;
|
||||
|
||||
let (_, user) = update.unwrap();
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn user_login(&self, req: UserLoginRequest) -> Result<User, UserLoginError> {
|
||||
let email = req.email();
|
||||
|
||||
let user = match self.repo.find_user_by_email(email).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::error!("{:#?}", e);
|
||||
return Err(UserLoginError::Unknown(e.into()));
|
||||
}
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
tracing::warn!("User not found");
|
||||
return Err(UserLoginError::NotFound {
|
||||
email: email.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
if !user.verified().is_verified() {
|
||||
return Err(UserLoginError::NotActive {
|
||||
email: email.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(e) = user.password().verify(req.password()) {
|
||||
tracing::warn!("{:#?}", e);
|
||||
return Err(UserLoginError::NotFound {
|
||||
email: email.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate any reset tokens that might exist
|
||||
let _ = self.repo.delete_password_reset_tokens_for_user(&user).await;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn forgot_password(&self, email: &EmailAddress) {
|
||||
let user = match self.repo.find_user_by_email(email).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::error!("{:#?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
tracing::warn!("User not found");
|
||||
return;
|
||||
};
|
||||
|
||||
let token = match self.repo.create_password_reset_token(&user).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("{:#?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.notifier.forgot_password(&user, &token).await;
|
||||
}
|
||||
|
||||
async fn reset_password(
|
||||
&self,
|
||||
token: &PasswordResetToken,
|
||||
password: &Password,
|
||||
) -> Result<User, ResetPasswordError> {
|
||||
let user = match self.repo.find_user_by_password_reset_token(token).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::error!("{:#?}", e);
|
||||
return Err(ResetPasswordError::Unknown(e.into()));
|
||||
}
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
tracing::warn!("User not found");
|
||||
return Err(ResetPasswordError::NotFound);
|
||||
};
|
||||
|
||||
let req = UpdateUserRequest::new().set_password(password.clone());
|
||||
|
||||
self.repo
|
||||
.update_user(&user, req)
|
||||
.await
|
||||
.map_err(|e| ResetPasswordError::Unknown(e.into()))?;
|
||||
|
||||
// Invalidate any reset tokens that might exist
|
||||
let _ = self.repo.delete_password_reset_tokens_for_user(&user).await;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn find_user_by_password_reset_token(&self, token: &PasswordResetToken) -> Option<User> {
|
||||
self.repo
|
||||
.find_user_by_password_reset_token(token)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
}
|
||||
}
|
89
src/lib/domain/leptos/app.rs
Normal file
89
src/lib/domain/leptos/app.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
mod components;
|
||||
mod pages;
|
||||
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use pages::{
|
||||
auth::{
|
||||
forgot::ForgotPage, login::LoginPage, logout::LogoutPage, register::RegisterPage,
|
||||
reset::ResetPage,
|
||||
},
|
||||
dashboard::DashboardPage,
|
||||
error::{AppError, ErrorTemplate},
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
let trigger_user = create_rw_signal(true);
|
||||
|
||||
let user = create_resource(trigger_user, move |_| async move {
|
||||
super::check_user().await.unwrap()
|
||||
});
|
||||
|
||||
view! {
|
||||
// https://fontawesome.com/v5/search
|
||||
<Stylesheet id="font-awesome" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css"/>
|
||||
<Stylesheet id="avam" href="/pkg/avam.css"/>
|
||||
|
||||
<Meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
|
||||
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/>
|
||||
<Link rel="icon" type_="image/png" sizes="32x32" href="/favicon-32x32.png"/>
|
||||
<Link rel="icon" type_="image/png" sizes="16x16" href="/favicon-16x16.png"/>
|
||||
<Link rel="manifest" href="/site.webmanifest"/>
|
||||
|
||||
<Title text={ crate::PROJECT_NAME }/>
|
||||
|
||||
<Router fallback=|| {
|
||||
let mut outside_errors = Errors::default();
|
||||
outside_errors.insert_with_default_key(AppError::NotFound);
|
||||
view! {
|
||||
<ErrorTemplate outside_errors/>
|
||||
}
|
||||
.into_view()
|
||||
}>
|
||||
<main class="h-screen overflow-auto dark:base-100 dark:text-white">
|
||||
<Routes>
|
||||
<Route path="/auth" view=move || {
|
||||
view! {
|
||||
<Suspense>
|
||||
<Show when=move || user().is_some_and(|u| u.is_some())>
|
||||
<Redirect path="/" />
|
||||
</Show>
|
||||
</Suspense>
|
||||
<Outlet />
|
||||
}
|
||||
}>
|
||||
<Route path="login" view=move || view! { <LoginPage user_signal=trigger_user /> } />
|
||||
<Route path="register" view=RegisterPage />
|
||||
<Route path="forgot" view=ForgotPage />
|
||||
<Route path="reset/:token" view=ResetPage />
|
||||
</Route> // auth
|
||||
|
||||
<Route path="" view=move || {
|
||||
view! {
|
||||
<Suspense>
|
||||
<Show when=move || user().is_some_and(|u| u.is_none())>
|
||||
<Redirect path="/auth/login" />
|
||||
</Show>
|
||||
</Suspense>
|
||||
<Outlet />
|
||||
}
|
||||
}>
|
||||
<Route path="" view=move || view! {
|
||||
<Suspense>
|
||||
<Show when=move || user().is_some_and(|u| u.is_some())>
|
||||
<DashboardPage user={ user().unwrap().unwrap() } />
|
||||
</Show>
|
||||
</Suspense>
|
||||
} />
|
||||
</Route> // dashboard
|
||||
<Route path="/auth/logout" view=move || view! { <LogoutPage user_signal=trigger_user /> } />
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
42
src/lib/domain/leptos/app/components/alert.rs
Normal file
42
src/lib/domain/leptos/app/components/alert.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use crate::domain::api::prelude::Alert;
|
||||
use leptos::*;
|
||||
|
||||
#[component]
|
||||
pub fn Alert(alert: Alert, children: Children) -> impl IntoView {
|
||||
match alert {
|
||||
Alert::None => view! {
|
||||
<div role="alert" class="alert my-2">
|
||||
<span>{ children() }</span>
|
||||
</div>
|
||||
}
|
||||
.into_view(),
|
||||
Alert::Info => view! {
|
||||
<div role="alert" class="alert alert-info my-2">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>{ children() }</span>
|
||||
</div>
|
||||
}
|
||||
.into_view(),
|
||||
Alert::Success => view! {
|
||||
<div role="alert" class="alert alert-success my-2">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span>{ children() }</span>
|
||||
</div>
|
||||
}
|
||||
.into_view(),
|
||||
Alert::Warning => view! {
|
||||
<div role="alert" class="alert alert-warning my-2">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span>{ children() }</span>
|
||||
</div>
|
||||
}
|
||||
.into_view(),
|
||||
Alert::Error => view! {
|
||||
<div role="alert" class="alert alert-error my-2">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>{ children() }</span>
|
||||
</div>
|
||||
}
|
||||
.into_view(),
|
||||
}
|
||||
}
|
1
src/lib/domain/leptos/app/components/mod.rs
Normal file
1
src/lib/domain/leptos/app/components/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod alert;
|
36
src/lib/domain/leptos/app/pages/auth/auth_base.rs
Normal file
36
src/lib/domain/leptos/app/pages/auth/auth_base.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use leptos::*;
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn AuthBase(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div class="h-full flex flex-row">
|
||||
|
||||
<div class="w-full md:basis-1/3 2xl:basis-1/4 flex h-full flex-col px-4 py-16">
|
||||
<div class="flex h-full grow-0 flex-col px-4 py-16">
|
||||
|
||||
<div class="mt-auto">
|
||||
|
||||
<img class="mx-auto" src="/android-chrome-192x192.png" alt={ crate::PROJECT_NAME }/>
|
||||
|
||||
{ children() }
|
||||
</div>
|
||||
|
||||
<div class="mt-auto text-center text-sm text-gray-500">
|
||||
<p>{ crate::COPYRIGHT }</p>
|
||||
<p class="">
|
||||
<span class="px-1"><a href="https://git.avii.nl/AVAM/avam" class="link" target="_BLANK"><i class="fab fa-git-alt"></i></a></span>
|
||||
<span class="px-1"><a href="#" class="link" target="_BLANK"><i class="fab fa-discord"></i></a></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:basis-2/3 2xl:basis-3/4 bg-cover bg-center bg-auth"></div>
|
||||
|
||||
</div>
|
||||
|
||||
}
|
||||
.into_view()
|
||||
}
|
81
src/lib/domain/leptos/app/pages/auth/forgot.rs
Normal file
81
src/lib/domain/leptos/app/pages/auth/forgot.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use super::auth_base::AuthBase;
|
||||
|
||||
#[server]
|
||||
async fn forgot_action(email: String) -> Result<(), ServerFnError<String>> {
|
||||
use crate::domain::api::prelude::*;
|
||||
|
||||
let email = EmailAddress::new(&email).map_err(|_| format!("\"{}\" is not a email address", email))?;
|
||||
|
||||
let app = use_context::<AppService>().unwrap();
|
||||
let flashbag = use_context::<FlashBag>().unwrap();
|
||||
|
||||
// If the email address is known, we'll set a recovery link, if it's not, we wont, but the flash remains the same.
|
||||
app.forgot_password(&email).await;
|
||||
|
||||
let flash = FlashMessage::new("login",
|
||||
format!(
|
||||
"An e-mail has been sent to {} with a link to reset your password.",
|
||||
email
|
||||
)).with_alert(Alert::Success);
|
||||
|
||||
flashbag.set(flash);
|
||||
|
||||
leptos_axum::redirect("/auth/login");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn ForgotPage() -> impl IntoView {
|
||||
let submit = Action::<ForgotAction, _>::server();
|
||||
let response = submit.value().read_only();
|
||||
|
||||
view! {
|
||||
<AuthBase>
|
||||
|
||||
<Suspense>
|
||||
<Show when=move || response.get().is_some_and(|e| e.is_err())>
|
||||
<div role="alert" class="alert alert-error my-2">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>
|
||||
{ move || if let Some(Err(e)) = response.get() {
|
||||
{format!("{}", e)}.into_view()
|
||||
} else {
|
||||
().into_view()
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<ActionForm action=submit class="w-full">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<input type="text" placeholder="E-mail" name="email" class="grow" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<input type="submit" value="Request Password Reset" class="btn btn-primary btn-block" />
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
"New Account? Sign up "<a href="/auth/register" class="link">"here"</a>"!"
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
"Remembered your password? Login "<a href="/auth/login" class="link">"here"</a>"!"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ActionForm>
|
||||
|
||||
</AuthBase>
|
||||
|
||||
}
|
||||
.into_view()
|
||||
}
|
106
src/lib/domain/leptos/app/pages/auth/login.rs
Normal file
106
src/lib/domain/leptos/app/pages/auth/login.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use crate::domain::leptos::app::components::alert::Alert;
|
||||
|
||||
use super::auth_base::AuthBase;
|
||||
|
||||
#[server]
|
||||
async fn login_action(email: String, password: String) -> Result<(), ServerFnError<String>> {
|
||||
use crate::domain::api::prelude::*;
|
||||
|
||||
let app = use_context::<AppService>().unwrap();
|
||||
let session = use_context::<Session>().unwrap();
|
||||
|
||||
let email = email.try_into().map_err(|e| format!("{}", e))?;
|
||||
|
||||
let user = match app.user_login(UserLoginRequest::new(email, password)).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Err(ServerFnError::WrappedServerError("Username or password incorrect".into())),
|
||||
};
|
||||
|
||||
session.set("user", user.id());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn LoginPage(user_signal: RwSignal<bool>) -> impl IntoView {
|
||||
let submit = Action::<LoginAction, _>::server();
|
||||
let response = submit.value().read_only();
|
||||
|
||||
create_effect(move |_| {
|
||||
if response.get().is_some() {
|
||||
user_signal.set(!user_signal.get_untracked());
|
||||
}
|
||||
});
|
||||
|
||||
let flash = create_resource(
|
||||
|| (),
|
||||
move |_| async move {
|
||||
use crate::domain::leptos::get_flash;
|
||||
|
||||
get_flash("login".to_string()).await.unwrap()
|
||||
},
|
||||
);
|
||||
|
||||
view! {
|
||||
<AuthBase>
|
||||
|
||||
<Suspense>
|
||||
<Show when=move || response.get().is_some_and(|e| e.is_err())>
|
||||
<div role="alert" class="alert alert-error my-2">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>
|
||||
{ move || if let Some(Err(e)) = response.get() {
|
||||
{format!("{}", e)}.into_view()
|
||||
} else {
|
||||
().into_view()
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<Suspense>
|
||||
<Show when=move || flash().is_some_and(|u| u.is_some())>
|
||||
|
||||
<Alert alert={ flash().unwrap().unwrap().alert() }>
|
||||
{ flash().unwrap().unwrap().message() }
|
||||
</Alert>
|
||||
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<ActionForm action=submit class="w-full">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<input type="text" placeholder="E-mail" name="email" class="grow" />
|
||||
</label>
|
||||
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-lock"></i>
|
||||
<input type="password" placeholder="●●●●●●●●" name="password" class="grow" on:input=move |_| submit.value().set(None) />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<input type="submit" value="Login" class="btn btn-primary btn-block" />
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
"New Account? Sign up "<a href="/auth/register" class="link">"here"</a>"!"
|
||||
</div>
|
||||
<div class="text-center text-sm">
|
||||
<a href="/auth/forgot" class="link">"Forgot password"</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ActionForm>
|
||||
|
||||
</AuthBase>
|
||||
|
||||
}
|
||||
.into_view()
|
||||
}
|
34
src/lib/domain/leptos/app/pages/auth/logout.rs
Normal file
34
src/lib/domain/leptos/app/pages/auth/logout.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use leptos::*;
|
||||
use leptos_router::Redirect;
|
||||
|
||||
#[server]
|
||||
async fn logout_action() -> Result<(), ServerFnError<String>> {
|
||||
use crate::domain::api::prelude::*;
|
||||
|
||||
let session = use_context::<Session>().unwrap();
|
||||
session.clear();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LogoutPage(user_signal: RwSignal<bool>) -> impl IntoView {
|
||||
let submit = Action::<LogoutAction, _>::server();
|
||||
let response = submit.value().read_only();
|
||||
|
||||
create_effect(move |_| {
|
||||
if response.get().is_some() {
|
||||
user_signal.set(!user_signal.get_untracked());
|
||||
}
|
||||
});
|
||||
|
||||
submit.dispatch(LogoutAction {});
|
||||
|
||||
view! {
|
||||
<Suspense>
|
||||
<Show when=move || response().is_some()>
|
||||
<Redirect path="/" />
|
||||
</Show>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
6
src/lib/domain/leptos/app/pages/auth/mod.rs
Normal file
6
src/lib/domain/leptos/app/pages/auth/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod auth_base;
|
||||
pub mod forgot;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod register;
|
||||
pub mod reset;
|
98
src/lib/domain/leptos/app/pages/auth/register.rs
Normal file
98
src/lib/domain/leptos/app/pages/auth/register.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
|
||||
use super::auth_base::AuthBase;
|
||||
|
||||
|
||||
|
||||
#[server]
|
||||
async fn register_action(
|
||||
email: String,
|
||||
password: String,
|
||||
confirm_password: String,
|
||||
) -> Result<(), ServerFnError<String>> {
|
||||
use crate::domain::api::prelude::*;
|
||||
|
||||
let app = use_context::<AppService>().unwrap();
|
||||
let flashbag = use_context::<FlashBag>().unwrap();
|
||||
|
||||
if password != confirm_password {
|
||||
return Err("Passwords don't match".to_string().into());
|
||||
}
|
||||
|
||||
let email = EmailAddress::new(&email).map_err(|_| format!("\"{}\" is not a email address", email))?;
|
||||
let password = Password::new(&password).map_err(|e| format!("{}", e))?;
|
||||
|
||||
let user = app.create_user(CreateUserRequest::new(email, password))
|
||||
.await
|
||||
.map_err(|e| format!("{}", e))?;
|
||||
|
||||
let flash = FlashMessage::new("login",
|
||||
format!(
|
||||
"An e-mail has been sent to {} with a link to activate your account.",
|
||||
user.email()
|
||||
)).with_alert(Alert::Success);
|
||||
|
||||
flashbag.set(flash);
|
||||
|
||||
leptos_axum::redirect("/auth/login");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn RegisterPage() -> impl IntoView {
|
||||
let submit = Action::<RegisterAction, _>::server();
|
||||
|
||||
let response = submit.value().read_only();
|
||||
|
||||
view! {
|
||||
<AuthBase>
|
||||
|
||||
<Suspense>
|
||||
<Show when=move || response.get().is_some_and(|e| e.is_err())>
|
||||
<div role="alert" class="alert alert-error my-2">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>
|
||||
{ move || if let Some(Err(e)) = response.get() {
|
||||
{format!("{}", e)}.into_view()
|
||||
} else {
|
||||
().into_view()
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<ActionForm action=submit class="w-full">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<input type="text" placeholder="E-mail" name="email" class="grow" />
|
||||
</label>
|
||||
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-lock"></i>
|
||||
<input type="password" placeholder="Password" name="password" class="grow" />
|
||||
</label>
|
||||
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-lock"></i>
|
||||
<input type="password" placeholder="Confirm Password" name="confirm_password" class="grow" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<input type="submit" value="Sign Up!" class="btn btn-primary btn-block" />
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
"Already have an Account? Login "<a href="/auth/login" class="link">"here"</a>"!"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ActionForm>
|
||||
</AuthBase>
|
||||
}.into_view()
|
||||
}
|
108
src/lib/domain/leptos/app/pages/auth/reset.rs
Normal file
108
src/lib/domain/leptos/app/pages/auth/reset.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use super::auth_base::AuthBase;
|
||||
|
||||
#[server]
|
||||
async fn reset_action(token: String, password: String, confirm_password: String) -> Result<(), ServerFnError<String>> {
|
||||
use crate::domain::api::prelude::*;
|
||||
|
||||
let app = use_context::<AppService>().unwrap();
|
||||
let flashbag = use_context::<FlashBag>().unwrap();
|
||||
|
||||
if password != confirm_password {
|
||||
return Err("Passwords don't match".to_string().into());
|
||||
}
|
||||
|
||||
let password = Password::new(&password).map_err(|e| format!("{}", e))?;
|
||||
|
||||
app.reset_password(&token.into(), &password).await.map_err(|e| format!("{}", e))?;
|
||||
|
||||
let flash = FlashMessage::new("login",
|
||||
format!(
|
||||
"Your password has been reset."
|
||||
)).with_alert(Alert::Success);
|
||||
|
||||
flashbag.set(flash);
|
||||
|
||||
leptos_axum::redirect("/auth/login");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn ResetPage() -> impl IntoView {
|
||||
let submit = Action::<ResetAction, _>::server();
|
||||
let response = submit.value().read_only();
|
||||
|
||||
let params = use_params_map();
|
||||
|
||||
let token = move || {
|
||||
params.with(|params| params.get("token").cloned())
|
||||
};
|
||||
|
||||
let user = create_resource(token, move |_| async move {
|
||||
crate::domain::leptos::check_forgot_password_token(token()).await.unwrap()
|
||||
});
|
||||
|
||||
view! {
|
||||
<AuthBase>
|
||||
|
||||
<Suspense>
|
||||
<Show when=move || response.get().is_some_and(|e| e.is_err())>
|
||||
<div role="alert" class="alert alert-error my-2">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>
|
||||
{ move || if let Some(Err(e)) = response.get() {
|
||||
{format!("{}", e)}.into_view()
|
||||
} else {
|
||||
().into_view()
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<ActionForm action=submit class="w-full">
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<Suspense>
|
||||
<Show when=move || user().is_some_and(|e| e.is_none())>
|
||||
<Redirect path="/auth/login"/>
|
||||
</Show>
|
||||
<Show when=move || user().is_some_and(|e| e.is_some())>
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<input type="text" class="grow" value={user().unwrap().unwrap().email().to_string()} disabled />
|
||||
</label>
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-lock"></i>
|
||||
<input type="password" placeholder="New Password" name="password" class="grow" />
|
||||
</label>
|
||||
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<i class="fas fa-lock"></i>
|
||||
<input type="password" placeholder="Confirm Password" name="confirm_password" class="grow" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<input type="hidden" name="token" value={token()} />
|
||||
<input type="submit" value="Reset!" class="btn btn-primary btn-block" />
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
"Remembered your password? Login "<a href="/auth/login" class="link">"here"</a>"!"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ActionForm>
|
||||
|
||||
</AuthBase>
|
||||
|
||||
}
|
||||
.into_view()
|
||||
}
|
46
src/lib/domain/leptos/app/pages/dashboard.rs
Normal file
46
src/lib/domain/leptos/app/pages/dashboard.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use leptos::*;
|
||||
|
||||
use crate::domain::api::prelude::User;
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn DashboardPage(user: User) -> impl IntoView {
|
||||
view! {
|
||||
<section class="login is-fullheight">
|
||||
<div class="columns is-fullheight">
|
||||
<div class="column is-one-third-fullhd is-half-widescreen">
|
||||
<div class="login_container">
|
||||
<div style="margin: auto 0">
|
||||
<div class="has-text-centered">
|
||||
<img src="/android-chrome-192x192.png" alt={ crate::PROJECT_NAME }/>
|
||||
</div>
|
||||
|
||||
<pre>Hello, { user.email().to_string() }!</pre>
|
||||
<div class="content has-text-centered">
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="content has-text-centered">
|
||||
<p>
|
||||
{ crate::PROJECT_NAME }
|
||||
</p>
|
||||
<p>
|
||||
<span class="icon"><a href="https://git.avii.nl/AVAM/avam" class="is-link" target="_BLANK"><i class="fab fa-git-alt"></i></a></span>
|
||||
<span class="icon"><a href="#" class="is-link" target="_BLANK"><i class="fab fa-discord"></i></a></span>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="column is-fullheight background is-hidden-mobile has-background-primary has-text-primary-invert">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}.into_view()
|
||||
}
|
73
src/lib/domain/leptos/app/pages/error.rs
Normal file
73
src/lib/domain/leptos/app/pages/error.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use http::status::StatusCode;
|
||||
use leptos::*;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not Found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::NotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A basic function to display errors served by the error boundaries.
|
||||
// Feel free to do more complicated things here than just displaying the error.
|
||||
#[component]
|
||||
pub fn ErrorTemplate(
|
||||
#[prop(optional)] outside_errors: Option<Errors>,
|
||||
#[prop(optional)] errors: Option<RwSignal<Errors>>,
|
||||
) -> impl IntoView {
|
||||
let errors = match outside_errors {
|
||||
Some(e) => create_rw_signal(e),
|
||||
None => match errors {
|
||||
Some(e) => e,
|
||||
None => panic!("No Errors found and we expected errors!"),
|
||||
},
|
||||
};
|
||||
// Get Errors from Signal
|
||||
let errors = errors.get_untracked();
|
||||
|
||||
// Downcast lets us take a type that implements `std::error::Error`
|
||||
let errors: Vec<AppError> = errors
|
||||
.into_iter()
|
||||
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
|
||||
.collect();
|
||||
println!("Errors: {errors:#?}");
|
||||
|
||||
// Only the response code for the first error is actually sent from the server
|
||||
// this may be customized by the specific application
|
||||
#[cfg(feature = "ssr")]
|
||||
{
|
||||
use leptos_axum::ResponseOptions;
|
||||
let response = use_context::<ResponseOptions>();
|
||||
if let Some(response) = response {
|
||||
response.set_status(errors[0].status_code());
|
||||
}
|
||||
}
|
||||
|
||||
view! {
|
||||
<For
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each= move || {errors.clone().into_iter().enumerate()}
|
||||
// a unique key for each item as a reference
|
||||
key=|(index, _error)| *index
|
||||
// renders each item to a view
|
||||
children=move |error| {
|
||||
let error_string = error.1.to_string();
|
||||
let error_code= error.1.status_code();
|
||||
view! {
|
||||
<div class="container">
|
||||
<p class="title">{error_code.to_string()}</p>
|
||||
<p>"Error: " {error_string}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
3
src/lib/domain/leptos/app/pages/mod.rs
Normal file
3
src/lib/domain/leptos/app/pages/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod dashboard;
|
||||
pub mod error;
|
107
src/lib/domain/leptos/flashbag.rs
Normal file
107
src/lib/domain/leptos/flashbag.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use axum_session::SessionAnySession;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub enum Alert {
|
||||
None,
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct FlashMessage<S>
|
||||
where
|
||||
S: Serialize,
|
||||
{
|
||||
name: String,
|
||||
message: S,
|
||||
alert: Alert,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Flash {
|
||||
name: String,
|
||||
message: String,
|
||||
alert: Alert,
|
||||
}
|
||||
|
||||
impl Flash {
|
||||
pub fn message(&self) -> String {
|
||||
self.message.clone()
|
||||
}
|
||||
|
||||
pub fn alert(&self) -> Alert {
|
||||
self.alert.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FlashMessage<S>
|
||||
where
|
||||
S: Serialize,
|
||||
{
|
||||
pub fn new(name: &str, message: S) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
message: message.into(),
|
||||
alert: Alert::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_alert(mut self, alert: Alert) -> Self {
|
||||
self.alert = alert;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Clone)]
|
||||
pub struct FlashBag {
|
||||
session: SessionAnySession,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl FlashBag {
|
||||
pub fn new(session: SessionAnySession) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
|
||||
pub fn set<T: Serialize>(&self, message: FlashMessage<T>) {
|
||||
let flash_name = &message.name;
|
||||
|
||||
let name = format!("__flash_alert:{}__", flash_name);
|
||||
self.session.set(&name, message.alert);
|
||||
|
||||
let name = format!("__flash:{}__", flash_name);
|
||||
self.session.set(&name, message.message);
|
||||
}
|
||||
|
||||
pub fn get(&self, flash_name: &str) -> Option<Flash> {
|
||||
let name = format!("__flash:{}__", flash_name);
|
||||
|
||||
let Some(message) = self.session.get(&name) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let alert_name = format!("__flash_alert:{}__", flash_name);
|
||||
let alert = self.session.get(&alert_name).unwrap_or(Alert::None);
|
||||
|
||||
self.clear(&flash_name);
|
||||
|
||||
Some(Flash {
|
||||
name,
|
||||
message,
|
||||
alert,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear(&self, name: &str) {
|
||||
let name = format!("__flash:{}__", name);
|
||||
let alert_name = format!("__flash_alert:{}__", name);
|
||||
self.session.remove(&name);
|
||||
self.session.remove(&alert_name);
|
||||
}
|
||||
}
|
41
src/lib/domain/leptos/mod.rs
Normal file
41
src/lib/domain/leptos/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use super::api::prelude::Flash;
|
||||
use leptos::*;
|
||||
|
||||
pub mod app;
|
||||
pub mod flashbag;
|
||||
|
||||
#[server]
|
||||
pub async fn get_flash(name: String) -> Result<Option<Flash>, ServerFnError<String>> {
|
||||
use super::api::prelude::*;
|
||||
|
||||
// use crate::flashbag::FlashBag;
|
||||
let Some(session) = use_context::<FlashBag>() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(session.get(&name))
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn check_user() -> Result<Option<crate::domain::api::prelude::User>, ServerFnError<String>>
|
||||
{
|
||||
use crate::domain::api::prelude::*;
|
||||
|
||||
let app = use_context::<AppService>().unwrap();
|
||||
let session = use_context::<Session>().unwrap();
|
||||
|
||||
Ok(app.get_user_session(&session).await)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn check_forgot_password_token(
|
||||
token: Option<String>,
|
||||
) -> Result<Option<crate::domain::api::prelude::User>, ServerFnError<String>> {
|
||||
use crate::domain::api::prelude::*;
|
||||
|
||||
let app = use_context::<AppService>().unwrap();
|
||||
|
||||
let Some(token) = token else { return Ok(None) };
|
||||
|
||||
Ok(app.find_user_by_password_reset_token(&(token.into())).await)
|
||||
}
|
2
src/lib/domain/mod.rs
Normal file
2
src/lib/domain/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
pub mod leptos;
|
90
src/lib/inbound/http.rs
Normal file
90
src/lib/inbound/http.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
pub mod handlers;
|
||||
pub mod state;
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::ConnectInfo,
|
||||
routing::{get, post},
|
||||
Extension,
|
||||
};
|
||||
use axum_session::{SessionAnyPool, SessionConfig, SessionLayer, SessionStore};
|
||||
use axum_session_sqlx::SessionPgPool;
|
||||
use handlers::{
|
||||
fileserv::file_and_error_handler,
|
||||
leptos::{leptos_routes_handler, server_fn_handler},
|
||||
user::activate_account,
|
||||
};
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use state::AppState;
|
||||
use tokio::net;
|
||||
|
||||
use crate::domain::{api::ports::ApiService, leptos::app::App};
|
||||
|
||||
/// The application's HTTP server. The underlying HTTP package is opaque to module consumers.
|
||||
pub struct HttpServer {
|
||||
router: axum::Router,
|
||||
listener: net::TcpListener,
|
||||
}
|
||||
|
||||
impl HttpServer {
|
||||
/// Returns a new HTTP server bound to the port specified in `config`.
|
||||
pub async fn new<S: ApiService>(app_state: AppState<S>, pool: sqlx::Pool<sqlx::Postgres>) -> anyhow::Result<Self> {
|
||||
let leptos_options = &app_state.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
|
||||
let session_config = SessionConfig::default();
|
||||
|
||||
let session_pool = SessionAnyPool::new(SessionPgPool::from(pool));
|
||||
|
||||
let session_store = SessionStore::<SessionAnyPool>::new(Some(session_pool), session_config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let trace_layer = tower_http::trace::TraceLayer::new_for_http().make_span_with(
|
||||
|request: &axum::extract::Request<_>| {
|
||||
let uri = request.uri().to_string();
|
||||
|
||||
let remote_addr = match request.headers().get("x-forwarded-for") {
|
||||
Some(a) => a.to_str().unwrap_or("127.0.0.1").parse().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||
None => {
|
||||
let socket_addr = request.extensions().get::<ConnectInfo<SocketAddr>>();
|
||||
socket_addr.unwrap().ip()
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info_span!("http_request", remote_addr = ?remote_addr, method = ?request.method(), uri)
|
||||
},
|
||||
);
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route("/auth/activate/:token", get(activate_account))
|
||||
.route("/api/*fn_name", post(server_fn_handler))
|
||||
.leptos_routes_with_handler(generate_route_list(App), get(leptos_routes_handler))
|
||||
.fallback(file_and_error_handler)
|
||||
.layer(Extension(app_state.api_service()))
|
||||
.layer(SessionLayer::new(session_store))
|
||||
.layer(trace_layer)
|
||||
.with_state(app_state);
|
||||
|
||||
let listener = net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.with_context(|| format!("failed to listen on {}", &addr))?;
|
||||
|
||||
Ok(Self { router, listener })
|
||||
}
|
||||
|
||||
/// Runs the HTTP server.
|
||||
pub async fn run(self) -> anyhow::Result<()> {
|
||||
tracing::debug!("listening on {}", self.listener.local_addr().unwrap());
|
||||
axum::serve(
|
||||
self.listener,
|
||||
self.router
|
||||
.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("received error from running server")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
74
src/lib/inbound/http/handlers/fileserv.rs
Normal file
74
src/lib/inbound/http/handlers/fileserv.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use axum::response::Response as AxumResponse;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use leptos::*;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::domain::api::ports::ApiService;
|
||||
use crate::domain::api::prelude::FlashBag;
|
||||
use crate::domain::leptos::app::App;
|
||||
use crate::inbound::http::state::AppState;
|
||||
|
||||
pub async fn file_and_error_handler<S: ApiService>(
|
||||
State(options): State<LeptosOptions>,
|
||||
State(app_state): State<AppState<S>>,
|
||||
session: axum_session::SessionAnySession,
|
||||
req: Request<Body>,
|
||||
) -> AxumResponse {
|
||||
let root = options.site_root.clone();
|
||||
let (parts, body) = req.into_parts();
|
||||
|
||||
let mut static_parts = parts.clone();
|
||||
static_parts.headers.clear();
|
||||
if let Some(encodings) = parts.headers.get("accept-encoding") {
|
||||
static_parts
|
||||
.headers
|
||||
.insert("accept-encoding", encodings.clone());
|
||||
}
|
||||
|
||||
let res = get_static_file(Request::from_parts(static_parts, Body::empty()), &root)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else {
|
||||
let handler = leptos_axum::render_app_to_stream_with_context(
|
||||
options.to_owned(),
|
||||
move || {
|
||||
provide_context(app_state.api_service().clone());
|
||||
provide_context(session.clone());
|
||||
provide_context(FlashBag::new(session.clone()));
|
||||
},
|
||||
App,
|
||||
);
|
||||
handler(Request::from_parts(parts, body))
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(
|
||||
request: Request<Body>,
|
||||
root: &str,
|
||||
) -> Result<Response<Body>, (StatusCode, String)> {
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root)
|
||||
.precompressed_gzip()
|
||||
.precompressed_br()
|
||||
.oneshot(request)
|
||||
.await
|
||||
{
|
||||
Ok(res) => Ok(res.into_response()),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Error serving files: {err}"),
|
||||
)),
|
||||
}
|
||||
}
|
48
src/lib/inbound/http/handlers/leptos.rs
Normal file
48
src/lib/inbound/http/handlers/leptos.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Request, State},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use leptos::*;
|
||||
use leptos_axum::handle_server_fns_with_context;
|
||||
|
||||
use crate::{
|
||||
domain::{
|
||||
api::{ports::ApiService, prelude::FlashBag},
|
||||
leptos::app::App,
|
||||
},
|
||||
inbound::http::state::AppState,
|
||||
};
|
||||
|
||||
pub(crate) async fn server_fn_handler<S: ApiService>(
|
||||
State(app_state): State<AppState<S>>,
|
||||
session: axum_session::SessionAnySession,
|
||||
request: Request<Body>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
handle_server_fns_with_context(
|
||||
move || {
|
||||
provide_context(app_state.api_service().clone());
|
||||
provide_context(session.clone());
|
||||
provide_context(FlashBag::new(session.clone()));
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn leptos_routes_handler<S: ApiService>(
|
||||
State(app_state): State<AppState<S>>,
|
||||
session: axum_session::SessionAnySession,
|
||||
req: Request<Body>,
|
||||
) -> Response {
|
||||
let handler = leptos_axum::render_app_to_stream_with_context(
|
||||
app_state.leptos_options.clone(),
|
||||
move || {
|
||||
provide_context(app_state.api_service().clone());
|
||||
provide_context(session.clone());
|
||||
provide_context(FlashBag::new(session.clone()));
|
||||
},
|
||||
|| view! { <App/> },
|
||||
);
|
||||
handler(req).await.into_response()
|
||||
}
|
3
src/lib/inbound/http/handlers/mod.rs
Normal file
3
src/lib/inbound/http/handlers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod fileserv;
|
||||
pub mod leptos;
|
||||
pub mod user;
|
40
src/lib/inbound/http/handlers/user.rs
Normal file
40
src/lib/inbound/http/handlers/user.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use crate::{
|
||||
domain::api::{
|
||||
models::user::ActivationToken,
|
||||
ports::ApiService,
|
||||
prelude::{Alert, FlashBag, FlashMessage},
|
||||
},
|
||||
inbound::http::state::AppState,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
pub async fn activate_account<S: ApiService>(
|
||||
State(app_state): State<AppState<S>>,
|
||||
session: axum_session::SessionAnySession,
|
||||
Path(token): Path<String>,
|
||||
) -> Response {
|
||||
let app = app_state.api_service();
|
||||
|
||||
let token = ActivationToken::from(token);
|
||||
|
||||
if let Err(e) = app.activate_user_account(token).await {
|
||||
warn!("{:#?}", e);
|
||||
let flash =
|
||||
FlashMessage::new("login", "Invalid activation token").with_alert(Alert::Warning);
|
||||
|
||||
FlashBag::new(session).set(flash);
|
||||
|
||||
return Redirect::to("/auth/login").into_response();
|
||||
}
|
||||
|
||||
let flash = FlashMessage::new("login", "Activation successful, you can now login")
|
||||
.with_alert(Alert::Success);
|
||||
|
||||
FlashBag::new(session).set(flash);
|
||||
Redirect::to("/auth/login").into_response()
|
||||
}
|
41
src/lib/inbound/http/state.rs
Normal file
41
src/lib/inbound/http/state.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use leptos::get_configuration;
|
||||
|
||||
use crate::domain::api::ports::ApiService;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// The global application state shared between all request handlers.
|
||||
pub struct AppState<S>
|
||||
where
|
||||
S: ApiService,
|
||||
{
|
||||
pub leptos_options: leptos::LeptosOptions,
|
||||
api_service: Arc<S>,
|
||||
}
|
||||
|
||||
impl<S> AppState<S>
|
||||
where
|
||||
S: ApiService,
|
||||
{
|
||||
pub async fn new(api_service: S) -> Self {
|
||||
Self {
|
||||
leptos_options: get_configuration(None).await.unwrap().leptos_options,
|
||||
api_service: Arc::new(api_service),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_service(&self) -> Arc<S> {
|
||||
self.api_service.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromRef<AppState<S>> for leptos::LeptosOptions
|
||||
where
|
||||
S: ApiService,
|
||||
{
|
||||
fn from_ref(input: &AppState<S>) -> Self {
|
||||
input.leptos_options.clone()
|
||||
}
|
||||
}
|
1
src/lib/inbound/mod.rs
Normal file
1
src/lib/inbound/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod http;
|
12
src/lib/lib.rs
Normal file
12
src/lib/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#![feature(trait_alias)]
|
||||
|
||||
pub static PROJECT_NAME: &str = "Avii's Virtual Airline Manager";
|
||||
pub static COPYRIGHT: &str = "Avii's Virtual Airline Manager © 2024";
|
||||
pub mod domain;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod config;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod inbound;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod outbound;
|
84
src/lib/outbound/dangerous_lettre.rs
Normal file
84
src/lib/outbound/dangerous_lettre.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
pub mod user_notifier;
|
||||
|
||||
use lettre::message::{header, Mailbox, MultiPart, SinglePart};
|
||||
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::AsyncSmtpTransport;
|
||||
use lettre::Message;
|
||||
use lettre::Tokio1Executor;
|
||||
|
||||
use tera::{Context, Tera};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DangerousLettre {
|
||||
mailer: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: Mailbox,
|
||||
tera: Tera,
|
||||
}
|
||||
|
||||
impl DangerousLettre {
|
||||
pub fn new(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
from: &str,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
let creds = Credentials::new(username.to_string(), password.to_string());
|
||||
|
||||
let tls = TlsParameters::builder(host.to_owned())
|
||||
.dangerous_accept_invalid_certs(true)
|
||||
.dangerous_accept_invalid_hostnames(true)
|
||||
.build()?;
|
||||
|
||||
let mailer: AsyncSmtpTransport<Tokio1Executor> =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host)
|
||||
.port(port)
|
||||
.tls(Tls::Opportunistic(tls))
|
||||
.credentials(creds)
|
||||
.build();
|
||||
|
||||
let tera = Tera::new("templates/**/*.email.*")?;
|
||||
|
||||
let from = from.parse::<Mailbox>()?;
|
||||
|
||||
Ok(Self { mailer, from, tera })
|
||||
}
|
||||
|
||||
pub fn template(
|
||||
&self,
|
||||
template: &str,
|
||||
subject: &str,
|
||||
context: &Context,
|
||||
to: Mailbox,
|
||||
) -> Result<Message, anyhow::Error> {
|
||||
let plain_text = self
|
||||
.tera
|
||||
.render(&format!("{}.email.txt", template), context)?;
|
||||
|
||||
let html = self
|
||||
.tera
|
||||
.render(&format!("{}.email.html", template), context)?;
|
||||
|
||||
let message = Message::builder()
|
||||
.subject(subject)
|
||||
.from(self.from.clone())
|
||||
.to(to)
|
||||
.multipart(
|
||||
MultiPart::alternative() // This is composed of two parts.
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(header::ContentType::TEXT_PLAIN)
|
||||
.body(plain_text), // Every message should have a plain text fallback.
|
||||
)
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(header::ContentType::TEXT_HTML)
|
||||
.body(html),
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
}
|
50
src/lib/outbound/dangerous_lettre/user_notifier.rs
Normal file
50
src/lib/outbound/dangerous_lettre/user_notifier.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use lettre::AsyncTransport;
|
||||
|
||||
use crate::domain::api::ports::UserNotifier;
|
||||
|
||||
use crate::domain::api::models::user::*;
|
||||
|
||||
use super::DangerousLettre;
|
||||
|
||||
impl UserNotifier for DangerousLettre {
|
||||
async fn user_created(&self, user: &User, token: &ActivationToken) {
|
||||
let mut context = tera::Context::new();
|
||||
|
||||
let url = format!("http://127.0.0.1:3000/auth/activate/{}", token); // Move base url to env
|
||||
|
||||
context.insert("activate_url", &url);
|
||||
|
||||
let to = format!("{}", user.email()).parse().unwrap();
|
||||
|
||||
let message = self
|
||||
.template("user/created", "Welcome to AVAM!", &context, to)
|
||||
.unwrap();
|
||||
|
||||
if let Err(e) = self.mailer.send(message).await {
|
||||
eprintln!("{:#?}", e);
|
||||
};
|
||||
}
|
||||
|
||||
async fn forgot_password(&self, user: &User, token: &PasswordResetToken) {
|
||||
let mut context = tera::Context::new();
|
||||
|
||||
let url = format!("http://127.0.0.1:3000/auth/reset/{}", token); // Move base url to env
|
||||
|
||||
context.insert("reset_url", &url);
|
||||
|
||||
let to = format!("{}", user.email()).parse().unwrap();
|
||||
|
||||
let message = self
|
||||
.template(
|
||||
"user/password_reset",
|
||||
"Password reset request",
|
||||
&context,
|
||||
to,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if let Err(e) = self.mailer.send(message).await {
|
||||
eprintln!("{:#?}", e);
|
||||
};
|
||||
}
|
||||
}
|
2
src/lib/outbound/mod.rs
Normal file
2
src/lib/outbound/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod dangerous_lettre;
|
||||
pub mod postgres;
|
28
src/lib/outbound/postgres.rs
Normal file
28
src/lib/outbound/postgres.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
pub mod user_repository;
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Context;
|
||||
use sqlx::{postgres::PgConnectOptions, PgPool, Pool};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Postgres {
|
||||
pool: Pool<sqlx::Postgres>,
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
pub async fn new(url: &str) -> Result<Self, anyhow::Error> {
|
||||
let pool = PgPool::connect_with(
|
||||
PgConnectOptions::from_str(url)
|
||||
.with_context(|| format!("Invalid database url: {}", url))?,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to open database at {}", url))?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> Pool<sqlx::Postgres> {
|
||||
self.pool.clone()
|
||||
}
|
||||
}
|
343
src/lib/outbound/postgres/user_repository.rs
Normal file
343
src/lib/outbound/postgres/user_repository.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
use anyhow::Context;
|
||||
use sqlx::postgres::PgDatabaseError;
|
||||
use sqlx::Executor;
|
||||
use sqlx::QueryBuilder;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::domain::api::models::user::*;
|
||||
use crate::domain::api::ports::UserRepository;
|
||||
|
||||
use super::Postgres;
|
||||
|
||||
impl UserRepository for Postgres {
|
||||
async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("Failed to start sql transaction")?;
|
||||
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let email = req.email();
|
||||
let password = req.password();
|
||||
|
||||
let query = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (id, email, password)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING
|
||||
id,
|
||||
email,
|
||||
password,
|
||||
verified
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(email.to_string())
|
||||
.bind(password.to_string());
|
||||
|
||||
let execute = tx.execute(query).await;
|
||||
|
||||
if let Err(ref e) = execute {
|
||||
if let Some(e) = e.as_database_error() {
|
||||
let e = e.downcast_ref::<PgDatabaseError>();
|
||||
if e.code() == "23505" {
|
||||
return Err(CreateUserError::Duplicate {
|
||||
email: email.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallthrough and handle other errors with `?`
|
||||
execute.map_err(|e| CreateUserError::Unknown(e.into()))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed to commit SQL transaction")?;
|
||||
|
||||
Ok(User::new(id, email.clone(), password.clone(), false.into()))
|
||||
}
|
||||
|
||||
async fn create_activation_token(&self, ent: &User) -> Result<ActivationToken, anyhow::Error> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("Failed to start sql transaction")?;
|
||||
|
||||
let user_id = ent.id();
|
||||
|
||||
let token = ActivationToken::new();
|
||||
|
||||
let query = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO activation_token (user_id, token)
|
||||
VALUES ($1, $2)"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(token.to_string());
|
||||
|
||||
tx.execute(query)
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?;
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed to commit SQL transaction")?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
async fn create_password_reset_token(
|
||||
&self,
|
||||
ent: &User,
|
||||
) -> Result<PasswordResetToken, anyhow::Error> {
|
||||
let _ = self.delete_password_reset_tokens_for_user(ent).await;
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("Failed to start sql transaction")?;
|
||||
|
||||
let user_id = ent.id();
|
||||
|
||||
let token = PasswordResetToken::new();
|
||||
|
||||
let query = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO forgot_password (user_id, token)
|
||||
VALUES ($1, $2)"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(token.to_string());
|
||||
|
||||
tx.execute(query)
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?;
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed to commit SQL transaction")?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
async fn all_users(&self) -> Vec<User> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn find_user_by_id(&self, id: uuid::Uuid) -> Result<Option<User>, anyhow::Error> {
|
||||
let query = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
password,
|
||||
verified
|
||||
FROM users WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id);
|
||||
|
||||
let row = self
|
||||
.pool
|
||||
.fetch_optional(query)
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let id = row.get("id");
|
||||
let email = EmailAddress::new(row.get("email"))?;
|
||||
let password = Password::new_hashed(row.get("password"));
|
||||
let verified = Verified::new(row.get("verified"));
|
||||
|
||||
Ok(Some(User::new(id, email, password, verified)))
|
||||
}
|
||||
|
||||
async fn find_user_by_email(
|
||||
&self,
|
||||
email: &EmailAddress,
|
||||
) -> Result<Option<User>, anyhow::Error> {
|
||||
let query = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
password,
|
||||
verified
|
||||
FROM users WHERE email = $1
|
||||
"#,
|
||||
)
|
||||
.bind(email.to_string());
|
||||
|
||||
let row = self
|
||||
.pool
|
||||
.fetch_optional(query)
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let id = row.get("id");
|
||||
let email = EmailAddress::new(row.get("email"))?;
|
||||
let password = Password::new_hashed(row.get("password"));
|
||||
let verified = Verified::new(row.get("verified"));
|
||||
|
||||
Ok(Some(User::new(id, email, password, verified)))
|
||||
}
|
||||
|
||||
async fn find_user_by_activation_token(
|
||||
&self,
|
||||
token: &ActivationToken,
|
||||
) -> Result<Option<User>, anyhow::Error> {
|
||||
let query = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
user_id,
|
||||
token
|
||||
FROM activation_token WHERE token = $1
|
||||
"#,
|
||||
)
|
||||
.bind(token.to_string());
|
||||
|
||||
let Some(row) = self
|
||||
.pool
|
||||
.fetch_optional(query)
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let id = row.get("user_id");
|
||||
|
||||
Ok(self.find_user_by_id(id).await?)
|
||||
}
|
||||
|
||||
async fn find_user_by_password_reset_token(
|
||||
&self,
|
||||
token: &PasswordResetToken,
|
||||
) -> Result<Option<User>, anyhow::Error> {
|
||||
let query = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
user_id,
|
||||
token
|
||||
FROM forgot_password WHERE token = $1
|
||||
"#,
|
||||
)
|
||||
.bind(token.to_string());
|
||||
|
||||
let Some(row) = self
|
||||
.pool
|
||||
.fetch_optional(query)
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let id = row.get("user_id");
|
||||
|
||||
Ok(self.find_user_by_id(id).await?)
|
||||
}
|
||||
|
||||
async fn update_user(
|
||||
&self,
|
||||
ent: &User,
|
||||
req: UpdateUserRequest,
|
||||
) -> Result<(User, User), UpdateUserError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("Failed to start sql transaction")?;
|
||||
|
||||
let mut query = QueryBuilder::new("UPDATE users SET ");
|
||||
|
||||
let mut new_email = ent.email();
|
||||
let mut new_password = ent.password();
|
||||
let mut new_verified = ent.verified();
|
||||
|
||||
if let Some(email) = req.email() {
|
||||
new_email = email;
|
||||
query.push(" email = ");
|
||||
query.push_bind(email.to_string());
|
||||
};
|
||||
|
||||
if let Some(password) = req.password() {
|
||||
new_password = password;
|
||||
query.push(" password = ");
|
||||
query.push_bind(password.to_string());
|
||||
};
|
||||
|
||||
if let Some(verified) = req.verified() {
|
||||
new_verified = verified;
|
||||
query.push(" verified = ");
|
||||
query.push_bind::<bool>((*verified).into());
|
||||
};
|
||||
|
||||
query.push(" WHERE id = ");
|
||||
query.push_bind(ent.id());
|
||||
|
||||
tx.execute(query.build())
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?;
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed to commit SQL transaction")?;
|
||||
|
||||
Ok((
|
||||
ent.clone(),
|
||||
User::new(
|
||||
ent.id().clone(),
|
||||
new_email.clone(),
|
||||
new_password.clone(),
|
||||
new_verified.clone(),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_activation_token_for_user(&self, ent: &User) -> Result<(), anyhow::Error> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("Failed to start sql transaction")?;
|
||||
|
||||
let query = sqlx::query("DELETE FROM activation_token WHERE user_id = $1").bind(ent.id());
|
||||
|
||||
tx.execute(query)
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?;
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed to commit SQL transaction")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_password_reset_tokens_for_user(&self, ent: &User) -> Result<(), anyhow::Error> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("Failed to start sql transaction")?;
|
||||
|
||||
let query = sqlx::query("DELETE FROM forgot_password WHERE user_id = $1").bind(ent.id());
|
||||
|
||||
tx.execute(query)
|
||||
.await
|
||||
.context("failed to execute SQL transaction")?;
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed to commit SQL transaction")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user