Initial Commit

This commit is contained in:
2024-10-12 14:36:36 +02:00
commit bfc5cbf624
67 changed files with 10860 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/node_modules
/target
/.env

19
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
"rust-analyzer.procMacro.ignored": {
"leptos_macro": [
// optional:
// "component",
"server"
],
},
// if code that is cfg-gated for the `ssr` feature is shown as inactive,
// you may want to tell rust-analyzer to enable the `ssr` feature by default
//
// you can also use `rust-analyzer.cargo.allFeatures` to enable all features
"rust-analyzer.cargo.features": [
"ssr"
],
"files.associations": {
"input.scss": "tailwindcss"
}
}

4603
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

128
Cargo.toml Normal file
View File

@@ -0,0 +1,128 @@
[workspace]
members = [".", "avam-wasm"]
resolver = "2"
[package]
name = "avam"
version = "0.1.0"
edition = "2021"
[lib]
name = "avam"
path = "src/lib/lib.rs"
[[bin]]
name = "avam"
path = "src/bin/server/main.rs"
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
strip = "debuginfo"
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:argon2",
"dep:dotenvy",
"dep:rand",
"dep:tokio",
"dep:tracing-subscriber",
"dep:leptos_axum",
"dep:lettre",
"dep:tera",
"dep:sqlx",
"dep:axum",
"dep:axum-macros",
"dep:axum_session",
"dep:axum_session_sqlx",
"dep:tower",
"dep:tower-http",
"dep:tower-layer",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
[dependencies]
# Utilities
anyhow = { version = "1.0.89", optional = false }
argon2 = { version = "0.5.3", optional = true }
derive_more = { version = "1.0.0", features = ["full"], optional = false }
dotenvy = { version = "0.15.7", optional = true }
rand = { version = "0.8.5", optional = true }
serde = { version = "1.0.210", features = ["std", "derive"], optional = false }
thiserror = { version = "1.0.64", optional = false }
tokio = { version = "1.40.0", features = ["full"], optional = true }
tracing = { version = "0.1.40", optional = false }
tracing-subscriber = { version = "0.3.18", features = [
"env-filter",
], optional = true }
uuid = { version = "1.10.0", features = [
"v4",
"fast-rng",
"serde",
], optional = false }
# Leptos
leptos = { version = "0.6", features = ["nightly"], optional = false }
leptos_axum = { version = "0.6", optional = true }
leptos_meta = { version = "0.6", features = ["nightly"], optional = false }
leptos_router = { version = "0.6", features = ["nightly"], optional = false }
# Email
lettre = { version = "0.11.9", default-features = false, features = [
"builder",
"hostname",
"pool",
"rustls-tls",
"smtp-transport",
"tokio1",
"tokio1-rustls-tls",
], optional = true }
tera = { version = "1.20.0", default-features = false, optional = true }
# Database
sqlx = { version = "0.8.2", default-features = false, features = [
"uuid",
"runtime-tokio-rustls",
"macros",
"postgres",
], optional = true }
# Web
axum = { version = "0.7.7", optional = true }
axum-macros = { version = "0.4.2", optional = true }
axum_session = { version = "0.14.0", optional = true }
axum_session_sqlx = { version = "0.3.0", optional = true }
tower = { version = "0.4", optional = true, features = ["util"] }
tower-http = { version = "0.6.1", features = ["trace", "fs"], optional = true }
tower-layer = { version = "0.3.3", optional = true }
http = "1"
validator = "0.18.1"
[[workspace.metadata.leptos]]
name = "avam"
site-root = "target/site"
site-pkg-dir = "pkg"
style-file = "style/main.scss"
assets-dir = "public"
site-addr = "0.0.0.0:3000"
reload-port = 3001
browserquery = "defaults"
watch = false
env = "DEV"
bin-package = "avam"
bin-default-features = false
bin-features = ["ssr"]
lib-package = "avam-wasm"
lib-default-features = false
lib-features = []

17
avam-wasm/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "avam-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
avam = { path = "../", features = ["hydrate"] }
console_error_panic_hook = "0.1"
leptos = { version = "0.6", features = ["nightly", "hydrate"] }
wasm-bindgen = "=0.2.93"
tracing-subscriber = "0.3.18"
tracing-subscriber-wasm = { version = "0.1.0" }

6
avam-wasm/src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use avam::domain::leptos::app::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@@ -0,0 +1,2 @@
-- Add migration script here
DROP TABLE "users";

View File

@@ -0,0 +1,13 @@
-- Add migration script here
CREATE TABLE "users" (
"id" uuid NOT NULL,
"email" text NOT NULL,
"password" text NOT NULL,
"verified" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_email_key" UNIQUE ("email"),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
) WITH (oids = false);
CREATE INDEX "users_email_idx" ON "users" USING btree ("email");

View File

@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE "activation_token";

View File

@@ -0,0 +1,8 @@
-- Add up migration script here
CREATE TABLE "activation_token" (
"user_id" uuid NOT NULL,
"token" character varying(255) NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "activation_token_user_id_token" PRIMARY KEY ("user_id", "token")
) WITH (oids = false);

View File

@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE "forgot_password";

View File

@@ -0,0 +1,8 @@
-- Add up migration script here
CREATE TABLE "forgot_password" (
"user_id" uuid NOT NULL,
"token" character varying(255) NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "forgot_password_user_id_token" PRIMARY KEY ("user_id", "token")
) WITH (oids = false);

1724
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"daisyui": "^4.12.13"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
public/site.webmanifest Normal file
View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

2
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

44
src/bin/server/main.rs Normal file
View File

@@ -0,0 +1,44 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
use avam::config::Config;
use avam::domain::api;
use avam::inbound::http::{state::AppState, HttpServer};
use avam::outbound::dangerous_lettre::DangerousLettre;
use avam::outbound::postgres::Postgres;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env()?)
.with(tracing_subscriber::fmt::layer())
.init();
let config = Config::from_env()?;
// outbound
let postgres = Postgres::new(&config.database_url).await?;
let dangerous_lettre = DangerousLettre::new(
&config.smtp_host,
config.smtp_port,
&config.smtp_username,
&config.smtp_password,
&config.smtp_sender,
)?;
let api_service = api::Service::new(postgres.clone(), dangerous_lettre);
let app_state = AppState::new(api_service).await;
let http_server = HttpServer::new(app_state, postgres.pool()).await?;
http_server.run().await
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
println!("Do run this main?!");
// no client-side main function
// unless we want this to work with e.g., Trunk for a purely client-side app
// see lib.rs for hydration function instead
}

45
src/lib/config.rs Normal file
View 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
View 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;
}

View File

@@ -0,0 +1 @@
pub mod user;

View 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
View 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;
}

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

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

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

View File

@@ -0,0 +1 @@
pub mod alert;

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

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

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

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

View File

@@ -0,0 +1,6 @@
pub mod auth_base;
pub mod forgot;
pub mod login;
pub mod logout;
pub mod register;
pub mod reset;

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

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

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

View 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>
}
}
/>
}
}

View File

@@ -0,0 +1,3 @@
pub mod auth;
pub mod dashboard;
pub mod error;

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

View 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
View File

@@ -0,0 +1,2 @@
pub mod api;
pub mod leptos;

90
src/lib/inbound/http.rs Normal file
View 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(())
}
}

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

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

View File

@@ -0,0 +1,3 @@
pub mod fileserv;
pub mod leptos;
pub mod user;

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

View 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
View File

@@ -0,0 +1 @@
pub mod http;

12
src/lib/lib.rs Normal file
View 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;

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

View 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
View File

@@ -0,0 +1,2 @@
pub mod dangerous_lettre;
pub mod postgres;

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

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

22
style/input.scss Normal file
View File

@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.bg-auth {
background-image: url("/background.jpg")
}
.input ::placeholder {
opacity: 0.35;
}
input:is(:-webkit-autofill, :autofill) {
border: 0px;
-webkit-text-fill-color: var(--fallback-a,oklch(var(--a)));
-webkit-box-shadow: 0 0 0px 1000px var(--fallback-b1,oklch(var(--b1))) inset;
}
label:has(input:is(:-webkit-autofill, :autofill)) {
border-color: var(--fallback-a,oklch(var(--a)/.2)) !important;
color: var(--fallback-a,oklch(var(--a)));
}

1589
style/main.scss Normal file

File diff suppressed because it is too large Load Diff

18
tailwind.config.js Normal file
View File

@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: {
files: ["*.scss", "*.html", "./src/**/*.rs"],
},
darkMode: ["class", '[data-theme="dark"]'],
theme: {
extend: {},
},
daisyui: {
themes: ["default", "light", "dark"],
base: true, // applies background color and foreground color for root element by default
styled: true, // include daisyUI colors and design decisions for all components
utils: true, // adds responsive and modifier utility classes
},
plugins: [require("@tailwindcss/typography"), require("daisyui")],
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AVAM - Activate your Account</title>
</head>
<body>
<p>Thank you for joining Avii's Virtual Airline Manager.</p>
<p>Please click <a href="{{ activate_url }}" target="_BLANK">here</a> or go to <a href="{{ activate_url }}"
target="_BLANK">{{ activate_url }}</a> to activate your
account.</p>
</body>
</html>

View File

@@ -0,0 +1,3 @@
Thank you for joining Avii's Virtual Airline Manager.
Please go to {{ activate_url }} to activate your account.

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AVAM - Reset Password Request</title>
</head>
<body>
<p>Reset Password</p>
<p>Please click <a href="{{ reset_url }}" target="_BLANK">here</a> or go to <a href="{{ reset_url }}"
target="_BLANK">{{ reset_url }}</a> to reset your password.</p>
</body>
</html>

View File

@@ -0,0 +1,3 @@
Password Reset
Please go to {{ reset_url }} to reset your password.

View 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: &ForgotPasswordToken) {
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);
};
}
}