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.
|
||||
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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 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) ---------- */
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
{% 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>
|
||||
|
||||
Reference in New Issue
Block a user