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

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