Profile edit (WIP)
authorGreg Burri <greg.burri@gmail.com>
Fri, 15 Nov 2024 13:47:10 +0000 (14:47 +0100)
committerGreg Burri <greg.burri@gmail.com>
Fri, 15 Nov 2024 13:47:10 +0000 (14:47 +0100)
16 files changed:
backend/launch_debug.nu
backend/src/data/db.rs
backend/src/data/utils.rs
backend/src/main.rs
backend/src/model.rs
backend/src/services.rs
backend/src/services/ron.rs [new file with mode: 0644]
backend/src/services/ron_api.rs [deleted file]
backend/templates/ask_reset_password.html
backend/templates/profile.html
backend/templates/sign_in_form.html
backend/templates/sign_up_form.html
common/src/ron_api.rs
frontend/Cargo.toml
frontend/src/handles.rs
frontend/src/lib.rs

index d095b12..5e20487 100644 (file)
@@ -1,2 +1,2 @@
 # To launch RUP and watching source. See https://actix.rs/docs/autoreload/.
-cargo [watch -x run]
\ No newline at end of file
+cargo watch -x run
\ No newline at end of file
index 8fccd78..0820cb5 100644 (file)
@@ -244,19 +244,46 @@ FROM [UserLoginToken] WHERE [token] = $1
     }
 
     pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> {
-        sqlx::query_as("SELECT [id], [email] FROM [User] WHERE [id] = $1")
+        sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1")
             .bind(user_id)
             .fetch_optional(&self.pool)
             .await
             .map_err(DBError::from)
     }
 
-    pub async fn set_user_name(&self, user_id: i64, name: &str) -> Result<()> {
-        sqlx::query("UPDATE [User] SET [name] = $2 WHERE [id] = $1")
-            .bind(user_id)
-            .bind(name)
-            .execute(&self.pool)
-            .await?;
+    pub async fn update_user(
+        &self,
+        user_id: i64,
+        new_email: Option<&str>,
+        new_name: Option<&str>,
+        new_password: Option<&str>,
+    ) -> Result<()> {
+        let mut tx = self.tx().await?;
+        let hashed_new_password = new_password.map(|p| hash(p).unwrap());
+
+        let (email, name, password) = sqlx::query_as::<_, (String, String, String)>(
+            "SELECT [email], [name], [password] FROM [User] WHERE [id] = $1",
+        )
+        .bind(user_id)
+        .fetch_one(&mut *tx)
+        .await?;
+
+        sqlx::query(
+            r#"
+UPDATE [User]
+SET [email] = $2, [name] = $3, [password] = $4
+WHERE [id] = $1
+            "#,
+        )
+        .bind(user_id)
+        .bind(new_email.unwrap_or(&email))
+        .bind(new_name.unwrap_or(&name))
+        .bind(hashed_new_password.unwrap_or(password))
+        .execute(&mut *tx)
+        .await?;
+
+        tx.commit().await?;
+
         Ok(())
     }
 
@@ -1075,6 +1102,59 @@ VALUES (
         Ok(())
     }
 
+    #[tokio::test]
+    async fn update_user() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        connection.execute_sql(
+            sqlx::query(
+            r#"
+INSERT INTO [User]
+    ([id], [email], [name], [password], [creation_datetime], [validation_token])
+VALUES
+    ($1, $2, $3, $4, $5, $6)
+            "#
+            )
+            .bind(1)
+            .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?;
+
+        let user = connection.load_user(1).await?.unwrap();
+
+        assert_eq!(user.name, "paul");
+        assert_eq!(user.email, "paul@atreides.com");
+
+        connection
+            .update_user(
+                1,
+                Some("muaddib@fremen.com"),
+                Some("muaddib"),
+                Some("Chani"),
+            )
+            .await?;
+
+        let user = connection.load_user(1).await?.unwrap();
+
+        assert_eq!(user.name, "muaddib");
+        assert_eq!(user.email, "muaddib@fremen.com");
+
+        // Tets if password has been updated correctly.
+        if let SignInResult::Ok(_token, id) = connection
+            .sign_in("muaddib@fremen.com", "Chani", "127.0.0.1", "Mozilla/5.0")
+            .await?
+        {
+            assert_eq!(id, 1);
+        } else {
+            panic!("Can't sign in");
+        }
+
+        Ok(())
+    }
+
     #[tokio::test]
     async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
         let connection = Connection::new_in_memory().await?;
index c32cfa2..a692892 100644 (file)
@@ -28,6 +28,7 @@ impl FromRow<'_, SqliteRow> for model::User {
         Ok(model::User {
             id: row.try_get("id")?,
             email: row.try_get("email")?,
+            name: row.try_get("name")?,
         })
     }
 }
index 2394670..74cc54c 100644 (file)
@@ -95,9 +95,12 @@ async fn main() {
             "/reset_password",
             get(services::reset_password_get).post(services::reset_password_post),
         )
+        // Recipes.
         .route("/recipe/view/:id", get(services::view_recipe))
+        // User.
+        .route("/user/edit", get(services::edit_user))
         // RON API.
-        .route("/user/set_name", put(services::ron_api::set_user_name))
+        .route("/user/set_name", put(services::ron::update_user))
         .layer(TraceLayer::new_for_http())
         .route_layer(middleware::from_fn_with_state(
             state.clone(),
index 011cab2..2cf53e8 100644 (file)
@@ -3,6 +3,7 @@ use chrono::prelude::*;
 #[derive(Debug, Clone)]\r
 pub struct User {\r
     pub id: i64,\r
+    pub name: String,\r
     pub email: String,\r
 }\r
 \r
index eee0117..7f9e75a 100644 (file)
@@ -16,7 +16,7 @@ use tracing::{event, Level};
 
 use crate::{config::Config, consts, data::db, email, model, utils, AppState};
 
-pub mod ron_api;
+pub mod ron;
 
 impl axum::response::IntoResponse for db::DBError {
     fn into_response(self) -> Response {
@@ -722,12 +722,26 @@ pub async fn reset_password_post(
 
 ///// EDIT PROFILE /////
 
+#[derive(Template)]
+#[template(path = "profile.html")]
+struct ProfileTemplate {
+    user: Option<model::User>,
+}
+
 #[debug_handler]
 pub async fn edit_user(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
-) -> Result<impl IntoResponse> {
-    Ok("todo")
+) -> Response {
+    if user.is_some() {
+        ProfileTemplate { user }.into_response()
+    } else {
+        MessageTemplate {
+            user: None,
+            message: "Not logged in",
+        }
+        .into_response()
+    }
 }
 
 ///// 404 /////
diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs
new file mode 100644 (file)
index 0000000..7592cb1
--- /dev/null
@@ -0,0 +1,95 @@
+// use actix_web::{
+//     http::{header, header::ContentType, StatusCode},
+//     post, put, web, HttpMessage, HttpRequest, HttpResponse, Responder,
+// };
+// use log::{debug, error, info, log_enabled, Level};
+// use ron::de::from_bytes;
+
+// use super::Result;
+// use crate::data::db;
+
+// #[put("/ron-api/recipe/set-title")]
+// pub async fn set_recipe_title(
+//     req: HttpRequest,
+//     body: web::Bytes,
+//     connection: web::Data<db::Connection>,
+// ) -> Result<HttpResponse> {
+//     let ron_req: common::ron_api::SetRecipeTitle = from_bytes(&body)?;
+//     connection
+//         .set_recipe_title_async(ron_req.recipe_id, &ron_req.title)
+//         .await?;
+//     Ok(HttpResponse::Ok().finish())
+// }
+
+// #[put("/ron-api/recipe/set-description")]
+// pub async fn set_recipe_description(
+//     req: HttpRequest,
+//     body: web::Bytes,
+//     connection: web::Data<db::Connection>,
+// ) -> Result<HttpResponse> {
+//     let ron_req: common::ron_api::SetRecipeDescription = from_bytes(&body)?;
+//     connection
+//         .set_recipe_description_async(ron_req.recipe_id, &ron_req.description)
+//         .await?;
+//     Ok(HttpResponse::Ok().finish())
+// }
+
+// #[put("/ron-api/recipe/add-image)]
+// #[put("/ron-api/recipe/rm-photo")]
+// #[put("/ron-api/recipe/add-ingredient")]
+// #[put("/ron-api/recipe/rm-ingredient")]
+// #[put("/ron-api/recipe/set-ingredients-order")]
+// #[put("/ron-api/recipe/add-group")]
+// #[put("/ron-api/recipe/rm-group")]
+// #[put("/ron-api/recipe/set-groups-order")]
+// #[put("/ron-api/recipe/add-step")]
+// #[put("/ron-api/recipe/rm-step")]
+// #[put("/ron-api/recipe/set-steps-order")]
+
+use askama_axum::IntoResponse;
+use axum::{
+    debug_handler,
+    extract::{Extension, State},
+    http::StatusCode,
+    response::ErrorResponse,
+    response::Result,
+};
+use tracing::{event, Level};
+
+use crate::{
+    data::db,
+    model,
+    ron_extractor::{ron_error, ron_response, ExtractRon},
+};
+
+#[debug_handler]
+pub async fn update_user(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::UpdateProfile>,
+) -> Result<StatusCode> {
+    if let Some(user) = user {
+        // connection.set_user_name(user.id, &ron.name).await?;
+    } else {
+        return Err(ErrorResponse::from(ron_error(
+            StatusCode::UNAUTHORIZED,
+            "Action not authorized",
+        )));
+    }
+    Ok(StatusCode::OK)
+}
+
+/* Example with RON return value.
+#[debug_handler]
+pub async fn set_user_name(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetProfileName>,
+) -> Result<impl IntoResponse> {
+
+
+    Ok(ron_response(common::ron_api::SetProfileName {
+        name: "abc".to_string(),
+    }))
+}
+*/
diff --git a/backend/src/services/ron_api.rs b/backend/src/services/ron_api.rs
deleted file mode 100644 (file)
index b92b943..0000000
+++ /dev/null
@@ -1,95 +0,0 @@
-// use actix_web::{
-//     http::{header, header::ContentType, StatusCode},
-//     post, put, web, HttpMessage, HttpRequest, HttpResponse, Responder,
-// };
-// use log::{debug, error, info, log_enabled, Level};
-// use ron::de::from_bytes;
-
-// use super::Result;
-// use crate::data::db;
-
-// #[put("/ron-api/recipe/set-title")]
-// pub async fn set_recipe_title(
-//     req: HttpRequest,
-//     body: web::Bytes,
-//     connection: web::Data<db::Connection>,
-// ) -> Result<HttpResponse> {
-//     let ron_req: common::ron_api::SetRecipeTitle = from_bytes(&body)?;
-//     connection
-//         .set_recipe_title_async(ron_req.recipe_id, &ron_req.title)
-//         .await?;
-//     Ok(HttpResponse::Ok().finish())
-// }
-
-// #[put("/ron-api/recipe/set-description")]
-// pub async fn set_recipe_description(
-//     req: HttpRequest,
-//     body: web::Bytes,
-//     connection: web::Data<db::Connection>,
-// ) -> Result<HttpResponse> {
-//     let ron_req: common::ron_api::SetRecipeDescription = from_bytes(&body)?;
-//     connection
-//         .set_recipe_description_async(ron_req.recipe_id, &ron_req.description)
-//         .await?;
-//     Ok(HttpResponse::Ok().finish())
-// }
-
-// #[put("/ron-api/recipe/add-image)]
-// #[put("/ron-api/recipe/rm-photo")]
-// #[put("/ron-api/recipe/add-ingredient")]
-// #[put("/ron-api/recipe/rm-ingredient")]
-// #[put("/ron-api/recipe/set-ingredients-order")]
-// #[put("/ron-api/recipe/add-group")]
-// #[put("/ron-api/recipe/rm-group")]
-// #[put("/ron-api/recipe/set-groups-order")]
-// #[put("/ron-api/recipe/add-step")]
-// #[put("/ron-api/recipe/rm-step")]
-// #[put("/ron-api/recipe/set-steps-order")]
-
-use askama_axum::IntoResponse;
-use axum::{
-    debug_handler,
-    extract::{Extension, State},
-    http::StatusCode,
-    response::ErrorResponse,
-    response::Result,
-};
-use tracing::{event, Level};
-
-use crate::{
-    data::db,
-    model,
-    ron_extractor::{ron_error, ron_response, ExtractRon},
-};
-
-#[debug_handler]
-pub async fn set_user_name(
-    State(connection): State<db::Connection>,
-    Extension(user): Extension<Option<model::User>>,
-    ExtractRon(ron): ExtractRon<common::ron_api::SetProfileName>,
-) -> Result<StatusCode> {
-    if let Some(user) = user {
-        connection.set_user_name(user.id, &ron.name).await?;
-    } else {
-        return Err(ErrorResponse::from(ron_error(
-            StatusCode::UNAUTHORIZED,
-            "Action not authorized",
-        )));
-    }
-    Ok(StatusCode::OK)
-}
-
-/* Example with RON return value.
-#[debug_handler]
-pub async fn set_user_name(
-    State(connection): State<db::Connection>,
-    Extension(user): Extension<Option<model::User>>,
-    ExtractRon(ron): ExtractRon<common::ron_api::SetProfileName>,
-) -> Result<impl IntoResponse> {
-
-
-    Ok(ron_response(common::ron_api::SetProfileName {
-        name: "abc".to_string(),
-    }))
-}
-*/
index 4a5fc8d..246c844 100644 (file)
@@ -4,7 +4,7 @@
     <div class="content">
         <form action="/ask_reset_password" method="post">
             <label for="email_field">Your email address</label>
-            <input id="email_field" type="text" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+            <input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
             {{ message_email }}
 
             <input type="submit" name="commit" value="Ask reset" />
index 9dc573c..42647d6 100644 (file)
@@ -1,13 +1,35 @@
-{% extends "base_with_list.html" %}
-
-{% block content %}
-<label for="title_field">Name</label>
-<input
-    id="name_field"
-    type="text"
-    name="name"
-    value="{{ user.name }}"
-    autocapitalize="none"
-    autocomplete="title"
-    autofocus="autofocus" />
+{% extends "base_with_header.html" %}
+
+{% block main_container %}
+
+<h2>Profile</h2>
+
+{% match user %}
+{% when Some with (user) %}
+
+<div id="user-edit">
+
+    <label for="title_field">Name</label>
+    <input
+        id="name_field"
+        type="text"
+        name="name"
+        value="{{ user.name }}"
+        autocapitalize="none"
+        autocomplete="title"
+        autofocus="autofocus" />
+
+
+    <label for="password_field_1">New password (minimum 8 characters)</label>
+    <input id="password_field_1" type="password" name="password_1" />
+
+    <label for="password_field_1">Re-enter password</label>
+    <input id="password_field_2" type="password" name="password_2" />
+
+    <button class="button" typed="button">Save</button>
+</div>
+
+{% when None %}
+{% endmatch %}
+
 {% endblock %}
\ No newline at end of file
index 1e54353..7edd530 100644 (file)
@@ -4,7 +4,7 @@
     <div class="content">
         <form action="/signin" method="post">
             <label for="email_field">Email address</label>
-            <input id="email_field" type="text" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+            <input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
 
             <label for="password_field">Password</label>
             <input id="password_field" type="password" name="password" autocomplete="current-password" />
index 54fe200..18d6f51 100644 (file)
@@ -4,7 +4,7 @@
     <div class="content">
         <form action="/signup" method="post">
             <label for="email_field">Your email address</label>
-            <input id="email_field" type="text" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+            <input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
             {{ message_email }}
 
             <label for="password_field_1">Choose a password (minimum 8 characters)</label>
index ce7b2d1..1890c85 100644 (file)
@@ -97,6 +97,8 @@ pub struct RemoveRecipeStep {
 ///// PROFILE /////
 
 #[derive(Serialize, Deserialize, Clone)]
-pub struct SetProfileName {
-    pub name: String,
+pub struct UpdateProfile {
+    pub name: Option<String>,
+    pub email: Option<String>,
+    pub password: Option<String>,
 }
index 267ea63..e1b2e55 100644 (file)
@@ -15,13 +15,15 @@ common = { path = "../common" }
 
 wasm-bindgen = "0.2"
 web-sys = { version = "0.3", features = [
-    'console',
-    'Document',
-    'Element',
-    'HtmlElement',
-    'Node',
-    'Window',
-    'Location',
+    "console",
+    "Document",
+    "Element",
+    "HtmlElement",
+    "Node",
+    "Window",
+    "Location",
+    "EventTarget",
+    "HtmlLabelElement",
 ] }
 
 # The `console_error_panic_hook` crate provides better debugging of panics by
@@ -30,11 +32,6 @@ web-sys = { version = "0.3", features = [
 # code size when deploying.
 console_error_panic_hook = { version = "0.1", optional = true }
 
-# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size
-# compared to the default allocator's ~10K. It is slower than the default
-# allocator, however.
-# wee_alloc = { version = "0.4", optional = true }
-
 # [dev-dependencies]
 # wasm-bindgen-test = "0.3"
 
index 55a927c..354f025 100644 (file)
@@ -1,6 +1,10 @@
 use wasm_bindgen::prelude::*;
-use web_sys::Document;
+use web_sys::{Document, HtmlLabelElement};
 
-pub fn edit_recipe(doc: &Document) {
+pub fn recipe_edit(doc: &Document) {
     let title_input = doc.get_element_by_id("title_field").unwrap();
 }
+
+pub fn user_edit(doc: &Document) {
+    // let name_input = doc.get_element_by_id("name_field").unwrap().dyn_ref::<>()
+}
index ddbe956..1f53a8d 100644 (file)
@@ -39,7 +39,11 @@ pub fn main() -> Result<(), JsValue> {
             let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
             console_log!("recipe edit ID: {}", id);
 
-            handles::edit_recipe(&document);
+            handles::recipe_edit(&document);
+        }
+
+        ["user", "edit"] => {
+            handles::user_edit(&document);
         }
         _ => (),
     }