Initial Commit
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/node_modules
|
||||||
|
/target
|
||||||
|
/.env
|
19
.vscode/settings.json
vendored
Normal 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
128
Cargo.toml
Normal 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
@@ -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
@@ -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);
|
||||||
|
}
|
2
migrations/20241009121432_user.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
DROP TABLE "users";
|
13
migrations/20241009121432_user.up.sql
Normal 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");
|
2
migrations/20241009170101_activation_token.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
DROP TABLE "activation_token";
|
8
migrations/20241009170101_activation_token.up.sql
Normal 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);
|
2
migrations/20241011204946_forgot_password.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
DROP TABLE "forgot_password";
|
8
migrations/20241011204946_forgot_password.up.sql
Normal 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
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"daisyui": "^4.12.13"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 54 KiB |
BIN
public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 241 KiB |
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
public/background.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 922 B |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
1
public/site.webmanifest
Normal 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
@@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
44
src/bin/server/main.rs
Normal 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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
pub mod user;
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
pub mod alert;
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod error;
|
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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod leptos;
|
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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod fileserv;
|
||||||
|
pub mod leptos;
|
||||||
|
pub mod user;
|
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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
pub mod http;
|
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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod dangerous_lettre;
|
||||||
|
pub mod postgres;
|
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
@@ -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
@@ -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
18
tailwind.config.js
Normal 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")],
|
||||||
|
}
|
18
templates/user/created.email.html
Normal 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>
|
3
templates/user/created.email.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Thank you for joining Avii's Virtual Airline Manager.
|
||||||
|
|
||||||
|
Please go to {{ activate_url }} to activate your account.
|
17
templates/user/password_reset.email.html
Normal 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>
|
3
templates/user/password_reset.email.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Password Reset
|
||||||
|
|
||||||
|
Please go to {{ reset_url }} to reset your password.
|
50
templates/user/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: &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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|