use actix_identity::Identity;
use actix_web::{HttpMessage, HttpRequest, HttpResponse, Responder, get, post, web};
use maud::{Markup, html};
use serde::Deserialize;
use crate::auth::{
create_user, current_user, validate_email, validate_password, validate_username,
verify_password,
};
use crate::config::Config;
use crate::db::Database;
use crate::view::{is_htmx, page_or_partial, render_error, render_success};
#[derive(Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct SignupForm {
pub username: String,
pub email: String,
pub password: String,
pub invite: String,
}
#[derive(Deserialize)]
pub struct InviteQuery {
pub invite: String,
}
#[derive(Deserialize)]
pub struct CreateInviteForm {
pub invite_secret: String,
}
fn field(id: &str, label: &str, input_type: &str, minlength: Option<&str>) -> Markup {
html! {
div class="form-control w-full" {
label class="label" for=(id) {
span class="label-text" { (label) }
}
input
class="input input-bordered w-full"
type=(input_type)
name=(id)
id=(id)
required
minlength=[minlength];
}
}
}
fn render_login_page(invites_enabled: bool) -> Markup {
html! {
div class="max-w-sm mx-auto mt-8" {
section class="card bg-base-200" aria-labelledby="login-title" {
div class="card-body" {
h1 id="login-title" class="card-title" { "Log in" }
p class="text-sm opacity-70" { "Welcome back to your private task list." }
form
hx-post="/auth/login"
hx-target="#login-result"
"hx-status:4xx"="swap:innerHTML target:#login-result"
"hx-status:5xx"="swap:innerHTML target:#login-result"
hx-swap="innerHTML"
method="POST"
action="/auth/login"
class="flex flex-col gap-4 mt-2"
{
(field("username", "Username", "text", None))
(field("password", "Password", "password", None))
button type="submit" class="btn btn-primary w-full" { "Log in" }
}
div id="login-result" aria-live="polite" {}
@if invites_enabled {
a class="btn btn-outline btn-primary w-full mt-4" href="/auth/invites" { "Create an invitation" }
}
}
}
}
}
}
fn render_signup_page(invite: &str) -> Markup {
html! {
div class="max-w-sm mx-auto mt-8" {
section class="card bg-base-200" aria-labelledby="signup-title" {
div class="card-body" {
h1 id="signup-title" class="card-title" { "Create account" }
p class="text-sm opacity-70" { "A quiet place for the tasks you want to remember." }
form
hx-post="/auth/signup"
hx-target="#signup-result"
"hx-status:4xx"="swap:innerHTML target:#signup-result"
"hx-status:5xx"="swap:innerHTML target:#signup-result"
hx-swap="innerHTML"
method="POST"
action="/auth/signup"
class="flex flex-col gap-4 mt-2"
{
(field("username", "Username", "text", Some("3")))
(field("email", "Email", "email", None))
(field("password", "Password", "password", Some("8")))
input type="hidden" name="invite" value=(invite);
button type="submit" class="btn btn-primary w-full" { "Sign up" }
}
div id="signup-result" aria-live="polite" {}
p class="text-sm opacity-70 mt-2" {
"Already have an account? "
a class="link link-primary" href="/auth/login" { "Log in" }
}
}
}
}
}
}
fn render_invite_page() -> Markup {
html! {
div class="max-w-sm mx-auto mt-8" {
section class="card bg-base-200" aria-labelledby="invite-title" {
div class="card-body" {
h1 id="invite-title" class="card-title" { "Create an invitation" }
p class="text-sm opacity-70" { "Generate a one-time link for someone to create an account." }
form
hx-post="/auth/invites"
hx-target="#invite-result"
"hx-status:4xx"="swap:innerHTML target:#invite-result"
"hx-status:5xx"="swap:innerHTML target:#invite-result"
hx-swap="innerHTML"
method="POST"
action="/auth/invites"
class="flex flex-col gap-4 mt-2"
{
(field("invite_secret", "Invite creation secret", "password", None))
button type="submit" class="btn btn-primary w-full" { "Create invite link" }
}
div id="invite-result" aria-live="polite" {}
p class="text-sm opacity-70 mt-2" {
a class="link link-primary" href="/auth/login" { "Back to log in" }
}
}
}
}
}
}
fn render_invite_link(link: &str) -> Markup {
html! {
div class="alert alert-success" role="status" {
span { "Invite created. Share this one-time link: " }
a class="link" href=(link) { (link) }
}
}
}
#[get("/auth/login")]
pub async fn login_page(
req: HttpRequest,
identity: Option<Identity>,
db: web::Data<Database>,
config: web::Data<Config>,
) -> Markup {
let username = current_user(identity, &db).await.map(|u| u.username);
page_or_partial(
&req,
render_login_page(config.invites_enabled()),
username.as_deref(),
"Log in",
)
}
#[get("/auth/signup")]
pub async fn signup_page(
req: HttpRequest,
identity: Option<Identity>,
db: web::Data<Database>,
query: Result<web::Query<InviteQuery>, actix_web::Error>,
) -> HttpResponse {
let Ok(query) = query else {
return HttpResponse::NotFound().finish();
};
match db.invite_exists(&query.invite).await {
Ok(true) => {}
Ok(false) => return HttpResponse::NotFound().finish(),
Err(e) => {
log::error!("Failed to validate invite: {e}");
return HttpResponse::InternalServerError().finish();
}
}
let username = current_user(identity, &db).await.map(|u| u.username);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(
page_or_partial(
&req,
render_signup_page(&query.invite),
username.as_deref(),
"Sign up",
)
.into_string(),
)
}
#[get("/auth/invites")]
pub async fn invite_page(
req: HttpRequest,
identity: Option<Identity>,
db: web::Data<Database>,
config: web::Data<Config>,
) -> HttpResponse {
if !config.invites_enabled() {
return HttpResponse::NotFound().finish();
}
let username = current_user(identity, &db).await.map(|u| u.username);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(
page_or_partial(
&req,
render_invite_page(),
username.as_deref(),
"Create invite",
)
.into_string(),
)
}
/// Redirects for HTMX requests (via `HX-Redirect`) and plain browser form posts.
fn redirect(req: &HttpRequest, location: &str) -> HttpResponse {
if is_htmx(req) {
HttpResponse::Ok()
.insert_header(("HX-Redirect", location.to_string()))
.finish()
} else {
HttpResponse::SeeOther()
.insert_header(("Location", location.to_string()))
.finish()
}
}
fn form_error(status: actix_web::http::StatusCode, message: &str) -> HttpResponse {
HttpResponse::build(status)
.content_type("text/html; charset=utf-8")
.body(render_error(message).into_string())
}
#[post("/auth/login")]
pub async fn login_submit(
req: HttpRequest,
db: web::Data<Database>,
form: web::Form<LoginForm>,
) -> impl Responder {
let user = match db.get_user_by_username(form.username.trim()).await {
Ok(Some(user)) => user,
Ok(None) => {
return form_error(
actix_web::http::StatusCode::UNAUTHORIZED,
"Invalid username or password",
);
}
Err(e) => {
log::error!("Failed to look up user: {e}");
return form_error(
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
);
}
};
match verify_password(&form.password, &user.password_hash) {
Ok(true) => {}
Ok(false) => {
return form_error(
actix_web::http::StatusCode::UNAUTHORIZED,
"Invalid username or password",
);
}
Err(e) => {
log::error!("Password verification error: {e}");
return form_error(
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
);
}
}
if let Err(e) = Identity::login(&req.extensions(), user.id.clone()) {
log::error!("Failed to create identity session: {e}");
return form_error(
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"Failed to create session",
);
}
log::info!("User logged in: {}", user.username);
redirect(&req, "/")
}
#[post("/auth/signup")]
pub async fn signup_submit(
req: HttpRequest,
db: web::Data<Database>,
form: web::Form<SignupForm>,
) -> impl Responder {
match db.invite_exists(&form.invite).await {
Ok(true) => {}
Ok(false) => return HttpResponse::NotFound().finish(),
Err(e) => {
log::error!("Failed to validate invite: {e}");
return HttpResponse::InternalServerError().finish();
}
}
if let Err(message) = validate_username(&form.username) {
return form_error(actix_web::http::StatusCode::BAD_REQUEST, &message);
}
if let Err(message) = validate_email(&form.email) {
return form_error(actix_web::http::StatusCode::BAD_REQUEST, &message);
}
if let Err(message) = validate_password(&form.password) {
return form_error(actix_web::http::StatusCode::BAD_REQUEST, &message);
}
let user = match create_user(&form.username, &form.email, &form.password) {
Ok(user) => user,
Err(e) => {
log::error!("Failed to create user: {e}");
return form_error(
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"Failed to create user",
);
}
};
match db.create_user_with_invite(&user, &form.invite).await {
Ok(true) => {}
Ok(false) => return HttpResponse::NotFound().finish(),
Err(e) if e.contains("UNIQUE constraint failed: users.username") => {
return form_error(
actix_web::http::StatusCode::CONFLICT,
"Username already exists",
);
}
Err(e) => {
log::error!("Failed to create user: {e}");
return form_error(
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"Failed to create user",
);
}
}
log::info!("User signed up: {}", user.username);
if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(render_success("Account created. You can now log in.").into_string())
} else {
redirect(&req, "/auth/login")
}
}
#[post("/auth/invites")]
pub async fn create_invite(
req: HttpRequest,
db: web::Data<Database>,
config: web::Data<Config>,
form: web::Form<CreateInviteForm>,
) -> impl Responder {
if !config.invite_secret_matches(&form.invite_secret) {
return HttpResponse::NotFound().finish();
}
let token = uuid::Uuid::new_v4().to_string();
if let Err(e) = db.create_invite(&token).await {
log::error!("Failed to create invite: {e}");
return form_error(
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"Failed to create invite",
);
}
let connection = req.connection_info();
let link = format!(
"{}://{}/auth/signup?invite={token}",
connection.scheme(),
connection.host()
);
log::info!("Invite created");
if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(render_invite_link(&link).into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(
page_or_partial(&req, render_invite_link(&link), None, "Invite created")
.into_string(),
)
}
}
#[post("/auth/logout")]
pub async fn logout(req: HttpRequest, identity: Option<Identity>) -> impl Responder {
if let Some(identity) = identity {
identity.logout();
}
redirect(&req, "/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_login_page_contract() {
let html = render_login_page(true).into_string();
for attribute in [
"hx-post=\"/auth/login\"",
"hx-target=\"#login-result\"",
"hx-status:4xx=\"swap:innerHTML target:#login-result\"",
"hx-swap=\"innerHTML\"",
] {
assert!(html.contains(attribute), "missing {attribute}: {html}");
}
assert!(html.contains("id=\"login-result\" aria-live=\"polite\""));
assert!(html.contains("href=\"/auth/invites\""));
assert!(
!render_login_page(false)
.into_string()
.contains("href=\"/auth/invites\"")
);
}
#[test]
fn test_signup_page_contract() {
let html = render_signup_page("test-token").into_string();
for attribute in [
"hx-post=\"/auth/signup\"",
"hx-target=\"#signup-result\"",
"hx-swap=\"innerHTML\"",
] {
assert!(html.contains(attribute), "missing {attribute}: {html}");
}
assert!(html.contains("id=\"signup-result\" aria-live=\"polite\""));
assert!(html.contains("name=\"email\""));
assert!(html.contains("name=\"invite\" value=\"test-token\""));
}
}