acton_htmx/auth/
handlers.rs

1//! Authentication handlers (login, register, logout)
2//!
3//! This module provides basic handler scaffolds for authentication.
4//! Full database integration and template rendering will be added in later phases.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use acton_htmx::auth::handlers::{login_form, login_post, logout_post};
10//! use axum::{Router, routing::{get, post}};
11//!
12//! # async fn example() {
13//! let app = Router::new()
14//!     .route("/login", get(login_form))
15//!     .route("/login", post(login_post))
16//!     .route("/logout", post(logout_post));
17//! # }
18//! ```
19
20use crate::auth::{CreateUser, EmailAddress, FlashMessage, Session, User, UserError};
21use crate::state::ActonHtmxState;
22use axum::{
23    extract::State,
24    http::StatusCode,
25    response::{Html, IntoResponse, Redirect, Response},
26    Form,
27};
28use axum_htmx::HxRequest;
29use serde::Deserialize;
30use validator::Validate;
31
32/// Login form data
33#[derive(Debug, Deserialize, Validate)]
34pub struct LoginForm {
35    /// User's email address
36    #[validate(email)]
37    pub email: String,
38
39    /// User's password (min 8 characters)
40    #[validate(length(min = 8))]
41    pub password: String,
42}
43
44/// Registration form data
45#[derive(Debug, Deserialize, Validate)]
46pub struct RegisterForm {
47    /// User's email address
48    #[validate(email)]
49    pub email: String,
50
51    /// User's password (min 8 characters)
52    #[validate(length(min = 8))]
53    pub password: String,
54
55    /// Password confirmation (must match password)
56    #[validate(length(min = 8))]
57    pub password_confirm: String,
58}
59
60/// GET /login - Display login form
61///
62/// # Example
63///
64/// ```rust,ignore
65/// use acton_htmx::auth::handlers::login_form;
66/// use axum::{Router, routing::get};
67///
68/// let app = Router::new().route("/login", get(login_form));
69/// ```
70pub async fn login_form(
71    HxRequest(_is_htmx): HxRequest,
72) -> Response {
73    let html = r#"
74<!DOCTYPE html>
75<html>
76<head>
77    <title>Login</title>
78    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
79</head>
80<body>
81    <h1>Login</h1>
82    <form hx-post="/login" hx-target="body">
83        <div>
84            <label for="email">Email:</label>
85            <input type="email" id="email" name="email" required />
86        </div>
87        <div>
88            <label for="password">Password:</label>
89            <input type="password" id="password" name="password" required />
90        </div>
91        <button type="submit">Login</button>
92    </form>
93    <p><a href="/register">Don't have an account? Register</a></p>
94</body>
95</html>
96    "#;
97
98    // For HTMX requests, return just the form
99    Html(html).into_response()
100}
101
102/// POST /login - Process login
103///
104/// # Errors
105///
106/// Returns [`AuthHandlerError`] if:
107/// - Form validation fails (invalid email format, missing fields)
108/// - Email address cannot be parsed
109/// - User authentication fails (invalid credentials, user not found)
110/// - Database query fails
111///
112/// # Example
113///
114/// ```rust,ignore
115/// use acton_htmx::auth::handlers::login_post;
116/// use axum::{Router, routing::post};
117///
118/// let app = Router::new().route("/login", post(login_post));
119/// ```
120pub async fn login_post(
121    State(state): State<ActonHtmxState>,
122    mut session: Session,
123    Form(form): Form<LoginForm>,
124) -> Result<Response, AuthHandlerError> {
125    // Validate form
126    form.validate()
127        .map_err(|e| AuthHandlerError::ValidationFailed(e.to_string()))?;
128
129    // Parse email
130    let email = EmailAddress::parse(&form.email)
131        .map_err(|_| AuthHandlerError::InvalidCredentials)?;
132
133    // Authenticate with database
134    let user = User::authenticate(&email, &form.password, state.database_pool())
135        .await
136        .map_err(|_| AuthHandlerError::InvalidCredentials)?;
137
138    // Set user ID in session
139    session.set_user_id(Some(user.id));
140
141    // Add success flash message
142    session.add_flash(FlashMessage::success("Successfully logged in!"));
143
144    // Redirect to dashboard/home
145    Ok(Redirect::to("/").into_response())
146}
147
148/// GET /register - Display registration form
149///
150/// # Example
151///
152/// ```rust,ignore
153/// use acton_htmx::auth::handlers::register_form;
154/// use axum::{Router, routing::get};
155///
156/// let app = Router::new().route("/register", get(register_form));
157/// ```
158pub async fn register_form(
159    HxRequest(_is_htmx): HxRequest,
160) -> Response {
161    let html = r#"
162<!DOCTYPE html>
163<html>
164<head>
165    <title>Register</title>
166    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
167</head>
168<body>
169    <h1>Register</h1>
170    <form hx-post="/register" hx-target="body">
171        <div>
172            <label for="email">Email:</label>
173            <input type="email" id="email" name="email" required />
174        </div>
175        <div>
176            <label for="password">Password:</label>
177            <input type="password" id="password" name="password" required minlength="8" />
178        </div>
179        <div>
180            <label for="password_confirm">Confirm Password:</label>
181            <input type="password" id="password_confirm" name="password_confirm" required minlength="8" />
182        </div>
183        <button type="submit">Register</button>
184    </form>
185    <p><a href="/login">Already have an account? Login</a></p>
186</body>
187</html>
188    "#;
189
190    Html(html).into_response()
191}
192
193/// POST /register - Process registration
194///
195/// # Errors
196///
197/// Returns [`AuthHandlerError`] if:
198/// - Form validation fails (invalid email, weak password, missing fields)
199/// - Email address cannot be parsed
200/// - Password and confirmation password do not match
201/// - Email address is already registered
202/// - Database query or user creation fails
203///
204/// # Example
205///
206/// ```rust,ignore
207/// use acton_htmx::auth::handlers::register_post;
208/// use axum::{Router, routing::post};
209///
210/// let app = Router::new().route("/register", post(register_post));
211/// ```
212pub async fn register_post(
213    State(state): State<ActonHtmxState>,
214    mut session: Session,
215    Form(form): Form<RegisterForm>,
216) -> Result<Response, AuthHandlerError> {
217    // Validate form
218    form.validate()
219        .map_err(|e| AuthHandlerError::ValidationFailed(e.to_string()))?;
220
221    // Parse email
222    let email = EmailAddress::parse(&form.email)
223        .map_err(|_| AuthHandlerError::InvalidEmail)?;
224
225    // Check password confirmation
226    if form.password != form.password_confirm {
227        return Err(AuthHandlerError::PasswordMismatch);
228    }
229
230    // Create user in database
231    let create_user = CreateUser {
232        email,
233        password: form.password,
234    };
235    let user = User::create(create_user, state.database_pool()).await?;
236
237    // Set user ID in session (auto-login after registration)
238    session.set_user_id(Some(user.id));
239
240    // Add success flash message
241    session.add_flash(FlashMessage::success("Account created successfully! Welcome!"));
242
243    // Redirect to dashboard/home
244    Ok(Redirect::to("/").into_response())
245}
246
247/// POST /logout - Clear session and logout
248///
249/// # Example
250///
251/// ```rust,ignore
252/// use acton_htmx::auth::handlers::logout_post;
253/// use axum::{Router, routing::post};
254///
255/// let app = Router::new().route("/logout", post(logout_post));
256/// ```
257pub async fn logout_post(
258    mut session: Session,
259) -> Response {
260    // Clear user ID from session
261    session.set_user_id(None);
262
263    // Add info flash message
264    session.add_flash(FlashMessage::info("You have been logged out."));
265
266    // Redirect to home or login
267    Redirect::to("/login").into_response()
268}
269
270/// Authentication handler errors
271#[derive(Debug)]
272pub enum AuthHandlerError {
273    /// Form validation failed
274    ValidationFailed(String),
275
276    /// Invalid email format
277    InvalidEmail,
278
279    /// Password confirmation doesn't match
280    PasswordMismatch,
281
282    /// Invalid credentials
283    InvalidCredentials,
284
285    /// User error
286    UserError(UserError),
287
288    /// Database not configured
289    DatabaseNotConfigured,
290}
291
292impl From<UserError> for AuthHandlerError {
293    fn from(err: UserError) -> Self {
294        Self::UserError(err)
295    }
296}
297
298impl IntoResponse for AuthHandlerError {
299    fn into_response(self) -> Response {
300        let (status, message) = match self {
301            Self::ValidationFailed(msg) => (StatusCode::BAD_REQUEST, msg),
302            Self::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid email format".to_string()),
303            Self::PasswordMismatch => (
304                StatusCode::BAD_REQUEST,
305                "Passwords do not match".to_string(),
306            ),
307            Self::InvalidCredentials => (
308                StatusCode::UNAUTHORIZED,
309                "Invalid email or password".to_string(),
310            ),
311            Self::UserError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
312            Self::DatabaseNotConfigured => (
313                StatusCode::INTERNAL_SERVER_ERROR,
314                "Database not configured".to_string(),
315            ),
316        };
317
318        (status, message).into_response()
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_login_form_struct() {
328        let form = LoginForm {
329            email: "test@example.com".to_string(),
330            password: "password123".to_string(),
331        };
332        assert!(form.validate().is_ok());
333    }
334
335    #[test]
336    fn test_register_form_struct() {
337        let form = RegisterForm {
338            email: "test@example.com".to_string(),
339            password: "password123".to_string(),
340            password_confirm: "password123".to_string(),
341        };
342        assert!(form.validate().is_ok());
343    }
344
345    #[test]
346    fn test_invalid_email() {
347        let form = LoginForm {
348            email: "not-an-email".to_string(),
349            password: "password123".to_string(),
350        };
351        assert!(form.validate().is_err());
352    }
353
354    #[test]
355    fn test_short_password() {
356        let form = LoginForm {
357            email: "test@example.com".to_string(),
358            password: "short".to_string(),
359        };
360        assert!(form.validate().is_err());
361    }
362}