From b69a7c09a3b7d39dd904806e892297e7d398ad09 Mon Sep 17 00:00:00 2001 From: Priec Date: Wed, 5 Aug 2026 12:07:14 +0200 Subject: [PATCH] registration --- web/Cargo.toml | 4 ++ web/src/lib.rs | 28 ++++++++++- web/src/pages/mod.rs | 1 + web/src/pages/register/logic.rs | 53 +++++++++++++++++++++ web/src/pages/register/mod.rs | 20 ++++++++ web/src/pages/register/state.rs | 20 ++++++++ web/src/pages/register/suggestions.rs | 39 ++++++++++++++++ web/src/pages/register/ui.rs | 54 ++++++++++++++++++++++ web/static/app.css | 3 +- web/templates/pages/login/login.html | 1 + web/templates/pages/register/register.html | 37 +++++++++++++++ web/templates/ui/navbar.html | 1 + 12 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 web/src/pages/register/logic.rs create mode 100644 web/src/pages/register/mod.rs create mode 100644 web/src/pages/register/state.rs create mode 100644 web/src/pages/register/suggestions.rs create mode 100644 web/src/pages/register/ui.rs create mode 100644 web/templates/pages/register/register.html diff --git a/web/Cargo.toml b/web/Cargo.toml index 5a446bd4..ccaf6bb2 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -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"] } diff --git a/web/src/lib.rs b/web/src/lib.rs index 727c6298..22c35719 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -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 diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index 7503ce96..7c2dacd4 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -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; diff --git a/web/src/pages/register/logic.rs b/web/src/pages/register/logic.rs new file mode 100644 index 00000000..89c01223 --- /dev/null +++ b/web/src/pages/register/logic.rs @@ -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 { + Html(ui::render_page(Nav::new(&headers, "register"))) +} + +pub(crate) async fn register( + State(state): State, + Form(input): Form, +) -> 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() +} diff --git a/web/src/pages/register/mod.rs b/web/src/pages/register/mod.rs new file mode 100644 index 00000000..a5754c62 --- /dev/null +++ b/web/src/pages/register/mod.rs @@ -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 { + Router::new().route("/register", get(logic::register_page).post(logic::register)) +} diff --git a/web/src/pages/register/state.rs b/web/src/pages/register/state.rs new file mode 100644 index 00000000..d5359060 --- /dev/null +++ b/web/src/pages/register/state.rs @@ -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, +} diff --git a/web/src/pages/register/suggestions.rs b/web/src/pages/register/suggestions.rs new file mode 100644 index 00000000..a77d5e75 --- /dev/null +++ b/web/src/pages/register/suggestions.rs @@ -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 `` elements and the browser does the filtering. + +pub(crate) const ROLES: &[&str] = &["admin", "moderator", "accountant", "viewer"]; + +pub(crate) fn timezones() -> Vec { + jiff::tz::db() + .available() + .map(|timezone| timezone.to_string()) + .collect() +} + +pub(crate) fn phone_countries() -> Vec { + 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())); + } +} diff --git a/web/src/pages/register/ui.rs b/web/src/pages/register/ui.rs new file mode 100644 index 00000000..0d958c21 --- /dev/null +++ b/web/src/pages/register/ui.rs @@ -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, + phone_countries: Vec, +} + +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)) +} diff --git a/web/static/app.css b/web/static/app.css index ee0185ad..a7038baa 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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) ---------- */ diff --git a/web/templates/pages/login/login.html b/web/templates/pages/login/login.html index 236123ad..beffd383 100644 --- a/web/templates/pages/login/login.html +++ b/web/templates/pages/login/login.html @@ -11,6 +11,7 @@ +
diff --git a/web/templates/pages/register/register.html b/web/templates/pages/register/register.html new file mode 100644 index 00000000..fdb30164 --- /dev/null +++ b/web/templates/pages/register/register.html @@ -0,0 +1,37 @@ +{# GET /register — crate::pages::register::ui::RegisterPage #} +{% extends "ui/base.html" %} + +{% block title %}Register{% endblock %} + +{% block content %} +
+ +
+{% endblock %} diff --git a/web/templates/ui/navbar.html b/web/templates/ui/navbar.html index ebcb66c9..8fb8013d 100644 --- a/web/templates/ui/navbar.html +++ b/web/templates/ui/navbar.html @@ -16,6 +16,7 @@ {% else %} Login + Register {% endif %}