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