From: Greg Burri Date: Sat, 21 Dec 2024 22:13:06 +0000 (+0100) Subject: Recipe edit (WIP) X-Git-Url: https://git.euphorik.ch/?a=commitdiff_plain;ds=sidebyside;p=recipes.git Recipe edit (WIP) --- diff --git a/Cargo.lock b/Cargo.lock index 8598d80..e1a2ddd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,9 +391,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e" dependencies = [ "shlex", ] @@ -1534,9 +1534,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.168" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libm" @@ -1748,9 +1748,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.5" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "memchr", ] @@ -2006,7 +2006,7 @@ dependencies = [ "ron", "serde", "sqlx", - "thiserror 2.0.8", + "thiserror 2.0.9", "tokio", "tower", "tower-http", @@ -2225,9 +2225,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.134" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" dependencies = [ "itoa", "memchr", @@ -2672,11 +2672,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.8" +version = "2.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a" +checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" dependencies = [ - "thiserror-impl 2.0.8", + "thiserror-impl 2.0.9", ] [[package]] @@ -2692,9 +2692,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.8" +version = "2.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943" +checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" dependencies = [ "proc-macro2", "quote", @@ -2754,9 +2754,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" dependencies = [ "tinyvec_macros", ] diff --git a/backend/sql/data_test.sql b/backend/sql/data_test.sql index af9cc03..4312ce9 100644 --- a/backend/sql/data_test.sql +++ b/backend/sql/data_test.sql @@ -3,7 +3,7 @@ VALUES ( 1, 'paul@atreides.com', 'Paul', - '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', + '$argon2id$v=19$m=4096,t=4,p=2$l1fAMRc0VfkNzqpEfFEReg$/gsUsY2aML8EbKjPeCxucenxkxhiFSXDmizWZPLvNuo', 0, NULL ); diff --git a/backend/sql/version_1.sql b/backend/sql/version_1.sql index 2462b2b..3fffc66 100644 --- a/backend/sql/version_1.sql +++ b/backend/sql/version_1.sql @@ -49,7 +49,7 @@ CREATE TABLE [Recipe] ( [title] TEXT NOT NULL, -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes [lang] TEXT NOT NULL DEFAULT 'en', - [estimate_time] INTEGER, -- in [s]. + [estimated_time] INTEGER, -- in [s]. [description] TEXT NOT NULL DEFAULT '', [difficulty] INTEGER NOT NULL DEFAULT 0, [servings] INTEGER DEFAULT 4, @@ -93,7 +93,7 @@ CREATE TABLE [Ingredient] ( [id] INTEGER PRIMARY KEY, [name] TEXT NOT NULL, [comment] TEXT NOT NULL DEFAULT '', - [quantity_value] REAL, + [quantity_value] INTEGER, [quantity_unit] TEXT NOT NULL DEFAULT '', [input_step_id] INTEGER NOT NULL, diff --git a/backend/src/consts.rs b/backend/src/consts.rs index a8f3fff..7b776e9 100644 --- a/backend/src/consts.rs +++ b/backend/src/consts.rs @@ -20,3 +20,5 @@ pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60); pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse proxy (Nginx). pub const MAX_DB_CONNECTION: u32 = 1024; + +pub const LANGUAGES: [(&'static str, &'static str); 2] = [("Français", "fr"), ("English", "en")]; diff --git a/backend/src/data/db.rs b/backend/src/data/db.rs index 3124db9..baff9d6 100644 --- a/backend/src/data/db.rs +++ b/backend/src/data/db.rs @@ -13,7 +13,6 @@ use sqlx::{ use thiserror::Error; use tracing::{event, Level}; -use super::model; use crate::consts; pub mod recipe; @@ -32,6 +31,9 @@ pub enum DBError { )] UnsupportedVersion(u32), + #[error("Unknown language: {0}")] + UnknownLanguage(String), + #[error("Unknown error: {0}")] Other(String), } diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs index a942e7e..96e5996 100644 --- a/backend/src/data/db/recipe.rs +++ b/backend/src/data/db/recipe.rs @@ -1,4 +1,8 @@ -use super::{model, Connection, DBError, Result}; +use super::{Connection, DBError, Result}; +use crate::{ + consts, + data::model::{self, Difficulty}, +}; impl Connection { pub async fn get_all_recipe_titles(&self) -> Result> { @@ -11,7 +15,10 @@ impl Connection { pub async fn get_recipe(&self, id: i64) -> Result> { sqlx::query_as( r#" -SELECT [id], [user_id], [title], [description] +SELECT + [id], [user_id], [title], [lang], + [estimated_time], [description], [difficulty], [servings], + [is_published] FROM [Recipe] WHERE [id] = $1 "#, ) @@ -24,6 +31,7 @@ FROM [Recipe] WHERE [id] = $1 pub async fn create_recipe(&self, user_id: i64) -> Result { let mut tx = self.tx().await?; + // Search for an existing empty recipe and return its id instead of creating a new one. match sqlx::query_scalar::<_, i64>( r#" SELECT [Recipe].[id] FROM [Recipe] @@ -31,7 +39,7 @@ LEFT JOIN [Image] ON [Image].[recipe_id] = [Recipe].[id] LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id] WHERE [Recipe].[user_id] = $1 AND [Recipe].[title] = '' - AND [Recipe].[estimate_time] IS NULL + AND [Recipe].[estimated_time] IS NULL AND [Recipe].[description] = '' AND [Image].[id] IS NULL AND [Group].[id] IS NULL @@ -74,6 +82,57 @@ WHERE [Recipe].[user_id] = $1 .map(|_| ()) .map_err(DBError::from) } + + pub async fn set_recipe_estimated_time( + &self, + recipe_id: i64, + estimated_time: Option, + ) -> Result<()> { + sqlx::query("UPDATE [Recipe] SET [estimated_time] = $2 WHERE [id] = $1") + .bind(recipe_id) + .bind(estimated_time) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(DBError::from) + } + + pub async fn set_recipe_difficulty( + &self, + recipe_id: i64, + difficulty: Difficulty, + ) -> Result<()> { + sqlx::query("UPDATE [Recipe] SET [difficulty] = $2 WHERE [id] = $1") + .bind(recipe_id) + .bind(u32::from(difficulty)) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(DBError::from) + } + + pub async fn set_recipe_language(&self, recipe_id: i64, lang: &str) -> Result<()> { + if !consts::LANGUAGES.iter().any(|(_, l)| *l == lang) { + return Err(DBError::UnknownLanguage(lang.to_string())); + } + sqlx::query("UPDATE [Recipe] SET [lang] = $2 WHERE [id] = $1") + .bind(recipe_id) + .bind(lang) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(DBError::from) + } + + pub async fn set_recipe_is_published(&self, recipe_id: i64, is_published: bool) -> Result<()> { + sqlx::query("UPDATE [Recipe] SET [is_published] = $2 WHERE [id] = $1") + .bind(recipe_id) + .bind(is_published) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(DBError::from) + } } #[cfg(test)] @@ -84,6 +143,69 @@ mod tests { async fn create_a_new_recipe_then_update_its_title() -> Result<()> { let connection = Connection::new_in_memory().await?; + let user_id = create_a_user(&connection).await?; + let recipe_id = connection.create_recipe(user_id).await?; + + connection.set_recipe_title(recipe_id, "Crêpe").await?; + let recipe = connection.get_recipe(recipe_id).await?.unwrap(); + assert_eq!(recipe.title, "Crêpe".to_string()); + + Ok(()) + } + + #[tokio::test] + async fn setters() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let user_id = create_a_user(&connection).await?; + let recipe_id = connection.create_recipe(user_id).await?; + + connection.set_recipe_title(recipe_id, "Ouiche").await?; + connection + .set_recipe_description(recipe_id, "C'est bon, mangez-en") + .await?; + connection + .set_recipe_estimated_time(recipe_id, Some(420)) + .await?; + connection + .set_recipe_difficulty(recipe_id, Difficulty::Medium) + .await?; + connection.set_recipe_language(recipe_id, "fr").await?; + connection.set_recipe_is_published(recipe_id, true).await?; + + let recipe = connection.get_recipe(recipe_id).await?.unwrap(); + + assert_eq!(recipe.id, recipe_id); + assert_eq!(recipe.title, "Ouiche"); + assert_eq!(recipe.description, "C'est bon, mangez-en"); + assert_eq!(recipe.estimated_time, Some(420)); + assert_eq!(recipe.difficulty, Difficulty::Medium); + assert_eq!(recipe.lang, "fr"); + assert_eq!(recipe.is_published, true); + + Ok(()) + } + + #[tokio::test] + async fn set_nonexistent_language() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let user_id = create_a_user(&connection).await?; + let recipe_id = connection.create_recipe(user_id).await?; + + match connection.set_recipe_language(recipe_id, "asdf").await { + // Nominal case. + Err(DBError::UnknownLanguage(message)) => { + println!("Ok: {}", message); + } + other => panic!("Set an nonexistent language must fail: {:?}", other), + } + + Ok(()) + } + + async fn create_a_user(connection: &Connection) -> Result { + let user_id = 1; connection.execute_sql( sqlx::query( r#" @@ -93,33 +215,13 @@ VALUES ($1, $2, $3, $4, $5, $6) "# ) - .bind(1) + .bind(user_id) .bind("paul@atreides.com") .bind("paul") .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc") .bind("2022-11-29 22:05:04.121407300+00:00") .bind(None::<&str>) // 'null'. ).await?; - - match connection.create_recipe(2).await { - Err(DBError::Sqlx(sqlx::Error::Database(err))) => { - // SQLITE_CONSTRAINT_FOREIGNKEY - // https://www.sqlite.org/rescode.html#constraint_foreignkey - assert_eq!(err.code(), Some(std::borrow::Cow::from("787"))); - } // Nominal case. TODO: check 'err' value. - other => panic!( - "Creating a recipe with an inexistant user must fail: {:?}", - other - ), - } - - let recipe_id = connection.create_recipe(1).await?; - assert_eq!(recipe_id, 1); - - connection.set_recipe_title(recipe_id, "Crêpe").await?; - let recipe = connection.get_recipe(recipe_id).await?.unwrap(); - assert_eq!(recipe.title, "Crêpe".to_string()); - - Ok(()) + Ok(user_id) } } diff --git a/backend/src/data/db/user.rs b/backend/src/data/db/user.rs index 06d6353..698aa64 100644 --- a/backend/src/data/db/user.rs +++ b/backend/src/data/db/user.rs @@ -2,9 +2,10 @@ use chrono::{prelude::*, Duration}; use rand::distributions::{Alphanumeric, DistString}; use sqlx::Sqlite; -use super::{model, Connection, DBError, Result}; +use super::{Connection, DBError, Result}; use crate::{ consts, + data::model, hash::{hash, verify_password}, }; diff --git a/backend/src/data/mod.rs b/backend/src/data/mod.rs index c91ea29..bdce02a 100644 --- a/backend/src/data/mod.rs +++ b/backend/src/data/mod.rs @@ -1,3 +1,2 @@ pub mod db; pub mod model; -mod utils; diff --git a/backend/src/data/model.rs b/backend/src/data/model.rs index f52704c..3ce9a26 100644 --- a/backend/src/data/model.rs +++ b/backend/src/data/model.rs @@ -1,77 +1,78 @@ use chrono::prelude::*; +use sqlx::{self, FromRow}; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, FromRow)] pub struct User { pub id: i64, pub name: String, pub email: String, } +#[derive(FromRow)] pub struct UserLoginInfo { pub last_login_datetime: DateTime, pub ip: String, pub user_agent: String, } +#[derive(FromRow)] pub struct Recipe { pub id: i64, pub user_id: i64, pub title: String, - pub description: String, - pub estimate_time: Option, // [s]. - pub difficulty: Difficulty, pub lang: String, + pub estimated_time: Option, // [s]. + pub description: String, - //ingredients: Vec, // For four people. - pub process: Vec, -} - -impl Recipe { - pub fn empty(id: i64, user_id: i64) -> Recipe { - Self::new(id, user_id, String::new(), String::new()) - } - - pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe { - Recipe { - id, - user_id, - title, - description, - estimate_time: None, - difficulty: Difficulty::Unknown, - lang: "en".to_string(), - process: Vec::new(), - } - } -} - -pub struct Ingredient { - pub quantity: Option, - pub name: String, -} + #[sqlx(try_from = "u32")] + pub difficulty: Difficulty, -pub struct Quantity { - pub value: f32, - pub unit: String, + pub servings: u32, + pub is_published: bool, + // pub tags: Vec, + // pub groups: Vec, } pub struct Group { - pub name: Option, - pub input: Vec, + pub name: String, + pub comment: String, pub steps: Vec, } pub struct Step { pub action: String, + pub ingredients: Vec, } -pub enum StepInput { - Ingredient(Ingredient), +pub struct Ingredient { + pub name: String, + pub comment: String, + pub quantity: i32, + pub quantity_unit: String, } +#[derive(PartialEq, Debug)] pub enum Difficulty { Unknown = 0, Easy = 1, Medium = 2, Hard = 3, } + +impl TryFrom for Difficulty { + type Error = &'static str; + fn try_from(value: u32) -> Result { + Ok(match value { + 1 => Self::Easy, + 2 => Self::Medium, + 3 => Self::Hard, + _ => Self::Unknown, + }) + } +} + +impl From for u32 { + fn from(value: Difficulty) -> Self { + value as u32 + } +} diff --git a/backend/src/data/utils.rs b/backend/src/data/utils.rs deleted file mode 100644 index 73b6c1c..0000000 --- a/backend/src/data/utils.rs +++ /dev/null @@ -1,34 +0,0 @@ -use sqlx::{sqlite::SqliteRow, FromRow, Row}; - -use super::model; - -impl FromRow<'_, SqliteRow> for model::Recipe { - fn from_row(row: &SqliteRow) -> sqlx::Result { - Ok(model::Recipe::new( - row.try_get("id")?, - row.try_get("user_id")?, - row.try_get("title")?, - row.try_get("description")?, - )) - } -} - -impl FromRow<'_, SqliteRow> for model::UserLoginInfo { - fn from_row(row: &SqliteRow) -> sqlx::Result { - Ok(model::UserLoginInfo { - last_login_datetime: row.try_get("last_login_datetime")?, - ip: row.try_get("ip")?, - user_agent: row.try_get("user_agent")?, - }) - } -} - -impl FromRow<'_, SqliteRow> for model::User { - fn from_row(row: &SqliteRow) -> sqlx::Result { - Ok(model::User { - id: row.try_get("id")?, - email: row.try_get("email")?, - name: row.try_get("name")?, - }) - } -} diff --git a/backend/src/html_templates.rs b/backend/src/html_templates.rs index 3f3fd4c..dbaf3b6 100644 --- a/backend/src/html_templates.rs +++ b/backend/src/html_templates.rs @@ -18,6 +18,7 @@ pub struct HomeTemplate { #[template(path = "message.html")] pub struct MessageTemplate { pub user: Option, + pub message: String, pub as_code: bool, // Display the message in
 markup.
 }
@@ -44,6 +45,7 @@ impl MessageTemplate {
 #[template(path = "sign_up_form.html")]
 pub struct SignUpFormTemplate {
     pub user: Option,
+
     pub email: String,
     pub message: String,
     pub message_email: String,
@@ -54,6 +56,7 @@ pub struct SignUpFormTemplate {
 #[template(path = "sign_in_form.html")]
 pub struct SignInFormTemplate {
     pub user: Option,
+
     pub email: String,
     pub message: String,
 }
@@ -62,6 +65,7 @@ pub struct SignInFormTemplate {
 #[template(path = "ask_reset_password.html")]
 pub struct AskResetPasswordTemplate {
     pub user: Option,
+
     pub email: String,
     pub message: String,
     pub message_email: String,
@@ -71,6 +75,7 @@ pub struct AskResetPasswordTemplate {
 #[template(path = "reset_password.html")]
 pub struct ResetPasswordTemplate {
     pub user: Option,
+
     pub reset_token: String,
     pub message: String,
     pub message_password: String,
@@ -80,6 +85,7 @@ pub struct ResetPasswordTemplate {
 #[template(path = "profile.html")]
 pub struct ProfileTemplate {
     pub user: Option,
+
     pub username: String,
     pub email: String,
     pub message: String,
@@ -92,6 +98,7 @@ pub struct ProfileTemplate {
 pub struct RecipeViewTemplate {
     pub user: Option,
     pub recipes: Recipes,
+
     pub recipe: model::Recipe,
 }
 
@@ -100,5 +107,7 @@ pub struct RecipeViewTemplate {
 pub struct RecipeEditTemplate {
     pub user: Option,
     pub recipes: Recipes,
+
     pub recipe: model::Recipe,
+    pub languages: [(&'static str, &'static str); 2],
 }
diff --git a/backend/src/main.rs b/backend/src/main.rs
index 2c00ddc..ea53f48 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -92,31 +92,32 @@ async fn main() {
         .route("/", get(services::home_page))
         .route(
             "/signup",
-            get(services::sign_up_get).post(services::sign_up_post),
+            get(services::user::sign_up_get).post(services::user::sign_up_post),
         )
-        .route("/validation", get(services::sign_up_validation))
-        .route("/revalidation", get(services::email_revalidation))
+        .route("/validation", get(services::user::sign_up_validation))
+        .route("/revalidation", get(services::user::email_revalidation))
         .route(
             "/signin",
-            get(services::sign_in_get).post(services::sign_in_post),
+            get(services::user::sign_in_get).post(services::user::sign_in_post),
         )
-        .route("/signout", get(services::sign_out))
+        .route("/signout", get(services::user::sign_out))
         .route(
             "/ask_reset_password",
-            get(services::ask_reset_password_get).post(services::ask_reset_password_post),
+            get(services::user::ask_reset_password_get)
+                .post(services::user::ask_reset_password_post),
         )
         .route(
             "/reset_password",
-            get(services::reset_password_get).post(services::reset_password_post),
+            get(services::user::reset_password_get).post(services::user::reset_password_post),
         )
         // Recipes.
-        .route("/recipe/new", get(services::create_recipe))
-        // .route("/recipe/edit/:id", get(services::edit_recipe))
-        .route("/recipe/view/:id", get(services::view_recipe))
+        .route("/recipe/new", get(services::recipe::create))
+        .route("/recipe/edit/:id", get(services::recipe::edit_recipe))
+        .route("/recipe/view/:id", get(services::recipe::view))
         // User.
         .route(
             "/user/edit",
-            get(services::edit_user_get).post(services::edit_user_post),
+            get(services::user::edit_user_get).post(services::user::edit_user_post),
         )
         .route_layer(middleware::from_fn(services::ron_error_to_html));
 
@@ -233,6 +234,11 @@ async fn process_args() -> bool {
                 )
                 .await
                 .unwrap();
+
+                event!(
+                    Level::INFO,
+                    "A new test database has been created successfully"
+                );
             }
             Err(error) => {
                 event!(Level::ERROR, "{}", error);
diff --git a/backend/src/services.rs b/backend/src/services.rs
index a14eda4..dd744b9 100644
--- a/backend/src/services.rs
+++ b/backend/src/services.rs
@@ -1,29 +1,21 @@
-use std::{collections::HashMap, net::SocketAddr};
-
 use axum::{
-    body::{self, Body},
-    debug_handler,
-    extract::{ConnectInfo, Extension, Host, Path, Query, Request, State},
-    http::{header, HeaderMap},
+    body, debug_handler,
+    extract::{Extension, Request, State},
+    http::header,
     middleware::Next,
-    response::{IntoResponse, Redirect, Response, Result},
-    Form,
+    response::{IntoResponse, Response, Result},
 };
-use axum_extra::extract::cookie::{Cookie, CookieJar};
-use chrono::Duration;
-use serde::Deserialize;
-use tracing::{event, Level};
+// use tracing::{event, Level};
 
 use crate::{
-    config::Config,
-    consts,
     data::{db, model},
-    email,
     html_templates::*,
-    ron_utils, utils, AppState,
+    ron_utils,
 };
 
+pub mod recipe;
 pub mod ron;
+pub mod user;
 
 // Will embed RON error in HTML page.
 pub async fn ron_error_to_html(req: Request, next: Next) -> Result {
@@ -61,774 +53,12 @@ pub async fn home_page(
         recipes: Recipes {
             list: recipes,
             current_id: None,
-        }, // current_recipe_id: None,
-           // recipes,
+        },
     })
 }
 
-///// RECIPE /////
-
-#[debug_handler]
-pub async fn create_recipe(
-    State(connection): State,
-    Extension(user): Extension>,
-) -> Result {
-    if let Some(user) = user {
-        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").into_response())
-    }
-}
-
-// #[debug_handler]
-// pub async fn edit_recipe(
-//     State(connection): State,
-//     Extension(user): Extension>,
-//     Path(recipe_id): Path,
-// ) -> Result {
-//     if let Some(user) = user {
-//         Ok(RecipeEditTemplate { user }.into_response())
-//     } else {
-//         Ok(MessageTemplate::new("Not logged in").into_response())
-//     }
-// }
-
-#[debug_handler]
-pub async fn view_recipe(
-    State(connection): State,
-    Extension(user): Extension>,
-    Path(recipe_id): Path,
-) -> Result {
-    let recipes = connection.get_all_recipe_titles().await?;
-    match connection.get_recipe(recipe_id).await? {
-        Some(recipe) => Ok(RecipeViewTemplate {
-            user,
-            recipes: Recipes {
-                list: recipes,
-                current_id: Some(recipe.id),
-            },
-            recipe,
-        }
-        .into_response()),
-        None => Ok(MessageTemplate::new_with_user(
-            &format!("Cannot find the recipe {}", recipe_id),
-            user,
-        )
-        .into_response()),
-    }
-}
-
-//// SIGN UP /////
-
-#[debug_handler]
-pub async fn sign_up_get(
-    Extension(user): Extension>,
-) -> Result {
-    Ok(SignUpFormTemplate {
-        user,
-        email: String::new(),
-        message: String::new(),
-        message_email: String::new(),
-        message_password: String::new(),
-    })
-}
-
-#[derive(Deserialize, Debug)]
-pub struct SignUpFormData {
-    email: String,
-    password_1: String,
-    password_2: String,
-}
-
-enum SignUpError {
-    InvalidEmail,
-    PasswordsNotEqual,
-    InvalidPassword,
-    UserAlreadyExists,
-    DatabaseError,
-    UnableSendEmail,
-}
-
-#[debug_handler(state = AppState)]
-pub async fn sign_up_post(
-    Host(host): Host,
-    State(connection): State,
-    State(config): State,
-    Extension(user): Extension>,
-    Form(form_data): Form,
-) -> Result {
-    fn error_response(
-        error: SignUpError,
-        form_data: &SignUpFormData,
-        user: Option,
-    ) -> Result {
-        Ok(SignUpFormTemplate {
-            user,
-            email: form_data.email.clone(),
-            message_email: match error {
-                SignUpError::InvalidEmail => "Invalid email",
-                _ => "",
-            }
-            .to_string(),
-            message_password: match error {
-                SignUpError::PasswordsNotEqual => "Passwords don't match",
-                SignUpError::InvalidPassword => "Password must have at least eight characters",
-                _ => "",
-            }
-            .to_string(),
-            message: match error {
-                SignUpError::UserAlreadyExists => "This email is not available",
-                SignUpError::DatabaseError => "Database error",
-                SignUpError::UnableSendEmail => "Unable to send the validation email",
-                _ => "",
-            }
-            .to_string(),
-        }
-        .into_response())
-    }
-
-    // Validation of email and password.
-    if let common::utils::EmailValidation::NotValid =
-        common::utils::validate_email(&form_data.email)
-    {
-        return error_response(SignUpError::InvalidEmail, &form_data, user);
-    }
-
-    if form_data.password_1 != form_data.password_2 {
-        return error_response(SignUpError::PasswordsNotEqual, &form_data, user);
-    }
-
-    if let common::utils::PasswordValidation::TooShort =
-        common::utils::validate_password(&form_data.password_1)
-    {
-        return error_response(SignUpError::InvalidPassword, &form_data, user);
-    }
-
-    match connection
-        .sign_up(&form_data.email, &form_data.password_1)
-        .await
-    {
-        Ok(db::user::SignUpResult::UserAlreadyExists) => {
-            error_response(SignUpError::UserAlreadyExists, &form_data, user)
-        }
-        Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
-            let url = utils::get_url_from_host(&host);
-            let email = form_data.email.clone();
-            match email::send_email(
-                &email,
-                &format!(
-                    "Follow this link to confirm your inscription: {}/validation?validation_token={}",
-                    url, token
-                ),
-                &config.smtp_relay_address,
-                &config.smtp_login,
-                &config.smtp_password,
-            )
-            .await
-            {
-                Ok(()) => Ok(
-                    MessageTemplate::new_with_user(
-                        "An email has been sent, follow the link to validate your account",
-                    user).into_response()),
-                Err(_) => {
-                    // error!("Email validation error: {}", error); // TODO: log
-                    error_response(SignUpError::UnableSendEmail, &form_data, user)
-                }
-            }
-        }
-        Err(_) => {
-            // error!("Signup database error: {}", error); // TODO: log
-            error_response(SignUpError::DatabaseError, &form_data, user)
-        }
-    }
-}
-
-#[debug_handler]
-pub async fn sign_up_validation(
-    State(connection): State,
-    Extension(user): Extension>,
-    ConnectInfo(addr): ConnectInfo,
-    Query(query): Query>,
-    headers: HeaderMap,
-) -> Result<(CookieJar, impl IntoResponse)> {
-    let mut jar = CookieJar::from_headers(&headers);
-    if user.is_some() {
-        return Ok((
-            jar,
-            MessageTemplate::new_with_user("User already exists", user),
-        ));
-    }
-    let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
-    match query.get("validation_token") {
-        // 'validation_token' exists only when a user tries to validate a new account.
-        Some(token) => {
-            match connection
-                .validation(
-                    token,
-                    Duration::seconds(consts::VALIDATION_TOKEN_DURATION),
-                    &client_ip,
-                    &client_user_agent,
-                )
-                .await?
-            {
-                db::user::ValidationResult::Ok(token, user_id) => {
-                    let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
-                    jar = jar.add(cookie);
-                    let user = connection.load_user(user_id).await?;
-                    Ok((
-                        jar,
-                        MessageTemplate::new_with_user(
-                            "Email validation successful, your account has been created",
-                            user,
-                        ),
-                    ))
-                }
-                db::user::ValidationResult::ValidationExpired => Ok((
-                    jar,
-                    MessageTemplate::new_with_user(
-                        "The validation has expired. Try to sign up again",
-                        user,
-                    ),
-                )),
-                db::user::ValidationResult::UnknownUser => Ok((
-                    jar,
-                    MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
-                )),
-            }
-        }
-        None => Ok((
-            jar,
-            MessageTemplate::new_with_user("Validation error", user),
-        )),
-    }
-}
-
-///// SIGN IN /////
-
-#[debug_handler]
-pub async fn sign_in_get(
-    Extension(user): Extension>,
-) -> Result {
-    Ok(SignInFormTemplate {
-        user,
-        email: String::new(),
-        message: String::new(),
-    })
-}
-
-#[derive(Deserialize, Debug)]
-pub struct SignInFormData {
-    email: String,
-    password: String,
-}
-
-#[debug_handler]
-pub async fn sign_in_post(
-    ConnectInfo(addr): ConnectInfo,
-    State(connection): State,
-    Extension(user): Extension>,
-    headers: HeaderMap,
-    Form(form_data): Form,
-) -> Result<(CookieJar, Response)> {
-    let jar = CookieJar::from_headers(&headers);
-    let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
-
-    match connection
-        .sign_in(
-            &form_data.email,
-            &form_data.password,
-            &client_ip,
-            &client_user_agent,
-        )
-        .await?
-    {
-        db::user::SignInResult::AccountNotValidated => Ok((
-            jar,
-            SignInFormTemplate {
-                user,
-                email: form_data.email,
-                message: "This account must be validated first".to_string(),
-            }
-            .into_response(),
-        )),
-        db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok((
-            jar,
-            SignInFormTemplate {
-                user,
-                email: form_data.email,
-                message: "Wrong email or password".to_string(),
-            }
-            .into_response(),
-        )),
-        db::user::SignInResult::Ok(token, _user_id) => {
-            let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
-            Ok((jar.add(cookie), Redirect::to("/").into_response()))
-        }
-    }
-}
-
-///// SIGN OUT /////
-
-#[debug_handler]
-pub async fn sign_out(
-    State(connection): State,
-    req: Request,
-) -> Result<(CookieJar, Redirect)> {
-    let mut jar = CookieJar::from_headers(req.headers());
-    if let Some(token_cookie) = jar.get(consts::COOKIE_AUTH_TOKEN_NAME) {
-        let token = token_cookie.value().to_string();
-        jar = jar.remove(consts::COOKIE_AUTH_TOKEN_NAME);
-        connection.sign_out(&token).await?;
-    }
-    Ok((jar, Redirect::to("/")))
-}
-
-///// RESET PASSWORD /////
-
-#[debug_handler]
-pub async fn ask_reset_password_get(
-    Extension(user): Extension>,
-) -> Result {
-    if user.is_some() {
-        Ok(MessageTemplate::new_with_user(
-            "Can't ask to reset password when already logged in",
-            user,
-        )
-        .into_response())
-    } else {
-        Ok(AskResetPasswordTemplate {
-            user,
-            email: String::new(),
-            message: String::new(),
-            message_email: String::new(),
-        }
-        .into_response())
-    }
-}
-
-#[derive(Deserialize, Debug)]
-pub struct AskResetPasswordForm {
-    email: String,
-}
-
-enum AskResetPasswordError {
-    InvalidEmail,
-    EmailAlreadyReset,
-    EmailUnknown,
-    UnableSendEmail,
-    DatabaseError,
-}
-
-#[debug_handler(state = AppState)]
-pub async fn ask_reset_password_post(
-    Host(host): Host,
-    State(connection): State,
-    State(config): State,
-    Extension(user): Extension>,
-    Form(form_data): Form,
-) -> Result {
-    fn error_response(
-        error: AskResetPasswordError,
-        email: &str,
-        user: Option,
-    ) -> Result {
-        Ok(AskResetPasswordTemplate {
-            user,
-            email: email.to_string(),
-            message_email: match error {
-                AskResetPasswordError::InvalidEmail => "Invalid email",
-                _ => "",
-            }
-            .to_string(),
-            message: match error {
-                AskResetPasswordError::EmailAlreadyReset => {
-                    "The password has already been reset for this email"
-                }
-                AskResetPasswordError::EmailUnknown => "Email unknown",
-                AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email",
-                AskResetPasswordError::DatabaseError => "Database error",
-                _ => "",
-            }
-            .to_string(),
-        }
-        .into_response())
-    }
-
-    // Validation of email.
-    if let common::utils::EmailValidation::NotValid =
-        common::utils::validate_email(&form_data.email)
-    {
-        return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
-    }
-
-    match connection
-        .get_token_reset_password(
-            &form_data.email,
-            Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
-        )
-        .await
-    {
-        Ok(db::user::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response(
-            AskResetPasswordError::EmailAlreadyReset,
-            &form_data.email,
-            user,
-        ),
-        Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
-            error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
-        }
-        Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
-            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
-                ),
-                &config.smtp_relay_address,
-                &config.smtp_login,
-                &config.smtp_password,
-            )
-            .await
-            {
-                Ok(()) => Ok(MessageTemplate::new_with_user(
-                    "An email has been sent, follow the link to reset your password.",
-                    user,
-                )
-                .into_response()),
-                Err(_) => {
-                    // error!("Email validation error: {}", error); // TODO: log
-                    error_response(
-                        AskResetPasswordError::UnableSendEmail,
-                        &form_data.email,
-                        user,
-                    )
-                }
-            }
-        }
-        Err(error) => {
-            event!(Level::ERROR, "{}", error);
-            error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
-        }
-    }
-}
-
-#[debug_handler]
-pub async fn reset_password_get(
-    Extension(user): Extension>,
-    Query(query): Query>,
-) -> Result {
-    if let Some(reset_token) = query.get("reset_token") {
-        Ok(ResetPasswordTemplate {
-            user,
-            reset_token: reset_token.to_string(),
-            message: String::new(),
-            message_password: String::new(),
-        }
-        .into_response())
-    } else {
-        Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
-    }
-}
-
-#[derive(Deserialize, Debug)]
-pub struct ResetPasswordForm {
-    password_1: String,
-    password_2: String,
-    reset_token: String,
-}
-
-enum ResetPasswordError {
-    PasswordsNotEqual,
-    InvalidPassword,
-    TokenExpired,
-    DatabaseError,
-}
-
-#[debug_handler]
-pub async fn reset_password_post(
-    State(connection): State,
-    Extension(user): Extension>,
-    Form(form_data): Form,
-) -> Result {
-    fn error_response(
-        error: ResetPasswordError,
-        form_data: &ResetPasswordForm,
-        user: Option,
-    ) -> Result {
-        Ok(ResetPasswordTemplate {
-            user,
-            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(),
-            message: match error {
-                ResetPasswordError::TokenExpired => "Token expired, try to reset password again",
-                ResetPasswordError::DatabaseError => "Database error",
-                _ => "",
-            }
-            .to_string(),
-        }
-        .into_response())
-    }
-
-    if form_data.password_1 != form_data.password_2 {
-        return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
-    }
-
-    if let common::utils::PasswordValidation::TooShort =
-        common::utils::validate_password(&form_data.password_1)
-    {
-        return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
-    }
-
-    match connection
-        .reset_password(
-            &form_data.password_1,
-            &form_data.reset_token,
-            Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
-        )
-        .await
-    {
-        Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
-            "Your password has been reset",
-            user,
-        )
-        .into_response()),
-        Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
-            error_response(ResetPasswordError::TokenExpired, &form_data, user)
-        }
-        Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
-    }
-}
-
-///// EDIT PROFILE /////
-
-#[debug_handler]
-pub async fn edit_user_get(Extension(user): Extension>) -> Response {
-    if let Some(user) = user {
-        ProfileTemplate {
-            username: user.name.clone(),
-            email: user.email.clone(),
-            user: Some(user),
-            message: String::new(),
-            message_email: String::new(),
-            message_password: String::new(),
-        }
-        .into_response()
-    } else {
-        MessageTemplate::new("Not logged in").into_response()
-    }
-}
-
-#[derive(Deserialize, Debug)]
-pub struct EditUserForm {
-    name: String,
-    email: String,
-    password_1: String,
-    password_2: String,
-}
-enum ProfileUpdateError {
-    InvalidEmail,
-    EmailAlreadyTaken,
-    PasswordsNotEqual,
-    InvalidPassword,
-    DatabaseError,
-    UnableSendEmail,
-}
-
-// TODO: A lot of code are similar to 'sign_up_post', maybe find a way to factorize some.
-#[debug_handler(state = AppState)]
-pub async fn edit_user_post(
-    Host(host): Host,
-    State(connection): State,
-    State(config): State,
-    Extension(user): Extension>,
-    Form(form_data): Form,
-) -> Result {
-    if let Some(user) = user {
-        fn error_response(
-            error: ProfileUpdateError,
-            form_data: &EditUserForm,
-            user: model::User,
-        ) -> Result {
-            Ok(ProfileTemplate {
-                user: Some(user),
-                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(),
-                message_password: match error {
-                    ProfileUpdateError::PasswordsNotEqual => "Passwords don't match",
-                    ProfileUpdateError::InvalidPassword => {
-                        "Password must have at least eight characters"
-                    }
-                    _ => "",
-                }
-                .to_string(),
-                message: match error {
-                    ProfileUpdateError::DatabaseError => "Database error",
-                    ProfileUpdateError::UnableSendEmail => "Unable to send the validation email",
-                    _ => "",
-                }
-                .to_string(),
-            }
-            .into_response())
-        }
-
-        if let common::utils::EmailValidation::NotValid =
-            common::utils::validate_email(&form_data.email)
-        {
-            return error_response(ProfileUpdateError::InvalidEmail, &form_data, user);
-        }
-
-        let new_password = if !form_data.password_1.is_empty() || !form_data.password_2.is_empty() {
-            if form_data.password_1 != form_data.password_2 {
-                return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user);
-            }
-            if let common::utils::PasswordValidation::TooShort =
-                common::utils::validate_password(&form_data.password_1)
-            {
-                return error_response(ProfileUpdateError::InvalidPassword, &form_data, user);
-            }
-            Some(form_data.password_1.as_ref())
-        } else {
-            None
-        };
-
-        let email_trimmed = form_data.email.trim();
-        let message: &str;
-
-        match connection
-            .update_user(
-                user.id,
-                Some(&email_trimmed),
-                Some(&form_data.name),
-                new_password,
-            )
-            .await
-        {
-            Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
-                return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
-            }
-            Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
-                let url = utils::get_url_from_host(&host);
-                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
-                    ),
-                    &config.smtp_relay_address,
-                    &config.smtp_login,
-                    &config.smtp_password,
-                )
-                .await
-                {
-                    Ok(()) => {
-                        message =
-                            "An email has been sent, follow the link to validate your new email";
-                    }
-                    Err(_) => {
-                        // error!("Email validation error: {}", error); // TODO: log
-                        return error_response(ProfileUpdateError::UnableSendEmail, &form_data, user);
-                    }
-                }
-            }
-            Ok(db::user::UpdateUserResult::Ok) => {
-                message = "Profile saved";
-            }
-            Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
-        }
-
-        // Reload after update.
-        let user = connection.load_user(user.id).await?;
-
-        Ok(ProfileTemplate {
-            user,
-            username: form_data.name,
-            email: form_data.email,
-            message: message.to_string(),
-            message_email: String::new(),
-            message_password: String::new(),
-        }
-        .into_response())
-    } else {
-        Ok(MessageTemplate::new("Not logged in").into_response())
-    }
-}
-
-#[debug_handler]
-pub async fn email_revalidation(
-    State(connection): State,
-    Extension(user): Extension>,
-    ConnectInfo(addr): ConnectInfo,
-    Query(query): Query>,
-    headers: HeaderMap,
-) -> Result<(CookieJar, impl IntoResponse)> {
-    let mut jar = CookieJar::from_headers(&headers);
-    if user.is_some() {
-        return Ok((
-            jar,
-            MessageTemplate::new_with_user("User already exists", user),
-        ));
-    }
-    let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
-    match query.get("validation_token") {
-        // 'validation_token' exists only when a user must validate a new email.
-        Some(token) => {
-            match connection
-                .validation(
-                    token,
-                    Duration::seconds(consts::VALIDATION_TOKEN_DURATION),
-                    &client_ip,
-                    &client_user_agent,
-                )
-                .await?
-            {
-                db::user::ValidationResult::Ok(token, user_id) => {
-                    let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
-                    jar = jar.add(cookie);
-                    let user = connection.load_user(user_id).await?;
-                    Ok((
-                        jar,
-                        MessageTemplate::new_with_user("Email validation successful", user),
-                    ))
-                }
-                db::user::ValidationResult::ValidationExpired => Ok((
-                    jar,
-                    MessageTemplate::new_with_user(
-                        "The validation has expired. Try to sign up again with the same email",
-                        user,
-                    ),
-                )),
-                db::user::ValidationResult::UnknownUser => Ok((
-                    jar,
-                    MessageTemplate::new_with_user(
-                        "Validation error. Try to sign up again with the same email",
-                        user,
-                    ),
-                )),
-            }
-        }
-        None => Ok((
-            jar,
-            MessageTemplate::new_with_user("Validation error", user),
-        )),
-    }
-}
-
 ///// 404 /////
+
 #[debug_handler]
 pub async fn not_found(Extension(user): Extension>) -> impl IntoResponse {
     MessageTemplate::new_with_user("404: Not found", user)
diff --git a/backend/src/services/recipe.rs b/backend/src/services/recipe.rs
new file mode 100644
index 0000000..12ce6fc
--- /dev/null
+++ b/backend/src/services/recipe.rs
@@ -0,0 +1,79 @@
+use axum::{
+    debug_handler,
+    extract::{Extension, Path, State},
+    response::{IntoResponse, Redirect, Response, Result},
+};
+// use tracing::{event, Level};
+
+use crate::{
+    consts,
+    data::{db, model},
+    html_templates::*,
+};
+
+///// RECIPE /////
+
+#[debug_handler]
+pub async fn create(
+    State(connection): State,
+    Extension(user): Extension>,
+) -> Result {
+    if let Some(user) = user {
+        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").into_response())
+    }
+}
+
+#[debug_handler]
+pub async fn edit_recipe(
+    State(connection): State,
+    Extension(user): Extension>,
+    Path(recipe_id): Path,
+) -> Result {
+    if let Some(user) = user {
+        let recipe = connection.get_recipe(recipe_id).await?.unwrap();
+        if recipe.user_id == user.id {
+            Ok(RecipeEditTemplate {
+                user: Some(user),
+                recipes: Recipes {
+                    list: connection.get_all_recipe_titles().await?,
+                    current_id: Some(recipe_id),
+                },
+                recipe,
+                languages: consts::LANGUAGES,
+            }
+            .into_response())
+        } else {
+            Ok(MessageTemplate::new("Unable to edit this recipe").into_response())
+        }
+    } else {
+        Ok(MessageTemplate::new("Not logged in").into_response())
+    }
+}
+
+#[debug_handler]
+pub async fn view(
+    State(connection): State,
+    Extension(user): Extension>,
+    Path(recipe_id): Path,
+) -> Result {
+    let recipes = connection.get_all_recipe_titles().await?;
+    match connection.get_recipe(recipe_id).await? {
+        Some(recipe) => Ok(RecipeViewTemplate {
+            user,
+            recipes: Recipes {
+                list: recipes,
+                current_id: Some(recipe.id),
+            },
+            recipe,
+        }
+        .into_response()),
+        None => Ok(MessageTemplate::new_with_user(
+            &format!("Cannot find the recipe {}", recipe_id),
+            user,
+        )
+        .into_response()),
+    }
+}
diff --git a/backend/src/services/user.rs b/backend/src/services/user.rs
new file mode 100644
index 0000000..a50eff1
--- /dev/null
+++ b/backend/src/services/user.rs
@@ -0,0 +1,732 @@
+use std::{collections::HashMap, net::SocketAddr};
+
+use axum::{
+    body::Body,
+    debug_handler,
+    extract::{ConnectInfo, Extension, Host, Query, Request, State},
+    http::HeaderMap,
+    response::{IntoResponse, Redirect, Response, Result},
+    Form,
+};
+use axum_extra::extract::cookie::{Cookie, CookieJar};
+use chrono::Duration;
+use serde::Deserialize;
+use tracing::{event, Level};
+
+use crate::{
+    config::Config,
+    consts,
+    data::{db, model},
+    email,
+    html_templates::*,
+    utils, AppState,
+};
+
+//// SIGN UP /////
+
+#[debug_handler]
+pub async fn sign_up_get(
+    Extension(user): Extension>,
+) -> Result {
+    Ok(SignUpFormTemplate {
+        user,
+        email: String::new(),
+        message: String::new(),
+        message_email: String::new(),
+        message_password: String::new(),
+    })
+}
+
+#[derive(Deserialize, Debug)]
+pub struct SignUpFormData {
+    email: String,
+    password_1: String,
+    password_2: String,
+}
+
+enum SignUpError {
+    InvalidEmail,
+    PasswordsNotEqual,
+    InvalidPassword,
+    UserAlreadyExists,
+    DatabaseError,
+    UnableSendEmail,
+}
+
+#[debug_handler(state = AppState)]
+pub async fn sign_up_post(
+    Host(host): Host,
+    State(connection): State,
+    State(config): State,
+    Extension(user): Extension>,
+    Form(form_data): Form,
+) -> Result {
+    fn error_response(
+        error: SignUpError,
+        form_data: &SignUpFormData,
+        user: Option,
+    ) -> Result {
+        Ok(SignUpFormTemplate {
+            user,
+            email: form_data.email.clone(),
+            message_email: match error {
+                SignUpError::InvalidEmail => "Invalid email",
+                _ => "",
+            }
+            .to_string(),
+            message_password: match error {
+                SignUpError::PasswordsNotEqual => "Passwords don't match",
+                SignUpError::InvalidPassword => "Password must have at least eight characters",
+                _ => "",
+            }
+            .to_string(),
+            message: match error {
+                SignUpError::UserAlreadyExists => "This email is not available",
+                SignUpError::DatabaseError => "Database error",
+                SignUpError::UnableSendEmail => "Unable to send the validation email",
+                _ => "",
+            }
+            .to_string(),
+        }
+        .into_response())
+    }
+
+    // Validation of email and password.
+    if let common::utils::EmailValidation::NotValid =
+        common::utils::validate_email(&form_data.email)
+    {
+        return error_response(SignUpError::InvalidEmail, &form_data, user);
+    }
+
+    if form_data.password_1 != form_data.password_2 {
+        return error_response(SignUpError::PasswordsNotEqual, &form_data, user);
+    }
+
+    if let common::utils::PasswordValidation::TooShort =
+        common::utils::validate_password(&form_data.password_1)
+    {
+        return error_response(SignUpError::InvalidPassword, &form_data, user);
+    }
+
+    match connection
+        .sign_up(&form_data.email, &form_data.password_1)
+        .await
+    {
+        Ok(db::user::SignUpResult::UserAlreadyExists) => {
+            error_response(SignUpError::UserAlreadyExists, &form_data, user)
+        }
+        Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
+            let url = utils::get_url_from_host(&host);
+            let email = form_data.email.clone();
+            match email::send_email(
+                &email,
+                &format!(
+                    "Follow this link to confirm your inscription: {}/validation?validation_token={}",
+                    url, token
+                ),
+                &config.smtp_relay_address,
+                &config.smtp_login,
+                &config.smtp_password,
+            )
+            .await
+            {
+                Ok(()) => Ok(
+                    MessageTemplate::new_with_user(
+                        "An email has been sent, follow the link to validate your account",
+                    user).into_response()),
+                Err(_) => {
+                    // error!("Email validation error: {}", error); // TODO: log
+                    error_response(SignUpError::UnableSendEmail, &form_data, user)
+                }
+            }
+        }
+        Err(_) => {
+            // error!("Signup database error: {}", error); // TODO: log
+            error_response(SignUpError::DatabaseError, &form_data, user)
+        }
+    }
+}
+
+#[debug_handler]
+pub async fn sign_up_validation(
+    State(connection): State,
+    Extension(user): Extension>,
+    ConnectInfo(addr): ConnectInfo,
+    Query(query): Query>,
+    headers: HeaderMap,
+) -> Result<(CookieJar, impl IntoResponse)> {
+    let mut jar = CookieJar::from_headers(&headers);
+    if user.is_some() {
+        return Ok((
+            jar,
+            MessageTemplate::new_with_user("User already exists", user),
+        ));
+    }
+    let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
+    match query.get("validation_token") {
+        // 'validation_token' exists only when a user tries to validate a new account.
+        Some(token) => {
+            match connection
+                .validation(
+                    token,
+                    Duration::seconds(consts::VALIDATION_TOKEN_DURATION),
+                    &client_ip,
+                    &client_user_agent,
+                )
+                .await?
+            {
+                db::user::ValidationResult::Ok(token, user_id) => {
+                    let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
+                    jar = jar.add(cookie);
+                    let user = connection.load_user(user_id).await?;
+                    Ok((
+                        jar,
+                        MessageTemplate::new_with_user(
+                            "Email validation successful, your account has been created",
+                            user,
+                        ),
+                    ))
+                }
+                db::user::ValidationResult::ValidationExpired => Ok((
+                    jar,
+                    MessageTemplate::new_with_user(
+                        "The validation has expired. Try to sign up again",
+                        user,
+                    ),
+                )),
+                db::user::ValidationResult::UnknownUser => Ok((
+                    jar,
+                    MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
+                )),
+            }
+        }
+        None => Ok((
+            jar,
+            MessageTemplate::new_with_user("Validation error", user),
+        )),
+    }
+}
+
+///// SIGN IN /////
+
+#[debug_handler]
+pub async fn sign_in_get(
+    Extension(user): Extension>,
+) -> Result {
+    Ok(SignInFormTemplate {
+        user,
+        email: String::new(),
+        message: String::new(),
+    })
+}
+
+#[derive(Deserialize, Debug)]
+pub struct SignInFormData {
+    email: String,
+    password: String,
+}
+
+#[debug_handler]
+pub async fn sign_in_post(
+    ConnectInfo(addr): ConnectInfo,
+    State(connection): State,
+    Extension(user): Extension>,
+    headers: HeaderMap,
+    Form(form_data): Form,
+) -> Result<(CookieJar, Response)> {
+    let jar = CookieJar::from_headers(&headers);
+    let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
+
+    match connection
+        .sign_in(
+            &form_data.email,
+            &form_data.password,
+            &client_ip,
+            &client_user_agent,
+        )
+        .await?
+    {
+        db::user::SignInResult::AccountNotValidated => Ok((
+            jar,
+            SignInFormTemplate {
+                user,
+                email: form_data.email,
+                message: "This account must be validated first".to_string(),
+            }
+            .into_response(),
+        )),
+        db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok((
+            jar,
+            SignInFormTemplate {
+                user,
+                email: form_data.email,
+                message: "Wrong email or password".to_string(),
+            }
+            .into_response(),
+        )),
+        db::user::SignInResult::Ok(token, _user_id) => {
+            let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
+            Ok((jar.add(cookie), Redirect::to("/").into_response()))
+        }
+    }
+}
+
+///// SIGN OUT /////
+
+#[debug_handler]
+pub async fn sign_out(
+    State(connection): State,
+    req: Request,
+) -> Result<(CookieJar, Redirect)> {
+    let mut jar = CookieJar::from_headers(req.headers());
+    if let Some(token_cookie) = jar.get(consts::COOKIE_AUTH_TOKEN_NAME) {
+        let token = token_cookie.value().to_string();
+        jar = jar.remove(consts::COOKIE_AUTH_TOKEN_NAME);
+        connection.sign_out(&token).await?;
+    }
+    Ok((jar, Redirect::to("/")))
+}
+
+///// RESET PASSWORD /////
+
+#[debug_handler]
+pub async fn ask_reset_password_get(
+    Extension(user): Extension>,
+) -> Result {
+    if user.is_some() {
+        Ok(MessageTemplate::new_with_user(
+            "Can't ask to reset password when already logged in",
+            user,
+        )
+        .into_response())
+    } else {
+        Ok(AskResetPasswordTemplate {
+            user,
+            email: String::new(),
+            message: String::new(),
+            message_email: String::new(),
+        }
+        .into_response())
+    }
+}
+
+#[derive(Deserialize, Debug)]
+pub struct AskResetPasswordForm {
+    email: String,
+}
+
+enum AskResetPasswordError {
+    InvalidEmail,
+    EmailAlreadyReset,
+    EmailUnknown,
+    UnableSendEmail,
+    DatabaseError,
+}
+
+#[debug_handler(state = AppState)]
+pub async fn ask_reset_password_post(
+    Host(host): Host,
+    State(connection): State,
+    State(config): State,
+    Extension(user): Extension>,
+    Form(form_data): Form,
+) -> Result {
+    fn error_response(
+        error: AskResetPasswordError,
+        email: &str,
+        user: Option,
+    ) -> Result {
+        Ok(AskResetPasswordTemplate {
+            user,
+            email: email.to_string(),
+            message_email: match error {
+                AskResetPasswordError::InvalidEmail => "Invalid email",
+                _ => "",
+            }
+            .to_string(),
+            message: match error {
+                AskResetPasswordError::EmailAlreadyReset => {
+                    "The password has already been reset for this email"
+                }
+                AskResetPasswordError::EmailUnknown => "Email unknown",
+                AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email",
+                AskResetPasswordError::DatabaseError => "Database error",
+                _ => "",
+            }
+            .to_string(),
+        }
+        .into_response())
+    }
+
+    // Validation of email.
+    if let common::utils::EmailValidation::NotValid =
+        common::utils::validate_email(&form_data.email)
+    {
+        return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
+    }
+
+    match connection
+        .get_token_reset_password(
+            &form_data.email,
+            Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
+        )
+        .await
+    {
+        Ok(db::user::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response(
+            AskResetPasswordError::EmailAlreadyReset,
+            &form_data.email,
+            user,
+        ),
+        Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
+            error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
+        }
+        Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
+            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
+                ),
+                &config.smtp_relay_address,
+                &config.smtp_login,
+                &config.smtp_password,
+            )
+            .await
+            {
+                Ok(()) => Ok(MessageTemplate::new_with_user(
+                    "An email has been sent, follow the link to reset your password.",
+                    user,
+                )
+                .into_response()),
+                Err(_) => {
+                    // error!("Email validation error: {}", error); // TODO: log
+                    error_response(
+                        AskResetPasswordError::UnableSendEmail,
+                        &form_data.email,
+                        user,
+                    )
+                }
+            }
+        }
+        Err(error) => {
+            event!(Level::ERROR, "{}", error);
+            error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
+        }
+    }
+}
+
+#[debug_handler]
+pub async fn reset_password_get(
+    Extension(user): Extension>,
+    Query(query): Query>,
+) -> Result {
+    if let Some(reset_token) = query.get("reset_token") {
+        Ok(ResetPasswordTemplate {
+            user,
+            reset_token: reset_token.to_string(),
+            message: String::new(),
+            message_password: String::new(),
+        }
+        .into_response())
+    } else {
+        Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
+    }
+}
+
+#[derive(Deserialize, Debug)]
+pub struct ResetPasswordForm {
+    password_1: String,
+    password_2: String,
+    reset_token: String,
+}
+
+enum ResetPasswordError {
+    PasswordsNotEqual,
+    InvalidPassword,
+    TokenExpired,
+    DatabaseError,
+}
+
+#[debug_handler]
+pub async fn reset_password_post(
+    State(connection): State,
+    Extension(user): Extension>,
+    Form(form_data): Form,
+) -> Result {
+    fn error_response(
+        error: ResetPasswordError,
+        form_data: &ResetPasswordForm,
+        user: Option,
+    ) -> Result {
+        Ok(ResetPasswordTemplate {
+            user,
+            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(),
+            message: match error {
+                ResetPasswordError::TokenExpired => "Token expired, try to reset password again",
+                ResetPasswordError::DatabaseError => "Database error",
+                _ => "",
+            }
+            .to_string(),
+        }
+        .into_response())
+    }
+
+    if form_data.password_1 != form_data.password_2 {
+        return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
+    }
+
+    if let common::utils::PasswordValidation::TooShort =
+        common::utils::validate_password(&form_data.password_1)
+    {
+        return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
+    }
+
+    match connection
+        .reset_password(
+            &form_data.password_1,
+            &form_data.reset_token,
+            Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
+        )
+        .await
+    {
+        Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
+            "Your password has been reset",
+            user,
+        )
+        .into_response()),
+        Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
+            error_response(ResetPasswordError::TokenExpired, &form_data, user)
+        }
+        Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
+    }
+}
+
+///// EDIT PROFILE /////
+
+#[debug_handler]
+pub async fn edit_user_get(Extension(user): Extension>) -> Response {
+    if let Some(user) = user {
+        ProfileTemplate {
+            username: user.name.clone(),
+            email: user.email.clone(),
+            user: Some(user),
+            message: String::new(),
+            message_email: String::new(),
+            message_password: String::new(),
+        }
+        .into_response()
+    } else {
+        MessageTemplate::new("Not logged in").into_response()
+    }
+}
+
+#[derive(Deserialize, Debug)]
+pub struct EditUserForm {
+    name: String,
+    email: String,
+    password_1: String,
+    password_2: String,
+}
+enum ProfileUpdateError {
+    InvalidEmail,
+    EmailAlreadyTaken,
+    PasswordsNotEqual,
+    InvalidPassword,
+    DatabaseError,
+    UnableSendEmail,
+}
+
+// TODO: A lot of code are similar to 'sign_up_post', maybe find a way to factorize some.
+#[debug_handler(state = AppState)]
+pub async fn edit_user_post(
+    Host(host): Host,
+    State(connection): State,
+    State(config): State,
+    Extension(user): Extension>,
+    Form(form_data): Form,
+) -> Result {
+    if let Some(user) = user {
+        fn error_response(
+            error: ProfileUpdateError,
+            form_data: &EditUserForm,
+            user: model::User,
+        ) -> Result {
+            Ok(ProfileTemplate {
+                user: Some(user),
+                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(),
+                message_password: match error {
+                    ProfileUpdateError::PasswordsNotEqual => "Passwords don't match",
+                    ProfileUpdateError::InvalidPassword => {
+                        "Password must have at least eight characters"
+                    }
+                    _ => "",
+                }
+                .to_string(),
+                message: match error {
+                    ProfileUpdateError::DatabaseError => "Database error",
+                    ProfileUpdateError::UnableSendEmail => "Unable to send the validation email",
+                    _ => "",
+                }
+                .to_string(),
+            }
+            .into_response())
+        }
+
+        if let common::utils::EmailValidation::NotValid =
+            common::utils::validate_email(&form_data.email)
+        {
+            return error_response(ProfileUpdateError::InvalidEmail, &form_data, user);
+        }
+
+        let new_password = if !form_data.password_1.is_empty() || !form_data.password_2.is_empty() {
+            if form_data.password_1 != form_data.password_2 {
+                return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user);
+            }
+            if let common::utils::PasswordValidation::TooShort =
+                common::utils::validate_password(&form_data.password_1)
+            {
+                return error_response(ProfileUpdateError::InvalidPassword, &form_data, user);
+            }
+            Some(form_data.password_1.as_ref())
+        } else {
+            None
+        };
+
+        let email_trimmed = form_data.email.trim();
+        let message: &str;
+
+        match connection
+            .update_user(
+                user.id,
+                Some(&email_trimmed),
+                Some(&form_data.name),
+                new_password,
+            )
+            .await
+        {
+            Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
+                return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
+            }
+            Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
+                let url = utils::get_url_from_host(&host);
+                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
+                    ),
+                    &config.smtp_relay_address,
+                    &config.smtp_login,
+                    &config.smtp_password,
+                )
+                .await
+                {
+                    Ok(()) => {
+                        message =
+                            "An email has been sent, follow the link to validate your new email";
+                    }
+                    Err(_) => {
+                        // error!("Email validation error: {}", error); // TODO: log
+                        return error_response(ProfileUpdateError::UnableSendEmail, &form_data, user);
+                    }
+                }
+            }
+            Ok(db::user::UpdateUserResult::Ok) => {
+                message = "Profile saved";
+            }
+            Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
+        }
+
+        // Reload after update.
+        let user = connection.load_user(user.id).await?;
+
+        Ok(ProfileTemplate {
+            user,
+            username: form_data.name,
+            email: form_data.email,
+            message: message.to_string(),
+            message_email: String::new(),
+            message_password: String::new(),
+        }
+        .into_response())
+    } else {
+        Ok(MessageTemplate::new("Not logged in").into_response())
+    }
+}
+
+#[debug_handler]
+pub async fn email_revalidation(
+    State(connection): State,
+    Extension(user): Extension>,
+    ConnectInfo(addr): ConnectInfo,
+    Query(query): Query>,
+    headers: HeaderMap,
+) -> Result<(CookieJar, impl IntoResponse)> {
+    let mut jar = CookieJar::from_headers(&headers);
+    if user.is_some() {
+        return Ok((
+            jar,
+            MessageTemplate::new_with_user("User already exists", user),
+        ));
+    }
+    let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
+    match query.get("validation_token") {
+        // 'validation_token' exists only when a user must validate a new email.
+        Some(token) => {
+            match connection
+                .validation(
+                    token,
+                    Duration::seconds(consts::VALIDATION_TOKEN_DURATION),
+                    &client_ip,
+                    &client_user_agent,
+                )
+                .await?
+            {
+                db::user::ValidationResult::Ok(token, user_id) => {
+                    let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
+                    jar = jar.add(cookie);
+                    let user = connection.load_user(user_id).await?;
+                    Ok((
+                        jar,
+                        MessageTemplate::new_with_user("Email validation successful", user),
+                    ))
+                }
+                db::user::ValidationResult::ValidationExpired => Ok((
+                    jar,
+                    MessageTemplate::new_with_user(
+                        "The validation has expired. Try to sign up again with the same email",
+                        user,
+                    ),
+                )),
+                db::user::ValidationResult::UnknownUser => Ok((
+                    jar,
+                    MessageTemplate::new_with_user(
+                        "Validation error. Try to sign up again with the same email",
+                        user,
+                    ),
+                )),
+            }
+        }
+        None => Ok((
+            jar,
+            MessageTemplate::new_with_user("Validation error", user),
+        )),
+    }
+}
diff --git a/backend/templates/base_with_list.html b/backend/templates/base_with_list.html
index 76d84ac..e9c4357 100644
--- a/backend/templates/base_with_list.html
+++ b/backend/templates/base_with_list.html
@@ -1,7 +1,14 @@
 {% extends "base_with_header.html" %}
 
 {% macro recipe_item(id, title, class) %}
-{{ title }}
+
+    {% if title == "" %}
+        {# TODO: Translation #}
+        No title defined
+    {% else %}
+        {{ title }}
+    {% endif %}
+
 {% endmacro %}
 
 {% block main_container %}
@@ -27,7 +34,5 @@
             {% endfor %}
         
     
-    
- {% block content %}{% endblock %} -
+ {% block content %}{% endblock %} {% endblock %} diff --git a/backend/templates/home.html b/backend/templates/home.html index ada913f..3177dab 100644 --- a/backend/templates/home.html +++ b/backend/templates/home.html @@ -2,6 +2,8 @@ {% block content %} -HOME: TODO +
+ HOME: TODO +
{% endblock %} \ No newline at end of file diff --git a/backend/templates/message.html b/backend/templates/message.html index 783e25a..d41f1da 100644 --- a/backend/templates/message.html +++ b/backend/templates/message.html @@ -2,17 +2,18 @@ {% block main_container %} -
- {% if as_code %} -

-        {% endif %}
+
+ {% if as_code %} +

+    {% endif %}
 
-        {{ message|markdown }}
+    {{ message|markdown }}
 
-        {% if as_code %}
-            
- {% endif %} -
+ {% if as_code %} +
+ {% endif %} Go to home +
+ {% endblock %} \ No newline at end of file diff --git a/backend/templates/profile.html b/backend/templates/profile.html index 49211b8..67bf5a4 100644 --- a/backend/templates/profile.html +++ b/backend/templates/profile.html @@ -5,10 +5,8 @@ {% match user %} {% when Some with (user) %} -
- +

Profile

-
diff --git a/backend/templates/recipe_edit.html b/backend/templates/recipe_edit.html index 28f5a80..0ab11ea 100644 --- a/backend/templates/recipe_edit.html +++ b/backend/templates/recipe_edit.html @@ -1,23 +1,69 @@ {% extends "base_with_list.html" %} +{% macro is_difficulty(diff) %} + {% if recipe.difficulty == diff %} + selected + {% endif %} +{% endmacro %} + {% block content %} - - - - - + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ {% endblock %} \ No newline at end of file diff --git a/backend/templates/recipe_view.html b/backend/templates/recipe_view.html index e94c38e..1be8187 100644 --- a/backend/templates/recipe_view.html +++ b/backend/templates/recipe_view.html @@ -2,17 +2,18 @@ {% block content %} -

{{ recipe.title }}

+
+

{{ recipe.title }}

+ {% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %} + Edit + {% endif %} -{% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %} - Edit -{% endif %} - -{% if !recipe.description.is_empty() %} -
- {{ recipe.description.clone()|markdown }} -
-{% endif %} + {% if !recipe.description.is_empty() %} +
+ {{ recipe.description.clone()|markdown }} +
+ {% endif %} +
{% endblock %} \ No newline at end of file diff --git a/backend/templates/reset_password.html b/backend/templates/reset_password.html index a0663fe..8df3fc9 100644 --- a/backend/templates/reset_password.html +++ b/backend/templates/reset_password.html @@ -1,20 +1,22 @@ {% extends "base_with_header.html" %} {% block main_container %} -
- - - - - - - {{ message_password }} +
+ + + - + + + + {{ message_password }} + + + + + + {{ message }} +
- - - {{ message }} -
{% endblock %} diff --git a/backend/templates/sign_in_form.html b/backend/templates/sign_in_form.html index 0393e64..76f6d5f 100644 --- a/backend/templates/sign_in_form.html +++ b/backend/templates/sign_in_form.html @@ -2,7 +2,7 @@ {% block main_container %} -
+

Sign in

diff --git a/backend/templates/sign_up_form.html b/backend/templates/sign_up_form.html index d7c5c27..b35970a 100644 --- a/backend/templates/sign_up_form.html +++ b/backend/templates/sign_up_form.html @@ -1,7 +1,8 @@ {% extends "base_with_header.html" %} {% block main_container %} -
+ +

Sign up

@@ -23,4 +24,5 @@ {{ message }}
+ {% endblock %}