HX-Redirect to handle redirections with htmx

This commit is contained in:
Alphonse Paix
2025-09-17 13:16:56 +02:00
parent 7689628ffb
commit 7364e2a23c
8 changed files with 41 additions and 95 deletions

View File

@@ -1,8 +1,9 @@
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, templates::ErrorTemplate}; use crate::{routes::error_chain_fmt, templates::ErrorTemplate};
use askama::Template; use askama::Template;
use axum::{ use axum::{
Json, Json,
@@ -10,6 +11,7 @@ use axum::{
}; };
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;
@@ -69,9 +71,3 @@ impl IntoResponse for AdminError {
} }
} }
} }
#[tracing::instrument(name = "Logging out", skip(session))]
pub async fn logout(session: TypedSession) -> Result<Response, AdminError> {
session.clear().await;
Ok(Redirect::to("/login").into_response())
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,12 +6,12 @@ use crate::{
templates::ErrorTemplate, templates::ErrorTemplate,
}; };
use askama::Template; 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 reqwest::StatusCode;
use secrecy::SecretString; use secrecy::SecretString;
#[derive(thiserror::Error)] #[derive(thiserror::Error)]
@@ -75,7 +75,7 @@ pub async fn post_login(
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,
@@ -104,7 +104,10 @@ pub async fn post_login(
.insert_username(form.username) .insert_username(form.username)
.await .await
.map_err(|e| LoginError::UnexpectedError(e.into()))?; .map_err(|e| LoginError::UnexpectedError(e.into()))?;
Ok(Redirect::to("/admin/dashboard"))
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
Ok((StatusCode::OK, headers).into_response())
} }
} }
} }

View File

@@ -7,15 +7,14 @@
<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 hx-post="/admin/logout"
<button type="submit" type="submit"
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1"> 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"> <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" /> <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> </svg>
<span>Logout</span> <span>Logout</span>
</button> </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">

View File

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