registration
This commit is contained in:
@@ -10,6 +10,10 @@ axum = "0.8"
|
|||||||
# the table builder posts one set of fields per already-added column.
|
# the table builder posts one set of fields per already-added column.
|
||||||
axum-extra = { version = "0.10", features = ["form"] }
|
axum-extra = { version = "0.10", features = ["form"] }
|
||||||
rusty-money = "0.5.0"
|
rusty-money = "0.5.0"
|
||||||
|
# The register form offers the same timezone and country suggestions the TUI
|
||||||
|
# client does, and reads them from the same two crates.
|
||||||
|
jiff = { version = "0.2.15", default-features = false, features = ["std", "tzdb-bundle-always"] }
|
||||||
|
isocountry = "0.3.2"
|
||||||
prost = "0.14.4"
|
prost = "0.14.4"
|
||||||
prost-types = "0.14.4"
|
prost-types = "0.14.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ fn router(state: AppState) -> Router {
|
|||||||
.route("/static/app.css", get(stylesheet))
|
.route("/static/app.css", get(stylesheet))
|
||||||
.merge(pages::analytics::router())
|
.merge(pages::analytics::router())
|
||||||
.merge(pages::login::router())
|
.merge(pages::login::router())
|
||||||
|
.merge(pages::register::router())
|
||||||
.merge(pages::admin::admin::router())
|
.merge(pages::admin::admin::router())
|
||||||
.merge(pages::admin::table_definition::router())
|
.merge(pages::admin::table_definition::router())
|
||||||
.merge(pages::add_table::router())
|
.merge(pages::add_table::router())
|
||||||
@@ -198,7 +199,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn every_backend_free_page_renders_the_shared_shell() {
|
async fn every_backend_free_page_renders_the_shared_shell() {
|
||||||
for path in ["/", "/login"] {
|
for path in ["/", "/login", "/register"] {
|
||||||
let (status, body) = get(path).await;
|
let (status, body) = get(path).await;
|
||||||
assert!(status.is_success(), "{path} returned {status}");
|
assert!(status.is_success(), "{path} returned {status}");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -230,9 +231,34 @@ mod tests {
|
|||||||
async fn signed_out_navbar_offers_login_instead_of_logout() {
|
async fn signed_out_navbar_offers_login_instead_of_logout() {
|
||||||
let (_, body) = get("/").await;
|
let (_, body) = get("/").await;
|
||||||
assert!(body.contains("href=\"/login\""));
|
assert!(body.contains("href=\"/login\""));
|
||||||
|
assert!(body.contains("href=\"/register\""));
|
||||||
assert!(!body.contains("hx-post=\"/logout\""));
|
assert!(!body.contains("hx-post=\"/logout\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The register form carries every `RegisterRequest` field the TUI client
|
||||||
|
/// asks for, and the suggestion lists for the three the client suggests.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_register_form_offers_the_same_fields_as_the_client() {
|
||||||
|
let (_, body) = get("/register").await;
|
||||||
|
for field in [
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"password",
|
||||||
|
"password_confirmation",
|
||||||
|
"role",
|
||||||
|
"timezone",
|
||||||
|
"phone_country",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("name=\"{field}\"")),
|
||||||
|
"the register form is missing the {field} field"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(body.contains("value=\"accountant\""));
|
||||||
|
assert!(body.contains("value=\"Europe/Bratislava\""));
|
||||||
|
assert!(body.contains("value=\"SK\""));
|
||||||
|
}
|
||||||
|
|
||||||
/// Every table-definition endpoint is mounted, and every one of them is
|
/// Every table-definition endpoint is mounted, and every one of them is
|
||||||
/// behind a session: without a cookie there is no request to sign, so each
|
/// behind a session: without a cookie there is no request to sign, so each
|
||||||
/// answers with the redirect to the login page rather than a 404 or a call
|
/// answers with the redirect to the login page rather than a 404 or a call
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ pub(crate) mod admin;
|
|||||||
pub(crate) mod analytics;
|
pub(crate) mod analytics;
|
||||||
pub(crate) mod import_export;
|
pub(crate) mod import_export;
|
||||||
pub(crate) mod login;
|
pub(crate) mod login;
|
||||||
|
pub(crate) mod register;
|
||||||
|
|||||||
53
web/src/pages/register/logic.rs
Normal file
53
web/src/pages/register/logic.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use axum::{
|
||||||
|
Form,
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use tonic::Request;
|
||||||
|
|
||||||
|
use crate::{AppState, auth::RegisterRequest, ui::Nav};
|
||||||
|
|
||||||
|
use super::{state::RegisterInput, ui};
|
||||||
|
|
||||||
|
pub(crate) async fn register_page(headers: HeaderMap) -> Html<String> {
|
||||||
|
Html(ui::render_page(Nav::new(&headers, "register")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(input): Form<RegisterInput>,
|
||||||
|
) -> Response {
|
||||||
|
// Same shaping as the client's `RegisterFormState::try_register`: trim
|
||||||
|
// every field, upper-case the country code, and leave the checking to the
|
||||||
|
// backend.
|
||||||
|
let timezone = input.timezone.trim().to_string();
|
||||||
|
let phone_country = input.phone_country.trim().to_ascii_uppercase();
|
||||||
|
|
||||||
|
let mut client = state.auth;
|
||||||
|
let registered = match client
|
||||||
|
.register(Request::new(RegisterRequest {
|
||||||
|
username: input.username.trim().to_string(),
|
||||||
|
email: input.email.trim().to_string(),
|
||||||
|
password: input.password.trim().to_string(),
|
||||||
|
password_confirmation: input.password_confirmation.trim().to_string(),
|
||||||
|
role: input.role.trim().to_string(),
|
||||||
|
timezone: timezone.clone(),
|
||||||
|
phone_country: phone_country.clone(),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => response.into_inner(),
|
||||||
|
Err(status) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Html(ui::render_error(status.message())),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The client reports the registered account back to the user and stays on
|
||||||
|
// the form — registering does not sign anyone in.
|
||||||
|
Html(ui::render_success(®istered, &timezone, &phone_country)).into_response()
|
||||||
|
}
|
||||||
20
web/src/pages/register/mod.rs
Normal file
20
web/src/pages/register/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! GET /register → register.html
|
||||||
|
//! POST /register → registers the account and reports the outcome inline
|
||||||
|
//!
|
||||||
|
//! The form is the TUI client's register page (client/src/pages/register): the
|
||||||
|
//! same seven fields, the same suggestion lists, the same handling — trim
|
||||||
|
//! everything, upper-case the country, send it, and let the backend do the
|
||||||
|
//! validating. Registering does not sign anyone in, exactly as in the client.
|
||||||
|
|
||||||
|
use axum::{Router, routing::get};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub(crate) mod logic;
|
||||||
|
pub(crate) mod state;
|
||||||
|
pub(crate) mod suggestions;
|
||||||
|
pub(crate) mod ui;
|
||||||
|
|
||||||
|
pub(crate) fn router() -> Router<AppState> {
|
||||||
|
Router::new().route("/register", get(logic::register_page).post(logic::register))
|
||||||
|
}
|
||||||
20
web/src/pages/register/state.rs
Normal file
20
web/src/pages/register/state.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/// The register form, one field per `RegisterRequest` field. Every one of them
|
||||||
|
/// is optional at this layer: the client does not validate either, it trims and
|
||||||
|
/// posts, and the backend answers with what is wrong.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub(crate) struct RegisterInput {
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password_confirmation: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub role: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timezone: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phone_country: String,
|
||||||
|
}
|
||||||
39
web/src/pages/register/suggestions.rs
Normal file
39
web/src/pages/register/suggestions.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//! The three suggestion lists the register form offers, from the same sources
|
||||||
|
//! as the TUI client's (client/src/pages/register/suggestions.rs): the four
|
||||||
|
//! roles the database accepts, the IANA timezone database, and the ISO 3166-1
|
||||||
|
//! alpha-2 country codes.
|
||||||
|
//!
|
||||||
|
//! The client filters these itself as the field is typed into; here the lists
|
||||||
|
//! ship whole in `<datalist>` elements and the browser does the filtering.
|
||||||
|
|
||||||
|
pub(crate) const ROLES: &[&str] = &["admin", "moderator", "accountant", "viewer"];
|
||||||
|
|
||||||
|
pub(crate) fn timezones() -> Vec<String> {
|
||||||
|
jiff::tz::db()
|
||||||
|
.available()
|
||||||
|
.map(|timezone| timezone.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn phone_countries() -> Vec<String> {
|
||||||
|
isocountry::CountryCode::iter_alpha2()
|
||||||
|
.map(|code| code.alpha2().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_timezone_list_is_the_iana_database() {
|
||||||
|
assert!(timezones().contains(&"Europe/Bratislava".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_country_list_is_every_iso_alpha2_code() {
|
||||||
|
let countries = phone_countries();
|
||||||
|
assert_eq!(countries.len(), 249);
|
||||||
|
assert!(countries.contains(&"SK".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
54
web/src/pages/register/ui.rs
Normal file
54
web/src/pages/register/ui.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthResponse,
|
||||||
|
ui::{Alert, Nav, render},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::suggestions;
|
||||||
|
|
||||||
|
/// GET /register
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/register/register.html")]
|
||||||
|
struct RegisterPage {
|
||||||
|
nav: Nav,
|
||||||
|
roles: &'static [&'static str],
|
||||||
|
timezones: Vec<String>,
|
||||||
|
phone_countries: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_page(nav: Nav) -> String {
|
||||||
|
render(&RegisterPage {
|
||||||
|
nav,
|
||||||
|
roles: suggestions::ROLES,
|
||||||
|
timezones: suggestions::timezones(),
|
||||||
|
phone_countries: suggestions::phone_countries(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /register — the #register-status swap when the account was created.
|
||||||
|
/// The lines are the client's success dialog, field for field.
|
||||||
|
pub(crate) fn render_success(
|
||||||
|
registered: &AuthResponse,
|
||||||
|
timezone: &str,
|
||||||
|
phone_country: &str,
|
||||||
|
) -> String {
|
||||||
|
let message = format!(
|
||||||
|
"User ID: {}\nUsername: {}\nEmail: {}\nRole: {}\nTimezone: {}\nPhone country: {}",
|
||||||
|
registered.id,
|
||||||
|
registered.username,
|
||||||
|
registered.email,
|
||||||
|
registered.role,
|
||||||
|
timezone,
|
||||||
|
phone_country,
|
||||||
|
);
|
||||||
|
render(&Alert::success(
|
||||||
|
"Registration successful. You can sign in now.",
|
||||||
|
&message,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /register — the #register-status swap when the backend refused.
|
||||||
|
pub(crate) fn render_error(message: &str) -> String {
|
||||||
|
render(&Alert::error("Could not register", message))
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
.error-page h1 { margin-top: 0; }
|
.error-page h1 { margin-top: 0; }
|
||||||
.error-page p { color: #a33a31; }
|
.error-page p { color: #a33a31; }
|
||||||
|
|
||||||
/* ---------- Login (pages/login.html) ---------- */
|
/* ---------- Login and register (pages/login, pages/register) ---------- */
|
||||||
|
|
||||||
.login-main { width: min(420px, calc(100% - 32px)); margin: 12vh auto; }
|
.login-main { width: min(420px, calc(100% - 32px)); margin: 12vh auto; }
|
||||||
.login-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
.login-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
||||||
@@ -95,6 +95,7 @@
|
|||||||
.login-card label { display: grid; gap: 5px; margin-bottom: 14px; color: #465267; font-size: 12px; }
|
.login-card label { display: grid; gap: 5px; margin-bottom: 14px; color: #465267; font-size: 12px; }
|
||||||
.login-card button { width: 100%; border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
.login-card button { width: 100%; border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
||||||
.login-card button:disabled, .login-card button.htmx-request { opacity: .55; cursor: wait; }
|
.login-card button:disabled, .login-card button.htmx-request { opacity: .55; cursor: wait; }
|
||||||
|
.login-alt { margin: 14px 0 0; color: #667385; font-size: 12px; text-align: center; }
|
||||||
|
|
||||||
/* ---------- Analytics (pages/analytics.html) ---------- */
|
/* ---------- Analytics (pages/analytics.html) ---------- */
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<label>Username or email<input name="identifier" autocomplete="username"></label>
|
<label>Username or email<input name="identifier" autocomplete="username"></label>
|
||||||
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
|
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
|
<p class="login-alt">No account yet? <a href="/register">Register</a></p>
|
||||||
<div id="login-status" aria-live="polite"></div>
|
<div id="login-status" aria-live="polite"></div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
37
web/templates/pages/register/register.html
Normal file
37
web/templates/pages/register/register.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{# GET /register — crate::pages::register::ui::RegisterPage #}
|
||||||
|
{% extends "ui/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Register{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="login-main">
|
||||||
|
<form class="login-card" hx-post="/register" hx-target="#register-status" hx-swap="innerHTML"
|
||||||
|
hx-disabled-elt="button" novalidate>
|
||||||
|
<h1>Register</h1>
|
||||||
|
<label>Username<input name="username" autocomplete="username"></label>
|
||||||
|
<label>Email<input name="email" type="email" autocomplete="email"></label>
|
||||||
|
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="new-password"></label>
|
||||||
|
<label>Confirm password<input name="password_confirmation" type="password" autocomplete="new-password"></label>
|
||||||
|
{#
|
||||||
|
The three fields the client offers suggestions for. A datalist keeps the
|
||||||
|
lists open — the backend, not this form, decides what is accepted — while
|
||||||
|
still showing the same choices the client's suggestion popup does.
|
||||||
|
#}
|
||||||
|
<label>Role<input name="role" list="role-options" autocomplete="off"></label>
|
||||||
|
<datalist id="role-options">
|
||||||
|
{% for role in roles %}<option value="{{ role }}"></option>{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
<label>Timezone<input name="timezone" list="timezone-options" autocomplete="off" placeholder="Europe/Bratislava"></label>
|
||||||
|
<datalist id="timezone-options">
|
||||||
|
{% for timezone in timezones %}<option value="{{ timezone }}"></option>{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
<label>Phone country<input name="phone_country" list="phone-country-options" autocomplete="off" placeholder="SK"></label>
|
||||||
|
<datalist id="phone-country-options">
|
||||||
|
{% for country in phone_countries %}<option value="{{ country }}"></option>{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
<p class="login-alt">Already have an account? <a href="/login">Sign in</a></p>
|
||||||
|
<div id="register-status" aria-live="polite"></div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/login" {% if nav.active == "login" %}class="active"{% endif %}>Login</a>
|
<a href="/login" {% if nav.active == "login" %}class="active"{% endif %}>Login</a>
|
||||||
|
<a href="/register" {% if nav.active == "register" %}class="active"{% endif %}>Register</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user