Compare commits
12 Commits
0a2c4a32c1
...
72d0306e35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d0306e35 | ||
|
|
e191d35664 | ||
|
|
b5f0f448d7 | ||
|
|
859247d900 | ||
|
|
2d336ed000 | ||
|
|
88dad022ce | ||
|
|
1d027b5460 | ||
|
|
38208654dc | ||
|
|
b736e2fe8d | ||
|
|
f948728348 | ||
|
|
5cdc3ea29d | ||
|
|
56035fab30 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
/node_modules
|
||||||
|
|||||||
167
Cargo.lock
generated
167
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
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
1
assets/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE subscriptions DROP COLUMN name;
|
||||||
1137
package-lock.json
generated
Normal file
1137
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
package.json
Normal file
9
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
14
src/routes/admin/logout.rs
Normal file
14
src/routes/admin/logout.rs
Normal 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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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,10 +45,19 @@ 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 {
|
||||||
@@ -56,22 +65,17 @@ pub struct LoginFormData {
|
|||||||
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
13
src/templates.rs
Normal 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
66
templates/base.html
Normal 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
27
templates/confirm.html
Normal 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
214
templates/dashboard.html
Normal 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
9
templates/error.html
Normal 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
120
templates/home.html
Normal 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
1
templates/input.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
46
templates/login.html
Normal file
46
templates/login.html
Normal 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
9
templates/success.html
Normal 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>
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "{}" 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 "{}" 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 "{}" 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"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,22 +49,14 @@ 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]
|
||||||
async fn subscribe_shows_an_error_message_when_fields_are_present_but_invalid() {
|
async fn subscribe_shows_an_error_message_when_fields_are_present_but_invalid() {
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user