feat: user profile page

This commit is contained in:
Max Hohlfeld 2024-06-28 23:48:29 +02:00
parent 33baf479b5
commit 7d47b38037
16 changed files with 519 additions and 197 deletions

View File

@ -37,6 +37,10 @@ pub fn init(cfg: &mut ServiceConfig) {
cfg.service(user::post_login::post);
cfg.service(user::get_reset::get);
cfg.service(user::post_reset::post);
cfg.service(user::get_profile::get);
cfg.service(user::post_toggle::post);
cfg.service(user::get_changepassword::get);
cfg.service(user::post_changepassword::post);
cfg.service(availability::delete::delete);
cfg.service(availability::get_new::get);

View File

@ -0,0 +1,45 @@
use actix_web::{HttpRequest, HttpResponse, Responder};
#[actix_web::get("/users/changepassword")]
pub async fn get(request: HttpRequest) -> impl Responder {
if let Some(_) = request.headers().get("HX-Request") {
return HttpResponse::Ok().body(r##"
<div class="field">
<div class="control">
<a href="/profile" hx-boost="true" class="button is-link is-light">Schließen</a>
</div>
</div>
<form class="box" hx-post="/users/changepassword" hx-target-400="#error-message" hx-on:change="document.getElementById('error-message').innerHTML = ''">
<div class="field">
<label class="label" for="currentpassword">aktuelles Passwort:</label>
<div class="control">
<input class="input" placeholder="**********" name="currentpassword" type="password" required>
</div>
</div>
<div class="field">
<label class="label" for="password">neues Passwort:</label>
<div class="control">
<input class="input" placeholder="**********" name="password" type="password" required>
</div>
</div>
<div class="field">
<label class="label" for="passwordretyped">neues Passwort wiederholen:</label>
<div class="control">
<input class="input" placeholder="**********" name="passwordretyped" type="password" required>
</div>
</div>
<div id="error-message" class="mb-3 help is-danger"></div>
<div class="level">
<input class="button is-primary level-left" type="submit" value="Passwort ändern" />
</div>
</form>
"##);
}
HttpResponse::NotFound().finish()
}

View File

@ -0,0 +1,24 @@
use actix_web::{web, Responder};
use askama::Template;
use askama_actix::TemplateToResponse;
use sqlx::PgPool;
use crate::models::{Area, Function, Role, User};
#[derive(Template)]
#[template(path = "user/profile.html")]
struct ProfileTemplate {
user: User
}
#[actix_web::get("/profile")]
pub async fn get(user: web::ReqData<User>, pool: web::Data<PgPool>) -> impl Responder {
let area = Area::read_by_id(pool.get_ref(), user.area_id).await.unwrap();
let mut user = user.into_inner();
user.area = Some(area);
let template = ProfileTemplate { user };
return template.to_response();
}

View File

@ -10,3 +10,7 @@ pub mod get_login;
pub mod post_login;
pub mod get_reset;
pub mod post_reset;
pub mod get_profile;
pub mod post_toggle;
pub mod get_changepassword;
pub mod post_changepassword;

View File

@ -13,32 +13,28 @@ use crate::{
pub struct JsonPatchDoc {
op: String,
path: String,
value: Value
value: Value,
}
#[actix_web::patch("/users/edit/{id}")]
pub async fn patch(
user: Identity,
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
patch_docs: web::Json<Vec<JsonPatchDoc>>,
) -> impl Responder {
let current_user = User::read_by_id(pool.get_ref(), user.id().unwrap().parse().unwrap())
.await
.unwrap();
if current_user.role != Role::AreaManager && current_user.role != Role::Admin {
return HttpResponse::Unauthorized().finish();
}
let is_superuser = user.role != Role::AreaManager && user.role != Role::Admin;
if let Ok(user_in_db) = User::read_by_id(pool.get_ref(), path.id).await {
if current_user.role == Role::AreaManager && current_user.area_id != user_in_db.area_id {
if user.role == Role::AreaManager && user.area_id != user_in_db.area_id {
return HttpResponse::Unauthorized().finish();
}
let mut changed = false;
let mut locked: Option<bool> = None;
let mut receive_notifications: Option<bool> = None;
for doc in patch_docs.iter() {
if doc.op.as_str() != "replace" {
@ -47,17 +43,40 @@ pub async fn patch(
match doc.path.as_str() {
"/locked" => {
if !is_superuser {
return HttpResponse::Unauthorized().finish();
}
changed = true;
if let Value::Bool(b) = doc.value {
locked = Some(b);
}
},
_ => panic!("other patch paths are not supported!"),
}
"/receiveNotifications" => {
changed = true;
if let Value::Bool(b) = doc.value {
receive_notifications = Some(b)
}
}
_ => return HttpResponse::BadRequest().body("Other PATCH paths are not supported!")
};
}
if changed {
if let Ok(_) = User::update(pool.get_ref(), path.id, None, None, None, None, None, None, None, locked).await {
if let Ok(_) = User::update(
pool.get_ref(),
path.id,
None,
None,
None,
None,
None,
None,
None,
receive_notifications,
locked,
)
.await
{
return HttpResponse::Ok().body("");
}
} else {

View File

@ -0,0 +1,49 @@
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use sqlx::PgPool;
use crate::{auth::utils, models::User};
#[derive(Deserialize)]
struct ChangePasswordForm {
currentpassword: String,
password: String,
passwordretyped: String,
}
#[actix_web::post("/users/changepassword")]
async fn post(
user: web::ReqData<User>,
form: web::Form<ChangePasswordForm>,
pool: web::Data<PgPool>,
) -> impl Responder {
if user.password
== utils::hash_plain_password_with_salt(&form.currentpassword, &user.salt).unwrap()
{
if form.password != form.passwordretyped {
return HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!");
}
let (hash, salt) = utils::generate_salt_and_hash_plain_password(&form.password).unwrap();
User::update(
pool.get_ref(),
user.id,
None,
None,
Some(&hash),
Some(&salt),
None,
None,
None,
None,
None,
)
.await
.unwrap();
return HttpResponse::Ok().body(r#"<div class="block">Passwort wurde geändert.</div>"#);
} else {
return HttpResponse::BadRequest().body("Aktuelles Passwort ist nicht korrekt!");
}
}

View File

@ -83,7 +83,7 @@ pub async fn post_edit(
};
if changed {
match User::update(pool.get_ref(), path.id, email, name, None, None, role, function, area, None).await {
match User::update(pool.get_ref(), path.id, email, name, None, None, role, function, area, None, None).await {
Ok(_) => return HttpResponse::Found().insert_header((LOCATION, "/users")).finish(),
Err(err) => println!("{}", err)
}

View File

@ -91,6 +91,7 @@ async fn post(form: web::Form<ResetPasswordForm>, pool: web::Data<PgPool>) -> im
None,
None,
None,
None
)
.await
.unwrap();

View File

@ -0,0 +1,70 @@
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
endpoints::IdPath,
models::{Role, User},
};
#[derive(Deserialize)]
struct ToggleQuery {
field: String,
}
#[actix_web::post("/users/{id}/toggle")]
pub async fn post(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
query: web::Query<ToggleQuery>,
) -> impl Responder {
if user.id != path.id && (user.role != Role::Admin || user.role != Role::AreaManager) {
return HttpResponse::Unauthorized().finish();
}
let user = if user.id != path.id {
User::read_by_id(pool.get_ref(), path.id).await.unwrap()
} else {
user.into_inner()
};
match query.field.as_str() {
"locked" => User::update(
pool.get_ref(),
user.id,
None,
None,
None,
None,
None,
None,
None,
None,
Some(!user.locked),
)
.await
.unwrap(),
"receiveNotifications" => {
User::update(
pool.get_ref(),
user.id,
None,
None,
None,
None,
None,
None,
None,
Some(!user.receive_notifications),
None,
)
.await
.unwrap();
//return HttpResponse::Ok().body("<input />");
}
_ => return HttpResponse::BadRequest().body("Other PATCH paths are not supported!"),
};
HttpResponse::Ok().finish()
}

View File

@ -275,6 +275,7 @@ impl User {
role: Option<Role>,
function: Option<Function>,
area_id: Option<i32>,
receive_notifications: Option<bool>,
locked: Option<bool>,
) -> anyhow::Result<()> {
let mut query_builder = sqlx::QueryBuilder::new("UPDATE user_ SET ");
@ -315,6 +316,11 @@ impl User {
separated.push_bind_unseparated(area_id);
}
if let Some(receive_notifications) = receive_notifications {
separated.push("receiveNotifications = ");
separated.push_bind_unseparated(receive_notifications);
}
if let Some(locked) = locked {
separated.push("locked = ");
separated.push_bind_unseparated(locked);

View File

@ -33,6 +33,16 @@
top: -.05em;
vertical-align: middle;
}
.result {
visibility: hidden;
}
.fadeout {
visibility: visible;
opacity: 0;
transition: opacity 2s ease-in;
}
</style>
</head>

View File

@ -66,10 +66,10 @@
<div class="navbar-item">
angemeldet als {{ user.name }}
<div class="buttons ml-3">
<a class="button is-success">
<a class="button is-success" hx-boost="true" href="/profile">
<span class="icon">
<svg class="feather">
<use href="/static/feather-sprite.svg#check-circle" />
<use href="/static/feather-sprite.svg#user" />
</svg>
</span>
<span>Profil</span>

View File

@ -13,7 +13,8 @@
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" type="text" name="email" placeholder="max.mustermann@brasiwa-leipzig.de" value="{{ email }}"/>
<input class="input" type="text" name="email" placeholder="max.mustermann@brasiwa-leipzig.de"
value="{{ email }}" />
</div>
</div>
</div>

View File

@ -0,0 +1,89 @@
{% extends "nav.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Profil</h1>
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" value="{{ user.name }}" readonly>
</div>
</div>
<div class="field">
<label class="label">E-Mail</label>
<div class="control">
<input class="input" type="email" value="{{ user.name }}" readonly>
</div>
</div>
<div class="field">
<label class="label">Rolle</label>
<div class="control">
{% match user.role %}
{% when Role::Staff %}
<span class="tag is-info is-light">Nutzer</span>
{% when Role::AreaManager %}
<span class="tag is-info is-light">Bereichsleiter</span>
{% when Role::Admin %}
<span class="tag is-info">Admin</span>
{% else %}
{% endmatch %}
</div>
</div>
<div class="field">
<label class="label">Funktion</label>
<div class="control">
{% match user.function %}
{% when Function::Posten %}
<span class="tag is-info is-light">Posten</span>
{% when Function::Wachhabender %}
<span class="tag is-info">Wachhabender</span>
{% else %}
{% endmatch %}
</div>
</div>
<div class="field">
<label class="label">Bereich</label>
<div class="control">
<input class="input" type="text" value="{{ user.area.as_ref().unwrap().name }}" readonly>
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
<input hx-post="/users/{{ user.id }}/toggle?field=receiveNotifications" type="checkbox"
hx-on::before-request="document.getElementById('success').classList.remove('fadeout')"
hx-on::after-request="document.getElementById('success').classList.add('fadeout')"
checked="{{ user.receive_notifications }}">
Ich möchte E-Mail Benachrichtungen zu neuen Brasiwa-Einteilungen erhalten.
<span id="success" class="result">
<span class="icon mr-2">
<svg class="feather">
<use href="/static/feather-sprite.svg#check" />
</svg>
</span>
<span>
gespeichert
</span>
</span>
</label>
</div>
</div>
<div class="field">
<div class="control">
<button hx-get="/users/changepassword" hx-target="closest div.field" hx-swap="outerHTML"
class="button is-link is-light">Passwort ändern</button>
</div>
</div>
</div>
</section>
{% endblock %}