Skip to content

Commit

Permalink
Allow users to change password in the admin dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
manuelmauro committed Mar 14, 2024
1 parent 759a4cb commit 331a553
Show file tree
Hide file tree
Showing 14 changed files with 232 additions and 48 deletions.
68 changes: 36 additions & 32 deletions assets/output.css
Original file line number Diff line number Diff line change
Expand Up @@ -767,14 +767,6 @@ select {
inset: 0px;
}

.-inset-1 {
inset: -0.25rem;
}

.-inset-1\.5 {
inset: -0.375rem;
}

.inset-x-0 {
left: 0px;
right: 0px;
Expand Down Expand Up @@ -856,6 +848,10 @@ select {
margin-left: 2.5rem;
}

.ml-4 {
margin-left: 1rem;
}

.mt-10 {
margin-top: 2.5rem;
}
Expand All @@ -872,10 +868,6 @@ select {
margin-top: 1.5rem;
}

.ml-4 {
margin-left: 1rem;
}

.block {
display: block;
}
Expand Down Expand Up @@ -1064,6 +1056,16 @@ select {
border-color: rgb(248 113 113 / var(--tw-border-opacity));
}

.border-green-400 {
--tw-border-opacity: 1;
border-color: rgb(74 222 128 / var(--tw-border-opacity));
}

.border-green-500 {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity));
}

.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
Expand Down Expand Up @@ -1094,6 +1096,26 @@ select {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}

.bg-green-100 {
--tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
}

.bg-green-400 {
--tw-bg-opacity: 1;
background-color: rgb(74 222 128 / var(--tw-bg-opacity));
}

.bg-green-200 {
--tw-bg-opacity: 1;
background-color: rgb(187 247 208 / var(--tw-bg-opacity));
}

.bg-green-300 {
--tw-bg-opacity: 1;
background-color: rgb(134 239 172 / var(--tw-bg-opacity));
}

.bg-gradient-to-tr {
background-image: linear-gradient(to top right, var(--tw-gradient-stops));
}
Expand Down Expand Up @@ -1298,9 +1320,9 @@ select {
color: rgb(255 255 255 / var(--tw-text-opacity));
}

.text-gray-400 {
.text-green-700 {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity));
color: rgb(21 128 61 / var(--tw-text-opacity));
}

.opacity-30 {
Expand Down Expand Up @@ -1382,11 +1404,6 @@ select {
--tw-ring-color: rgb(17 24 39 / 0.2);
}

.focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}

.focus\:ring-2:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
Expand All @@ -1402,19 +1419,6 @@ select {
--tw-ring-color: rgb(79 70 229 / var(--tw-ring-opacity));
}

.focus\:ring-white:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(255 255 255 / var(--tw-ring-opacity));
}

.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}

.focus\:ring-offset-gray-800:focus {
--tw-ring-offset-color: #1f2937;
}

.focus-visible\:outline:focus-visible {
outline-style: solid;
}
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/subscription/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ async fn insert_subscriber(
) -> Result<Uuid, sqlx::Error> {
let subscriber_id = Uuid::new_v4();
let query = sqlx::query!(
r#"INSERT INTO subscriptions (id, email, name, subscribed_at, status) VALUES ($1, $2, $3, $4, 'pending_confirmation')"#,
r#"insert into subscriptions (id, email, name, subscribed_at, status) values ($1, $2, $3, $4, 'pending_confirmation')"#,
subscriber_id,
subscriber.email.as_ref(),
subscriber.name.as_ref(),
Expand Down Expand Up @@ -147,8 +147,8 @@ pub async fn store_token(
subscription_token: &str,
) -> Result<(), sqlx::Error> {
let query = sqlx::query!(
r#"INSERT INTO subscription_tokens (subscription_token, subscriber_id)
VALUES ($1, $2)"#,
r#"insert into subscription_tokens (subscription_token, subscriber_id)
values ($1, $2)"#,
subscription_token,
subscriber_id
);
Expand All @@ -161,7 +161,7 @@ pub async fn store_token(
#[tracing::instrument(name = "Mark subscriber as confirmed", skip(subscriber_id, pool))]
pub async fn confirm_subscriber(pool: &PgPool, subscriber_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"UPDATE subscriptions SET status = 'confirmed' WHERE id = $1"#,
r#"update subscriptions set status = 'confirmed' where id = $1"#,
subscriber_id,
)
.execute(pool)
Expand Down
1 change: 0 additions & 1 deletion src/app/api/user/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ use crate::app::AppState;
use axum::routing::{get, post};
use axum::Router;

pub mod auth;
pub mod route;
pub mod schema;

Expand Down
3 changes: 2 additions & 1 deletion src/app/api/user/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ use axum::extract::State;
use axum::Json;
use secrecy::{ExposeSecret, Secret};

use super::auth::{compute_password_hash, validate_credentials, Credentials};
use super::schema::{
CreateUserRequestBody, CreateUserResponseBody, LoginUserRequestBody, LoginUserResponseBody,
WhoamiResponseBody,
};
use super::AppState;

use crate::app::authentication::{compute_password_hash, validate_credentials, Credentials};
use crate::app::error::{AppError, AppResult};
use crate::app::extractor::authorization_header::ApiToken;
use crate::telemetry::spawn_blocking_with_tracing;
Expand Down
24 changes: 24 additions & 0 deletions src/app/api/user/auth.rs → src/app/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,30 @@ fn verify_password_hash(
.map_err(AuthError::InvalidCredentials)
}

#[tracing::instrument(name = "Change password", skip(password, pool))]
pub async fn change_password(
user_id: uuid::Uuid,
password: Secret<String>,
pool: &PgPool,
) -> Result<(), anyhow::Error> {
let password_hash = spawn_blocking_with_tracing(move || compute_password_hash(password))
.await?
.context("Failed to hash password")?;
sqlx::query!(
r#"
UPDATE users
SET password_hash = $1
WHERE user_id = $2
"#,
password_hash.expose_secret(),
user_id
)
.execute(pool)
.await
.context("Failed to change user's password in the database.")?;
Ok(())
}

/// Computes the password hash using the Argon2id algorithm.
///
/// *Expensive computation*: should be run in a blocking task.
Expand Down
5 changes: 3 additions & 2 deletions src/app/extractor/session_user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use uuid::Uuid;

const USER_ID: &str = "user_id";

#[derive(Debug, Default, Deserialize, Serialize)]
pub struct SessionUser {
pub id: String,
pub id: Uuid,
}

#[async_trait]
Expand All @@ -22,7 +23,7 @@ where

async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(req, state).await?;
let user_id: String = session
let user_id: Uuid = session
.get(USER_ID)
.await
.unwrap()
Expand Down
1 change: 1 addition & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::{config::Settings, email::EmailClient};
use self::{session_store::RedisStore, ui::not_found::not_found_page};

mod api;
mod authentication;
mod error;
mod extractor;
mod session_store;
Expand Down
10 changes: 8 additions & 2 deletions src/app/ui/admin/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
use super::AppState;
use axum::{routing::get, Router};
use axum::{
routing::{get, post},
Router,
};

pub mod route;
pub mod schema;

pub fn router() -> Router<AppState> {
Router::new().route("/app", get(route::admin_dashboard))
Router::new()
.route("/app", get(route::admin_dashboard))
.route("/change-password", post(route::change_password))
}
94 changes: 90 additions & 4 deletions src/app/ui/admin/route.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,114 @@
use anyhow::Context;
use askama::Template;
use axum::{
body::Body,
extract::State,
http::{Response, StatusCode},
response::{IntoResponse, Redirect},
Json,
};
use secrecy::ExposeSecret;
use sqlx::PgPool;
use uuid::Uuid;

use crate::app::extractor::session_user::SessionUser;
use crate::app::{
authentication::{self, validate_credentials, Credentials},
extractor::session_user::SessionUser,
AppState,
};

use super::schema::ChangePasswordRequestBody;

#[derive(Template)]
#[template(path = "incorrect_username_or_password.html")]
struct IncorrectPasswordTemplate;

#[derive(Template)]
#[template(path = "success.html")]
struct Success {
message: String,
}

#[derive(Template)]
#[template(path = "admin_dashboard.html")]
struct AdminDashboardTemplate {
user: String,
}

#[tracing::instrument(name = "Admin dashboard", skip(session))]
pub async fn admin_dashboard(session: Option<SessionUser>) -> impl IntoResponse {
#[tracing::instrument(name = "Admin dashboard", skip(state, session))]
pub async fn admin_dashboard(
state: State<AppState>,
session: Option<SessionUser>,
) -> impl IntoResponse {
if let Some(user) = session {
Response::builder()
.status(StatusCode::OK)
.body(Body::from(
AdminDashboardTemplate { user: user.id }.render().unwrap(),
AdminDashboardTemplate {
user: get_username(user.id, &state.db).await.unwrap(),
}
.render()
.unwrap(),
))
.unwrap()
} else {
Redirect::temporary("/login").into_response()
}
}

#[tracing::instrument(name = "Change password", skip(user, state, body))]
pub async fn change_password(
user: SessionUser,
state: State<AppState>,
Json(body): Json<ChangePasswordRequestBody>,
) -> impl IntoResponse {
if body.new_password.expose_secret() != body.new_password_check.expose_secret() {
return Response::builder()
.status(StatusCode::OK)
.body(Body::from(IncorrectPasswordTemplate.render().unwrap()))
.unwrap();
}

let username = get_username(user.id, &state.db).await.unwrap();
let credentials = Credentials {
username,
password: body.current_password,
};
if validate_credentials(credentials, &state.db).await.is_err() {
return Response::builder()
.status(StatusCode::OK)
.body(Body::from(IncorrectPasswordTemplate.render().unwrap()))
.unwrap();
}

authentication::change_password(user.id, body.new_password, &state.db)
.await
.unwrap();

Response::builder()
.status(StatusCode::OK)
.body(Body::from(
Success {
message: "Password successfully changed.".to_owned(),
}
.render()
.unwrap(),
))
.unwrap()
}

#[tracing::instrument(name = "Get username", skip(pool))]
pub async fn get_username(user_id: Uuid, pool: &PgPool) -> Result<String, anyhow::Error> {
let row = sqlx::query!(
r#"
select username
from users
where user_id = $1
"#,
user_id,
)
.fetch_one(pool)
.await
.context("Failed to perform a query to retrieve a username.")?;
Ok(row.username)
}
9 changes: 9 additions & 0 deletions src/app/ui/admin/schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use secrecy::Secret;

#[derive(serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ChangePasswordRequestBody {
pub current_password: Secret<String>,
pub new_password: Secret<String>,
pub new_password_check: Secret<String>,
}
2 changes: 1 addition & 1 deletion src/app/ui/login/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use tower_sessions::Session;

use super::schema;
use crate::app::{
api::user::auth::{validate_credentials, Credentials},
authentication::{validate_credentials, Credentials},
extractor::session_user::SessionUser,
AppState,
};
Expand Down
Loading

0 comments on commit 331a553

Please sign in to comment.