Profile edit (WIP)

This commit is contained in:
Greg Burri 2024-11-15 14:47:10 +01:00
parent 405aa68526
commit 327b2d0a5b
15 changed files with 174 additions and 46 deletions

View file

@ -1,2 +1,2 @@
# To launch RUP and watching source. See https://actix.rs/docs/autoreload/. # To launch RUP and watching source. See https://actix.rs/docs/autoreload/.
cargo [watch -x run] cargo watch -x run

View file

@ -244,19 +244,46 @@ FROM [UserLoginToken] WHERE [token] = $1
} }
pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> { 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) .bind(user_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn set_user_name(&self, user_id: i64, name: &str) -> Result<()> { pub async fn update_user(
sqlx::query("UPDATE [User] SET [name] = $2 WHERE [id] = $1") &self,
.bind(user_id) user_id: i64,
.bind(name) new_email: Option<&str>,
.execute(&self.pool) new_name: Option<&str>,
.await?; 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(()) Ok(())
} }
@ -1075,6 +1102,59 @@ VALUES (
Ok(()) 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] #[tokio::test]
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?;

View file

@ -28,6 +28,7 @@ impl FromRow<'_, SqliteRow> for model::User {
Ok(model::User { Ok(model::User {
id: row.try_get("id")?, id: row.try_get("id")?,
email: row.try_get("email")?, email: row.try_get("email")?,
name: row.try_get("name")?,
}) })
} }
} }

View file

@ -95,9 +95,12 @@ async fn main() {
"/reset_password", "/reset_password",
get(services::reset_password_get).post(services::reset_password_post), get(services::reset_password_get).post(services::reset_password_post),
) )
// Recipes.
.route("/recipe/view/:id", get(services::view_recipe)) .route("/recipe/view/:id", get(services::view_recipe))
// User.
.route("/user/edit", get(services::edit_user))
// RON API. // 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()) .layer(TraceLayer::new_for_http())
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),

View file

@ -3,6 +3,7 @@ use chrono::prelude::*;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct User { pub struct User {
pub id: i64, pub id: i64,
pub name: String,
pub email: String, pub email: String,
} }

View file

@ -16,7 +16,7 @@ use tracing::{event, Level};
use crate::{config::Config, consts, data::db, email, model, utils, AppState}; 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 { impl axum::response::IntoResponse for db::DBError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
@ -722,12 +722,26 @@ pub async fn reset_password_post(
///// EDIT PROFILE ///// ///// EDIT PROFILE /////
#[derive(Template)]
#[template(path = "profile.html")]
struct ProfileTemplate {
user: Option<model::User>,
}
#[debug_handler] #[debug_handler]
pub async fn edit_user( pub async fn edit_user(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> { ) -> Response {
Ok("todo") if user.is_some() {
ProfileTemplate { user }.into_response()
} else {
MessageTemplate {
user: None,
message: "Not logged in",
}
.into_response()
}
} }
///// 404 ///// ///// 404 /////

View file

@ -63,13 +63,13 @@ use crate::{
}; };
#[debug_handler] #[debug_handler]
pub async fn set_user_name( pub async fn update_user(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetProfileName>, ExtractRon(ron): ExtractRon<common::ron_api::UpdateProfile>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
if let Some(user) = user { if let Some(user) = user {
connection.set_user_name(user.id, &ron.name).await?; // connection.set_user_name(user.id, &ron.name).await?;
} else { } else {
return Err(ErrorResponse::from(ron_error( return Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,

View file

@ -4,7 +4,7 @@
<div class="content"> <div class="content">
<form action="/ask_reset_password" method="post"> <form action="/ask_reset_password" method="post">
<label for="email_field">Your email address</label> <label for="email_field">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 }} {{ message_email }}
<input type="submit" name="commit" value="Ask reset" /> <input type="submit" name="commit" value="Ask reset" />

View file

@ -1,13 +1,35 @@
{% extends "base_with_list.html" %} {% 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 %}
{% 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" />
{% endblock %} {% endblock %}

View file

@ -4,7 +4,7 @@
<div class="content"> <div class="content">
<form action="/signin" method="post"> <form action="/signin" method="post">
<label for="email_field">Email address</label> <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> <label for="password_field">Password</label>
<input id="password_field" type="password" name="password" autocomplete="current-password" /> <input id="password_field" type="password" name="password" autocomplete="current-password" />

View file

@ -4,7 +4,7 @@
<div class="content"> <div class="content">
<form action="/signup" method="post"> <form action="/signup" method="post">
<label for="email_field">Your email address</label> <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 }} {{ message_email }}
<label for="password_field_1">Choose a password (minimum 8 characters)</label> <label for="password_field_1">Choose a password (minimum 8 characters)</label>

View file

@ -97,6 +97,8 @@ pub struct RemoveRecipeStep {
///// PROFILE ///// ///// PROFILE /////
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct SetProfileName { pub struct UpdateProfile {
pub name: String, pub name: Option<String>,
pub email: Option<String>,
pub password: Option<String>,
} }

View file

@ -15,13 +15,15 @@ common = { path = "../common" }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = [ web-sys = { version = "0.3", features = [
'console', "console",
'Document', "Document",
'Element', "Element",
'HtmlElement', "HtmlElement",
'Node', "Node",
'Window', "Window",
'Location', "Location",
"EventTarget",
"HtmlLabelElement",
] } ] }
# The `console_error_panic_hook` crate provides better debugging of panics by # 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. # code size when deploying.
console_error_panic_hook = { version = "0.1", optional = true } 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] # [dev-dependencies]
# wasm-bindgen-test = "0.3" # wasm-bindgen-test = "0.3"

View file

@ -1,6 +1,10 @@
use wasm_bindgen::prelude::*; 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(); 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::<>()
}

View file

@ -39,7 +39,11 @@ pub fn main() -> Result<(), JsValue> {
let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap. let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
console_log!("recipe edit ID: {}", id); console_log!("recipe edit ID: {}", id);
handles::edit_recipe(&document); handles::recipe_edit(&document);
}
["user", "edit"] => {
handles::user_edit(&document);
} }
_ => (), _ => (),
} }