diff --git a/Cargo.lock b/Cargo.lock index 0af33eb..cc80745 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4070,7 +4070,6 @@ dependencies = [ "argon2", "askama", "axum", - "base64 0.22.1", "chrono", "claims", "config", @@ -4087,7 +4086,6 @@ dependencies = [ "serde", "serde-aux", "serde_json", - "serde_urlencoded", "sqlx", "thiserror", "tokio", @@ -4096,8 +4094,6 @@ dependencies = [ "tower-sessions-redis-store", "tracing", "tracing-subscriber", - "unicode-segmentation", - "urlencoding", "uuid", "validator", "wiremock", diff --git a/Cargo.toml b/Cargo.toml index 72999ab..406e58b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,6 @@ anyhow = "1.0.99" argon2 = { version = "0.5.3", features = ["std"] } askama = "0.14.0" axum = { version = "0.8.4", features = ["macros"] } -base64 = "0.22.1" chrono = { version = "0.4.41", default-features = false, features = ["clock"] } config = "0.15.14" markdown = "1.0.0" @@ -56,8 +55,6 @@ tower-sessions = "0.14.0" tower-sessions-redis-store = "0.16.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -unicode-segmentation = "1.12.0" -urlencoding = "2.1.3" uuid = { version = "1.18.0", features = ["v4", "serde"] } validator = { version = "0.20.0", features = ["derive"] } @@ -70,5 +67,4 @@ quickcheck = "1.0.3" quickcheck_macros = "1.1.0" scraper = "0.24.0" serde_json = "1.0.143" -serde_urlencoded = "0.7.1" wiremock = "0.6.4" diff --git a/src/routes.rs b/src/routes.rs index bde2dbe..66ed5ca 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -161,6 +161,7 @@ impl From for AppError { } pub async fn not_found() -> Response { + tracing::error!("Not found."); not_found_html() } diff --git a/src/routes/admin.rs b/src/routes/admin.rs index a00fbe7..ae47729 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -12,6 +12,7 @@ use crate::{ templates::{HtmlTemplate, MessageTemplate}, }; use anyhow::Context; +use axum::response::Redirect; use axum::{ extract::Request, middleware::Next, @@ -49,11 +50,17 @@ pub async fn require_auth( mut request: Request, next: Next, ) -> Result { - let user_id = session + let user_id = match session .get_user_id() .await .map_err(|e| AdminError::UnexpectedError(e.into()))? - .ok_or(AdminError::NotAuthenticated)?; + { + None => { + tracing::error!("Not authenticated. Redirecting to /login."); + return Ok(Redirect::to("/login").into_response()); + } + Some(user_id) => user_id, + }; let username = session .get_username() .await diff --git a/src/routes/login.rs b/src/routes/login.rs index 4369816..a21b47f 100644 --- a/src/routes/login.rs +++ b/src/routes/login.rs @@ -29,7 +29,7 @@ pub async fn get_login(session: TypedSession) -> Result { .context("Failed to retrieve user id from data store.")? .is_some() { - Ok(Redirect::to("/admin/dashboard").into_response()) + Ok(Redirect::to("dashboard").into_response()) } else { Ok(Html(LoginTemplate.render().unwrap()).into_response()) } @@ -66,6 +66,6 @@ pub async fn post_login( .context("Failed to insert role in session data store.")?; let mut headers = HeaderMap::new(); - headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap()); + headers.insert("HX-Redirect", "/dashboard".parse().unwrap()); Ok((StatusCode::OK, headers).into_response()) } diff --git a/src/routes/users.rs b/src/routes/users.rs index dbf10e7..ec60dbd 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -1,5 +1,7 @@ +use crate::authentication::AuthenticatedUser; use crate::routes::verify_password; -use crate::templates::MessageTemplate; +use crate::session_state::TypedSession; +use crate::templates::{ErrorTemplate, MessageTemplate, UserEditTemplate}; use crate::{ authentication::Role, domain::{PostEntry, UserEntry}, @@ -9,17 +11,107 @@ use crate::{ }; use anyhow::Context; use axum::{ - Form, + Extension, Form, extract::{Path, State}, response::{IntoResponse, Response}, }; use secrecy::{ExposeSecret, SecretString}; use sqlx::PgPool; +use tower_sessions::Session; use uuid::Uuid; +pub async fn get_user_edit( + Path(username): Path, + Extension(AuthenticatedUser { + user_id, + username: session_username, + .. + }): Extension, + State(AppState { + connection_pool, .. + }): State, +) -> Result { + if username != session_username { + let template = HtmlTemplate(ErrorTemplate::Forbidden); + return Ok(template.into_response()); + } + let user = sqlx::query_as!( + UserEntry, + r#" + SELECT user_id, username, role as "role: Role", full_name, bio, member_since + FROM users + WHERE user_id = $1 + "#, + user_id + ) + .fetch_one(&connection_pool) + .await + .context("Could not fetch user in database.")?; + let template = HtmlTemplate(UserEditTemplate { user }); + Ok(template.into_response()) +} + #[derive(serde::Deserialize)] -pub struct ProfilePath { +pub struct EditProfileForm { username: String, + full_name: String, + bio: String, +} + +pub async fn put_user_edit( + State(AppState { + connection_pool, .. + }): State, + session: TypedSession, + Extension(AuthenticatedUser { + user_id, + username: session_username, + .. + }): Extension, + Path(username): Path, + Form(form): Form, +) -> Result { + let updated_username = form.username.trim(); + if updated_username != session_username + && sqlx::query!( + "SELECT user_id FROM users WHERE username = $1", + updated_username + ) + .fetch_optional(&connection_pool) + .await + .context("Could not fetch users table.")? + .is_some() + { + let template = HtmlTemplate(MessageTemplate::error( + "The username is already taken.".into(), + )); + return Ok(template.into_response()); + } + let updated_full_name = form.full_name.trim(); + let bio = form.bio.trim(); + sqlx::query!( + " + UPDATE users + SET username = $1, full_name = $2, bio = $3 + WHERE user_id = $4 + ", + updated_username, + updated_full_name, + bio, + user_id + ) + .execute(&connection_pool) + .await + .context("Failed to apply changes.") + .map_err(AppError::FormError)?; + session + .insert_username(updated_username.to_owned()) + .await + .context("Could not update session username.")?; + let template = HtmlTemplate(MessageTemplate::success( + "Your profile has been updated.".into(), + )); + Ok(template.into_response()) } #[tracing::instrument(name = "Get users from database", skip(connection_pool))] @@ -144,12 +236,13 @@ pub async fn delete_user( Ok(template.into_response()) } -#[tracing::instrument(name = "Fetching user data", skip(connection_pool))] +#[tracing::instrument(name = "Fetching user data", skip(connection_pool, session))] pub async fn user_profile( + session: TypedSession, State(AppState { connection_pool, .. }): State, - Path(ProfilePath { username }): Path, + Path(username): Path, ) -> Result { match fetch_user_data(&connection_pool, &username) .await @@ -159,7 +252,15 @@ pub async fn user_profile( let posts = fetch_user_posts(&connection_pool, &user.user_id) .await .context("Could not fetch user posts.")?; - let template = HtmlTemplate(UserTemplate { user, posts }); + let session_username = session + .get_username() + .await + .context("Could not fetch session username.")?; + let template = HtmlTemplate(UserTemplate { + user, + session_username, + posts, + }); Ok(template.into_response()) } None => { diff --git a/src/startup.rs b/src/startup.rs index 9b378ab..5bbe3f7 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,7 +1,7 @@ use crate::{configuration::Settings, email_client::EmailClient, routes::require_auth, routes::*}; use anyhow::Context; use axum::{ - Router, + Router, ServiceExt, body::Bytes, extract::MatchedPath, http::Request, @@ -100,7 +100,11 @@ pub fn app( .route("/newsletters", post(publish_newsletter)) .route("/posts", post(create_post)) .route("/logout", get(logout)) - .merge(admin_routes) + .route( + "/users/{username}/edit", + get(get_user_edit).put(put_user_edit), + ) + .nest("/admin", admin_routes) .layer(middleware::from_fn(require_auth)); Router::new() .route("/", get(home)) @@ -119,7 +123,7 @@ pub fn app( ) .route("/users/{username}", get(user_profile)) .route("/favicon.ico", get(favicon)) - .nest("/admin", auth_routes) + .merge(auth_routes) .nest_service("/assets", ServeDir::new("assets")) .layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { diff --git a/src/templates.rs b/src/templates.rs index ec1691e..ffed083 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -25,9 +25,16 @@ where #[template(path = "user/profile.html")] pub struct UserTemplate { pub user: UserEntry, + pub session_username: Option, pub posts: Vec, } +#[derive(Template)] +#[template(path = "user/edit.html")] +pub struct UserEditTemplate { + pub user: UserEntry, +} + #[derive(Template)] #[template(path = "message.html")] pub struct MessageTemplate { @@ -147,6 +154,8 @@ pub enum ErrorTemplate { NotFound, #[template(path = "error/500.html")] InternalServer, + #[template(path = "error/403.html")] + Forbidden, } #[derive(Template)] diff --git a/templates/base.html b/templates/base.html index efeed39..07e4539 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,110 +1,113 @@ - - - - - + + + + + - {% block title %}{% endblock %} - - zero2prod + {% block title %}{% endblock %} + - zero2prod - + - - -
-
+ + +
+ -
-
-
- {% block content %}{% endblock %} -
-
-
+
+
+
+ {% block content %}{% endblock %} +
+
+ - + + + diff --git a/templates/dashboard/change_password.html b/templates/dashboard/change_password.html deleted file mode 100644 index c011dfa..0000000 --- a/templates/dashboard/change_password.html +++ /dev/null @@ -1,59 +0,0 @@ -
-
-

- - - - Change your password -

-

Set a new password for your account.

-
-
-
-
- - -
-
- - -
-
- - -
- -
-
-
-
diff --git a/templates/dashboard/dashboard.html b/templates/dashboard/dashboard.html index bf443b5..cd2f225 100644 --- a/templates/dashboard/dashboard.html +++ b/templates/dashboard/dashboard.html @@ -14,7 +14,7 @@ {% endif %}

- diff --git a/templates/dashboard/send_email.html b/templates/dashboard/send_email.html index 366e15a..fc97f90 100644 --- a/templates/dashboard/send_email.html +++ b/templates/dashboard/send_email.html @@ -5,18 +5,19 @@ fill="none" viewBox="0 0 24 24" stroke="currentColor"> - + Send an email

Contact your subscribers directly.

- - +
@@ -24,7 +25,7 @@ id="newsletter-title" name="title" required - class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> + class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"/>
{% endblock %} diff --git a/templates/user/activity.html b/templates/user/activity.html index f092cf5..48079c1 100644 --- a/templates/user/activity.html +++ b/templates/user/activity.html @@ -1,5 +1,5 @@
-

Activity

+

Activity

{% if posts.is_empty() %}
No posts yet

{% else %} -
+
{% for post in posts %} diff --git a/templates/user/edit.html b/templates/user/edit.html new file mode 100644 index 0000000..38e09c0 --- /dev/null +++ b/templates/user/edit.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Edit profile{% endblock %} +{% block content %} +
+
+

Edit Profile

+

Manage your profile and account settings.

+
+ +
+ {% include "edit/update_profile.html" %} + {% include "edit/change_password.html" %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/user/edit/change_password.html b/templates/user/edit/change_password.html new file mode 100644 index 0000000..03e7579 --- /dev/null +++ b/templates/user/edit/change_password.html @@ -0,0 +1,48 @@ +
+

Change Password

+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ +
\ No newline at end of file diff --git a/templates/user/edit/update_profile.html b/templates/user/edit/update_profile.html new file mode 100644 index 0000000..34b1db3 --- /dev/null +++ b/templates/user/edit/update_profile.html @@ -0,0 +1,53 @@ +
+

Profile Information

+ +
+ +
+ + +
+ +
+ + +

Your real name (optional)

+
+ +
+ + +

Maximum 500 characters

+
+ + + +
+
+
\ No newline at end of file diff --git a/templates/user/profile.html b/templates/user/profile.html index 6ea11d1..5313fc1 100644 --- a/templates/user/profile.html +++ b/templates/user/profile.html @@ -1,43 +1,55 @@ {% extends "base.html" %} {% block title %}{{ user.username }}{% endblock %} {% block content %} -
-
-
-
-
- {{ user.username }} -
-
-
-
-

{{ user.full_name.as_deref().unwrap_or(user.username) }}

- {% if user.is_admin() %} - - - - {% endif %} -
-

@{{ user.username }}

-
- - - - {{ user.formatted_date() }} -
+
+ {% endblock %} diff --git a/tests/api/admin_dashboard.rs b/tests/api/admin_dashboard.rs index c10e165..4f7a756 100644 --- a/tests/api/admin_dashboard.rs +++ b/tests/api/admin_dashboard.rs @@ -21,7 +21,7 @@ async fn logout_clears_session_state(connection_pool: PgPool) { "password": &app.test_user.password, }); let response = app.post_login(&login_body).await; - assert_is_redirect_to(&response, "/admin/dashboard"); + assert_is_redirect_to(&response, "/dashboard"); let html_page = app.get_admin_dashboard_html().await; assert!(html_page.contains("Connected as")); diff --git a/tests/api/change_password.rs b/tests/api/change_password.rs index 5ab97d5..1afd9a6 100644 --- a/tests/api/change_password.rs +++ b/tests/api/change_password.rs @@ -85,5 +85,5 @@ async fn changing_password_works(connection_pool: PgPool) { "password": new_password, }); let response = app.post_login(login_body).await; - assert_is_redirect_to(&response, "/admin/dashboard"); + assert_is_redirect_to(&response, "/dashboard"); } diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 1629181..bcfdf4e 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -279,7 +279,7 @@ impl TestApp { pub async fn get_admin_dashboard(&self) -> reqwest::Response { self.api_client - .get(format!("{}/admin/dashboard", &self.address)) + .get(format!("{}/dashboard", &self.address)) .send() .await .expect("Failed to execute request") @@ -328,7 +328,7 @@ impl TestApp { Body: serde::Serialize, { self.api_client - .post(format!("{}/admin/newsletters", self.address)) + .post(format!("{}/newsletters", self.address)) .form(body) .send() .await @@ -357,7 +357,7 @@ impl TestApp { pub async fn logout(&self) -> reqwest::Response { self.api_client - .get(format!("{}/admin/logout", self.address)) + .get(format!("{}/logout", self.address)) .send() .await .expect("Failed to execute request") @@ -368,7 +368,7 @@ impl TestApp { Body: serde::Serialize, { self.api_client - .post(format!("{}/admin/password", self.address)) + .post(format!("{}/password", self.address)) .form(body) .send() .await @@ -380,7 +380,7 @@ impl TestApp { Body: serde::Serialize, { self.api_client - .post(format!("{}/admin/posts", self.address)) + .post(format!("{}/posts", self.address)) .form(body) .send() .await diff --git a/tests/api/login.rs b/tests/api/login.rs index 4b02611..0b7acfd 100644 --- a/tests/api/login.rs +++ b/tests/api/login.rs @@ -28,7 +28,7 @@ async fn login_redirects_to_admin_dashboard_after_login_success(connection_pool: }); let response = app.post_login(&login_body).await; - assert_is_redirect_to(&response, "/admin/dashboard"); + assert_is_redirect_to(&response, "/dashboard"); let html_page = app.get_admin_dashboard_html().await; assert!(html_page.contains("Connected as"));