login register

This commit is contained in:
Priec
2026-06-18 17:19:04 +02:00
parent 7af0a48e92
commit ed607e3d27
17 changed files with 417 additions and 121 deletions

View File

@@ -0,0 +1,216 @@
//! Cookie-based HTML auth pages (login, registration, email verification) for
//! all users. There is no role column — an "admin" is simply the user whose
//! email matches `settings.admin_email` (see [`guard::is_admin`]). On login,
//! admins are redirected to the admin dashboard and everyone else to the
//! storefront; both share the same `auth_token` cookie that the admin handlers
//! already validate. This is the unified replacement for the former
//! admin-only `/admin/login`. The JSON `/api/auth` flow in `auth.rs` is
//! separate and untouched.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{
controllers::auth as auth_controller,
controllers::i18n::current_lang,
mailers::auth::AuthMailer,
models::users::{self, LoginParams, RegisterParams},
shared::guard,
};
/// Where a freshly-authenticated `user` should land.
fn home_for(ctx: &AppContext, user: &users::Model) -> &'static str {
if guard::is_admin(ctx, user) {
"/admin/dashboard"
} else {
"/"
}
}
fn login_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
format::view(
v,
"auth/login.html",
json!({
"error": error,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
fn register_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
format::view(
v,
"auth/register.html",
json!({
"error": error,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn login_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
return format::redirect(home_for(&ctx, &user));
}
login_view(&v, &jar, None)
}
#[debug_handler]
async fn login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return login_view(&v, &jar, Some("invalid"));
};
if !user.verify_password(&params.password) {
return login_view(&v, &jar, Some("invalid"));
}
// Registration requires email verification before the account can sign in.
if user.email_verified_at.is_none() {
return login_view(&v, &jar, Some("unverified"));
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect(home_for(&ctx, &user))
}
#[debug_handler]
async fn register_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
return format::redirect(home_for(&ctx, &user));
}
register_view(&v, &jar, None)
}
#[debug_handler]
async fn register(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<RegisterParams>,
) -> Result<Response> {
let user = match users::Model::create_with_password(&ctx.db, &params).await {
Ok(user) => user,
Err(ModelError::EntityAlreadyExists {}) => {
return register_view(&v, &jar, Some("exists"));
}
Err(err) => {
// Most commonly a validation failure (name too short / invalid email).
tracing::info!(
message = err.to_string(),
user_email = &params.email,
"could not register user",
);
return register_view(&v, &jar, Some("invalid"));
}
};
let user = user
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
// The account already exists; a failed email send shouldn't 500 the page —
// log it and let the user fall back to resend-verification.
if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await {
tracing::error!(
error = err.to_string(),
user_email = &user.email,
"failed to send verification email",
);
}
format::view(
&v,
"auth/verify_sent.html",
json!({
"email": user.email,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn verify(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Path(token): Path<String>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
return verified_view(&v, &jar, false);
};
if user.email_verified_at.is_none() {
user.into_active_model().verified(&ctx.db).await?;
}
verified_view(&v, &jar, true)
}
fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result<Response> {
format::view(
v,
"auth/verified.html",
json!({
"ok": ok,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/login")
}
/// Backwards-compatible entry point: `/admin` sends admins to their dashboard
/// and everyone else to the unified login.
#[debug_handler]
async fn admin_entry(jar: CookieJar, State(ctx): State<AppContext>) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
}
format::redirect("/login")
}
pub fn routes() -> Routes {
Routes::new()
.add("/login", get(login_page))
.add("/login", post(login))
.add("/register", get(register_page))
.add("/register", post(register))
.add("/verify/{token}", get(verify))
.add("/logout", post(logout))
.add("/admin", get(admin_entry))
}