login register
This commit is contained in:
@@ -57,12 +57,28 @@ album-by = by
|
||||
album-play-full = Play full album
|
||||
album-queue-all = queue all tracks in order
|
||||
album-no-tracks = no tracks yet
|
||||
login-title = Admin login
|
||||
login-title = Sign in
|
||||
login-error = Access denied - invalid email or password.
|
||||
login-error-unverified = Your account isn't verified yet. Check your email and click the verification link.
|
||||
login-root = root
|
||||
login-auth = Authenticate
|
||||
login-auth = Sign in
|
||||
login-email = Email
|
||||
login-password = Password
|
||||
login-no-account = Don't have an account?
|
||||
login-have-account = Already have an account?
|
||||
nav-login = Sign in
|
||||
nav-register = Register
|
||||
register-title = Create account
|
||||
register-name = Name
|
||||
register-submit = Create account
|
||||
register-error-exists = An account with this email already exists.
|
||||
register-error-invalid = Please check the details you entered and try again.
|
||||
verify-sent-title = Check your email
|
||||
verify-sent-body = We've sent a verification link to
|
||||
verify-ok-title = Account verified
|
||||
verify-ok-body = Your account is verified. You can now sign in.
|
||||
verify-fail-title = Verification failed
|
||||
verify-fail-body = This link is invalid or has expired.
|
||||
auth = Auth
|
||||
admin-session = Session
|
||||
readonly = readonly
|
||||
|
||||
@@ -57,12 +57,28 @@ album-by = od
|
||||
album-play-full = Prehrať celý album
|
||||
album-queue-all = zoradiť všetky skladby v poradí
|
||||
album-no-tracks = zatiaľ žiadne skladby
|
||||
login-title = Prihlásenie admina
|
||||
login-title = Prihlásenie
|
||||
login-error = Prístup odmietnutý - nesprávny e-mail alebo heslo.
|
||||
login-error-unverified = Účet ešte nie je overený. Skontrolujte si e-mail a kliknite na overovací odkaz.
|
||||
login-root = root
|
||||
login-auth = Prihlásiť sa
|
||||
login-email = E-mail
|
||||
login-password = Heslo
|
||||
login-no-account = Nemáte účet?
|
||||
login-have-account = Už máte účet?
|
||||
nav-login = Prihlásiť sa
|
||||
nav-register = Registrácia
|
||||
register-title = Vytvoriť účet
|
||||
register-name = Meno
|
||||
register-submit = Zaregistrovať sa
|
||||
register-error-exists = Účet s týmto e-mailom už existuje.
|
||||
register-error-invalid = Skontrolujte zadané údaje a skúste to znova.
|
||||
verify-sent-title = Skontrolujte si e-mail
|
||||
verify-sent-body = Poslali sme overovací odkaz na adresu
|
||||
verify-ok-title = Účet overený
|
||||
verify-ok-body = Váš účet je overený. Teraz sa môžete prihlásiť.
|
||||
verify-fail-title = Overenie zlyhalo
|
||||
verify-fail-body = Tento odkaz je neplatný alebo mu vypršala platnosť.
|
||||
auth = Overenie
|
||||
admin-session = Relácia
|
||||
readonly = iba na čítanie
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -95,7 +95,7 @@
|
||||
<a href="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-info underline-offset-2 transition hover:bg-info/5 focus:outline-hidden focus-visible:underline">
|
||||
{{ t(key="admin-exit", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline">
|
||||
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
|
||||
@@ -10,21 +10,23 @@
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
|
||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="nav-admin", lang=lang | default(value='sk')) }}
|
||||
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
||||
</span>
|
||||
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="danger") }}
|
||||
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="login-auth", lang=lang | default(value='sk')) }}
|
||||
{{ t(key="login-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
|
||||
{% if error %}
|
||||
{% if error == "unverified" %}
|
||||
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% elif error %}
|
||||
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||
<form method="post" action="/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email"
|
||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
@@ -43,6 +45,12 @@
|
||||
|
||||
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="login-no-account", lang=lang | default(value='sk')) }}
|
||||
<a href="/register"
|
||||
class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
assets/views/auth/register.html
Normal file
65
assets/views/auth/register.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="register-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<div
|
||||
class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
|
||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
||||
</span>
|
||||
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="register-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
|
||||
{% if error == "exists" %}
|
||||
{{ ui::alert_danger(message=t(key="register-error-exists", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% elif error %}
|
||||
{{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/register" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="name"
|
||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="register-name", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
{{ ui::input(name="name", id="name", required=true, autocomplete="name", attrs="autofocus") }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email"
|
||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="login-email", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="password"
|
||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="login-password", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password") }}
|
||||
</div>
|
||||
|
||||
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="login-have-account", lang=lang | default(value='sk')) }}
|
||||
<a href="/login"
|
||||
class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
27
assets/views/auth/verified.html
Normal file
27
assets/views/auth/verified.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{% if ok %}{{ t(key="verify-ok-title", lang=lang | default(value='sk')) }}{% else %}{{ t(key="verify-fail-title", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<div
|
||||
class="rounded-radius border border-outline bg-surface-alt p-5 shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
{% if ok %}
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="verify-ok-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
<p class="mt-3 text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="verify-ok-body", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), href="/login", extra="mt-4 w-full") }}
|
||||
{% else %}
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="verify-fail-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
{{ ui::alert_danger(message=t(key="verify-fail-body", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{{ ui::button(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", variant="outline-primary", extra="mt-4 w-full") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
20
assets/views/auth/verify_sent.html
Normal file
20
assets/views/auth/verify_sent.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="verify-sent-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<div
|
||||
class="rounded-radius border border-outline bg-surface-alt p-5 shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="verify-sent-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
<p class="mt-3 text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="verify-sent-body", lang=lang | default(value='sk')) }}
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ email }}</span>
|
||||
</p>
|
||||
{{ ui::button(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", variant="outline-primary", extra="mt-4 w-full") }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -81,12 +81,13 @@
|
||||
{% if logged_in_admin %}
|
||||
<li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li>
|
||||
<li>
|
||||
<form method="post" action="/admin/logout" hx-boost="false">
|
||||
<form method="post" action="/logout" hx-boost="false">
|
||||
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>{{ ui::nav_link(label=t(key="nav-admin", lang=lang | default(value='sk')), href="/admin/login", data_nav="/admin/login") }}</li>
|
||||
<li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
|
||||
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@@ -126,12 +127,13 @@
|
||||
{% if logged_in_admin %}
|
||||
<li><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li>
|
||||
<form method="post" action="/admin/logout" hx-boost="false">
|
||||
<form method="post" action="/logout" hx-boost="false">
|
||||
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><a href="/admin/login" data-nav="/admin/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li><a href="/login" data-nav="/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li><a href="/register" data-nav="/register" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -17,8 +17,8 @@ use std::{path::Path, sync::Arc};
|
||||
#[allow(unused_imports)]
|
||||
use crate::{
|
||||
controllers::{
|
||||
admin_categories, admin_dashboard, admin_form, admin_login, admin_orders,
|
||||
admin_products, admin_shipping, auth, cart, checkout, home, i18n, media, shop,
|
||||
admin_categories, admin_dashboard, admin_form, admin_orders,
|
||||
admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, shop,
|
||||
},
|
||||
initializers,
|
||||
models::_entities::users,
|
||||
@@ -73,11 +73,11 @@ impl Hooks for App {
|
||||
.add_route(checkout::routes())
|
||||
// cross-cutting
|
||||
.add_route(auth::routes())
|
||||
.add_route(auth_pages::routes())
|
||||
.add_route(i18n::routes())
|
||||
.add_route(media::routes())
|
||||
// admin
|
||||
.add_route(admin_dashboard::routes())
|
||||
.add_route(admin_login::routes())
|
||||
.add_route(admin_products::routes())
|
||||
.add_route(admin_categories::routes())
|
||||
.add_route(admin_orders::routes())
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
//! Cookie-based admin login/logout pages (separate from the JSON `/api/auth`
|
||||
//! flow used by the SPA/API).
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::auth as auth_controller,
|
||||
models::users::{self, LoginParams},
|
||||
controllers::i18n::current_lang,
|
||||
shared::guard,
|
||||
};
|
||||
|
||||
fn login_error(v: &TeraView, jar: &CookieJar) -> Result<Response> {
|
||||
format::view(
|
||||
v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": "Invalid credentials",
|
||||
"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 guard::logged_in(&ctx, &jar).await {
|
||||
return format::redirect("/admin/dashboard");
|
||||
}
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": null,
|
||||
"logged_in_admin": false,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[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, ¶ms.email).await else {
|
||||
return login_error(&v, &jar);
|
||||
};
|
||||
|
||||
if !user.verify_password(¶ms.password) || !guard::is_admin(&ctx, &user) {
|
||||
return login_error(&v, &jar);
|
||||
}
|
||||
|
||||
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("/admin/dashboard")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn logout() -> Result<Response> {
|
||||
format::render()
|
||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
||||
.redirect("/admin/login")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin", get(login_page))
|
||||
.add("/admin/login", get(login_page))
|
||||
.add("/admin/login", post(login))
|
||||
.add("/admin/logout", post(logout))
|
||||
}
|
||||
216
src/controllers/auth_pages.rs
Normal file
216
src/controllers/auth_pages.rs
Normal 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, ¶ms.email).await else {
|
||||
return login_view(&v, &jar, Some("invalid"));
|
||||
};
|
||||
|
||||
if !user.verify_password(¶ms.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, ¶ms).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 = ¶ms.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))
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
pub mod auth;
|
||||
pub mod auth_pages;
|
||||
pub mod admin_categories;
|
||||
pub mod admin_dashboard;
|
||||
pub mod admin_form;
|
||||
pub mod admin_login;
|
||||
pub mod admin_orders;
|
||||
pub mod admin_products;
|
||||
pub mod admin_shipping;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::offset::Local;
|
||||
use loco_rs::prelude::*;
|
||||
use loco_rs::hash;
|
||||
use sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
|
||||
@@ -30,10 +31,13 @@ impl Initializer for AdminSeeder {
|
||||
let mut am = user.into_active_model();
|
||||
am.password = Set(hash);
|
||||
am.name = Set(name);
|
||||
// The admin signs in through the same /login flow as everyone else,
|
||||
// which requires a verified email — keep the seeded admin verified.
|
||||
am.email_verified_at = Set(Some(Local::now().into()));
|
||||
am.update(&ctx.db).await?;
|
||||
tracing::info!(admin = %email, "admin password synced from .env");
|
||||
} else {
|
||||
users::Model::create_with_password(
|
||||
let user = users::Model::create_with_password(
|
||||
&ctx.db,
|
||||
&RegisterParams {
|
||||
email: email.clone(),
|
||||
@@ -42,6 +46,10 @@ impl Initializer for AdminSeeder {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Auto-verify so the seeded admin can log in without an email round-trip.
|
||||
let mut am = user.into_active_model();
|
||||
am.email_verified_at = Set(Some(Local::now().into()));
|
||||
am.update(&ctx.db).await?;
|
||||
tracing::info!(admin = %email, "admin user seeded");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Dear {{name}},
|
||||
Welcome to Loco! You can now log in to your account.
|
||||
Before you get started, please verify your account by clicking the link below:
|
||||
<a href="{{domain}}/api/auth/verify/{{verifyToken}}">
|
||||
<a href="{{domain}}/verify/{{verifyToken}}">
|
||||
Verify Your Account
|
||||
</a>
|
||||
<p>Best regards,<br>The Loco Team</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Welcome {{name}}, you can now log in.
|
||||
Verify your account with the link below:
|
||||
|
||||
{{domain}}/api/auth/verify/{{verifyToken}}
|
||||
{{domain}}/verify/{{verifyToken}}
|
||||
|
||||
@@ -23,21 +23,25 @@ pub async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::M
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Soft auth for public pages: returns the user behind a valid auth cookie, or
|
||||
/// `None`. Never errors — used to decide chrome and post-login redirects, not
|
||||
/// to gate protected handlers (use [`current_admin`] for that).
|
||||
pub async fn current_user(ctx: &AppContext, jar: &CookieJar) -> Option<users::Model> {
|
||||
let cookie = jar.get(AUTH_COOKIE)?;
|
||||
let jwt_config = ctx.config.get_jwt_config().ok()?;
|
||||
let claims = loco_rs::auth::jwt::JWT::new(&jwt_config.secret)
|
||||
.validate(cookie.value())
|
||||
.ok()?;
|
||||
users::Model::find_by_pid(&ctx.db, &claims.claims.pid)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Soft check for public pages: does the request carry a valid admin auth
|
||||
/// cookie? Never errors — used only to decide whether to show admin chrome.
|
||||
pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool {
|
||||
let Some(cookie) = jar.get(AUTH_COOKIE) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
|
||||
return false;
|
||||
};
|
||||
is_admin(ctx, &user)
|
||||
match current_user(ctx, jar).await {
|
||||
Some(user) => is_admin(ctx, &user),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user