Compare commits

..

12 Commits

Author SHA1 Message Date
Alphonse Paix
72d0306e35 Update README
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-17 14:35:39 +02:00
Alphonse Paix
e191d35664 Formatting 2025-09-17 14:24:45 +02:00
Alphonse Paix
b5f0f448d7 Test suite refactoring to match new htmx HTML swapping in pages 2025-09-17 14:16:27 +02:00
Alphonse Paix
859247d900 HX-Redirect to handle redirections with htmx 2025-09-17 13:16:56 +02:00
Alphonse Paix
2d336ed000 Use HTML swap to display success and error messages 2025-09-17 03:40:23 +02:00
Alphonse Paix
88dad022ce Basic dashboard for newsletter issue and password systems 2025-09-17 01:47:03 +02:00
Alphonse Paix
1d027b5460 htmx and Tailwind CSS production setup 2025-09-16 20:30:34 +02:00
Alphonse Paix
38208654dc Run on port 8080 for local env + minor fix for subscription confirm page 2025-09-16 19:09:11 +02:00
Alphonse Paix
b736e2fe8d Confirmation page and minor improvements to homepage and form messages
Basic redirect with flash messages for success and error messages
2025-09-16 16:47:28 +02:00
Alphonse Paix
f948728348 Merge remote-tracking branch 'origin/main' into askama 2025-09-16 15:25:28 +02:00
Alphonse Paix
5cdc3ea29d Remove name from subscriptions table 2025-09-16 15:24:08 +02:00
Alphonse Paix
56035fab30 Askama + htmx for frontend
Server-side rendering with htmx and Tailwind CSS for the styling
2025-09-16 01:47:18 +02:00
51 changed files with 1994 additions and 634 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target /target
/node_modules

167
Cargo.lock generated
View File

@@ -90,6 +90,48 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "askama"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
dependencies = [
"askama_derive",
"itoa",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"proc-macro2",
"quote",
"rustc-hash",
"serde",
"serde_derive",
"syn",
]
[[package]]
name = "askama_parser"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
dependencies = [
"memchr",
"serde",
"serde_derive",
"winnow",
]
[[package]] [[package]]
name = "assert-json-diff" name = "assert-json-diff"
version = "2.0.2" version = "2.0.2"
@@ -187,32 +229,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-extra"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde",
"serde_html_form",
"serde_path_to_error",
"tower",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "axum-macros" name = "axum-macros"
version = "0.5.0" version = "0.5.0"
@@ -224,22 +240,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "axum-messages"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d67ce6e7bc1e1e71f2a4e86d418045a29c63c4ebb631f3d9bb2f81c4958ea391"
dependencies = [
"axum-core",
"http",
"parking_lot",
"serde",
"serde_json",
"tower",
"tower-sessions-core",
"tracing",
]
[[package]] [[package]]
name = "axum-server" name = "axum-server"
version = "0.7.2" version = "0.7.2"
@@ -295,6 +295,15 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.2" version = "2.9.2"
@@ -1090,12 +1099,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "htmlescape"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
[[package]] [[package]]
name = "http" name = "http"
version = "1.3.1" version = "1.3.1"
@@ -1130,6 +1133,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -1407,15 +1416,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "keccak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654"
dependencies = [
"cpufeatures",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1539,6 +1539,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -2389,19 +2399,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_html_form"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
dependencies = [
"form_urlencoded",
"indexmap",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.143" version = "1.0.143"
@@ -2467,16 +2464,6 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sha3"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
dependencies = [
"digest",
"keccak",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -3042,11 +3029,20 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string", "iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -3251,6 +3247,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@@ -3840,16 +3842,14 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"askama",
"axum", "axum",
"axum-extra",
"axum-messages",
"axum-server", "axum-server",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"claims", "claims",
"config", "config",
"fake", "fake",
"htmlescape",
"linkify", "linkify",
"once_cell", "once_cell",
"quickcheck", "quickcheck",
@@ -3861,7 +3861,6 @@ dependencies = [
"serde-aux", "serde-aux",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sha3",
"sqlx", "sqlx",
"thiserror", "thiserror",
"tokio", "tokio",

View File

@@ -14,14 +14,12 @@ name = "zero2prod"
[dependencies] [dependencies]
anyhow = "1.0.99" anyhow = "1.0.99"
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
askama = "0.14.0"
axum = { version = "0.8.4", features = ["macros"] } axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10.1", features = ["query", "cookie"] }
axum-messages = "0.8.0"
axum-server = { version = "0.7.2", features = ["tls-rustls-no-provider"] } axum-server = { version = "0.7.2", features = ["tls-rustls-no-provider"] }
base64 = "0.22.1" base64 = "0.22.1"
chrono = { version = "0.4.41", default-features = false, features = ["clock"] } chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
config = "0.15.14" config = "0.15.14"
htmlescape = "0.3.1"
rand = { version = "0.9.2", features = ["std_rng"] } rand = { version = "0.9.2", features = ["std_rng"] }
reqwest = { version = "0.12.23", default-features = false, features = [ reqwest = { version = "0.12.23", default-features = false, features = [
"rustls-tls", "rustls-tls",
@@ -31,7 +29,6 @@ reqwest = { version = "0.12.23", default-features = false, features = [
secrecy = { version = "0.10.3", features = ["serde"] } secrecy = { version = "0.10.3", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde-aux = "4.7.0" serde-aux = "4.7.0"
sha3 = "0.10.8"
sqlx = { version = "0.8.6", features = [ sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls", "runtime-tokio-rustls",
"macros", "macros",
@@ -42,7 +39,7 @@ sqlx = { version = "0.8.6", features = [
] } ] }
thiserror = "2.0.16" thiserror = "2.0.16"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.6.6", features = ["trace"] } tower-http = { version = "0.6.6", features = ["fs", "trace"] }
tower-sessions = "0.14.0" tower-sessions = "0.14.0"
tower-sessions-redis-store = "0.16.0" tower-sessions-redis-store = "0.16.0"
tracing = "0.1.41" tracing = "0.1.41"

View File

@@ -12,12 +12,11 @@ cargo install sqlx-cli --no-default-features --features rustls,postgres
## Documentation ## Documentation
- [axum](https://docs.rs/axum/latest/axum/) + [examples](https://github.com/tokio-rs/axum/tree/main/examples) - [axum](https://docs.rs/axum/latest/axum/) + [examples](https://github.com/tokio-rs/axum/tree/main/examples)
- [Tailwind CSS](tailwindcss.com) - [Tailwind CSS](https://tailwindcss.com)
- [htmx](htmx.org) - [htmx](https://htmx.org)
- [Rust](rust-lang.org) - [Rust](https://rust-lang.org)
## Repositories ## Repositories
- [Book repository](https://github.com/LukeMathWalker/zero-to-production) - [Book repository](https://github.com/LukeMathWalker/zero-to-production)
- [Gitea](https://gitea.alphonsepaix.xyz/alphonse/zero2prod.git) - [Gitea](https://gitea.alphonsepaix.xyz/alphonse/zero2prod.git)
- [GitHub](https://github.com/alphonsepaix/zero2prod.git)

2
assets/css/main.css Normal file

File diff suppressed because one or more lines are too long

1
assets/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
application: application:
port: 8000 port: 8080
host: "127.0.0.1" host: "127.0.0.1"
base_url: "http://127.0.0.1:8000" base_url: "http://127.0.0.1:8080"
database: database:
host: "127.0.0.1" host: "127.0.0.1"
port: 5432 port: 5432

View File

@@ -0,0 +1 @@
ALTER TABLE subscriptions DROP COLUMN name;

1137
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

9
package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"scripts": {
"build-css": "tailwindcss -i ./templates/input.css -o ./assets/css/main.css --minify --watch"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.13",
"tailwindcss": "^4.1.13"
}
}

View File

@@ -1,7 +1,5 @@
mod new_subscriber; mod new_subscriber;
mod subscriber_email; mod subscriber_email;
mod subscriber_name;
pub use new_subscriber::NewSubscriber; pub use new_subscriber::NewSubscriber;
pub use subscriber_email::SubscriberEmail; pub use subscriber_email::SubscriberEmail;
pub use subscriber_name::SubscriberName;

View File

@@ -1,6 +1,5 @@
use crate::domain::{SubscriberName, subscriber_email::SubscriberEmail}; use crate::domain::subscriber_email::SubscriberEmail;
pub struct NewSubscriber { pub struct NewSubscriber {
pub email: SubscriberEmail, pub email: SubscriberEmail,
pub name: SubscriberName,
} }

View File

@@ -1,69 +0,0 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<Self, String> {
let is_empty_or_whitespace = s.trim().is_empty();
let is_too_long = s.graphemes(true).count() > 256;
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g));
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
Err(format!("{} is not a valid subscriber name.", s))
} else {
Ok(Self(s))
}
}
}
impl AsRef<str> for SubscriberName {
fn as_ref(&self) -> &str {
self.0.as_str()
}
}
#[cfg(test)]
mod tests {
use crate::domain::SubscriberName;
use claims::{assert_err, assert_ok};
#[test]
fn a_256_grapheme_long_name_is_valid() {
let name = "ê".repeat(256);
assert_ok!(SubscriberName::parse(name));
}
#[test]
fn a_name_longer_than_256_graphemes_is_rejected() {
let name = "ê".repeat(257);
assert_err!(SubscriberName::parse(name));
}
#[test]
fn a_whitespace_only_name_is_rejected() {
let name = "\n \t ".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn empty_string_is_rejected() {
let name = "".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn a_name_containing_invalid_character_is_rejected() {
for name in ['/', '(', ')', '"', '<', '>', '\\', '{', '}'] {
let name = name.to_string();
assert_err!(SubscriberName::parse(name));
}
}
#[test]
fn a_valid_name_is_parsed_successfully() {
let name = "Alphonse".to_string();
assert_ok!(SubscriberName::parse(name));
}
}

View File

@@ -137,7 +137,7 @@ mod tests {
Mock::given(header_exists("Authorization")) Mock::given(header_exists("Authorization"))
.and(header("Content-Type", "application/json")) .and(header("Content-Type", "application/json"))
.and(header("X-Requested-With", "XMLHttpRequest")) .and(header("X-Requested-With", "XMLHttpRequest"))
.and(path("v1/email")) .and(path("email"))
.and(method("POST")) .and(method("POST"))
.and(SendEmailBodyMatcher) .and(SendEmailBodyMatcher)
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))

View File

@@ -23,14 +23,14 @@ pub async fn get_saved_response(
) -> Result<Option<Response>, anyhow::Error> { ) -> Result<Option<Response>, anyhow::Error> {
let saved_response = sqlx::query!( let saved_response = sqlx::query!(
r#" r#"
SELECT SELECT
response_status_code as "response_status_code!", response_status_code as "response_status_code!",
response_headers as "response_headers!: Vec<HeaderPairRecord>", response_headers as "response_headers!: Vec<HeaderPairRecord>",
response_body as "response_body!" response_body as "response_body!"
FROM idempotency FROM idempotency
WHERE WHERE
user_id = $1 user_id = $1
AND idempotency_key = $2 AND idempotency_key = $2
"#, "#,
user_id, user_id,
idempotency_key.as_ref() idempotency_key.as_ref()
@@ -73,15 +73,15 @@ pub async fn save_response(
let query = sqlx::query_unchecked!( let query = sqlx::query_unchecked!(
r#" r#"
UPDATE idempotency UPDATE idempotency
SET SET
response_status_code = $3, response_status_code = $3,
response_headers = $4, response_headers = $4,
response_body = $5 response_body = $5
WHERE WHERE
user_id = $1 user_id = $1
AND idempotency_key = $2 AND idempotency_key = $2
"#, "#,
user_id, user_id,
idempotency_key.as_ref(), idempotency_key.as_ref(),
status_code, status_code,
@@ -109,10 +109,10 @@ pub async fn try_processing(
let mut transaction = connection_pool.begin().await?; let mut transaction = connection_pool.begin().await?;
let query = sqlx::query!( let query = sqlx::query!(
r#" r#"
INSERT INTO idempotency (user_id, idempotency_key, created_at) INSERT INTO idempotency (user_id, idempotency_key, created_at)
VALUES ($1, $2, now()) VALUES ($1, $2, now())
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
"#, "#,
user_id, user_id,
idempotency_key.as_ref() idempotency_key.as_ref()
); );

View File

@@ -8,3 +8,4 @@ pub mod routes;
pub mod session_state; pub mod session_state;
pub mod startup; pub mod startup;
pub mod telemetry; pub mod telemetry;
pub mod templates;

View File

@@ -2,7 +2,6 @@ mod admin;
mod health_check; mod health_check;
mod home; mod home;
mod login; mod login;
mod register;
mod subscriptions; mod subscriptions;
mod subscriptions_confirm; mod subscriptions_confirm;
@@ -10,6 +9,5 @@ pub use admin::*;
pub use health_check::*; pub use health_check::*;
pub use home::*; pub use home::*;
pub use login::*; pub use login::*;
pub use register::*;
pub use subscriptions::*; pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;

View File

@@ -1,15 +1,18 @@
pub mod change_password; mod change_password;
pub mod dashboard; mod dashboard;
pub mod newsletters; mod logout;
mod newsletters;
use crate::{routes::error_chain_fmt, session_state::TypedSession}; use crate::{routes::error_chain_fmt, templates::ErrorTemplate};
use askama::Template;
use axum::{ use axum::{
Json, Json,
response::{IntoResponse, Redirect, Response}, http::HeaderMap,
response::{Html, IntoResponse, Response},
}; };
use axum_messages::Messages;
pub use change_password::*; pub use change_password::*;
pub use dashboard::*; pub use dashboard::*;
pub use logout::*;
pub use newsletters::*; pub use newsletters::*;
use reqwest::StatusCode; use reqwest::StatusCode;
@@ -20,7 +23,7 @@ pub enum AdminError {
#[error("Trying to access admin dashboard without authentication.")] #[error("Trying to access admin dashboard without authentication.")]
NotAuthenticated, NotAuthenticated,
#[error("Updating password failed.")] #[error("Updating password failed.")]
ChangePassword, ChangePassword(String),
#[error("Could not publish newsletter.")] #[error("Could not publish newsletter.")]
Publish(#[source] anyhow::Error), Publish(#[source] anyhow::Error),
#[error("The idempotency key was invalid.")] #[error("The idempotency key was invalid.")]
@@ -50,19 +53,26 @@ impl IntoResponse for AdminError {
}), }),
) )
.into_response(), .into_response(),
AdminError::NotAuthenticated => Redirect::to("/login").into_response(), AdminError::NotAuthenticated => {
AdminError::ChangePassword => Redirect::to("/admin/password").into_response(), let mut headers = HeaderMap::new();
AdminError::Publish(_) => Redirect::to("/admin/newsletters").into_response(), headers.insert("HX-Redirect", "/login".parse().unwrap());
(StatusCode::OK, headers).into_response()
}
AdminError::ChangePassword(e) => {
let template = ErrorTemplate {
error_message: e.to_owned(),
};
Html(template.render().unwrap()).into_response()
}
AdminError::Publish(e) => {
let template = ErrorTemplate {
error_message: e.to_string(),
};
Html(template.render().unwrap()).into_response()
}
AdminError::Idempotency(e) => { AdminError::Idempotency(e) => {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { message: e })).into_response() (StatusCode::BAD_REQUEST, Json(ErrorResponse { message: e })).into_response()
} }
} }
} }
} }
#[tracing::instrument(name = "Logging out", skip(messages, session))]
pub async fn logout(messages: Messages, session: TypedSession) -> Result<Response, AdminError> {
session.clear().await;
messages.success("You have successfully logged out.");
Ok(Redirect::to("/login").into_response())
}

View File

@@ -2,15 +2,15 @@ use crate::{
authentication::{self, AuthenticatedUser, Credentials, validate_credentials}, authentication::{self, AuthenticatedUser, Credentials, validate_credentials},
routes::AdminError, routes::AdminError,
startup::AppState, startup::AppState,
templates::SuccessTemplate,
}; };
use askama::Template;
use axum::{ use axum::{
Extension, Form, Extension, Form,
extract::State, extract::State,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_messages::Messages;
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use std::fmt::Write;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct PasswordFormData { pub struct PasswordFormData {
@@ -19,24 +19,11 @@ pub struct PasswordFormData {
pub new_password_check: SecretString, pub new_password_check: SecretString,
} }
pub async fn change_password_form(messages: Messages) -> Result<Response, AdminError> {
let mut error_html = String::new();
for message in messages {
writeln!(error_html, "<p><i>{}</i></p>", message).unwrap();
}
Ok(Html(format!(
include_str!("html/change_password_form.html"),
error_html
))
.into_response())
}
pub async fn change_password( pub async fn change_password(
Extension(AuthenticatedUser { user_id, username }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { user_id, username }): Extension<AuthenticatedUser>,
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
messages: Messages,
Form(form): Form<PasswordFormData>, Form(form): Form<PasswordFormData>,
) -> Result<Response, AdminError> { ) -> Result<Response, AdminError> {
let credentials = Credentials { let credentials = Credentials {
@@ -44,23 +31,26 @@ pub async fn change_password(
password: form.current_password, password: form.current_password,
}; };
if form.new_password.expose_secret() != form.new_password_check.expose_secret() { if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
messages.error("You entered two different passwords - the field values must match."); Err(AdminError::ChangePassword(
Err(AdminError::ChangePassword) "You entered two different passwords - the field values must match.".to_string(),
))
} else if validate_credentials(credentials, &connection_pool) } else if validate_credentials(credentials, &connection_pool)
.await .await
.is_err() .is_err()
{ {
messages.error("The current password is incorrect."); Err(AdminError::ChangePassword(
Err(AdminError::ChangePassword) "The current password is incorrect.".to_string(),
))
} else if let Err(e) = verify_password(form.new_password.expose_secret()) { } else if let Err(e) = verify_password(form.new_password.expose_secret()) {
messages.error(e); Err(AdminError::ChangePassword(e))
Err(AdminError::ChangePassword)
} else { } else {
authentication::change_password(user_id, form.new_password, &connection_pool) authentication::change_password(user_id, form.new_password, &connection_pool)
.await .await
.map_err(|_| AdminError::ChangePassword)?; .map_err(|e| AdminError::ChangePassword(e.to_string()))?;
messages.success("Your password has been changed."); let template = SuccessTemplate {
Ok(Redirect::to("/admin/password").into_response()) success_message: "Your password has been changed.".to_string(),
};
Ok(Html(template.render().unwrap()).into_response())
} }
} }

View File

@@ -1,11 +1,25 @@
use crate::authentication::AuthenticatedUser; use crate::authentication::AuthenticatedUser;
use askama::Template;
use axum::{ use axum::{
Extension, Extension,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use uuid::Uuid;
#[derive(Template)]
#[template(path = "../templates/dashboard.html")]
struct DashboardTemplate {
username: String,
idempotency_key: String,
}
pub async fn admin_dashboard( pub async fn admin_dashboard(
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>,
) -> Response { ) -> Response {
Html(format!(include_str!("html/dashboard.html"), username)).into_response() let idempotency_key = Uuid::new_v4().to_string();
let template = DashboardTemplate {
username,
idempotency_key,
};
Html(template.render().unwrap()).into_response()
} }

View File

@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Change password</title>
</head>
<body>
<form action="/admin/password" method="post">
<input
type="password"
name="current_password"
placeholder="Current password"
/>
<input type="password" name="new_password" placeholder="New password" />
<input
type="password"
name="new_password_check"
placeholder="Confirm new password"
/>
<button type="submit">Change password</button>
</form>
{}
<p><a href="/admin/dashboard">Back</a></p>
</body>
</html>

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Admin dashboard</title>
</head>
<body>
<p>Welcome {}!</p>
<p>Available actions:</p>
<ol>
<li><a href="/admin/password">Change password</a></li>
<li><a href="/admin/newsletters">Send a newsletter</a></li>
<li>
<form name="logoutForm" action="/admin/logout" method="post">
<input type="submit" value="Logout" />
</form>
</li>
</ol>
</body>
</html>

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Send a newsletter</title>
</head>
<body>
<form action="/admin/newsletters" method="post">
<input type="text" name="title" placeholder="Subject" />
<input type="text" name="html" placeholder="Content (HTML)" />
<input type="text" name="text" placeholder="Content (text)" />
<input hidden type="text" name="idempotency_key" value="{}" />
<button type="submit">Send</button>
</form>
{}
<p><a href="/admin/dashboard">Back</a></p>
</body>
</html>

View File

@@ -0,0 +1,14 @@
use crate::{routes::AdminError, session_state::TypedSession};
use axum::{
http::HeaderMap,
response::{IntoResponse, Response},
};
use reqwest::StatusCode;
#[tracing::instrument(name = "Logging out", skip(session))]
pub async fn logout(session: TypedSession) -> Result<Response, AdminError> {
session.clear().await;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap());
Ok((StatusCode::OK, headers).into_response())
}

View File

@@ -3,16 +3,16 @@ use crate::{
idempotency::{IdempotencyKey, save_response, try_processing}, idempotency::{IdempotencyKey, save_response, try_processing},
routes::AdminError, routes::AdminError,
startup::AppState, startup::AppState,
templates::SuccessTemplate,
}; };
use anyhow::Context; use anyhow::Context;
use askama::Template;
use axum::{ use axum::{
Extension, Form, Extension, Form,
extract::State, extract::State,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_messages::Messages;
use sqlx::{Executor, Postgres, Transaction}; use sqlx::{Executor, Postgres, Transaction};
use std::fmt::Write;
use uuid::Uuid; use uuid::Uuid;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -23,19 +23,6 @@ pub struct BodyData {
idempotency_key: String, idempotency_key: String,
} }
pub async fn publish_newsletter_form(messages: Messages) -> Response {
let mut error_html = String::new();
for message in messages {
writeln!(error_html, "<p><i>{}</i></p>", message).unwrap();
}
let idempotency_key = Uuid::new_v4();
Html(format!(
include_str!("html/send_newsletter_form.html"),
idempotency_key, error_html
))
.into_response()
}
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub async fn insert_newsletter_issue( pub async fn insert_newsletter_issue(
transaction: &mut Transaction<'static, Postgres>, transaction: &mut Transaction<'static, Postgres>,
@@ -81,20 +68,15 @@ async fn enqueue_delivery_tasks(
Ok(()) Ok(())
} }
#[tracing::instrument( #[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))]
name = "Publishing a newsletter",
skip(connection_pool, form, messages)
)]
pub async fn publish_newsletter( pub async fn publish_newsletter(
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
messages: Messages,
Form(form): Form<BodyData>, Form(form): Form<BodyData>,
) -> Result<Response, AdminError> { ) -> Result<Response, AdminError> {
if let Err(e) = validate_form(&form) { if let Err(e) = validate_form(&form) {
messages.error(e);
return Err(AdminError::Publish(anyhow::anyhow!(e))); return Err(AdminError::Publish(anyhow::anyhow!(e)));
} }
@@ -103,17 +85,9 @@ pub async fn publish_newsletter(
.try_into() .try_into()
.map_err(AdminError::Idempotency)?; .map_err(AdminError::Idempotency)?;
let success_message = || {
messages.success(format!(
"The newsletter issue '{}' has been published!",
form.title
))
};
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? { let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
crate::idempotency::NextAction::StartProcessing(t) => t, crate::idempotency::NextAction::StartProcessing(t) => t,
crate::idempotency::NextAction::ReturnSavedResponse(response) => { crate::idempotency::NextAction::ReturnSavedResponse(response) => {
success_message();
return Ok(response); return Ok(response);
} }
}; };
@@ -126,8 +100,12 @@ pub async fn publish_newsletter(
.await .await
.context("Failed to enqueue delivery tasks")?; .context("Failed to enqueue delivery tasks")?;
let response = Redirect::to("/admin/newsletters").into_response(); let success_message = format!(
success_message(); r#"The newsletter issue "{}" has been published!"#,
form.title
);
let template = SuccessTemplate { success_message };
let response = Html(template.render().unwrap()).into_response();
save_response(transaction, &idempotency_key, user_id, response) save_response(transaction, &idempotency_key, user_id, response)
.await .await
.map_err(AdminError::UnexpectedError) .map_err(AdminError::UnexpectedError)

View File

@@ -1,5 +1,10 @@
use axum::response::{Html, IntoResponse}; use askama::Template;
use axum::response::Html;
pub async fn home() -> impl IntoResponse { #[derive(Template)]
Html(include_str!("home/home.html")) #[template(path = "../templates/home.html")]
struct HomeTemplate;
pub async fn home() -> Html<String> {
Html(HomeTemplate.render().unwrap())
} }

View File

@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Home</title>
</head>
<body>
<p>Welcome to our newsletter!</p>
<ol>
<li><a href="/login">Admin login</a></li>
<li><a href="/register">Register</a></li>
</ol>
</body>
</html>

View File

@@ -3,16 +3,16 @@ use crate::{
routes::error_chain_fmt, routes::error_chain_fmt,
session_state::TypedSession, session_state::TypedSession,
startup::AppState, startup::AppState,
templates::ErrorTemplate,
}; };
use askama::Template;
use axum::http::{HeaderMap, StatusCode};
use axum::{ use axum::{
Form, Json, Form, Json,
extract::State, extract::State,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_messages::Messages;
use reqwest::StatusCode;
use secrecy::SecretString; use secrecy::SecretString;
use std::fmt::Write;
#[derive(thiserror::Error)] #[derive(thiserror::Error)]
pub enum LoginError { pub enum LoginError {
@@ -45,33 +45,37 @@ impl IntoResponse for LoginError {
}), }),
) )
.into_response(), .into_response(),
LoginError::AuthError(_) => Redirect::to("/login").into_response(), LoginError::AuthError(e) => {
let template = ErrorTemplate {
error_message: e.to_string(),
};
Html(template.render().unwrap()).into_response()
}
} }
} }
} }
#[derive(Template)]
#[template(path = "../templates/login.html")]
struct LoginTemplate;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct LoginFormData { pub struct LoginFormData {
username: String, username: String,
password: SecretString, password: SecretString,
} }
pub async fn get_login(messages: Messages) -> impl IntoResponse { pub async fn get_login() -> Html<String> {
let mut error_html = String::new(); Html(LoginTemplate.render().unwrap())
for message in messages {
writeln!(error_html, "<p><i>{}</i></p>", message).unwrap();
}
Html(format!(include_str!("login/login.html"), error_html))
} }
pub async fn post_login( pub async fn post_login(
session: TypedSession, session: TypedSession,
messages: Messages,
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Form(form): Form<LoginFormData>, Form(form): Form<LoginFormData>,
) -> Result<Redirect, LoginError> { ) -> Result<Response, LoginError> {
let credentials = Credentials { let credentials = Credentials {
username: form.username.clone(), username: form.username.clone(),
password: form.password, password: form.password,
@@ -81,11 +85,7 @@ pub async fn post_login(
Err(e) => { Err(e) => {
let e = match e { let e = match e {
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()), AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
AuthError::InvalidCredentials(_) => { AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
let e = LoginError::AuthError(e.into());
messages.error(e.to_string());
e
}
AuthError::NotAuthenticated => unreachable!(), AuthError::NotAuthenticated => unreachable!(),
}; };
Err(e) Err(e)
@@ -104,7 +104,10 @@ pub async fn post_login(
.insert_username(form.username) .insert_username(form.username)
.await .await
.map_err(|e| LoginError::UnexpectedError(e.into()))?; .map_err(|e| LoginError::UnexpectedError(e.into()))?;
Ok(Redirect::to("/admin/dashboard"))
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
Ok((StatusCode::OK, headers).into_response())
} }
} }
} }

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Login</title>
</head>
<body>
<form action="/login" method="post">
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
{}
</body>
</html>

View File

@@ -1,11 +0,0 @@
use axum::response::{Html, IntoResponse, Response};
use axum_messages::Messages;
use std::fmt::Write;
pub async fn register(messages: Messages) -> Response {
let mut error_html = String::new();
for message in messages {
writeln!(error_html, "<p><i>{}</i></p>", message).unwrap();
}
Html(format!(include_str!("register/register.html"), error_html)).into_response()
}

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Account confirmed</title>
</head>
<body>
<p>Your account has been confirmed. Welcome!</p>
</body>
</html>

View File

@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Register</title>
</head>
<body>
<form action="/subscriptions" method="post">
<input type="text" name="name" placeholder="Name" />
<input type="text" name="email" placeholder="Email address" />
<input
type="text"
name="email_check"
placeholder="Confirm email address"
/>
<button type="Register">Register</button>
</form>
{}
<p><a href="/">Back</a></p>
</body>
</html>

View File

@@ -1,16 +1,17 @@
use crate::{ use crate::{
domain::{NewSubscriber, SubscriberEmail, SubscriberName}, domain::{NewSubscriber, SubscriberEmail},
email_client::EmailClient, email_client::EmailClient,
startup::AppState, startup::AppState,
templates::{ErrorTemplate, SuccessTemplate},
}; };
use anyhow::Context; use anyhow::Context;
use askama::Template;
use axum::{ use axum::{
Form, Json, Form, Json,
extract::State, extract::State,
http::StatusCode, http::StatusCode,
response::{IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_messages::Messages;
use chrono::Utc; use chrono::Utc;
use rand::{Rng, distr::Alphanumeric}; use rand::{Rng, distr::Alphanumeric};
use serde::Deserialize; use serde::Deserialize;
@@ -72,21 +73,22 @@ impl IntoResponse for SubscribeError {
}), }),
) )
.into_response(), .into_response(),
SubscribeError::ValidationError(_) => Redirect::to("/register").into_response(), SubscribeError::ValidationError(e) => {
let template = ErrorTemplate { error_message: e };
Html(template.render().unwrap()).into_response()
}
} }
} }
} }
#[tracing::instrument( #[tracing::instrument(
name = "Adding a new subscriber", name = "Adding a new subscriber",
skip(messages, connection_pool, email_client, base_url, form), skip(connection_pool, email_client, base_url, form),
fields( fields(
subscriber_email = %form.email, subscriber_email = %form.email,
subscriber_name = %form.name
) )
)] )]
pub async fn subscribe( pub async fn subscribe(
messages: Messages,
State(AppState { State(AppState {
connection_pool, connection_pool,
email_client, email_client,
@@ -98,7 +100,6 @@ pub async fn subscribe(
let new_subscriber = match form.try_into() { let new_subscriber = match form.try_into() {
Ok(new_sub) => new_sub, Ok(new_sub) => new_sub,
Err(e) => { Err(e) => {
messages.error(&e);
return Err(SubscribeError::ValidationError(e)); return Err(SubscribeError::ValidationError(e));
} }
}; };
@@ -125,8 +126,10 @@ pub async fn subscribe(
.commit() .commit()
.await .await
.context("Failed to commit the database transaction to store a new subscriber.")?; .context("Failed to commit the database transaction to store a new subscriber.")?;
messages.success("A confirmation email has been sent."); let template = SuccessTemplate {
Ok(Redirect::to("/register").into_response()) success_message: "A confirmation email has been sent.".to_string(),
};
Ok(Html(template.render().unwrap()).into_response())
} }
#[tracing::instrument( #[tracing::instrument(
@@ -140,12 +143,11 @@ pub async fn insert_subscriber(
let subscriber_id = Uuid::new_v4(); let subscriber_id = Uuid::new_v4();
let query = sqlx::query!( let query = sqlx::query!(
r#" r#"
INSERT INTO subscriptions (id, email, name, subscribed_at, status) INSERT INTO subscriptions (id, email, subscribed_at, status)
VALUES ($1, $2, $3, $4, 'pending_confirmation') VALUES ($1, $2, $3, 'pending_confirmation')
"#, "#,
subscriber_id, subscriber_id,
new_subscriber.email.as_ref(), new_subscriber.email.as_ref(),
new_subscriber.name.as_ref(),
Utc::now() Utc::now()
); );
transaction.execute(query).await?; transaction.execute(query).await?;
@@ -207,22 +209,15 @@ Click <a href=\"{}\">here</a> to confirm your subscription.",
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct SubscriptionFormData { pub struct SubscriptionFormData {
name: String,
email: String, email: String,
email_check: String,
} }
impl TryFrom<SubscriptionFormData> for NewSubscriber { impl TryFrom<SubscriptionFormData> for NewSubscriber {
type Error = String; type Error = String;
fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> { fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> {
let name = SubscriberName::parse(value.name)?;
if value.email != value.email_check {
return Err("Email addresses don't match.".into());
}
let email = SubscriberEmail::parse(value.email)?; let email = SubscriberEmail::parse(value.email)?;
Ok(Self { name, email }) Ok(Self { email })
} }
} }

View File

@@ -1,4 +1,5 @@
use crate::startup::AppState; use crate::startup::AppState;
use askama::Template;
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
http::StatusCode, http::StatusCode,
@@ -8,6 +9,10 @@ use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
#[derive(Template)]
#[template(path = "../templates/confirm.html")]
struct ConfirmTemplate;
#[tracing::instrument(name = "Confirming new subscriber", skip(params))] #[tracing::instrument(name = "Confirming new subscriber", skip(params))]
pub async fn confirm( pub async fn confirm(
State(AppState { State(AppState {
@@ -27,7 +32,7 @@ pub async fn confirm(
{ {
StatusCode::INTERNAL_SERVER_ERROR.into_response() StatusCode::INTERNAL_SERVER_ERROR.into_response()
} else { } else {
Html(include_str!("register/confirm.html")).into_response() Html(ConfirmTemplate.render().unwrap()).into_response()
} }
} else { } else {
StatusCode::UNAUTHORIZED.into_response() StatusCode::UNAUTHORIZED.into_response()

View File

@@ -8,12 +8,11 @@ use axum::{
middleware, middleware,
routing::{get, post}, routing::{get, post},
}; };
use axum_messages::MessagesManagerLayer;
use axum_server::tls_rustls::RustlsConfig; use axum_server::tls_rustls::RustlsConfig;
use secrecy::ExposeSecret; use secrecy::ExposeSecret;
use sqlx::{PgPool, postgres::PgPoolOptions}; use sqlx::{PgPool, postgres::PgPoolOptions};
use std::{net::TcpListener, sync::Arc}; use std::{net::TcpListener, sync::Arc};
use tower_http::trace::TraceLayer; use tower_http::{services::ServeDir, trace::TraceLayer};
use tower_sessions::SessionManagerLayer; use tower_sessions::SessionManagerLayer;
use tower_sessions_redis_store::{ use tower_sessions_redis_store::{
RedisStore, RedisStore,
@@ -118,16 +117,13 @@ pub fn app(
}; };
let admin_routes = Router::new() let admin_routes = Router::new()
.route("/dashboard", get(admin_dashboard)) .route("/dashboard", get(admin_dashboard))
.route("/password", get(change_password_form).post(change_password)) .route("/password", post(change_password))
.route( .route("/newsletters", post(publish_newsletter))
"/newsletters",
get(publish_newsletter_form).post(publish_newsletter),
)
.route("/logout", post(logout)) .route("/logout", post(logout))
.layer(middleware::from_fn(require_auth)); .layer(middleware::from_fn(require_auth));
Router::new() Router::new()
.nest_service("/assets", ServeDir::new("assets"))
.route("/", get(home)) .route("/", get(home))
.route("/register", get(register))
.route("/login", get(get_login).post(post_login)) .route("/login", get(get_login).post(post_login))
.route("/health_check", get(health_check)) .route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe)) .route("/subscriptions", post(subscribe))
@@ -150,7 +146,6 @@ pub fn app(
) )
}), }),
) )
.layer(MessagesManagerLayer)
.layer(SessionManagerLayer::new(redis_store).with_secure(false)) .layer(SessionManagerLayer::new(redis_store).with_secure(false))
.with_state(app_state) .with_state(app_state)
} }

13
src/templates.rs Normal file
View File

@@ -0,0 +1,13 @@
use askama::Template;
#[derive(Template)]
#[template(path = "../templates/success.html")]
pub struct SuccessTemplate {
pub success_message: String,
}
#[derive(Template)]
#[template(path = "../templates/error.html")]
pub struct ErrorTemplate {
pub error_message: String,
}

66
templates/base.html Normal file
View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="zero2prod newsletter" />
<meta name="keywords" content="newsletter, rust, axum, htmx" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
{% block title %}zero2prod{% endblock %}
</title>
<link href="/assets/css/main.css" rel="stylesheet" />
<script src="../assets/js/htmx.min.js"></script>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
<header class="bg-white shadow-sm border-b border-gray-200 top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0">
<a href="/" class="hover:opacity-80 transition-opacity">
<h1 class="text-xl font-bold text-gray-900">
<span class="text-blue-600">zero2prod</span>
</h1>
</a>
</div>
<nav>
<a href="/admin/dashboard"
class="bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 rounded-md text-sm font-medium transition-colors">
Dashboard
</a>
</nav>
</div>
</div>
</header>
<div class="flex flex-1">
<main class="flex-1 lg:ml-0">
<div class="py-8 px-4 sm:px-6 lg:px-8">
{% block content %}{% endblock %}
</div>
</main>
</div>
<footer class="bg-white border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row justify-between items-center">
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
<div class="flex items-center space-x-4">
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
target="_blank"
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
Code repository
<svg class="ml-1 h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
<div class="mt-4 md:mt-0">
<p class="text-xs text-gray-500">Built with ❤️ using Rust, axum & htmx</p>
</div>
</div>
</div>
</footer>
</body>
</html>

27
templates/confirm.html Normal file
View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}zero2prod{% endblock %}
{% block content %}
<div class="min-h-[60vh] flex items-center justify-center">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-6">
<svg class="h-8 w-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-4">Subscription confirmed</h1>
<p class="text-lg text-gray-600 mb-8">
Your email has been confirmed! You're all set to receive our newsletter
updates.
</p>
</div>
<div class="text-center">
<a href="/"
class="text-sm text-blue-600 hover:text-blue-500 transition-colors">← Back to homepage</a>
</div>
</div>
</div>
{% endblock %}

214
templates/dashboard.html Normal file
View File

@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}Dashboard - zero2prod{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-gray-600">
Connected as <span class="font-bold">{{ username }}</span>
</p>
<button hx-post="/admin/logout"
type="submit"
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Logout</span>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Total subscribers</p>
<p class="text-2xl font-semibold text-gray-900">2,143</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Issues sent</p>
<p class="text-2xl font-semibold text-gray-900">23</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Open rate</p>
<p class="text-2xl font-semibold text-gray-900">68%</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Growth</p>
<p class="text-2xl font-semibold text-gray-900">+12%</p>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Send an issue
</h2>
<p class="text-sm text-gray-600 mt-1">Create and send a newsletter issue.</p>
</div>
<div class="p-6">
<form hx-post="/admin/newsletters"
hx-target="#newsletter-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}" />
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text"
id="title"
name="title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label for="html" class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
<textarea id="html"
name="html"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
</div>
<div>
<label for="text" class="block text-sm font-medium text-gray-700 mb-2">Plain text content</label>
<textarea id="text"
name="text"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send
</button>
<div id="newsletter-messages" class="mt-4"></div>
</form>
</div>
</div>
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Password
</h2>
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
</div>
<div class="p-6">
<form hx-post="/admin/password"
hx-target="#password-messages"
hx-swap="innerHTML"
class="space-y-4">
<div>
<label for="current_password"
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
<input type="password"
id="current_password"
name="current_password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<div>
<label for="new_password"
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
<input type="password"
id="new_password"
name="new_password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<div>
<label for="new_password_check"
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
<input type="password"
id="new_password_check"
name="new_password_check"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<button type="submit"
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update password
</button>
<div id="password-messages" class="mt-4"></div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

9
templates/error.html Normal file
View File

@@ -0,0 +1,9 @@
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd">
</path>
</svg>
<span class="font-medium">{{ error_message }}</span>
</div>
</div>

120
templates/home.html Normal file
View File

@@ -0,0 +1,120 @@
{% extends "base.html" %}
{% block title %}Home - zero2prod{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 mb-8">
<div class="max-w-3xl">
<h1 class="text-4xl font-bold mb-4">zero2prod</h1>
<p class="text-xl text-blue-100 mb-6">
Welcome to our newsletter! Stay updated on our latest projects and
thoughts. Unsubscribe at any time.
</p>
<div class="flex flex-col sm:flex-row gap-4">
<a href="#newsletter-signup"
class="bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors text-center">
Subscribe
</a>
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
target="_blank"
class="border border-white text-white hover:bg-white hover:text-blue-600 font-semibold py-3 px-6 rounded-md transition-colors text-center">
View code
</a>
</div>
</div>
</div>
<div class="grid md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Idempotent</h3>
<p class="text-gray-600 text-sm">
Smart duplicate prevention ensures you'll never receive the same email
twice.
</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Privacy first</h3>
<p class="text-gray-600 text-sm">
Zero spam, zero tracking, zero data sharing. Your email stays private
and secure. Unsubscribe at any time.
</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Quality content</h3>
<p class="text-gray-600 text-sm">
Curated insights on Rust backend development, performance tips, and
production war stories.
</p>
</div>
</div>
<div id="newsletter-signup"
class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2>
<p class="text-gray-600 mb-6">Subscribe to our newsletter to get the latest updates.</p>
<form hx-post="/subscriptions"
hx-target="#subscribe-messages"
hx-swap="innerHTML"
class="max-w-md mx-auto">
<div class="flex flex-col sm:flex-row gap-3">
<input type="email"
name="email"
placeholder="you@example.com"
required
class="flex-1 px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
<button type="submit"
class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors">
Subscribe
</button>
</div>
<div id="subscribe-messages" class="mt-4"></div>
</form>
</div>
</div>
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 text-center">Stats</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<div class="text-2xl font-bold text-blue-600" id="subscriber-count">2</div>
<div class="text-sm text-gray-600">subscribers</div>
</div>
<div>
<div>
<div class="text-2xl font-bold text-orange-600">23</div>
<div class="text-sm text-gray-600">emails sent</div>
</div>
</div>
<div>
<div class="text-2xl font-bold text-green-600">0</div>
<div class="text-sm text-gray-600">email opened</div>
</div>
<div>
<div class="text-2xl font-bold text-purple-600">3</div>
<div class="text-sm text-gray-600">issues delivered</div>
</div>
</div>
</div>
</div>
{% endblock %}

1
templates/input.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

46
templates/login.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Login - zero2prod{% endblock %}
{% block content %}
<div class="min-h-[60vh] flex items-center justify-center">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h2 class="text-3xl font-bold text-gray-900">Login</h2>
<p class="mt-2 text-sm text-gray-600">Sign in to access the admin dashboard.</p>
</div>
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<form hx-post="/login"
hx-target="#login-messages"
hx-swap="innerHTML"
class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">Username</label>
<input type="text"
id="username"
name="username"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
<input type="password"
id="password"
name="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<button type="submit"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
Continue
</button>
</div>
<div id="login-messages" class="mt-4"></div>
</form>
</div>
<div class="text-center">
<a href="/"
class="text-sm text-blue-600 hover:text-blue-500 transition-colors">← Back to homepage</a>
</div>
</div>
</div>
{% endblock %}

9
templates/success.html Normal file
View File

@@ -0,0 +1,9 @@
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md mb-4">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd">
</path>
</svg>
<span class="font-medium">{{ success_message }}</span>
</div>
</div>

View File

@@ -21,14 +21,12 @@ async fn logout_clears_session_state() {
assert_is_redirect_to(&response, "/admin/dashboard"); assert_is_redirect_to(&response, "/admin/dashboard");
let html_page = app.get_admin_dashboard_html().await; let html_page = app.get_admin_dashboard_html().await;
assert!(html_page.contains(&format!("Welcome {}", app.test_user.username))); assert!(html_page.contains("Connected as"));
assert!(html_page.contains(&app.test_user.username));
let response = app.post_logout().await; let response = app.post_logout().await;
assert_is_redirect_to(&response, "/login"); assert_is_redirect_to(&response, "/login");
let html_page = app.get_login_html().await;
assert!(html_page.contains("You have successfully logged out"));
let response = app.get_admin_dashboard().await; let response = app.get_admin_dashboard().await;
assert_is_redirect_to(&response, "/login"); assert_is_redirect_to(&response, "/login");
} }

View File

@@ -1,15 +1,5 @@
use uuid::Uuid;
use crate::helpers::{TestApp, assert_is_redirect_to}; use crate::helpers::{TestApp, assert_is_redirect_to};
use uuid::Uuid;
#[tokio::test]
async fn you_must_be_logged_in_to_see_the_change_password_form() {
let app = TestApp::spawn().await;
let response = app.get_change_password().await;
assert_is_redirect_to(&response, "/login");
}
#[tokio::test] #[tokio::test]
async fn you_must_be_logged_in_to_change_your_password() { async fn you_must_be_logged_in_to_change_your_password() {
@@ -46,10 +36,10 @@ async fn new_password_fields_must_match() {
"new_password_check": another_new_password, "new_password_check": another_new_password,
})) }))
.await; .await;
assert_is_redirect_to(&response, "/admin/password"); assert!(response.status().is_success());
let html_page = app.get_change_password_html().await; let html_fragment = response.text().await.unwrap();
assert!(html_page.contains("You entered two different passwords")); assert!(html_fragment.contains("You entered two different passwords"));
} }
#[tokio::test] #[tokio::test]
@@ -70,10 +60,10 @@ async fn current_password_is_invalid() {
"new_password_check": new_password, "new_password_check": new_password,
})) }))
.await; .await;
assert_is_redirect_to(&response, "/admin/password"); assert!(response.status().is_success());
let html_page = app.get_change_password_html().await; let html_fragment = response.text().await.unwrap();
assert!(html_page.contains("The current password is incorrect")); assert!(html_fragment.contains("The current password is incorrect"));
} }
#[tokio::test] #[tokio::test]
@@ -95,17 +85,14 @@ async fn changing_password_works() {
"new_password_check": new_password, "new_password_check": new_password,
})) }))
.await; .await;
assert_is_redirect_to(&response, "/admin/password"); assert!(response.status().is_success());
let html_page = app.get_change_password_html().await; let html_page_fragment = response.text().await.unwrap();
assert!(html_page.contains("Your password has been changed")); assert!(html_page_fragment.contains("Your password has been changed"));
let response = app.post_logout().await; let response = app.post_logout().await;
assert_is_redirect_to(&response, "/login"); assert_is_redirect_to(&response, "/login");
let html_page = app.get_login_html().await;
assert!(html_page.contains("You have successfully logged out"));
let login_body = &serde_json::json!({ let login_body = &serde_json::json!({
"username": app.test_user.username, "username": app.test_user.username,
"password": new_password, "password": new_password,

View File

@@ -6,7 +6,10 @@ use linkify::LinkFinder;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool}; use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid; use uuid::Uuid;
use wiremock::MockServer; use wiremock::{
Mock, MockBuilder, MockServer,
matchers::{method, path},
};
use zero2prod::{ use zero2prod::{
configuration::{DatabaseSettings, get_configuration}, configuration::{DatabaseSettings, get_configuration},
email_client::EmailClient, email_client::EmailClient,
@@ -149,17 +152,6 @@ impl TestApp {
ConfirmationLinks { html, text } ConfirmationLinks { html, text }
} }
pub async fn get_login_html(&self) -> String {
self.api_client
.get(format!("{}/login", &self.address))
.send()
.await
.expect("Failed to execute request")
.text()
.await
.unwrap()
}
pub async fn get_admin_dashboard(&self) -> reqwest::Response { pub async fn get_admin_dashboard(&self) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/admin/dashboard", &self.address)) .get(format!("{}/admin/dashboard", &self.address))
@@ -172,29 +164,6 @@ impl TestApp {
self.get_admin_dashboard().await.text().await.unwrap() self.get_admin_dashboard().await.text().await.unwrap()
} }
pub async fn get_register_html(&self) -> String {
self.api_client
.get(format!("{}/register", &self.address))
.send()
.await
.expect("Failed to execute request")
.text()
.await
.unwrap()
}
pub async fn get_change_password(&self) -> reqwest::Response {
self.api_client
.get(format!("{}/admin/password", &self.address))
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_change_password_html(&self) -> String {
self.get_change_password().await.text().await.unwrap()
}
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
self.api_client self.api_client
.post(format!("{}/subscriptions", self.address)) .post(format!("{}/subscriptions", self.address))
@@ -205,18 +174,6 @@ impl TestApp {
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn get_newsletter_form(&self) -> reqwest::Response {
self.api_client
.get(format!("{}/admin/password", &self.address))
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_newsletter_form_html(&self) -> String {
self.get_newsletter_form().await.text().await.unwrap()
}
pub async fn post_newsletters<Body>(&self, body: &Body) -> reqwest::Response pub async fn post_newsletters<Body>(&self, body: &Body) -> reqwest::Response
where where
Body: serde::Serialize, Body: serde::Serialize,
@@ -291,6 +248,11 @@ async fn configure_database(config: &DatabaseSettings) -> PgPool {
} }
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) { pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
assert_eq!(response.status().as_u16(), 303); dbg!(&response);
assert_eq!(response.headers().get("Location").unwrap(), location); assert_eq!(response.status().as_u16(), 200);
assert_eq!(response.headers().get("hx-redirect").unwrap(), location);
}
pub fn when_sending_an_email() -> MockBuilder {
Mock::given(path("/email")).and(method("POST"))
} }

View File

@@ -1,7 +1,7 @@
use crate::helpers::{TestApp, assert_is_redirect_to}; use crate::helpers::{TestApp, assert_is_redirect_to};
#[tokio::test] #[tokio::test]
async fn an_error_flash_message_is_set_on_failure() { async fn an_error_html_fragment_is_returned_on_failure() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
let login_body = serde_json::json!({ let login_body = serde_json::json!({
@@ -11,11 +11,10 @@ async fn an_error_flash_message_is_set_on_failure() {
let response = app.post_login(&login_body).await; let response = app.post_login(&login_body).await;
assert_eq!(response.status().as_u16(), 303); assert_eq!(response.status().as_u16(), 200);
assert_is_redirect_to(&response, "/login");
let login_page_html = app.get_login_html().await; let response_html = response.text().await.unwrap();
assert!(login_page_html.contains("Authentication failed")); assert!(response_html.contains("Invalid credentials"));
} }
#[tokio::test] #[tokio::test]
@@ -31,5 +30,6 @@ async fn login_redirects_to_admin_dashboard_after_login_success() {
assert_is_redirect_to(&response, "/admin/dashboard"); assert_is_redirect_to(&response, "/admin/dashboard");
let html_page = app.get_admin_dashboard_html().await; let html_page = app.get_admin_dashboard_html().await;
assert!(html_page.contains(&format!("Welcome {}", app.test_user.username))); assert!(html_page.contains("Connected as"));
assert!(html_page.contains(&app.test_user.username));
} }

View File

@@ -1,14 +1,8 @@
use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to}; use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to, when_sending_an_email};
use fake::{ use fake::{Fake, faker::internet::en::SafeEmail};
Fake,
faker::{internet::en::SafeEmail, name::fr_fr::Name},
};
use std::time::Duration; use std::time::Duration;
use uuid::Uuid; use uuid::Uuid;
use wiremock::{ use wiremock::ResponseTemplate;
Mock, MockBuilder, ResponseTemplate,
matchers::{method, path},
};
#[tokio::test] #[tokio::test]
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() { async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
@@ -72,11 +66,11 @@ async fn newsletters_are_delivered_to_confirmed_subscribers() {
}); });
let response = app.post_newsletters(&newsletter_request_body).await; let response = app.post_newsletters(&newsletter_request_body).await;
assert_is_redirect_to(&response, "/admin/newsletters"); assert!(response.status().is_success());
let html_page = app.get_newsletter_form_html().await; let html_fragment = response.text().await.unwrap();
assert!(html_page.contains(&format!( assert!(html_fragment.contains(&format!(
"The newsletter issue '{}' has been published", r#"The newsletter issue &#34;{}&#34; has been published"#,
newsletter_title newsletter_title
))); )));
@@ -116,9 +110,13 @@ async fn form_shows_error_for_invalid_data() {
]; ];
for (invalid_body, error_message) in test_cases { for (invalid_body, error_message) in test_cases {
app.post_newsletters(&invalid_body).await; let html_fragment = app
let html_page = app.get_newsletter_form_html().await; .post_newsletters(&invalid_body)
assert!(html_page.contains(error_message)); .await
.text()
.await
.unwrap();
assert!(html_fragment.contains(error_message));
} }
} }
@@ -143,20 +141,20 @@ async fn newsletter_creation_is_idempotent() {
}); });
let response = app.post_newsletters(&newsletter_request_body).await; let response = app.post_newsletters(&newsletter_request_body).await;
assert_is_redirect_to(&response, "/admin/newsletters"); assert!(response.status().is_success());
let html_page = app.get_newsletter_form_html().await; let html_fragment = response.text().await.unwrap();
assert!(html_page.contains(&format!( assert!(html_fragment.contains(&format!(
"The newsletter issue '{}' has been published", r#"The newsletter issue &#34;{}&#34; has been published"#,
newsletter_title newsletter_title
))); )));
let response = app.post_newsletters(&newsletter_request_body).await; let response = app.post_newsletters(&newsletter_request_body).await;
assert_is_redirect_to(&response, "/admin/newsletters"); assert!(response.status().is_success());
let html_page = app.get_newsletter_form_html().await; let html_fragment = response.text().await.unwrap();
assert!(html_page.contains(&format!( assert!(html_fragment.contains(&format!(
"The newsletter issue '{}' has been published", r#"The newsletter issue &#34;{}&#34; has been published"#,
newsletter_title newsletter_title
))); )));
@@ -195,22 +193,16 @@ async fn concurrent_form_submission_is_handled_gracefully() {
} }
async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks { async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
let name: String = Name().fake();
let email: String = SafeEmail().fake(); let email: String = SafeEmail().fake();
let body = serde_urlencoded::to_string(serde_json::json!({ let body = format!("email={email}");
"name": name,
"email": email,
"email_check": email
}))
.unwrap();
let _mock_guard = Mock::given(path("/v1/email")) let _mock_guard = when_sending_an_email()
.and(method("POST"))
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.named("Create unconfirmed subscriber") .named("Create unconfirmed subscriber")
.expect(1) .expect(1)
.mount_as_scoped(&app.email_server) .mount_as_scoped(&app.email_server)
.await; .await;
app.post_subscriptions(body) app.post_subscriptions(body)
.await .await
.error_for_status() .error_for_status()
@@ -223,6 +215,7 @@ async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
.unwrap() .unwrap()
.pop() .pop()
.unwrap(); .unwrap();
app.get_confirmation_links(email_request) app.get_confirmation_links(email_request)
} }
@@ -234,7 +227,3 @@ async fn create_confirmed_subscriber(app: &TestApp) {
.error_for_status() .error_for_status()
.unwrap(); .unwrap();
} }
fn when_sending_an_email() -> MockBuilder {
Mock::given(path("/v1/email")).and(method("POST"))
}

View File

@@ -1,53 +1,47 @@
use crate::helpers::{TestApp, assert_is_redirect_to}; use crate::helpers::{TestApp, when_sending_an_email};
use wiremock::{ use wiremock::ResponseTemplate;
Mock, ResponseTemplate,
matchers::{method, path},
};
#[tokio::test] #[tokio::test]
async fn subscribe_displays_a_confirmation_message_for_valid_form_data() { async fn subscribe_displays_a_confirmation_message_for_valid_form_data() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
Mock::given(path("/v1/email")) when_sending_an_email()
.and(method("POST"))
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.mount(&app.email_server) .mount(&app.email_server)
.await; .await;
let email = "alphonse.paix@outlook.com"; let email = "alphonse.paix@outlook.com";
let body = format!("name=Alphonse&email={0}&email_check={0}", email); let body = format!("email={email}");
let response = app.post_subscriptions(body).await; let response = app.post_subscriptions(body).await;
assert_is_redirect_to(&response, "/register"); assert!(response.status().is_success());
let page_html = app.get_register_html().await; let html_fragment = response.text().await.unwrap();
assert!(page_html.contains("A confirmation email has been sent")); assert!(html_fragment.contains("A confirmation email has been sent"));
} }
#[tokio::test] #[tokio::test]
async fn subscribe_persists_the_new_subscriber() { async fn subscribe_persists_the_new_subscriber() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
Mock::given(path("/v1/email")) when_sending_an_email()
.and(method("POST"))
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.mount(&app.email_server) .mount(&app.email_server)
.await; .await;
let email = "alphonse.paix@outlook.com"; let email = "alphonse.paix@outlook.com";
let body = format!("name=Alphonse&email={0}&email_check={0}", email); let body = format!("email={email}");
let response = app.post_subscriptions(body).await; let response = app.post_subscriptions(body).await;
assert_is_redirect_to(&response, "/register"); assert!(response.status().is_success());
let page_html = app.get_register_html().await; let html_fragment = response.text().await.unwrap();
assert!(page_html.contains("A confirmation email has been sent")); assert!(html_fragment.contains("A confirmation email has been sent"));
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions") let saved = sqlx::query!("SELECT email, status FROM subscriptions")
.fetch_one(&app.connection_pool) .fetch_one(&app.connection_pool)
.await .await
.expect("Failed to fetch saved subscription"); .expect("Failed to fetch saved subscription");
assert_eq!(saved.email, "alphonse.paix@outlook.com"); assert_eq!(saved.email, "alphonse.paix@outlook.com");
assert_eq!(saved.name, "Alphonse");
assert_eq!(saved.status, "pending_confirmation"); assert_eq!(saved.status, "pending_confirmation");
} }
@@ -55,21 +49,13 @@ async fn subscribe_persists_the_new_subscriber() {
async fn subscribe_returns_a_422_when_data_is_missing() { async fn subscribe_returns_a_422_when_data_is_missing() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
let test_cases = [ let response = app.post_subscriptions(String::new()).await;
("name=Alphonse", "missing the email"),
("email=alphonse.paix%40outlook.com", "missing the name"),
("", "missing both name and email"),
];
for (invalid_body, error_message) in test_cases {
let response = app.post_subscriptions(invalid_body.into()).await;
assert_eq!( assert_eq!(
422, 422,
response.status().as_u16(), response.status().as_u16(),
"the API did not fail with 422 Unprocessable Entity when the payload was {}.", "the API did not fail with 422 Unprocessable Entity when the payload was missing the email"
error_message );
);
}
} }
#[tokio::test] #[tokio::test]
@@ -109,10 +95,9 @@ async fn subscribe_sends_a_confirmation_email_for_valid_data() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
let email = "alphonse.paix@outlook.com"; let email = "alphonse.paix@outlook.com";
let body = format!("name=Alphonse&email={0}&email_check={0}", email); let body = format!("email={email}");
Mock::given(path("v1/email")) when_sending_an_email()
.and(method("POST"))
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.expect(1) .expect(1)
.mount(&app.email_server) .mount(&app.email_server)
@@ -126,10 +111,9 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
let email = "alphonse.paix@outlook.com"; let email = "alphonse.paix@outlook.com";
let body = format!("name=Alphonse&email={0}&email_check={0}", email); let body = format!("email={email}");
Mock::given(path("v1/email")) when_sending_an_email()
.and(method("POST"))
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.expect(1) .expect(1)
.mount(&app.email_server) .mount(&app.email_server)

View File

@@ -1,8 +1,5 @@
use crate::helpers::TestApp; use crate::helpers::{TestApp, when_sending_an_email};
use wiremock::{ use wiremock::ResponseTemplate;
Mock, ResponseTemplate,
matchers::{method, path},
};
#[tokio::test] #[tokio::test]
async fn confirmation_links_without_token_are_rejected_with_a_400() { async fn confirmation_links_without_token_are_rejected_with_a_400() {
@@ -15,14 +12,13 @@ async fn confirmation_links_without_token_are_rejected_with_a_400() {
} }
#[tokio::test] #[tokio::test]
async fn clicking_on_the_link_shows_a_confiramtion_message() { async fn clicking_on_the_link_shows_a_confirmation_message() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
let email = "alphonse.paix@outlook.com"; let email = "alphonse.paix@outlook.com";
let body = format!("name=Alphonse&email={email}&email_check={email}"); let body = format!("email={email}");
Mock::given(path("v1/email")) when_sending_an_email()
.and(method("POST"))
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.expect(1) .expect(1)
.mount(&app.email_server) .mount(&app.email_server)
@@ -39,7 +35,7 @@ async fn clicking_on_the_link_shows_a_confiramtion_message() {
.text() .text()
.await .await
.unwrap() .unwrap()
.contains("Your account has been confirmed") .contains("Subscription confirmed")
); );
} }
@@ -48,10 +44,9 @@ async fn clicking_on_the_confirmation_link_confirms_a_subscriber() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
let email = "alphonse.paix@outlook.com"; let email = "alphonse.paix@outlook.com";
let body = format!("name=Alphonse&email={email}&email_check={email}"); let body = format!("email={email}");
Mock::given(path("v1/email")) when_sending_an_email()
.and(method("POST"))
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.expect(1) .expect(1)
.mount(&app.email_server) .mount(&app.email_server)
@@ -67,12 +62,11 @@ async fn clicking_on_the_confirmation_link_confirms_a_subscriber() {
.error_for_status() .error_for_status()
.unwrap(); .unwrap();
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions") let saved = sqlx::query!("SELECT email, status FROM subscriptions")
.fetch_one(&app.connection_pool) .fetch_one(&app.connection_pool)
.await .await
.expect("Failed to fetch saved subscription"); .expect("Failed to fetch saved subscription");
assert_eq!(saved.email, "alphonse.paix@outlook.com"); assert_eq!(saved.email, "alphonse.paix@outlook.com");
assert_eq!(saved.name, "Alphonse");
assert_eq!(saved.status, "confirmed"); assert_eq!(saved.status, "confirmed");
} }