personal settings: update username, name, email

This commit is contained in:
Philippe Loctaux 2023-04-05 23:41:25 +02:00
parent 1346b57b30
commit a47e4c204a
14 changed files with 247 additions and 5 deletions

1
Cargo.lock generated
View file

@ -724,6 +724,7 @@ dependencies = [
"authorization_codes",
"base64 0.21.0",
"database_pool",
"email_address",
"erased-serde",
"futures",
"hash",

View file

@ -13,6 +13,7 @@ serde = "1"
serde_json = "1"
nanoid = "0.4"
nanoid-dictionary = "0.4"
email_address = { version = "0.2", default-features = false }
[profile.dev.package.sqlx-macros]
opt-level = 3

View file

@ -0,0 +1,5 @@
update users
set email = ?
where id is ?

View file

@ -0,0 +1,5 @@
update users
set name = ?
where id is ?

View file

@ -0,0 +1,5 @@
update users
set username = ?
where id is ?

View file

@ -268,6 +268,16 @@
},
"query": "insert into users (id, is_admin, username, password)\nvalues (?, ?, ?, ?)\n"
},
"545f19b0373c7ffe16864eb242c15a0092355e120c5cbe006c877afdfc4a4e8c": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 2
}
},
"query": "update users\n\nset username = ?\n\nwhere id is ?"
},
"56a9c0dff010858189a95087d014c7d0ce930da5d841b9d788a9c0e84b580bc6": {
"describe": {
"columns": [
@ -518,6 +528,16 @@
},
"query": "update settings\n\nset url = ?\n\nwhere id is 0\n"
},
"8c131e1f73ffa01fc3e5e08071a786b85f23b9638d1c7eaa7b633c052703c911": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 2
}
},
"query": "update users\n\nset name = ?\n\nwhere id is ?"
},
"93b15a942a6c7db595990f00e14fde26d6d36b8c8de9935179d41f6c7c755978": {
"describe": {
"columns": [],
@ -656,6 +676,16 @@
},
"query": "update refresh_tokens\n\nset revoked_at = CURRENT_TIMESTAMP\n\nwhere user is ?\n and revoked_at is null"
},
"c28c88869239edc02c073f461645eca82d816650fabe65464e2059d5908d8a28": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 2
}
},
"query": "update users\n\nset email = ?\n\nwhere id is ?"
},
"c5a57c971d07532ec0cc897b5ac06e0814e506f9c24647d1eaf44174dc0a5954": {
"describe": {
"columns": [

View file

@ -95,4 +95,44 @@ impl Users {
.await
.map_err(handle_error)
}
pub async fn set_username(
conn: impl SqliteExecutor<'_>,
id: &str,
username: &str,
) -> Result<Option<()>, Error> {
let query: SqliteQueryResult =
sqlx::query_file!("queries/users/set_username.sql", username, id)
.execute(conn)
.await
.map_err(handle_error)?;
Ok((query.rows_affected() == 1).then_some(()))
}
pub async fn set_name(
conn: impl SqliteExecutor<'_>,
id: &str,
name: &str,
) -> Result<Option<()>, Error> {
let query: SqliteQueryResult = sqlx::query_file!("queries/users/set_name.sql", name, id)
.execute(conn)
.await
.map_err(handle_error)?;
Ok((query.rows_affected() == 1).then_some(()))
}
pub async fn set_email(
conn: impl SqliteExecutor<'_>,
id: &str,
email: &str,
) -> Result<Option<()>, Error> {
let query: SqliteQueryResult = sqlx::query_file!("queries/users/set_email.sql", email, id)
.execute(conn)
.await
.map_err(handle_error)?;
Ok((query.rows_affected() == 1).then_some(()))
}
}

View file

@ -14,6 +14,7 @@ identicon-rs = "4.0"
futures = "0.3"
base64 = "0.21.0"
rocket_cors = "0.6.0-alpha2"
email_address = { workspace = true }
# local crates
database_pool = { path = "../database_pool" }

View file

@ -31,7 +31,7 @@ impl UserMenu {
icon: Icon::Settings.svg,
sub: Some(vec![SubItem {
label: "Personal",
link: uri!(routes::settings::user_settings_personal).to_string(),
link: uri!(routes::settings::personal::user_settings_personal).to_string(),
}]),
},
]

View file

@ -1,11 +1,15 @@
use super::prelude::*;
pub use personal::*;
use personal::*;
use rocket::get;
pub mod personal;
pub fn routes() -> Vec<Route> {
routes![user_settings, user_settings_personal]
routes![
user_settings,
user_settings_personal,
user_settings_personal_form,
]
}
#[get("/settings")]

View file

@ -1,5 +1,8 @@
use crate::routes::prelude::*;
use rocket::get;
use crate::tokens::JWT_DURATION_MINUTES;
use email_address::EmailAddress;
use rocket::{get, post};
use std::str::FromStr;
use users::User;
#[get("/settings/personal")]
@ -23,3 +26,91 @@ pub async fn user_settings_personal(
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into()))
}
#[derive(Debug, FromForm)]
pub struct UpdatePersonalSettings<'r> {
pub username: &'r str,
pub name: &'r str,
pub email: &'r str,
}
#[post("/settings/personal", data = "<form>")]
pub async fn user_settings_personal_form(
mut db: Connection<Database>,
jwt_user: JwtUser,
form: Form<UpdatePersonalSettings<'_>>,
) -> Result<Flash<Redirect>> {
let mut transaction = db.begin().await?;
let user = User::get_by_login(&mut transaction, &jwt_user.0.subject)
.await?
.ok_or_else(|| Error::not_found(jwt_user.0.subject.to_string()))?;
if user.is_archived() {
return Err(Error::forbidden("User is archived"));
}
// Update username
if user.username() != form.username {
if let Err(e) = user.set_username(&mut transaction, form.username).await {
return Ok(Flash::new(
Redirect::to(uri!(user_settings_personal)),
FlashKind::Danger,
e.to_string(),
));
}
}
// Update name
if !form.name.is_empty()
&& user
.name()
// If it exists in database, check if provided value is different
.map(|current| current != form.name)
// If it does not exist, use provided value
.unwrap_or(true)
{
user.set_name(&mut transaction, form.name).await?;
}
// Update email
if !form.email.is_empty()
&& user
.email()
// If it exists in database, check if provided value is different
.map(|current| current != form.email)
// If it does not exist, use provided value
.unwrap_or(true)
{
// Parse email address
let email = match EmailAddress::from_str(form.email) {
Ok(email) => email,
Err(e) => {
return Ok(Flash::new(
Redirect::to(uri!(user_settings_personal)),
FlashKind::Danger,
e.to_string(),
));
}
};
if let Err(e) = user.set_email(&mut transaction, email).await {
return Ok(Flash::new(
Redirect::to(uri!(user_settings_personal)),
FlashKind::Danger,
e.to_string(),
));
}
}
transaction.commit().await?;
Ok(Flash::new(
Redirect::to(uri!(user_settings_personal)),
FlashKind::Success,
format!(
"Personal settings have been saved.\
<br>Some changes can take up to {JWT_DURATION_MINUTES} minutes to appear."
),
))
}

View file

@ -9,4 +9,4 @@ hash = { path = "../hash" }
id = { path = "../id" }
thiserror = { workspace = true }
chrono = { workspace = true }
email_address = { version = "0.2", default-features = false }
email_address = { workspace = true }

View file

@ -1,6 +1,7 @@
use crate::error::Error;
use crate::User;
use database::sqlx::SqliteExecutor;
use database::Error as DatabaseError;
use database::Users as DatabaseUsers;
use email_address::EmailAddress;
use hash::Password;
@ -105,4 +106,53 @@ impl User {
.await?
.map(Self::from))
}
pub async fn set_username(
&self,
conn: impl SqliteExecutor<'_>,
username: &str,
) -> Result<(), Error> {
DatabaseUsers::set_username(conn, self.id.as_ref(), username)
.await
.map_err(|e| match e {
DatabaseError::UniqueConstraint(column) => {
if &column == "username" {
Error::UsernameNotAvailable(username.into())
} else {
Error::ColumnNotAvailable(column)
}
}
_ => e.into(),
})?;
Ok(())
}
pub async fn set_name(&self, conn: impl SqliteExecutor<'_>, name: &str) -> Result<(), Error> {
DatabaseUsers::set_name(conn, self.id.as_ref(), name).await?;
Ok(())
}
pub async fn set_email(
&self,
conn: impl SqliteExecutor<'_>,
email: EmailAddress,
) -> Result<(), Error> {
let email = email.as_str();
DatabaseUsers::set_email(conn, self.id.as_ref(), email)
.await
.map_err(|e| match e {
DatabaseError::UniqueConstraint(column) => {
if &column == "email" {
Error::EmailNotAvailable(email.into())
} else {
Error::ColumnNotAvailable(column)
}
}
_ => e.into(),
})?;
Ok(())
}
}

View file

@ -5,4 +5,13 @@
pub enum Error {
#[error("Database: {0}")]
Database(#[from] database::Error),
#[error("The database column \"{0}\" is not available.")]
ColumnNotAvailable(String),
#[error("The username \"{0}\" is not available.")]
UsernameNotAvailable(String),
#[error("The email \"{0}\" is linked to another user.")]
EmailNotAvailable(String),
}