registration

This commit is contained in:
Priec
2026-08-05 12:07:14 +02:00
parent 7f93865f7d
commit b69a7c09a3
12 changed files with 259 additions and 2 deletions

View File

@@ -10,6 +10,10 @@ axum = "0.8"
# the table builder posts one set of fields per already-added column.
axum-extra = { version = "0.10", features = ["form"] }
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-types = "0.14.4"
serde = { version = "1", features = ["derive"] }

View File

@@ -136,6 +136,7 @@ fn router(state: AppState) -> Router {
.route("/static/app.css", get(stylesheet))
.merge(pages::analytics::router())
.merge(pages::login::router())
.merge(pages::register::router())
.merge(pages::admin::admin::router())
.merge(pages::admin::table_definition::router())
.merge(pages::add_table::router())
@@ -198,7 +199,7 @@ mod tests {
#[tokio::test]
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;
assert!(status.is_success(), "{path} returned {status}");
assert!(
@@ -230,9 +231,34 @@ mod tests {
async fn signed_out_navbar_offers_login_instead_of_logout() {
let (_, body) = get("/").await;
assert!(body.contains("href=\"/login\""));
assert!(body.contains("href=\"/register\""));
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
/// 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

View File

@@ -5,3 +5,4 @@ pub(crate) mod admin;
pub(crate) mod analytics;
pub(crate) mod import_export;
pub(crate) mod login;
pub(crate) mod register;

View 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(&registered, &timezone, &phone_country)).into_response()
}

View 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))
}

View 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,
}

View 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()));
}
}

View 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))
}

View File

@@ -87,7 +87,7 @@
.error-page h1 { margin-top: 0; }
.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-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 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-alt { margin: 14px 0 0; color: #667385; font-size: 12px; text-align: center; }
/* ---------- Analytics (pages/analytics.html) ---------- */

View File

@@ -11,6 +11,7 @@
<label>Username or email<input name="identifier" autocomplete="username"></label>
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
<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>
</form>
</main>

View 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 %}

View File

@@ -16,6 +16,7 @@
</form>
{% else %}
<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 %}
</nav>
</header>