Use HTML swap to display success and error messages
This commit is contained in:
84
Cargo.lock
generated
84
Cargo.lock
generated
@@ -229,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"
|
||||||
@@ -266,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"
|
||||||
@@ -1141,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"
|
||||||
@@ -1464,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"
|
||||||
@@ -2456,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"
|
||||||
@@ -2534,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"
|
||||||
@@ -3924,15 +3844,12 @@ dependencies = [
|
|||||||
"argon2",
|
"argon2",
|
||||||
"askama",
|
"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",
|
||||||
@@ -3944,7 +3861,6 @@ dependencies = [
|
|||||||
"serde-aux",
|
"serde-aux",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sha3",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -16,13 +16,10 @@ anyhow = "1.0.99"
|
|||||||
argon2 = { version = "0.5.3", features = ["std"] }
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
askama = "0.14.0"
|
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",
|
||||||
@@ -32,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",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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,12 +2,12 @@ pub mod change_password;
|
|||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod newsletters;
|
pub mod newsletters;
|
||||||
|
|
||||||
use crate::{routes::error_chain_fmt, session_state::TypedSession};
|
use crate::{routes::error_chain_fmt, session_state::TypedSession, templates::ErrorTemplate};
|
||||||
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use axum_messages::Messages;
|
|
||||||
pub use change_password::*;
|
pub use change_password::*;
|
||||||
pub use dashboard::*;
|
pub use dashboard::*;
|
||||||
pub use newsletters::*;
|
pub use newsletters::*;
|
||||||
@@ -20,7 +20,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.")]
|
||||||
@@ -51,8 +51,18 @@ impl IntoResponse for AdminError {
|
|||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
AdminError::NotAuthenticated => Redirect::to("/login").into_response(),
|
AdminError::NotAuthenticated => Redirect::to("/login").into_response(),
|
||||||
AdminError::ChangePassword => Redirect::to("/admin/password").into_response(),
|
AdminError::ChangePassword(e) => {
|
||||||
AdminError::Publish(_) => Redirect::to("/admin/newsletters").into_response(),
|
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()
|
||||||
}
|
}
|
||||||
@@ -60,9 +70,8 @@ impl IntoResponse for AdminError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Logging out", skip(messages, session))]
|
#[tracing::instrument(name = "Logging out", skip(session))]
|
||||||
pub async fn logout(messages: Messages, session: TypedSession) -> Result<Response, AdminError> {
|
pub async fn logout(session: TypedSession) -> Result<Response, AdminError> {
|
||||||
session.clear().await;
|
session.clear().await;
|
||||||
messages.success("You have successfully logged out.");
|
|
||||||
Ok(Redirect::to("/login").into_response())
|
Ok(Redirect::to("/login").into_response())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ 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::{IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use axum_messages::Messages;
|
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -23,7 +24,6 @@ pub async fn change_password(
|
|||||||
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 {
|
||||||
@@ -31,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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ 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::{IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use axum_messages::Messages;
|
|
||||||
use sqlx::{Executor, Postgres, Transaction};
|
use sqlx::{Executor, Postgres, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -67,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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -112,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,19 +1,10 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::response::Html;
|
use axum::response::Html;
|
||||||
use axum_messages::Messages;
|
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "../templates/home.html")]
|
#[template(path = "../templates/home.html")]
|
||||||
struct HomeTemplate {
|
struct HomeTemplate;
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn home(messages: Messages) -> Html<String> {
|
pub async fn home() -> Html<String> {
|
||||||
let template = HomeTemplate {
|
Html(HomeTemplate.render().unwrap())
|
||||||
message: messages
|
|
||||||
.last()
|
|
||||||
.map(|msg| msg.to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
};
|
|
||||||
Html(template.render().unwrap())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -10,7 +11,6 @@ use axum::{
|
|||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use axum_messages::Messages;
|
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
|
||||||
@@ -45,16 +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)]
|
#[derive(Template)]
|
||||||
#[template(path = "../templates/login.html")]
|
#[template(path = "../templates/login.html")]
|
||||||
struct LoginTemplate {
|
struct LoginTemplate;
|
||||||
error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct LoginFormData {
|
pub struct LoginFormData {
|
||||||
@@ -62,19 +65,12 @@ pub struct LoginFormData {
|
|||||||
password: SecretString,
|
password: SecretString,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_login(messages: Messages) -> Html<String> {
|
pub async fn get_login() -> Html<String> {
|
||||||
let template = LoginTemplate {
|
Html(LoginTemplate.render().unwrap())
|
||||||
error: messages
|
|
||||||
.last()
|
|
||||||
.map(|msg| msg.to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
};
|
|
||||||
Html(template.render().unwrap())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>,
|
||||||
@@ -89,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)
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ use crate::{
|
|||||||
domain::{NewSubscriber, SubscriberEmail},
|
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,20 +73,22 @@ impl IntoResponse for SubscribeError {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
SubscribeError::ValidationError(_) => Redirect::to("/").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,
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn subscribe(
|
pub async fn subscribe(
|
||||||
messages: Messages,
|
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool,
|
connection_pool,
|
||||||
email_client,
|
email_client,
|
||||||
@@ -97,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));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -124,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("/").into_response())
|
success_message: "A confirmation email has been sent.".to_string(),
|
||||||
|
};
|
||||||
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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};
|
||||||
@@ -147,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,
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Admin Dashboard - zero2prod{% endblock %}
|
{% block title %}Dashboard - zero2prod{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-6xl mx-auto">
|
<div class="max-w-6xl mx-auto">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -7,6 +7,15 @@
|
|||||||
<p class="mt-2 text-gray-600">
|
<p class="mt-2 text-gray-600">
|
||||||
Connected as <span class="font-bold">{{ username }}</span>
|
Connected as <span class="font-bold">{{ username }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<form action="/admin/logout" method="post" class="inline">
|
||||||
|
<button 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>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
<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="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||||
@@ -99,7 +108,10 @@
|
|||||||
<p class="text-sm text-gray-600 mt-1">Create and send a newsletter issue.</p>
|
<p class="text-sm text-gray-600 mt-1">Create and send a newsletter issue.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form action="/admin/newsletters" method="post" class="space-y-4">
|
<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 }}" />
|
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}" />
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||||
@@ -107,7 +119,6 @@
|
|||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
required
|
required
|
||||||
placeholder="Enter newsletter title..."
|
|
||||||
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" />
|
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>
|
||||||
<div>
|
<div>
|
||||||
@@ -116,7 +127,6 @@
|
|||||||
name="html"
|
name="html"
|
||||||
rows="6"
|
rows="6"
|
||||||
required
|
required
|
||||||
placeholder="Enter HTML content..."
|
|
||||||
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>
|
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>
|
||||||
<div>
|
<div>
|
||||||
@@ -125,7 +135,6 @@
|
|||||||
name="text"
|
name="text"
|
||||||
rows="6"
|
rows="6"
|
||||||
required
|
required
|
||||||
placeholder="Enter plain text content..."
|
|
||||||
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>
|
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>
|
</div>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
@@ -138,6 +147,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
|
<div id="newsletter-messages" class="mt-4"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +165,10 @@
|
|||||||
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
|
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form action="/admin/password" method="post" class="space-y-4">
|
<form hx-post="/admin/password"
|
||||||
|
hx-target="#password-messages"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="current_password"
|
<label for="current_password"
|
||||||
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
||||||
@@ -193,60 +206,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Update password
|
Update password
|
||||||
</button>
|
</button>
|
||||||
|
<div id="password-messages" class="mt-4"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 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">Recent activity</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center p-3 bg-gray-50 rounded-lg">
|
|
||||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
|
||||||
<svg class="w-4 h-4 text-blue-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="flex-1">
|
|
||||||
<p class="text-sm font-medium text-gray-900">Newsletter "Weekly Update #23" sent</p>
|
|
||||||
<p class="text-xs text-gray-500">2 hours ago • 2,143 recipients</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center p-3 bg-gray-50 rounded-lg">
|
|
||||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3">
|
|
||||||
<svg class="w-4 h-4 text-green-600"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium text-gray-900">5 new subscribers confirmed</p>
|
|
||||||
<p class="text-xs text-gray-500">1 day ago</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center p-3 bg-gray-50 rounded-lg">
|
|
||||||
<div class="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center mr-3">
|
|
||||||
<svg class="w-4 h-4 text-purple-600"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium text-gray-900">Password changed successfully</p>
|
|
||||||
<p class="text-xs text-gray-500">3 days ago</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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>
|
||||||
@@ -74,11 +74,14 @@
|
|||||||
<div class="max-w-2xl mx-auto text-center">
|
<div class="max-w-2xl mx-auto text-center">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2>
|
<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>
|
<p class="text-gray-600 mb-6">Subscribe to our newsletter to get the latest updates.</p>
|
||||||
<form action="/subscriptions" method="post" class="max-w-md mx-auto">
|
<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">
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
<input type="email"
|
<input type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="Enter your email address"
|
placeholder="you@example.com"
|
||||||
required
|
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" />
|
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"
|
<button type="submit"
|
||||||
@@ -86,8 +89,8 @@
|
|||||||
Subscribe
|
Subscribe
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="subscribe-messages" class="mt-4"></div>
|
||||||
</form>
|
</form>
|
||||||
<div class="mt-4">{{ message }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
<p class="mt-2 text-sm text-gray-600">Sign in to access the admin dashboard.</p>
|
<p class="mt-2 text-sm text-gray-600">Sign in to access the admin dashboard.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
|
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
|
||||||
<form action="/login" method="post" class="space-y-6">
|
<form hx-post="/login"
|
||||||
|
hx-target="#login-messages"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
@@ -31,8 +34,8 @@
|
|||||||
Continue
|
Continue
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="login-messages" class="mt-4"></div>
|
||||||
</form>
|
</form>
|
||||||
<div class="mt-4 text-center">{{ error }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a href="/"
|
<a href="/"
|
||||||
|
|||||||
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>
|
||||||
Reference in New Issue
Block a user