Recipe edit (WIP)

This commit is contained in:
Greg Burri 2024-12-21 23:13:06 +01:00
parent fce4eade73
commit c6dfff065c
24 changed files with 1157 additions and 971 deletions

32
Cargo.lock generated
View file

@ -391,9 +391,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.4" version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e"
dependencies = [ dependencies = [
"shlex", "shlex",
] ]
@ -1534,9 +1534,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.168" version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]] [[package]]
name = "libm" name = "libm"
@ -1748,9 +1748,9 @@ dependencies = [
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.5" version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@ -2006,7 +2006,7 @@ dependencies = [
"ron", "ron",
"serde", "serde",
"sqlx", "sqlx",
"thiserror 2.0.8", "thiserror 2.0.9",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
@ -2225,9 +2225,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.133" version = "1.0.134"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -2672,11 +2672,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.8" version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a" checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
dependencies = [ dependencies = [
"thiserror-impl 2.0.8", "thiserror-impl 2.0.9",
] ]
[[package]] [[package]]
@ -2692,9 +2692,9 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.8" version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943" checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2754,9 +2754,9 @@ dependencies = [
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.8.0" version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8"
dependencies = [ dependencies = [
"tinyvec_macros", "tinyvec_macros",
] ]

View file

@ -3,7 +3,7 @@ VALUES (
1, 1,
'paul@atreides.com', 'paul@atreides.com',
'Paul', 'Paul',
'$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', '$argon2id$v=19$m=4096,t=4,p=2$l1fAMRc0VfkNzqpEfFEReg$/gsUsY2aML8EbKjPeCxucenxkxhiFSXDmizWZPLvNuo',
0, 0,
NULL NULL
); );

View file

@ -49,7 +49,7 @@ CREATE TABLE [Recipe] (
[title] TEXT NOT NULL, [title] TEXT NOT NULL,
-- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
[lang] TEXT NOT NULL DEFAULT 'en', [lang] TEXT NOT NULL DEFAULT 'en',
[estimate_time] INTEGER, -- in [s]. [estimated_time] INTEGER, -- in [s].
[description] TEXT NOT NULL DEFAULT '', [description] TEXT NOT NULL DEFAULT '',
[difficulty] INTEGER NOT NULL DEFAULT 0, [difficulty] INTEGER NOT NULL DEFAULT 0,
[servings] INTEGER DEFAULT 4, [servings] INTEGER DEFAULT 4,
@ -93,7 +93,7 @@ CREATE TABLE [Ingredient] (
[id] INTEGER PRIMARY KEY, [id] INTEGER PRIMARY KEY,
[name] TEXT NOT NULL, [name] TEXT NOT NULL,
[comment] TEXT NOT NULL DEFAULT '', [comment] TEXT NOT NULL DEFAULT '',
[quantity_value] REAL, [quantity_value] INTEGER,
[quantity_unit] TEXT NOT NULL DEFAULT '', [quantity_unit] TEXT NOT NULL DEFAULT '',
[input_step_id] INTEGER NOT NULL, [input_step_id] INTEGER NOT NULL,

View file

@ -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 REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse proxy (Nginx).
pub const MAX_DB_CONNECTION: u32 = 1024; pub const MAX_DB_CONNECTION: u32 = 1024;
pub const LANGUAGES: [(&'static str, &'static str); 2] = [("Français", "fr"), ("English", "en")];

View file

@ -13,7 +13,6 @@ use sqlx::{
use thiserror::Error; use thiserror::Error;
use tracing::{event, Level}; use tracing::{event, Level};
use super::model;
use crate::consts; use crate::consts;
pub mod recipe; pub mod recipe;
@ -32,6 +31,9 @@ pub enum DBError {
)] )]
UnsupportedVersion(u32), UnsupportedVersion(u32),
#[error("Unknown language: {0}")]
UnknownLanguage(String),
#[error("Unknown error: {0}")] #[error("Unknown error: {0}")]
Other(String), Other(String),
} }

View file

@ -1,4 +1,8 @@
use super::{model, Connection, DBError, Result}; use super::{Connection, DBError, Result};
use crate::{
consts,
data::model::{self, Difficulty},
};
impl Connection { impl Connection {
pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> { pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> {
@ -11,7 +15,10 @@ impl Connection {
pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> { pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
sqlx::query_as( sqlx::query_as(
r#" 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 FROM [Recipe] WHERE [id] = $1
"#, "#,
) )
@ -24,6 +31,7 @@ FROM [Recipe] WHERE [id] = $1
pub async fn create_recipe(&self, user_id: i64) -> Result<i64> { pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
let mut tx = self.tx().await?; 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>( match sqlx::query_scalar::<_, i64>(
r#" r#"
SELECT [Recipe].[id] FROM [Recipe] 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] LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
WHERE [Recipe].[user_id] = $1 WHERE [Recipe].[user_id] = $1
AND [Recipe].[title] = '' AND [Recipe].[title] = ''
AND [Recipe].[estimate_time] IS NULL AND [Recipe].[estimated_time] IS NULL
AND [Recipe].[description] = '' AND [Recipe].[description] = ''
AND [Image].[id] IS NULL AND [Image].[id] IS NULL
AND [Group].[id] IS NULL AND [Group].[id] IS NULL
@ -74,6 +82,57 @@ WHERE [Recipe].[user_id] = $1
.map(|_| ()) .map(|_| ())
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn set_recipe_estimated_time(
&self,
recipe_id: i64,
estimated_time: Option<u32>,
) -> 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)] #[cfg(test)]
@ -84,6 +143,69 @@ mod tests {
async fn create_a_new_recipe_then_update_its_title() -> Result<()> { async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
let connection = Connection::new_in_memory().await?; 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<i64> {
let user_id = 1;
connection.execute_sql( connection.execute_sql(
sqlx::query( sqlx::query(
r#" r#"
@ -93,33 +215,13 @@ VALUES
($1, $2, $3, $4, $5, $6) ($1, $2, $3, $4, $5, $6)
"# "#
) )
.bind(1) .bind(user_id)
.bind("paul@atreides.com") .bind("paul@atreides.com")
.bind("paul") .bind("paul")
.bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc") .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
.bind("2022-11-29 22:05:04.121407300+00:00") .bind("2022-11-29 22:05:04.121407300+00:00")
.bind(None::<&str>) // 'null'. .bind(None::<&str>) // 'null'.
).await?; ).await?;
Ok(user_id)
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(())
} }
} }

View file

@ -2,9 +2,10 @@ use chrono::{prelude::*, Duration};
use rand::distributions::{Alphanumeric, DistString}; use rand::distributions::{Alphanumeric, DistString};
use sqlx::Sqlite; use sqlx::Sqlite;
use super::{model, Connection, DBError, Result}; use super::{Connection, DBError, Result};
use crate::{ use crate::{
consts, consts,
data::model,
hash::{hash, verify_password}, hash::{hash, verify_password},
}; };

View file

@ -1,3 +1,2 @@
pub mod db; pub mod db;
pub mod model; pub mod model;
mod utils;

View file

@ -1,77 +1,78 @@
use chrono::prelude::*; use chrono::prelude::*;
use sqlx::{self, FromRow};
#[derive(Debug, Clone)] #[derive(Debug, Clone, FromRow)]
pub struct User { pub struct User {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
pub email: String, pub email: String,
} }
#[derive(FromRow)]
pub struct UserLoginInfo { pub struct UserLoginInfo {
pub last_login_datetime: DateTime<Utc>, pub last_login_datetime: DateTime<Utc>,
pub ip: String, pub ip: String,
pub user_agent: String, pub user_agent: String,
} }
#[derive(FromRow)]
pub struct Recipe { pub struct Recipe {
pub id: i64, pub id: i64,
pub user_id: i64, pub user_id: i64,
pub title: String, pub title: String,
pub description: String,
pub estimate_time: Option<i32>, // [s].
pub difficulty: Difficulty,
pub lang: String, pub lang: String,
pub estimated_time: Option<u32>, // [s].
pub description: String,
//ingredients: Vec<Ingredient>, // For four people. #[sqlx(try_from = "u32")]
pub process: Vec<Group>, pub difficulty: Difficulty,
}
impl Recipe { pub servings: u32,
pub fn empty(id: i64, user_id: i64) -> Recipe { pub is_published: bool,
Self::new(id, user_id, String::new(), String::new()) // pub tags: Vec<String>,
} // pub groups: Vec<Group>,
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<Quantity>,
pub name: String,
}
pub struct Quantity {
pub value: f32,
pub unit: String,
} }
pub struct Group { pub struct Group {
pub name: Option<String>, pub name: String,
pub input: Vec<StepInput>, pub comment: String,
pub steps: Vec<Step>, pub steps: Vec<Step>,
} }
pub struct Step { pub struct Step {
pub action: String, pub action: String,
pub ingredients: Vec<Ingredient>,
} }
pub enum StepInput { pub struct Ingredient {
Ingredient(Ingredient), pub name: String,
pub comment: String,
pub quantity: i32,
pub quantity_unit: String,
} }
#[derive(PartialEq, Debug)]
pub enum Difficulty { pub enum Difficulty {
Unknown = 0, Unknown = 0,
Easy = 1, Easy = 1,
Medium = 2, Medium = 2,
Hard = 3, Hard = 3,
} }
impl TryFrom<u32> for Difficulty {
type Error = &'static str;
fn try_from(value: u32) -> Result<Self, Self::Error> {
Ok(match value {
1 => Self::Easy,
2 => Self::Medium,
3 => Self::Hard,
_ => Self::Unknown,
})
}
}
impl From<Difficulty> for u32 {
fn from(value: Difficulty) -> Self {
value as u32
}
}

View file

@ -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<Self> {
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<Self> {
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<Self> {
Ok(model::User {
id: row.try_get("id")?,
email: row.try_get("email")?,
name: row.try_get("name")?,
})
}
}

View file

@ -18,6 +18,7 @@ pub struct HomeTemplate {
#[template(path = "message.html")] #[template(path = "message.html")]
pub struct MessageTemplate { pub struct MessageTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub message: String, pub message: String,
pub as_code: bool, // Display the message in <pre> markup. pub as_code: bool, // Display the message in <pre> markup.
} }
@ -44,6 +45,7 @@ impl MessageTemplate {
#[template(path = "sign_up_form.html")] #[template(path = "sign_up_form.html")]
pub struct SignUpFormTemplate { pub struct SignUpFormTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub email: String, pub email: String,
pub message: String, pub message: String,
pub message_email: String, pub message_email: String,
@ -54,6 +56,7 @@ pub struct SignUpFormTemplate {
#[template(path = "sign_in_form.html")] #[template(path = "sign_in_form.html")]
pub struct SignInFormTemplate { pub struct SignInFormTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub email: String, pub email: String,
pub message: String, pub message: String,
} }
@ -62,6 +65,7 @@ pub struct SignInFormTemplate {
#[template(path = "ask_reset_password.html")] #[template(path = "ask_reset_password.html")]
pub struct AskResetPasswordTemplate { pub struct AskResetPasswordTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub email: String, pub email: String,
pub message: String, pub message: String,
pub message_email: String, pub message_email: String,
@ -71,6 +75,7 @@ pub struct AskResetPasswordTemplate {
#[template(path = "reset_password.html")] #[template(path = "reset_password.html")]
pub struct ResetPasswordTemplate { pub struct ResetPasswordTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub reset_token: String, pub reset_token: String,
pub message: String, pub message: String,
pub message_password: String, pub message_password: String,
@ -80,6 +85,7 @@ pub struct ResetPasswordTemplate {
#[template(path = "profile.html")] #[template(path = "profile.html")]
pub struct ProfileTemplate { pub struct ProfileTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub username: String, pub username: String,
pub email: String, pub email: String,
pub message: String, pub message: String,
@ -92,6 +98,7 @@ pub struct ProfileTemplate {
pub struct RecipeViewTemplate { pub struct RecipeViewTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub recipes: Recipes, pub recipes: Recipes,
pub recipe: model::Recipe, pub recipe: model::Recipe,
} }
@ -100,5 +107,7 @@ pub struct RecipeViewTemplate {
pub struct RecipeEditTemplate { pub struct RecipeEditTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub recipes: Recipes, pub recipes: Recipes,
pub recipe: model::Recipe, pub recipe: model::Recipe,
pub languages: [(&'static str, &'static str); 2],
} }

View file

@ -92,31 +92,32 @@ async fn main() {
.route("/", get(services::home_page)) .route("/", get(services::home_page))
.route( .route(
"/signup", "/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("/validation", get(services::user::sign_up_validation))
.route("/revalidation", get(services::email_revalidation)) .route("/revalidation", get(services::user::email_revalidation))
.route( .route(
"/signin", "/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( .route(
"/ask_reset_password", "/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( .route(
"/reset_password", "/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. // Recipes.
.route("/recipe/new", get(services::create_recipe)) .route("/recipe/new", get(services::recipe::create))
// .route("/recipe/edit/:id", get(services::edit_recipe)) .route("/recipe/edit/:id", get(services::recipe::edit_recipe))
.route("/recipe/view/:id", get(services::view_recipe)) .route("/recipe/view/:id", get(services::recipe::view))
// User. // User.
.route( .route(
"/user/edit", "/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)); .route_layer(middleware::from_fn(services::ron_error_to_html));
@ -233,6 +234,11 @@ async fn process_args() -> bool {
) )
.await .await
.unwrap(); .unwrap();
event!(
Level::INFO,
"A new test database has been created successfully"
);
} }
Err(error) => { Err(error) => {
event!(Level::ERROR, "{}", error); event!(Level::ERROR, "{}", error);

View file

@ -1,29 +1,21 @@
use std::{collections::HashMap, net::SocketAddr};
use axum::{ use axum::{
body::{self, Body}, body, debug_handler,
debug_handler, extract::{Extension, Request, State},
extract::{ConnectInfo, Extension, Host, Path, Query, Request, State}, http::header,
http::{header, HeaderMap},
middleware::Next, middleware::Next,
response::{IntoResponse, Redirect, Response, Result}, response::{IntoResponse, Response, Result},
Form,
}; };
use axum_extra::extract::cookie::{Cookie, CookieJar}; // use tracing::{event, Level};
use chrono::Duration;
use serde::Deserialize;
use tracing::{event, Level};
use crate::{ use crate::{
config::Config,
consts,
data::{db, model}, data::{db, model},
email,
html_templates::*, html_templates::*,
ron_utils, utils, AppState, ron_utils,
}; };
pub mod recipe;
pub mod ron; pub mod ron;
pub mod user;
// Will embed RON error in HTML page. // Will embed RON error in HTML page.
pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> { pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
@ -61,774 +53,12 @@ pub async fn home_page(
recipes: Recipes { recipes: Recipes {
list: recipes, list: recipes,
current_id: None, current_id: None,
}, // current_recipe_id: None,
// recipes,
})
}
///// RECIPE /////
#[debug_handler]
pub async fn create_recipe(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
) -> Result<Response> {
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<db::Connection>,
// Extension(user): Extension<Option<model::User>>,
// Path(recipe_id): Path<i64>,
// ) -> Result<Response> {
// 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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
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<Option<model::User>>,
) -> Result<impl IntoResponse> {
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<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<SignUpFormData>,
) -> Result<Response> {
fn error_response(
error: SignUpError,
form_data: &SignUpFormData,
user: Option<model::User>,
) -> Result<Response> {
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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
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<Option<model::User>>,
) -> Result<impl IntoResponse> {
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<SocketAddr>,
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
headers: HeaderMap,
Form(form_data): Form<SignInFormData>,
) -> 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<db::Connection>,
req: Request<Body>,
) -> 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<Option<model::User>>,
) -> Result<Response> {
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<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<AskResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: AskResetPasswordError,
email: &str,
user: Option<model::User>,
) -> Result<Response> {
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<Option<model::User>>,
Query(query): Query<HashMap<String, String>>,
) -> Result<Response> {
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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<ResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: ResetPasswordError,
form_data: &ResetPasswordForm,
user: Option<model::User>,
) -> Result<Response> {
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<Option<model::User>>) -> 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<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<EditUserForm>,
) -> Result<Response> {
if let Some(user) = user {
fn error_response(
error: ProfileUpdateError,
form_data: &EditUserForm,
user: model::User,
) -> Result<Response> {
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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
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 ///// ///// 404 /////
#[debug_handler] #[debug_handler]
pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse { pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
MessageTemplate::new_with_user("404: Not found", user) MessageTemplate::new_with_user("404: Not found", user)

View file

@ -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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
) -> Result<Response> {
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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
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()),
}
}

View file

@ -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<Option<model::User>>,
) -> Result<impl IntoResponse> {
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<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<SignUpFormData>,
) -> Result<Response> {
fn error_response(
error: SignUpError,
form_data: &SignUpFormData,
user: Option<model::User>,
) -> Result<Response> {
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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
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<Option<model::User>>,
) -> Result<impl IntoResponse> {
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<SocketAddr>,
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
headers: HeaderMap,
Form(form_data): Form<SignInFormData>,
) -> 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<db::Connection>,
req: Request<Body>,
) -> 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<Option<model::User>>,
) -> Result<Response> {
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<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<AskResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: AskResetPasswordError,
email: &str,
user: Option<model::User>,
) -> Result<Response> {
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<Option<model::User>>,
Query(query): Query<HashMap<String, String>>,
) -> Result<Response> {
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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<ResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: ResetPasswordError,
form_data: &ResetPasswordForm,
user: Option<model::User>,
) -> Result<Response> {
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<Option<model::User>>) -> 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<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<EditUserForm>,
) -> Result<Response> {
if let Some(user) = user {
fn error_response(
error: ProfileUpdateError,
form_data: &EditUserForm,
user: model::User,
) -> Result<Response> {
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<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
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),
)),
}
}

View file

@ -1,7 +1,14 @@
{% extends "base_with_header.html" %} {% extends "base_with_header.html" %}
{% macro recipe_item(id, title, class) %} {% macro recipe_item(id, title, class) %}
<a href="/recipe/view/{{ id }}" class="{{ class }}">{{ title }}</a> <a href="/recipe/view/{{ id }}" class="{{ class }}">
{% if title == "" %}
{# TODO: Translation #}
No title defined
{% else %}
{{ title }}
{% endif %}
</a>
{% endmacro %} {% endmacro %}
{% block main_container %} {% block main_container %}
@ -27,7 +34,5 @@
{% endfor %} {% endfor %}
</ul> </ul>
</nav> </nav>
<div class="content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div>
{% endblock %} {% endblock %}

View file

@ -2,6 +2,8 @@
{% block content %} {% block content %}
<div class="content" id="home">
HOME: TODO HOME: TODO
</div>
{% endblock %} {% endblock %}

View file

@ -2,7 +2,7 @@
{% block main_container %} {% block main_container %}
<div class="message"> <div class="content" id="message">
{% if as_code %} {% if as_code %}
<pre><code> <pre><code>
{% endif %} {% endif %}
@ -12,7 +12,8 @@
{% if as_code %} {% if as_code %}
</code></pre> </code></pre>
{% endif %} {% endif %}
</div>
<a href="/">Go to home</a> <a href="/">Go to home</a>
</div>
{% endblock %} {% endblock %}

View file

@ -5,10 +5,8 @@
{% match user %} {% match user %}
{% when Some with (user) %} {% when Some with (user) %}
<div class="content"> <div class="content" id="user-edit">
<h1>Profile</h1> <h1>Profile</h1>
<form action="/user/edit" method="post"> <form action="/user/edit" method="post">
<label for="input-name">Name</label> <label for="input-name">Name</label>

View file

@ -1,23 +1,69 @@
{% extends "base_with_list.html" %} {% extends "base_with_list.html" %}
{% macro is_difficulty(diff) %}
{% if recipe.difficulty == diff %}
selected
{% endif %}
{% endmacro %}
{% block content %} {% block content %}
<label for="title_field">Title</label>
<div class="content" id="recipe-edit">
<label for="input-title">Title</label>
<input <input
id="title_field" id="input-title"
type="text" type="text"
name="title" name="title"
value="{{ recipe.title }}" value="{{ recipe.title }}"
autocapitalize="none"
autocomplete="title" autocomplete="title"
autofocus="autofocus" /> autofocus="true" />
<label for="description_field">Description</label> <label for="input-description">Description</label>
<input <input
id="title_field" id="input-description"
type="text" type="text"
name="title" name="description"
value="{{ recipe.description }}" value="{{ recipe.description }}"
autocapitalize="none" autocomplete="title" />
autocomplete="title"
autofocus="autofocus" /> <label for="input-description">Estimated time</label>
<input
id="input-estimated-time"
type="number"
name="estimated-time"
value="
{% match recipe.estimated_time %}
{% when Some with (t) %}
{{ t }}
{% when None %}
0
{% endmatch %}"
autocomplete="title" />
<label for="select-difficulty">Difficulty</label>
<select id="select-difficulty" name="difficulty">
<option value="0" {%+ call is_difficulty(crate::data::model::Difficulty::Unknown) %}> - </option>
<option value="1" {%+ call is_difficulty(crate::data::model::Difficulty::Easy) %}>Easy</option>
<option value="2" {%+ call is_difficulty(crate::data::model::Difficulty::Medium) %}>Medium</option>
<option value="3" {%+ call is_difficulty(crate::data::model::Difficulty::Hard) %}>Hard</option>
</select>
<label for="select-language">Language</label>
<select id="select-language" name="language">
{% for lang in languages %}
<option value="{{ lang.1 }}">{{ lang.0 }}</option>
{% endfor %}
</select>
<input
id="input-is-published"
type="checkbox"
name="is-published"
value="{{ recipe.is_published }}" />
<label for="input-is-published">Is published</label>
<div id="groups-container">
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -2,9 +2,9 @@
{% block content %} {% block content %}
<div class="content" id="recipe-view">
<h2 class="recipe-title" >{{ recipe.title }}</h2> <h2 class="recipe-title" >{{ recipe.title }}</h2>
{% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %} {% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %}
<a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a> <a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
{% endif %} {% endif %}
@ -14,5 +14,6 @@
{{ recipe.description.clone()|markdown }} {{ recipe.description.clone()|markdown }}
</div> </div>
{% endif %} {% endif %}
</div>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,8 @@
{% extends "base_with_header.html" %} {% extends "base_with_header.html" %}
{% block main_container %} {% block main_container %}
<div class="content">
<div class="content" id="reset-password">
<form action="/reset_password" method="post"> <form action="/reset_password" method="post">
<label for="password_field_1">Choose a new password (minimum 8 characters)</label> <label for="password_field_1">Choose a new password (minimum 8 characters)</label>
<input id="password_field_1" type="password" name="password_1" /> <input id="password_field_1" type="password" name="password_1" />
@ -17,4 +18,5 @@
</form> </form>
{{ message }} {{ message }}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -2,7 +2,7 @@
{% block main_container %} {% block main_container %}
<div id="sign-in" class="content"> <div class="content" id="sign-in">
<h1>Sign in</h1> <h1>Sign in</h1>

View file

@ -1,7 +1,8 @@
{% extends "base_with_header.html" %} {% extends "base_with_header.html" %}
{% block main_container %} {% block main_container %}
<div class="content">
<div class="content" id="sign-up">
<h1>Sign up</h1> <h1>Sign up</h1>
@ -23,4 +24,5 @@
</form> </form>
{{ message }} {{ message }}
</div> </div>
{% endblock %} {% endblock %}