Translation support + french.

This commit is contained in:
Greg Burri 2025-01-06 16:04:48 +01:00
parent e9873c1943
commit f059d3c61f
16 changed files with 380 additions and 169 deletions

34
TODO.md
View file

@ -1,21 +1,29 @@
* Finish updating profile
* check password and message error
* user can change email: add a field + revalidation of new email
* Check position of message error in profile/sign in/sign up with flex grid layout * Check position of message error in profile/sign in/sign up with flex grid layout
* Review the recipe model (SQL) * Define the UI (mockups).
* Describe the use cases in details. * Two CSS: one for desktop and one for mobile
* Define the UI (mockups). * Use CSS flex/grid to define a good design/layout
* Two CSS: one for desktop and one for mobile * Drag and drop of steps and groups to define their order
* Use CSS flex/grid to define a good design/layout * Make a search page
* Define the logic behind each page and action. * Use of markdown for some field (how to add markdown as rinja filter?)
* Implement: * Quick search left panel by tags ?
* Make the home page: Define what to display to the user
* Show existing tags when editing a recipe
[ok] Add support to translations.
* Make a Text database (a bit like d-lan.net) and think about translation.
* The language is stored in cookie or in user profile if the user is connected
* A combobox in the header shows all languages
[ok] Set a lang cookie (when not connected)
[ok] User can choose language
[ok] Implement:
.service(services::edit_recipe) .service(services::edit_recipe)
.service(services::new_recipe) .service(services::new_recipe)
.service(services::webapi::set_recipe_title) .service(services::webapi::set_recipe_title)
.service(services::webapi::set_recipe_description) .service(services::webapi::set_recipe_description)
* Add support to translations into db model. [ok] Review the recipe model (SQL)
* Make a Text database (a bit like d-lan.net) and think about translation. [ok] Finish updating profile
[ok] check password and message error
[ok] user can change email: add a field + revalidation of new email
[ok] Try using WASM for all the client logic (test on editing/creating a recipe) [ok] Try using WASM for all the client logic (test on editing/creating a recipe)
[ok] How to log error to journalctl or elsewhere + debug log? [ok] How to log error to journalctl or elsewhere + debug log?
[ok] Clean the old code + commit [ok] Clean the old code + commit

View file

@ -6,7 +6,9 @@ pub const DB_DIRECTORY: &str = "data";
pub const DB_FILENAME: &str = "recipes.sqlite"; pub const DB_FILENAME: &str = "recipes.sqlite";
pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql"; pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";
pub const VALIDATION_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour). pub const VALIDATION_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token"; pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token";
pub const COOKIE_LANG_NAME: &str = "lang";
pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour). pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
@ -22,6 +24,7 @@ pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse
pub const MAX_DB_CONNECTION: u32 = 1; // To avoid database lock. pub const MAX_DB_CONNECTION: u32 = 1; // To avoid database lock.
// TODO: remove, should be replaced by the translation module.
pub static LANGUAGES: LazyLock<[(&str, &str); 2]> = LazyLock::new(|| { pub static LANGUAGES: LazyLock<[(&str, &str); 2]> = LazyLock::new(|| {
let mut langs = [("Français", "fr"), ("English", "en")]; let mut langs = [("Français", "fr"), ("English", "en")];
langs.sort(); langs.sort();

View file

@ -7,6 +7,7 @@ use crate::{
consts, consts,
data::model, data::model,
hash::{hash, verify_password}, hash::{hash, verify_password},
services::user,
}; };
#[derive(Debug)] #[derive(Debug)]
@ -162,6 +163,16 @@ WHERE [id] = $1
}) })
} }
pub async fn set_user_lang(&self, user_id: i64, lang: &str) -> Result<()> {
sqlx::query("UPDATE [User] SET [lang] = $2 WHERE [id] = $1")
.bind(user_id)
.bind(lang)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn sign_up(&self, email: &str, password: &str) -> Result<SignUpResult> { pub async fn sign_up(&self, email: &str, password: &str) -> Result<SignUpResult> {
self.sign_up_with_given_time(email, password, Utc::now()) self.sign_up_with_given_time(email, password, Utc::now())
.await .await

View file

@ -37,20 +37,20 @@ pub struct MessageTemplate {
} }
impl MessageTemplate { impl MessageTemplate {
pub fn new(message: &str, tr: Tr) -> MessageTemplate { pub fn new(message: String, tr: Tr) -> MessageTemplate {
MessageTemplate { MessageTemplate {
user: None, user: None,
tr, tr,
message: message.to_string(), message,
as_code: false, as_code: false,
} }
} }
pub fn new_with_user(message: &str, tr: Tr, user: Option<model::User>) -> MessageTemplate { pub fn new_with_user(message: String, tr: Tr, user: Option<model::User>) -> MessageTemplate {
MessageTemplate { MessageTemplate {
user, user,
tr, tr,
message: message.to_string(), message,
as_code: false, as_code: false,
} }
} }

View file

@ -89,6 +89,7 @@ async fn main() {
let ron_api_routes = Router::new() let ron_api_routes = Router::new()
// Disabled: update user profile is now made with a post data ('edit_user_post'). // Disabled: update user profile is now made with a post data ('edit_user_post').
// .route("/user/update", put(services::ron::update_user)) // .route("/user/update", put(services::ron::update_user))
.route("/set_lang", put(services::ron::set_lang))
.route("/recipe/set_title", put(services::ron::set_recipe_title)) .route("/recipe/set_title", put(services::ron::set_recipe_title))
.route( .route(
"/recipe/set_description", "/recipe/set_description",
@ -231,26 +232,26 @@ async fn translation(
user.lang user.lang
} else { } else {
let available_codes = Tr::available_codes(); let available_codes = Tr::available_codes();
let jar = CookieJar::from_headers(req.headers());
match jar.get(consts::COOKIE_LANG_NAME) {
Some(lang) if available_codes.contains(&lang.value()) => lang.value().to_string(),
_ => {
let accept_language = req
.headers()
.get(axum::http::header::ACCEPT_LANGUAGE)
.map(|v| v.to_str().unwrap_or_default())
.unwrap_or_default()
.split(',')
.map(|l| l.split('-').next().unwrap_or_default())
.find_or_first(|l| available_codes.contains(l));
// TODO: Check cookies before http headers. accept_language.unwrap_or("en").to_string()
}
let accept_language = req }
.headers()
.get(axum::http::header::ACCEPT_LANGUAGE)
.map(|v| v.to_str().unwrap_or_default())
.unwrap_or_default()
.split(',')
.map(|l| l.split('-').next().unwrap_or_default())
.find_or_first(|l| available_codes.contains(l));
// TODO: Save to cookies.
accept_language.unwrap_or("en").to_string()
}; };
let tr = Tr::new(&language); let tr = Tr::new(&language);
// let jar = CookieJar::from_headers(req.headers());
req.extensions_mut().insert(tr); req.extensions_mut().insert(tr);
Ok(next.run(req).await) Ok(next.run(req).await)
} }

View file

@ -77,6 +77,6 @@ pub async fn not_found(
) -> impl IntoResponse { ) -> impl IntoResponse {
( (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
MessageTemplate::new_with_user("404: Not found", tr, user), MessageTemplate::new_with_user("404: Not found".to_string(), tr, user),
) )
} }

View file

@ -9,7 +9,7 @@ use crate::{
consts, consts,
data::{db, model}, data::{db, model},
html_templates::*, html_templates::*,
translation, translation::{self, Sentence},
}; };
#[debug_handler] #[debug_handler]
@ -22,7 +22,7 @@ pub async fn create(
let recipe_id = connection.create_recipe(user.id).await?; let recipe_id = connection.create_recipe(user.id).await?;
Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response()) Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
} else { } else {
Ok(MessageTemplate::new("Not logged in", tr).into_response()) Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
} }
} }
@ -53,13 +53,16 @@ pub async fn edit_recipe(
} }
.into_response()) .into_response())
} else { } else {
Ok(MessageTemplate::new("Not allowed to edit this recipe", tr).into_response()) Ok(
MessageTemplate::new(tr.t(Sentence::RecipeNotAllowedToEdit), tr)
.into_response(),
)
} }
} else { } else {
Ok(MessageTemplate::new("Recipe not found", tr).into_response()) Ok(MessageTemplate::new(tr.t(Sentence::RecipeNotFound), tr).into_response())
} }
} else { } else {
Ok(MessageTemplate::new("Not logged in", tr).into_response()) Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
} }
} }
@ -76,7 +79,7 @@ pub async fn view(
&& (user.is_none() || recipe.user_id != user.as_ref().unwrap().id) && (user.is_none() || recipe.user_id != user.as_ref().unwrap().id)
{ {
return Ok(MessageTemplate::new_with_user( return Ok(MessageTemplate::new_with_user(
&format!("Not allowed the view the recipe {}", recipe_id), tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]),
tr, tr,
user, user,
) )
@ -103,11 +106,9 @@ pub async fn view(
} }
.into_response()) .into_response())
} }
None => Ok(MessageTemplate::new_with_user( None => Ok(
&format!("Cannot find the recipe {}", recipe_id), MessageTemplate::new_with_user(tr.t(Sentence::RecipeNotFound), tr, user)
tr, .into_response(),
user, ),
)
.into_response()),
} }
} }

View file

@ -1,13 +1,15 @@
use axum::{ use axum::{
debug_handler, debug_handler,
extract::{Extension, Query, State}, extract::{Extension, Query, State},
http::StatusCode, http::{HeaderMap, StatusCode},
response::{ErrorResponse, IntoResponse, Result}, response::{ErrorResponse, IntoResponse, Result},
}; };
use axum_extra::extract::cookie::{Cookie, CookieJar};
use serde::Deserialize; use serde::Deserialize;
// use tracing::{event, Level}; // use tracing::{event, Level};
use crate::{ use crate::{
consts,
data::db, data::db,
model, model,
ron_extractor::ExtractRon, ron_extractor::ExtractRon,
@ -22,29 +24,46 @@ pub struct RecipeId {
id: i64, id: i64,
} }
#[allow(dead_code)] // #[allow(dead_code)]
// #[debug_handler]
// pub async fn update_user(
// State(connection): State<db::Connection>,
// Extension(user): Extension<Option<model::User>>,
// ExtractRon(ron): ExtractRon<common::ron_api::UpdateProfile>,
// ) -> Result<StatusCode> {
// if let Some(user) = user {
// connection
// .update_user(
// user.id,
// ron.email.as_deref().map(str::trim),
// ron.name.as_deref(),
// ron.password.as_deref(),
// )
// .await?;
// } else {
// return Err(ErrorResponse::from(ron_error(
// StatusCode::UNAUTHORIZED,
// NOT_AUTHORIZED_MESSAGE,
// )));
// }
// Ok(StatusCode::OK)
// }
#[debug_handler] #[debug_handler]
pub async fn update_user( pub async fn set_lang(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::UpdateProfile>, headers: HeaderMap,
) -> Result<StatusCode> { ExtractRon(ron): ExtractRon<common::ron_api::SetLang>,
) -> Result<(CookieJar, StatusCode)> {
let mut jar = CookieJar::from_headers(&headers);
if let Some(user) = user { if let Some(user) = user {
connection connection.set_user_lang(user.id, &ron.lang).await?;
.update_user(
user.id,
ron.email.as_deref().map(str::trim),
ron.name.as_deref(),
ron.password.as_deref(),
)
.await?;
} else { } else {
return Err(ErrorResponse::from(ron_error( let cookie = Cookie::build((consts::COOKIE_LANG_NAME, ron.lang)).path("/");
StatusCode::UNAUTHORIZED, jar = jar.add(cookie);
NOT_AUTHORIZED_MESSAGE,
)));
} }
Ok(StatusCode::OK) Ok((jar, StatusCode::OK))
} }
async fn check_user_rights_recipe( async fn check_user_rights_recipe(

View file

@ -126,9 +126,12 @@ pub async fn sign_up_post(
let email = form_data.email.clone(); let email = form_data.email.clone();
match email::send_email( match email::send_email(
&email, &email,
&format!( &tr.tp(
"Follow this link to confirm your inscription: {}/validation?validation_token={}", Sentence::SignUpFollowEmailLink,
url, token &[Box::new(format!(
"{}/validation?validation_token={}",
url, token
))],
), ),
&config.smtp_relay_address, &config.smtp_relay_address,
&config.smtp_login, &config.smtp_login,
@ -136,10 +139,12 @@ pub async fn sign_up_post(
) )
.await .await
{ {
Ok(()) => Ok( Ok(()) => {
MessageTemplate::new_with_user( Ok(
"An email has been sent, follow the link to validate your account", MessageTemplate::new_with_user(tr.t(Sentence::SignUpEmailSent), tr, user)
tr, user).into_response()), .into_response(),
)
}
Err(_) => { Err(_) => {
// error!("Email validation error: {}", error); // TODO: log // error!("Email validation error: {}", error); // TODO: log
error_response(SignUpError::UnableSendEmail, &form_data, user, tr) error_response(SignUpError::UnableSendEmail, &form_data, user, tr)
@ -166,7 +171,7 @@ pub async fn sign_up_validation(
if user.is_some() { if user.is_some() {
return Ok(( return Ok((
jar, jar,
MessageTemplate::new_with_user("User already exists", tr, user), MessageTemplate::new_with_user(tr.t(Sentence::ValidationUserAlreadyExists), tr, user),
)); ));
} }
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr); let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -189,7 +194,7 @@ pub async fn sign_up_validation(
Ok(( Ok((
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"Email validation successful, your account has been created", tr.t(Sentence::SignUpEmailValidationSuccess),
tr, tr,
user, user,
), ),
@ -198,7 +203,7 @@ pub async fn sign_up_validation(
db::user::ValidationResult::ValidationExpired => Ok(( db::user::ValidationResult::ValidationExpired => Ok((
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again", tr.t(Sentence::SignUpValidationExpired),
tr, tr,
user, user,
), ),
@ -206,7 +211,7 @@ pub async fn sign_up_validation(
db::user::ValidationResult::UnknownUser => Ok(( db::user::ValidationResult::UnknownUser => Ok((
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"Validation error. Try to sign up again", tr.t(Sentence::SignUpValidationErrorTryAgain),
tr, tr,
user, user,
), ),
@ -215,7 +220,7 @@ pub async fn sign_up_validation(
} }
None => Ok(( None => Ok((
jar, jar,
MessageTemplate::new_with_user("Validation error", tr, user), MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user),
)), )),
} }
} }
@ -313,12 +318,10 @@ pub async fn ask_reset_password_get(
Extension(tr): Extension<translation::Tr>, Extension(tr): Extension<translation::Tr>,
) -> Result<Response> { ) -> Result<Response> {
if user.is_some() { if user.is_some() {
Ok(MessageTemplate::new_with_user( Ok(
"Can't ask to reset password when already logged in", MessageTemplate::new_with_user(tr.t(Sentence::AskResetAlreadyLoggedInError), tr, user)
tr, .into_response(),
user,
) )
.into_response())
} else { } else {
Ok(AskResetPasswordTemplate { Ok(AskResetPasswordTemplate {
user, user,
@ -361,23 +364,21 @@ pub async fn ask_reset_password_post(
) -> Result<Response> { ) -> Result<Response> {
Ok(AskResetPasswordTemplate { Ok(AskResetPasswordTemplate {
user, user,
tr,
email: email.to_string(), email: email.to_string(),
message_email: match error { message_email: match error {
AskResetPasswordError::InvalidEmail => "Invalid email", AskResetPasswordError::InvalidEmail => tr.t(Sentence::InvalidEmail),
_ => "", _ => String::new(),
} },
.to_string(),
message: match error { message: match error {
AskResetPasswordError::EmailAlreadyReset => { AskResetPasswordError::EmailAlreadyReset => {
"The password has already been reset for this email" tr.t(Sentence::AskResetEmailAlreadyResetError)
} }
AskResetPasswordError::EmailUnknown => "Email unknown", AskResetPasswordError::EmailUnknown => tr.t(Sentence::EmailUnknown),
AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email", AskResetPasswordError::UnableSendEmail => tr.t(Sentence::UnableToSendResetEmail),
AskResetPasswordError::DatabaseError => "Database error", AskResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
_ => "", _ => String::new(),
} },
.to_string(), tr,
} }
.into_response()) .into_response())
} }
@ -417,9 +418,12 @@ pub async fn ask_reset_password_post(
let url = utils::get_url_from_host(&host); let url = utils::get_url_from_host(&host);
match email::send_email( match email::send_email(
&form_data.email, &form_data.email,
&format!( &tr.tp(
"Follow this link to reset your password: {}/reset_password?reset_token={}", Sentence::AskResetFollowEmailLink,
url, token &[Box::new(format!(
"{}/reset_password?reset_token={}",
url, token
))],
), ),
&config.smtp_relay_address, &config.smtp_relay_address,
&config.smtp_login, &config.smtp_login,
@ -427,12 +431,12 @@ pub async fn ask_reset_password_post(
) )
.await .await
{ {
Ok(()) => Ok(MessageTemplate::new_with_user( Ok(()) => {
"An email has been sent, follow the link to reset your password.", Ok(
tr, MessageTemplate::new_with_user(tr.t(Sentence::AskResetEmailSent), tr, user)
user, .into_response(),
) )
.into_response()), }
Err(_) => { Err(_) => {
// error!("Email validation error: {}", error); // TODO: log // error!("Email validation error: {}", error); // TODO: log
error_response( error_response(
@ -472,7 +476,10 @@ pub async fn reset_password_get(
} }
.into_response()) .into_response())
} else { } else {
Ok(MessageTemplate::new_with_user("Reset token missing", tr, user).into_response()) Ok(
MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user)
.into_response(),
)
} }
} }
@ -505,22 +512,21 @@ pub async fn reset_password_post(
) -> Result<Response> { ) -> Result<Response> {
Ok(ResetPasswordTemplate { Ok(ResetPasswordTemplate {
user, user,
tr,
reset_token: form_data.reset_token.clone(), reset_token: form_data.reset_token.clone(),
message_password: match error { message_password: match error {
ResetPasswordError::PasswordsNotEqual => "Passwords don't match", ResetPasswordError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
ResetPasswordError::InvalidPassword => { ResetPasswordError::InvalidPassword => tr.tp(
"Password must have at least eight characters" Sentence::InvalidPassword,
} &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
_ => "", ),
} _ => String::new(),
.to_string(), },
message: match error { message: match error {
ResetPasswordError::TokenExpired => "Token expired, try to reset password again", ResetPasswordError::TokenExpired => tr.t(Sentence::AskResetTokenExpired),
ResetPasswordError::DatabaseError => "Database error", ResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
_ => "", _ => String::new(),
} },
.to_string(), tr,
} }
.into_response()) .into_response())
} }
@ -545,7 +551,7 @@ pub async fn reset_password_post(
{ {
Ok(db::user::ResetPasswordResult::Ok) => { Ok(db::user::ResetPasswordResult::Ok) => {
Ok( Ok(
MessageTemplate::new_with_user("Your password has been reset", tr, user) MessageTemplate::new_with_user(tr.t(Sentence::PasswordReset), tr, user)
.into_response(), .into_response(),
) )
} }
@ -575,7 +581,7 @@ pub async fn edit_user_get(
} }
.into_response() .into_response()
} else { } else {
MessageTemplate::new("Not logged in", tr).into_response() MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response()
} }
} }
@ -617,25 +623,23 @@ pub async fn edit_user_post(
username: form_data.name.clone(), username: form_data.name.clone(),
email: form_data.email.clone(), email: form_data.email.clone(),
message_email: match error { message_email: match error {
ProfileUpdateError::InvalidEmail => "Invalid email", ProfileUpdateError::InvalidEmail => tr.t(Sentence::InvalidEmail),
ProfileUpdateError::EmailAlreadyTaken => "Email already taken", ProfileUpdateError::EmailAlreadyTaken => tr.t(Sentence::EmailAlreadyTaken),
_ => "", _ => String::new(),
} },
.to_string(),
message_password: match error { message_password: match error {
ProfileUpdateError::PasswordsNotEqual => "Passwords don't match", ProfileUpdateError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
ProfileUpdateError::InvalidPassword => { ProfileUpdateError::InvalidPassword => tr.tp(
"Password must have at least eight characters" Sentence::InvalidPassword,
} &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
_ => "", ),
} _ => String::new(),
.to_string(), },
message: match error { message: match error {
ProfileUpdateError::DatabaseError => "Database error", ProfileUpdateError::DatabaseError => tr.t(Sentence::DatabaseError),
ProfileUpdateError::UnableSendEmail => "Unable to send the validation email", ProfileUpdateError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
_ => "", _ => String::new(),
} },
.to_string(),
tr, tr,
} }
.into_response()) .into_response())
@ -662,7 +666,7 @@ pub async fn edit_user_post(
}; };
let email_trimmed = form_data.email.trim(); let email_trimmed = form_data.email.trim();
let message: &str; let message: String;
match connection match connection
.update_user( .update_user(
@ -681,9 +685,12 @@ pub async fn edit_user_post(
let email = form_data.email.clone(); let email = form_data.email.clone();
match email::send_email( match email::send_email(
&email, &email,
&format!( &tr.tp(
"Follow this link to validate this email address: {}/revalidation?validation_token={}", Sentence::ProfileFollowEmailLink,
url, token &[Box::new(format!(
"{}/revalidation?validation_token={}",
url, token
))],
), ),
&config.smtp_relay_address, &config.smtp_relay_address,
&config.smtp_login, &config.smtp_login,
@ -692,18 +699,21 @@ pub async fn edit_user_post(
.await .await
{ {
Ok(()) => { Ok(()) => {
message = message = tr.t(Sentence::ProfileEmailSent);
"An email has been sent, follow the link to validate your new email";
} }
Err(_) => { Err(_) => {
// error!("Email validation error: {}", error); // TODO: log // error!("Email validation error: {}", error); // TODO: log
return error_response( return error_response(
ProfileUpdateError::UnableSendEmail, &form_data, user, tr); ProfileUpdateError::UnableSendEmail,
&form_data,
user,
tr,
);
} }
} }
} }
Ok(db::user::UpdateUserResult::Ok) => { Ok(db::user::UpdateUserResult::Ok) => {
message = "Profile saved"; message = tr.t(Sentence::ProfileSaved);
} }
Err(_) => { Err(_) => {
return error_response(ProfileUpdateError::DatabaseError, &form_data, user, tr) return error_response(ProfileUpdateError::DatabaseError, &form_data, user, tr)
@ -717,14 +727,14 @@ pub async fn edit_user_post(
user, user,
username: form_data.name, username: form_data.name,
email: form_data.email, email: form_data.email,
message: message.to_string(), message,
message_email: String::new(), message_email: String::new(),
message_password: String::new(), message_password: String::new(),
tr, tr,
} }
.into_response()) .into_response())
} else { } else {
Ok(MessageTemplate::new("Not logged in", tr).into_response()) Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
} }
} }
@ -741,7 +751,7 @@ pub async fn email_revalidation(
if user.is_some() { if user.is_some() {
return Ok(( return Ok((
jar, jar,
MessageTemplate::new_with_user("User already exists", tr, user), MessageTemplate::new_with_user(tr.t(Sentence::ValidationUserAlreadyExists), tr, user),
)); ));
} }
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr); let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -763,21 +773,21 @@ pub async fn email_revalidation(
let user = connection.load_user(user_id).await?; let user = connection.load_user(user_id).await?;
Ok(( Ok((
jar, jar,
MessageTemplate::new_with_user("Email validation successful", tr, user), MessageTemplate::new_with_user(
tr.t(Sentence::ValidationSuccessful),
tr,
user,
),
)) ))
} }
db::user::ValidationResult::ValidationExpired => Ok(( db::user::ValidationResult::ValidationExpired => Ok((
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(tr.t(Sentence::ValidationExpired), tr, user),
"The validation has expired. Try to sign up again with the same email",
tr,
user,
),
)), )),
db::user::ValidationResult::UnknownUser => Ok(( db::user::ValidationResult::UnknownUser => Ok((
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"Validation error. Try to sign up again with the same email", tr.t(Sentence::ValidationErrorTryToSignUpAgain),
tr, tr,
user, user,
), ),
@ -786,7 +796,7 @@ pub async fn email_revalidation(
} }
None => Ok(( None => Ok((
jar, jar,
MessageTemplate::new_with_user("Validation error", tr, user), MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user),
)), )),
} }
} }

View file

@ -9,15 +9,21 @@ use crate::consts;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Clone)] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Clone)]
pub enum Sentence { pub enum Sentence {
ProfileTitle,
MainTitle, MainTitle,
CreateNewRecipe, CreateNewRecipe,
UnpublishedRecipes, UnpublishedRecipes,
UntitledRecipe, UntitledRecipe,
Name,
EmailAddress, EmailAddress,
Password, Password,
SignOut,
Save,
NotLoggedIn,
DatabaseError,
// Sign in page. // Sign in page.
SignInMenu, SignInMenu,
SignInTitle, SignInTitle,
@ -28,6 +34,11 @@ pub enum Sentence {
SignUpMenu, SignUpMenu,
SignUpTitle, SignUpTitle,
SignUpButton, SignUpButton,
SignUpEmailSent,
SignUpFollowEmailLink,
SignUpEmailValidationSuccess,
SignUpValidationExpired,
SignUpValidationErrorTryAgain,
ChooseAPassword, ChooseAPassword,
ReEnterPassword, ReEnterPassword,
AccountMustBeValidatedFirst, AccountMustBeValidatedFirst,
@ -37,9 +48,38 @@ pub enum Sentence {
EmailAlreadyTaken, EmailAlreadyTaken,
UnableToSendEmail, UnableToSendEmail,
// Validation.
ValidationSuccessful,
ValidationExpired,
ValidationErrorTryToSignUpAgain,
ValidationError,
ValidationUserAlreadyExists,
// Reset password page. // Reset password page.
LostPassword, LostPassword,
AskResetButton, AskResetButton,
AskResetAlreadyLoggedInError,
AskResetEmailAlreadyResetError,
AskResetFollowEmailLink,
AskResetEmailSent,
AskResetTokenMissing,
AskResetTokenExpired,
PasswordReset,
EmailUnknown,
UnableToSendResetEmail,
// Profile
ProfileTitle,
ProfileEmail,
ProfileNewPassword,
ProfileFollowEmailLink,
ProfileEmailSent,
ProfileSaved,
// Recipe.
RecipeNotAllowedToEdit,
RecipeNotAllowedToView,
RecipeNotFound,
} }
#[derive(Clone)] #[derive(Clone)]
@ -74,7 +114,7 @@ impl Tr {
} }
} }
pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString>]) -> String { pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString + Send>]) -> String {
match self.lang.translation.get(&sentence) { match self.lang.translation.get(&sentence) {
Some(str) => { Some(str) => {
let mut result = str.clone(); let mut result = str.clone();
@ -90,6 +130,10 @@ impl Tr {
} }
} }
pub fn current_lang_code(&self) -> &str {
&self.lang.code
}
pub fn available_languages() -> Vec<(&'static str, &'static str)> { pub fn available_languages() -> Vec<(&'static str, &'static str)> {
TRANSLATIONS TRANSLATIONS
.iter() .iter()

View file

@ -2,13 +2,15 @@
{% block main_container %} {% block main_container %}
<div class="content"> <div class="content">
<h1></h1> <h1>{{ tr.t(Sentence::LostPassword) }}</h1>
<form action="/ask_reset_password" method="post"> <form action="/ask_reset_password" method="post">
<label for="email_field">Your email address</label> <label for="email_field">{{ tr.t(Sentence::EmailAddress) }}</label>
<input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" /> <input id="email_field" type="email"
name="email" value="{{ email }}"
autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }} {{ message_email }}
<input type="submit" name="commit" value="Ask reset" /> <input type="submit" name="commit" value="{{ tr.t(Sentence::AskResetButton) }}" />
</form> </form>
{{ message }} {{ message }}
</div> </div>

View file

@ -13,15 +13,26 @@
{% else %} {% else %}
{{ user.name }} {{ user.name }}
{% endif %} {% endif %}
</a> / <a href="/signout" />Sign out</a></span> </a> / <a href="/signout" />{{ tr.t(Sentence::SignOut) }}</a></span>
{% when None %} {% when None %}
<span> <span>
<a href="/signin" >{{ tr.t(Sentence::SignInMenu) }}</a>/<a href="/signup">{{ tr.t(Sentence::SignUpMenu) }}</a>/<a href="/ask_reset_password">{{ tr.t(Sentence::LostPassword) }}</a> <a href="/signin" >{{ tr.t(Sentence::SignInMenu) }}</a>/<a href="/signup">{{ tr.t(Sentence::SignUpMenu) }}</a>/<a href="/ask_reset_password">{{ tr.t(Sentence::LostPassword) }}</a>
</span> </span>
{% endmatch %} {% endmatch %}
<select id="select-website-language">
{% for lang in Tr::available_languages() %}
<option value="{{ lang.0 }}"
{%+ if tr.current_lang_code() == lang.0 %}
selected
{% endif %}
>{{ lang.1 }}</option>
{% endfor %}
</select>
</div> </div>
<div class="main-container"> <div class="main-container">
{% block main_container %}{% endblock %} {% block main_container %}{% endblock %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -10,7 +10,7 @@
<form action="/user/edit" method="post"> <form action="/user/edit" method="post">
<label for="input-name">Name</label> <label for="input-name">{{ tr.t(Sentence::Name) }}</label>
<input <input
id="input-name" id="input-name"
type="text" type="text"
@ -20,22 +20,22 @@
autocomplete="title" autocomplete="title"
autofocus="autofocus" /> autofocus="autofocus" />
<label for="input-email">Email (need to be revalidated if changed)</label> <label for="input-email">{{ tr.t(Sentence::ProfileEmail) }}</label>
<input id="input-email" type="email" <input id="input-email" type="email"
name="email" value="{{ email }}" name="email" value="{{ email }}"
autocapitalize="none" autocomplete="email" autofocus="autofocus" /> autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }} {{ message_email }}
<label for="input-password-1">New password (minimum 8 characters)</label> <label for="input-password-1">{{ tr.tp(Sentence::ProfileNewPassword, [Box::new(common::consts::MIN_PASSWORD_SIZE)]) }}</label>
<input id="input-password-1" type="password" name="password_1" autocomplete="new-password" /> <input id="input-password-1" type="password" name="password_1" autocomplete="new-password" />
<label for="input-password-2">Re-enter password</label> <label for="input-password-2">{{ tr.t(Sentence::ReEnterPassword) }}</label>
<input id="input-password-2" type="password" name="password_2" autocomplete="new-password" /> <input id="input-password-2" type="password" name="password_2" autocomplete="new-password" />
{{ message_password }} {{ message_password }}
<input type="submit" name="commit" value="Save" /> <input type="submit" name="commit" value="{{ tr.t(Sentence::Save) }}" />
</form> </form>
{{ message }} {{ message }}
</div> </div>

View file

@ -3,15 +3,21 @@
code: "en", code: "en",
name: "English", name: "English",
translation: { translation: {
ProfileTitle: "Profile",
MainTitle: "Cooking Recipes", MainTitle: "Cooking Recipes",
CreateNewRecipe: "Create a new recipe", CreateNewRecipe: "Create a new recipe",
UnpublishedRecipes: "Unpublished recipes", UnpublishedRecipes: "Unpublished recipes",
UntitledRecipe: "Untitled recipe", UntitledRecipe: "Untitled recipe",
Name: "Name",
EmailAddress: "Email address", EmailAddress: "Email address",
Password: "Password", Password: "Password",
SignOut: "Sign out",
Save: "Save",
NotLoggedIn: "No logged in",
DatabaseError: "Database error",
SignInMenu: "Sign in", SignInMenu: "Sign in",
SignInTitle: "Sign in", SignInTitle: "Sign in",
SignInButton: "Sign in", SignInButton: "Sign in",
@ -23,29 +29,66 @@
EmailAlreadyTaken: "This email is not available", EmailAlreadyTaken: "This email is not available",
UnableToSendEmail: "Unable to send the validation email", UnableToSendEmail: "Unable to send the validation email",
ValidationSuccessful: "Email validation successful",
ValidationExpired: "The validation has expired. Try to sign up again with the same email",
ValidationErrorTryToSignUpAgain: "Validation error. Try to sign up again with the same email",
ValidationError: "Validation error",
ValidationUserAlreadyExists: "User already exists",
SignUpMenu: "Sign up", SignUpMenu: "Sign up",
SignUpTitle: "Sign up", SignUpTitle: "Sign up",
SignUpButton: "Sign up", SignUpButton: "Sign up",
SignUpEmailSent: "An email has been sent, follow the link to validate your account",
SignUpFollowEmailLink: "Follow this link to confirm your inscription: {}",
SignUpEmailValidationSuccess: "Email validation successful, your account has been created",
SignUpValidationExpired: "The validation has expired. Try to sign up again",
SignUpValidationErrorTryAgain: "Validation error. Try to sign up again",
ChooseAPassword: "Choose a password (minimum {} characters)", ChooseAPassword: "Choose a password (minimum {} characters)",
ReEnterPassword: "Re-enter password", ReEnterPassword: "Re-enter password",
LostPassword: "Lost password", LostPassword: "Lost password",
AskResetButton: "Ask reset", AskResetButton: "Ask reset",
AskResetAlreadyLoggedInError: "Can't ask to reset password when already logged in",
AskResetEmailAlreadyResetError: "The password has already been reset for this email",
AskResetFollowEmailLink: "Follow this link to reset your password: {}",
AskResetEmailSent: "An email has been sent, follow the link to reset your password",
AskResetTokenMissing: "Reset token missing",
AskResetTokenExpired: "Token expired, try to reset password again",
PasswordReset: "Your password has been reset",
EmailUnknown: "Email unknown",
UnableToSendResetEmail: "Unable to send the reset password email",
ProfileTitle: "Profile",
ProfileEmail: "Email (need to be revalidated if changed)",
ProfileNewPassword: "New password (minimum {} characters)",
ProfileFollowEmailLink: "Follow this link to validate this email address: {}",
ProfileEmailSent: "An email has been sent, follow the link to validate your new email",
ProfileSaved: "Profile saved",
RecipeNotAllowedToEdit: "Not allowed to edit this recipe",
RecipeNotAllowedToView: "Not allowed the view the recipe {}",
RecipeNotFound: "Recipe not found",
} }
), ),
( (
code: "fr", code: "fr",
name: "Français", name: "Français",
translation: { translation: {
ProfileTitle: "Profile", MainTitle: "Recettes de Cuisine",
MainTitle: "Recette de Cuisine",
CreateNewRecipe: "Créer une nouvelle recette", CreateNewRecipe: "Créer une nouvelle recette",
UnpublishedRecipes: "Recettes non-publiés", UnpublishedRecipes: "Recettes non-publiés",
UntitledRecipe: "Recette sans nom", UntitledRecipe: "Recette sans nom",
Name: "Nom",
EmailAddress: "Adresse email", EmailAddress: "Adresse email",
Password: "Mot de passe", Password: "Mot de passe",
SignOut: "Se déconnecter",
Save: "Sauvegarder",
NotLoggedIn: "Pas connecté",
DatabaseError: "Erreur de la base de données",
SignInMenu: "Se connecter", SignInMenu: "Se connecter",
SignInTitle: "Se connecter", SignInTitle: "Se connecter",
SignInButton: "Se connecter", SignInButton: "Se connecter",
@ -57,14 +100,45 @@
EmailAlreadyTaken: "Cette adresse email n'est pas disponible", EmailAlreadyTaken: "Cette adresse email n'est pas disponible",
UnableToSendEmail: "L'email de validation n'a pas pu être envoyé", UnableToSendEmail: "L'email de validation n'a pas pu être envoyé",
ValidationSuccessful: "Email validé avec succès",
ValidationExpired: "La validation a expiré. Essayez de vous inscrire à nouveau avec la même adresse email",
ValidationErrorTryToSignUpAgain: "Erreur de validation. Essayez de vous inscrire à nouveau avec la même adresse email",
ValidationError: "Erreur de validation",
ValidationUserAlreadyExists: "Utilisateur déjà existant",
SignUpMenu: "S'inscrire", SignUpMenu: "S'inscrire",
SignUpTitle: "Inscription", SignUpTitle: "Inscription",
SignUpButton: "Valider", SignUpButton: "Valider",
SignUpEmailSent: "Un email a été envoyé, suivez le lien pour valider votre compte",
SignUpFollowEmailLink: "Suivez ce lien pour valider votre inscription: {}",
SignUpEmailValidationSuccess: "La validation de votre email s'est déroulée avec succès, votre compte a été créé",
SignUpValidationExpired: "La validation a expiré. Essayez de vous inscrire à nouveau",
SignUpValidationErrorTryAgain: "Erreur de validation. Essayez de vous inscrire à nouveau",
ChooseAPassword: "Choisir un mot de passe (minimum {} caractères)", ChooseAPassword: "Choisir un mot de passe (minimum {} caractères)",
ReEnterPassword: "Entrez à nouveau le mot de passe", ReEnterPassword: "Entrez à nouveau le mot de passe",
LostPassword: "Mot de passe perdu", LostPassword: "Mot de passe perdu",
AskResetButton: "Demander la réinitialisation", AskResetButton: "Demander la réinitialisation",
AskResetAlreadyLoggedInError: "Impossible de demander une réinitialisation du mot de passe lorsque déjà connecté",
AskResetEmailAlreadyResetError: "Le mot de passe a déjà été réinitialisé pour cette adresse email",
AskResetFollowEmailLink: "Suivez ce lien pour réinitialiser votre mot de passe: {}",
AskResetEmailSent: "Un email a été envoyé, suivez le lien pour réinitialiser votre mot de passe",
AskResetTokenMissing: "Jeton de réinitialisation manquant",
AskResetTokenExpired: "Jeton expiré, essayez de réinitialiser votre mot de passe à nouveau",
PasswordReset: "Votre mot de passe a été réinitialisé",
EmailUnknown: "Email inconnu",
UnableToSendResetEmail: "Impossible d'envoyer l'email pour la réinitialisation du mot de passe",
ProfileTitle: "Profile",
ProfileEmail: "Email (doit être revalidé si changé)",
ProfileNewPassword: "Nouveau mot de passe (minimum {} caractères)",
ProfileFollowEmailLink: "Suivez ce lien pour valider l'adresse email: {}",
ProfileEmailSent: "Un email a été envoyé, suivez le lien pour valider la nouvelle adresse email",
ProfileSaved: "Profile sauvegardé",
RecipeNotAllowedToEdit: "Vous n'êtes pas autorisé à éditer cette recette",
RecipeNotAllowedToView: "Vous n'êtes pas autorisé à voir la recette {}",
RecipeNotFound: "Recette non-trouvée",
} }
) )
] ]

View file

@ -1,6 +1,11 @@
use ron::ser::{to_string_pretty, PrettyConfig}; use ron::ser::{to_string_pretty, PrettyConfig};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct SetLang {
pub lang: String,
}
///// RECIPE ///// ///// RECIPE /////
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]

View file

@ -5,8 +5,17 @@ mod request;
mod toast; mod toast;
mod utils; mod utils;
use gloo::utils::window; use gloo::{
console::log,
events::EventListener,
utils::{document, window},
};
use utils::by_id;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlSelectElement;
use common::ron_api;
// #[wasm_bindgen] // #[wasm_bindgen]
// extern "C" { // extern "C" {
@ -39,5 +48,18 @@ pub fn main() -> Result<(), JsValue> {
// } // }
} }
let select_language: HtmlSelectElement = by_id("select-website-language");
EventListener::new(&select_language.clone(), "input", move |_event| {
let lang = select_language.value();
let body = ron_api::SetLang { lang };
spawn_local(async move {
let _ = request::put::<(), _>("set_lang", body).await;
let _ = window().location().reload();
});
// log!(lang);
})
.forget();
Ok(()) Ok(())
} }