From f059d3c61f3efe14e76d15bcd490c73231d55038 Mon Sep 17 00:00:00 2001 From: Greg Burri Date: Mon, 6 Jan 2025 16:04:48 +0100 Subject: [PATCH] Translation support + french. --- TODO.md | 34 ++-- backend/src/consts.rs | 3 + backend/src/data/db/user.rs | 11 ++ backend/src/html_templates.rs | 8 +- backend/src/main.rs | 33 ++-- backend/src/services.rs | 2 +- backend/src/services/recipe.rs | 25 +-- backend/src/services/ron.rs | 55 +++++-- backend/src/services/user.rs | 190 ++++++++++++---------- backend/src/translation.rs | 48 +++++- backend/templates/ask_reset_password.html | 10 +- backend/templates/base_with_header.html | 13 +- backend/templates/profile.html | 10 +- backend/translation.ron | 80 ++++++++- common/src/ron_api.rs | 5 + frontend/src/lib.rs | 24 ++- 16 files changed, 381 insertions(+), 170 deletions(-) diff --git a/TODO.md b/TODO.md index 972051a..a8fc10d 100644 --- a/TODO.md +++ b/TODO.md @@ -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 -* Review the recipe model (SQL) -* Describe the use cases in details. - * Define the UI (mockups). - * Two CSS: one for desktop and one for mobile - * Use CSS flex/grid to define a good design/layout - * Define the logic behind each page and action. -* Implement: +* Define the UI (mockups). + * Two CSS: one for desktop and one for mobile + * Use CSS flex/grid to define a good design/layout +* Drag and drop of steps and groups to define their order +* Make a search page +* Use of markdown for some field (how to add markdown as rinja filter?) +* 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::new_recipe) .service(services::webapi::set_recipe_title) .service(services::webapi::set_recipe_description) -* Add support to translations into db model. -* Make a Text database (a bit like d-lan.net) and think about translation. - +[ok] Review the recipe model (SQL) +[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] How to log error to journalctl or elsewhere + debug log? [ok] Clean the old code + commit diff --git a/backend/src/consts.rs b/backend/src/consts.rs index 76986db..222499a 100644 --- a/backend/src/consts.rs +++ b/backend/src/consts.rs @@ -6,7 +6,9 @@ pub const DB_DIRECTORY: &str = "data"; pub const DB_FILENAME: &str = "recipes.sqlite"; pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql"; pub const VALIDATION_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour). + 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). @@ -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. +// TODO: remove, should be replaced by the translation module. pub static LANGUAGES: LazyLock<[(&str, &str); 2]> = LazyLock::new(|| { let mut langs = [("Français", "fr"), ("English", "en")]; langs.sort(); diff --git a/backend/src/data/db/user.rs b/backend/src/data/db/user.rs index a95feb4..53a62c1 100644 --- a/backend/src/data/db/user.rs +++ b/backend/src/data/db/user.rs @@ -7,6 +7,7 @@ use crate::{ consts, data::model, hash::{hash, verify_password}, + services::user, }; #[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 { self.sign_up_with_given_time(email, password, Utc::now()) .await diff --git a/backend/src/html_templates.rs b/backend/src/html_templates.rs index d7d4160..25b0587 100644 --- a/backend/src/html_templates.rs +++ b/backend/src/html_templates.rs @@ -37,20 +37,20 @@ pub struct MessageTemplate { } impl MessageTemplate { - pub fn new(message: &str, tr: Tr) -> MessageTemplate { + pub fn new(message: String, tr: Tr) -> MessageTemplate { MessageTemplate { user: None, tr, - message: message.to_string(), + message, as_code: false, } } - pub fn new_with_user(message: &str, tr: Tr, user: Option) -> MessageTemplate { + pub fn new_with_user(message: String, tr: Tr, user: Option) -> MessageTemplate { MessageTemplate { user, tr, - message: message.to_string(), + message, as_code: false, } } diff --git a/backend/src/main.rs b/backend/src/main.rs index af80c90..610ea49 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -89,6 +89,7 @@ async fn main() { let ron_api_routes = Router::new() // Disabled: update user profile is now made with a post data ('edit_user_post'). // .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_description", @@ -231,26 +232,26 @@ async fn translation( user.lang } else { let available_codes = Tr::available_codes(); - - // TODO: Check cookies before http headers. - - 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 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)); + + accept_language.unwrap_or("en").to_string() + } + } }; let tr = Tr::new(&language); - // let jar = CookieJar::from_headers(req.headers()); req.extensions_mut().insert(tr); Ok(next.run(req).await) } diff --git a/backend/src/services.rs b/backend/src/services.rs index 8036a14..9fcfc1a 100644 --- a/backend/src/services.rs +++ b/backend/src/services.rs @@ -77,6 +77,6 @@ pub async fn not_found( ) -> impl IntoResponse { ( StatusCode::NOT_FOUND, - MessageTemplate::new_with_user("404: Not found", tr, user), + MessageTemplate::new_with_user("404: Not found".to_string(), tr, user), ) } diff --git a/backend/src/services/recipe.rs b/backend/src/services/recipe.rs index 8c7eaa4..7367d40 100644 --- a/backend/src/services/recipe.rs +++ b/backend/src/services/recipe.rs @@ -9,7 +9,7 @@ use crate::{ consts, data::{db, model}, html_templates::*, - translation, + translation::{self, Sentence}, }; #[debug_handler] @@ -22,7 +22,7 @@ pub async fn create( let recipe_id = connection.create_recipe(user.id).await?; Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response()) } 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()) } else { - Ok(MessageTemplate::new("Not allowed to edit this recipe", tr).into_response()) + Ok( + MessageTemplate::new(tr.t(Sentence::RecipeNotAllowedToEdit), tr) + .into_response(), + ) } } else { - Ok(MessageTemplate::new("Recipe not found", tr).into_response()) + Ok(MessageTemplate::new(tr.t(Sentence::RecipeNotFound), tr).into_response()) } } 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) { return Ok(MessageTemplate::new_with_user( - &format!("Not allowed the view the recipe {}", recipe_id), + tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]), tr, user, ) @@ -103,11 +106,9 @@ pub async fn view( } .into_response()) } - None => Ok(MessageTemplate::new_with_user( - &format!("Cannot find the recipe {}", recipe_id), - tr, - user, - ) - .into_response()), + None => Ok( + MessageTemplate::new_with_user(tr.t(Sentence::RecipeNotFound), tr, user) + .into_response(), + ), } } diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs index 73330aa..d7c52bc 100644 --- a/backend/src/services/ron.rs +++ b/backend/src/services/ron.rs @@ -1,13 +1,15 @@ use axum::{ debug_handler, extract::{Extension, Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::{ErrorResponse, IntoResponse, Result}, }; +use axum_extra::extract::cookie::{Cookie, CookieJar}; use serde::Deserialize; // use tracing::{event, Level}; use crate::{ + consts, data::db, model, ron_extractor::ExtractRon, @@ -22,29 +24,46 @@ pub struct RecipeId { id: i64, } -#[allow(dead_code)] +// #[allow(dead_code)] +// #[debug_handler] +// pub async fn update_user( +// State(connection): State, +// Extension(user): Extension>, +// ExtractRon(ron): ExtractRon, +// ) -> Result { +// 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] -pub async fn update_user( +pub async fn set_lang( State(connection): State, Extension(user): Extension>, - ExtractRon(ron): ExtractRon, -) -> Result { + headers: HeaderMap, + ExtractRon(ron): ExtractRon, +) -> Result<(CookieJar, StatusCode)> { + let mut jar = CookieJar::from_headers(&headers); 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?; + connection.set_user_lang(user.id, &ron.lang).await?; } else { - return Err(ErrorResponse::from(ron_error( - StatusCode::UNAUTHORIZED, - NOT_AUTHORIZED_MESSAGE, - ))); + let cookie = Cookie::build((consts::COOKIE_LANG_NAME, ron.lang)).path("/"); + jar = jar.add(cookie); } - Ok(StatusCode::OK) + Ok((jar, StatusCode::OK)) } async fn check_user_rights_recipe( diff --git a/backend/src/services/user.rs b/backend/src/services/user.rs index 76b7676..ae2b138 100644 --- a/backend/src/services/user.rs +++ b/backend/src/services/user.rs @@ -126,9 +126,12 @@ pub async fn sign_up_post( let email = form_data.email.clone(); match email::send_email( &email, - &format!( - "Follow this link to confirm your inscription: {}/validation?validation_token={}", - url, token + &tr.tp( + Sentence::SignUpFollowEmailLink, + &[Box::new(format!( + "{}/validation?validation_token={}", + url, token + ))], ), &config.smtp_relay_address, &config.smtp_login, @@ -136,10 +139,12 @@ pub async fn sign_up_post( ) .await { - Ok(()) => Ok( - MessageTemplate::new_with_user( - "An email has been sent, follow the link to validate your account", - tr, user).into_response()), + Ok(()) => { + Ok( + MessageTemplate::new_with_user(tr.t(Sentence::SignUpEmailSent), tr, user) + .into_response(), + ) + } Err(_) => { // error!("Email validation error: {}", error); // TODO: log error_response(SignUpError::UnableSendEmail, &form_data, user, tr) @@ -166,7 +171,7 @@ pub async fn sign_up_validation( if user.is_some() { return Ok(( 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); @@ -189,7 +194,7 @@ pub async fn sign_up_validation( Ok(( jar, MessageTemplate::new_with_user( - "Email validation successful, your account has been created", + tr.t(Sentence::SignUpEmailValidationSuccess), tr, user, ), @@ -198,7 +203,7 @@ pub async fn sign_up_validation( db::user::ValidationResult::ValidationExpired => Ok(( jar, MessageTemplate::new_with_user( - "The validation has expired. Try to sign up again", + tr.t(Sentence::SignUpValidationExpired), tr, user, ), @@ -206,7 +211,7 @@ pub async fn sign_up_validation( db::user::ValidationResult::UnknownUser => Ok(( jar, MessageTemplate::new_with_user( - "Validation error. Try to sign up again", + tr.t(Sentence::SignUpValidationErrorTryAgain), tr, user, ), @@ -215,7 +220,7 @@ pub async fn sign_up_validation( } None => Ok(( 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, ) -> Result { if user.is_some() { - Ok(MessageTemplate::new_with_user( - "Can't ask to reset password when already logged in", - tr, - user, + Ok( + MessageTemplate::new_with_user(tr.t(Sentence::AskResetAlreadyLoggedInError), tr, user) + .into_response(), ) - .into_response()) } else { Ok(AskResetPasswordTemplate { user, @@ -361,23 +364,21 @@ pub async fn ask_reset_password_post( ) -> Result { Ok(AskResetPasswordTemplate { user, - tr, email: email.to_string(), message_email: match error { - AskResetPasswordError::InvalidEmail => "Invalid email", - _ => "", - } - .to_string(), + AskResetPasswordError::InvalidEmail => tr.t(Sentence::InvalidEmail), + _ => String::new(), + }, message: match error { AskResetPasswordError::EmailAlreadyReset => { - "The password has already been reset for this email" + tr.t(Sentence::AskResetEmailAlreadyResetError) } - AskResetPasswordError::EmailUnknown => "Email unknown", - AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email", - AskResetPasswordError::DatabaseError => "Database error", - _ => "", - } - .to_string(), + AskResetPasswordError::EmailUnknown => tr.t(Sentence::EmailUnknown), + AskResetPasswordError::UnableSendEmail => tr.t(Sentence::UnableToSendResetEmail), + AskResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError), + _ => String::new(), + }, + tr, } .into_response()) } @@ -417,9 +418,12 @@ pub async fn ask_reset_password_post( let url = utils::get_url_from_host(&host); match email::send_email( &form_data.email, - &format!( - "Follow this link to reset your password: {}/reset_password?reset_token={}", - url, token + &tr.tp( + Sentence::AskResetFollowEmailLink, + &[Box::new(format!( + "{}/reset_password?reset_token={}", + url, token + ))], ), &config.smtp_relay_address, &config.smtp_login, @@ -427,12 +431,12 @@ pub async fn ask_reset_password_post( ) .await { - Ok(()) => Ok(MessageTemplate::new_with_user( - "An email has been sent, follow the link to reset your password.", - tr, - user, - ) - .into_response()), + Ok(()) => { + Ok( + MessageTemplate::new_with_user(tr.t(Sentence::AskResetEmailSent), tr, user) + .into_response(), + ) + } Err(_) => { // error!("Email validation error: {}", error); // TODO: log error_response( @@ -472,7 +476,10 @@ pub async fn reset_password_get( } .into_response()) } 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 { Ok(ResetPasswordTemplate { user, - tr, reset_token: form_data.reset_token.clone(), message_password: match error { - ResetPasswordError::PasswordsNotEqual => "Passwords don't match", - ResetPasswordError::InvalidPassword => { - "Password must have at least eight characters" - } - _ => "", - } - .to_string(), + ResetPasswordError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch), + ResetPasswordError::InvalidPassword => tr.tp( + Sentence::InvalidPassword, + &[Box::new(common::consts::MIN_PASSWORD_SIZE)], + ), + _ => String::new(), + }, message: match error { - ResetPasswordError::TokenExpired => "Token expired, try to reset password again", - ResetPasswordError::DatabaseError => "Database error", - _ => "", - } - .to_string(), + ResetPasswordError::TokenExpired => tr.t(Sentence::AskResetTokenExpired), + ResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError), + _ => String::new(), + }, + tr, } .into_response()) } @@ -545,7 +551,7 @@ pub async fn reset_password_post( { Ok(db::user::ResetPasswordResult::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(), ) } @@ -575,7 +581,7 @@ pub async fn edit_user_get( } .into_response() } 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(), email: form_data.email.clone(), message_email: match error { - ProfileUpdateError::InvalidEmail => "Invalid email", - ProfileUpdateError::EmailAlreadyTaken => "Email already taken", - _ => "", - } - .to_string(), + ProfileUpdateError::InvalidEmail => tr.t(Sentence::InvalidEmail), + ProfileUpdateError::EmailAlreadyTaken => tr.t(Sentence::EmailAlreadyTaken), + _ => String::new(), + }, message_password: match error { - ProfileUpdateError::PasswordsNotEqual => "Passwords don't match", - ProfileUpdateError::InvalidPassword => { - "Password must have at least eight characters" - } - _ => "", - } - .to_string(), + ProfileUpdateError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch), + ProfileUpdateError::InvalidPassword => tr.tp( + Sentence::InvalidPassword, + &[Box::new(common::consts::MIN_PASSWORD_SIZE)], + ), + _ => String::new(), + }, message: match error { - ProfileUpdateError::DatabaseError => "Database error", - ProfileUpdateError::UnableSendEmail => "Unable to send the validation email", - _ => "", - } - .to_string(), + ProfileUpdateError::DatabaseError => tr.t(Sentence::DatabaseError), + ProfileUpdateError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail), + _ => String::new(), + }, tr, } .into_response()) @@ -662,7 +666,7 @@ pub async fn edit_user_post( }; let email_trimmed = form_data.email.trim(); - let message: &str; + let message: String; match connection .update_user( @@ -681,9 +685,12 @@ pub async fn edit_user_post( let email = form_data.email.clone(); match email::send_email( &email, - &format!( - "Follow this link to validate this email address: {}/revalidation?validation_token={}", - url, token + &tr.tp( + Sentence::ProfileFollowEmailLink, + &[Box::new(format!( + "{}/revalidation?validation_token={}", + url, token + ))], ), &config.smtp_relay_address, &config.smtp_login, @@ -692,18 +699,21 @@ pub async fn edit_user_post( .await { Ok(()) => { - message = - "An email has been sent, follow the link to validate your new email"; + message = tr.t(Sentence::ProfileEmailSent); } Err(_) => { // error!("Email validation error: {}", error); // TODO: log return error_response( - ProfileUpdateError::UnableSendEmail, &form_data, user, tr); + ProfileUpdateError::UnableSendEmail, + &form_data, + user, + tr, + ); } } } Ok(db::user::UpdateUserResult::Ok) => { - message = "Profile saved"; + message = tr.t(Sentence::ProfileSaved); } Err(_) => { return error_response(ProfileUpdateError::DatabaseError, &form_data, user, tr) @@ -717,14 +727,14 @@ pub async fn edit_user_post( user, username: form_data.name, email: form_data.email, - message: message.to_string(), + message, message_email: String::new(), message_password: String::new(), tr, } .into_response()) } 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() { return Ok(( 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); @@ -763,21 +773,21 @@ pub async fn email_revalidation( let user = connection.load_user(user_id).await?; Ok(( 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(( jar, - MessageTemplate::new_with_user( - "The validation has expired. Try to sign up again with the same email", - tr, - user, - ), + MessageTemplate::new_with_user(tr.t(Sentence::ValidationExpired), tr, user), )), db::user::ValidationResult::UnknownUser => Ok(( jar, MessageTemplate::new_with_user( - "Validation error. Try to sign up again with the same email", + tr.t(Sentence::ValidationErrorTryToSignUpAgain), tr, user, ), @@ -786,7 +796,7 @@ pub async fn email_revalidation( } None => Ok(( jar, - MessageTemplate::new_with_user("Validation error", tr, user), + MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user), )), } } diff --git a/backend/src/translation.rs b/backend/src/translation.rs index 97740cb..ccf22c8 100644 --- a/backend/src/translation.rs +++ b/backend/src/translation.rs @@ -9,15 +9,21 @@ use crate::consts; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Clone)] pub enum Sentence { - ProfileTitle, MainTitle, CreateNewRecipe, UnpublishedRecipes, UntitledRecipe, + Name, EmailAddress, Password, + SignOut, + Save, + NotLoggedIn, + + DatabaseError, + // Sign in page. SignInMenu, SignInTitle, @@ -28,6 +34,11 @@ pub enum Sentence { SignUpMenu, SignUpTitle, SignUpButton, + SignUpEmailSent, + SignUpFollowEmailLink, + SignUpEmailValidationSuccess, + SignUpValidationExpired, + SignUpValidationErrorTryAgain, ChooseAPassword, ReEnterPassword, AccountMustBeValidatedFirst, @@ -37,9 +48,38 @@ pub enum Sentence { EmailAlreadyTaken, UnableToSendEmail, + // Validation. + ValidationSuccessful, + ValidationExpired, + ValidationErrorTryToSignUpAgain, + ValidationError, + ValidationUserAlreadyExists, + // Reset password page. LostPassword, AskResetButton, + AskResetAlreadyLoggedInError, + AskResetEmailAlreadyResetError, + AskResetFollowEmailLink, + AskResetEmailSent, + AskResetTokenMissing, + AskResetTokenExpired, + PasswordReset, + EmailUnknown, + UnableToSendResetEmail, + + // Profile + ProfileTitle, + ProfileEmail, + ProfileNewPassword, + ProfileFollowEmailLink, + ProfileEmailSent, + ProfileSaved, + + // Recipe. + RecipeNotAllowedToEdit, + RecipeNotAllowedToView, + RecipeNotFound, } #[derive(Clone)] @@ -74,7 +114,7 @@ impl Tr { } } - pub fn tp(&self, sentence: Sentence, params: &[Box]) -> String { + pub fn tp(&self, sentence: Sentence, params: &[Box]) -> String { match self.lang.translation.get(&sentence) { Some(str) => { 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)> { TRANSLATIONS .iter() diff --git a/backend/templates/ask_reset_password.html b/backend/templates/ask_reset_password.html index e670cee..a9655ff 100644 --- a/backend/templates/ask_reset_password.html +++ b/backend/templates/ask_reset_password.html @@ -2,13 +2,15 @@ {% block main_container %}
-

+

{{ tr.t(Sentence::LostPassword) }}

- - + + {{ message_email }} - +
{{ message }}
diff --git a/backend/templates/base_with_header.html b/backend/templates/base_with_header.html index e2de70c..dfca040 100644 --- a/backend/templates/base_with_header.html +++ b/backend/templates/base_with_header.html @@ -13,15 +13,26 @@ {% else %} {{ user.name }} {% endif %} - / Sign out + / {{ tr.t(Sentence::SignOut) }} {% when None %} {{ tr.t(Sentence::SignInMenu) }}/{{ tr.t(Sentence::SignUpMenu) }}/{{ tr.t(Sentence::LostPassword) }} {% endmatch %} + +
{% block main_container %}{% endblock %}
+ {% endblock %} \ No newline at end of file diff --git a/backend/templates/profile.html b/backend/templates/profile.html index 72b042b..1ec5955 100644 --- a/backend/templates/profile.html +++ b/backend/templates/profile.html @@ -10,7 +10,7 @@
- + - + {{ message_email }} - + - + {{ message_password }} - +
{{ message }} diff --git a/backend/translation.ron b/backend/translation.ron index 4daa75b..4ed85ca 100644 --- a/backend/translation.ron +++ b/backend/translation.ron @@ -3,15 +3,21 @@ code: "en", name: "English", translation: { - ProfileTitle: "Profile", MainTitle: "Cooking Recipes", CreateNewRecipe: "Create a new recipe", UnpublishedRecipes: "Unpublished recipes", UntitledRecipe: "Untitled recipe", + Name: "Name", EmailAddress: "Email address", Password: "Password", + SignOut: "Sign out", + Save: "Save", + NotLoggedIn: "No logged in", + + DatabaseError: "Database error", + SignInMenu: "Sign in", SignInTitle: "Sign in", SignInButton: "Sign in", @@ -23,29 +29,66 @@ EmailAlreadyTaken: "This email is not available", 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", SignUpTitle: "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)", ReEnterPassword: "Re-enter password", LostPassword: "Lost password", 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", name: "Français", translation: { - ProfileTitle: "Profile", - MainTitle: "Recette de Cuisine", + MainTitle: "Recettes de Cuisine", CreateNewRecipe: "Créer une nouvelle recette", UnpublishedRecipes: "Recettes non-publiés", UntitledRecipe: "Recette sans nom", + Name: "Nom", EmailAddress: "Adresse email", Password: "Mot de passe", + SignOut: "Se déconnecter", + Save: "Sauvegarder", + NotLoggedIn: "Pas connecté", + + DatabaseError: "Erreur de la base de données", + SignInMenu: "Se connecter", SignInTitle: "Se connecter", SignInButton: "Se connecter", @@ -57,14 +100,45 @@ EmailAlreadyTaken: "Cette adresse email n'est pas disponible", 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", SignUpTitle: "Inscription", 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)", ReEnterPassword: "Entrez à nouveau le mot de passe", LostPassword: "Mot de passe perdu", 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", } ) ] \ No newline at end of file diff --git a/common/src/ron_api.rs b/common/src/ron_api.rs index 9525dd6..3f233fa 100644 --- a/common/src/ron_api.rs +++ b/common/src/ron_api.rs @@ -1,6 +1,11 @@ use ron::ser::{to_string_pretty, PrettyConfig}; use serde::{Deserialize, Serialize}; +#[derive(Serialize, Deserialize, Clone)] +pub struct SetLang { + pub lang: String, +} + ///// RECIPE ///// #[derive(Serialize, Deserialize, Clone)] diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs index 1a2bde4..06a8bf7 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -5,8 +5,17 @@ mod request; mod toast; 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_futures::spawn_local; +use web_sys::HtmlSelectElement; + +use common::ron_api; // #[wasm_bindgen] // 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(()) } -- 2.49.0