feat: registration of users

This commit is contained in:
Max Hohlfeld 2024-09-08 19:47:05 +02:00
parent 2374f78a07
commit ea35f5475f
13 changed files with 88 additions and 56 deletions

View File

@ -13,7 +13,7 @@ CREATE TABLE location
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
areaId INTEGER NOT NULL REFERENCES area (id) areaId INTEGER NOT NULL REFERENCES area (id) ON DELETE CASCADE
); );
CREATE TABLE user_ CREATE TABLE user_
@ -25,7 +25,7 @@ CREATE TABLE user_
salt TEXT , salt TEXT ,
role role NOT NULL, role role NOT NULL,
function function NOT NULL, function function NOT NULL,
areaId INTEGER NOT NULL REFERENCES area (id), areaId INTEGER NOT NULL REFERENCES area (id) ON DELETE CASCADE,
locked BOOLEAN NOT NULL DEFAULT false, locked BOOLEAN NOT NULL DEFAULT false,
lastLogin TIMESTAMP WITH TIME ZONE, lastLogin TIMESTAMP WITH TIME ZONE,
receiveNotifications BOOLEAN NOT NULL DEFAULT true receiveNotifications BOOLEAN NOT NULL DEFAULT true
@ -34,7 +34,7 @@ CREATE TABLE user_
CREATE TABLE availabillity CREATE TABLE availabillity
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
userId INTEGER NOT NULL REFERENCES user_ (id), userId INTEGER NOT NULL REFERENCES user_ (id) ON DELETE CASCADE,
date DATE NOT NULL, date DATE NOT NULL,
startTime TIME, startTime TIME,
endTime TIME, endTime TIME,
@ -48,7 +48,7 @@ CREATE TABLE event
startTime TIME NOT NULL, startTime TIME NOT NULL,
endTime TIME NOT NULL, endTime TIME NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
locationId INTEGER NOT NULL REFERENCES location (id), locationId INTEGER NOT NULL REFERENCES location (id) ON DELETE CASCADE,
voluntaryWachhabender BOOLEAN NOT NULL, voluntaryWachhabender BOOLEAN NOT NULL,
amountOfPosten SMALLINT NOT NULL CHECK (amountOfPosten >= 0), amountOfPosten SMALLINT NOT NULL CHECK (amountOfPosten >= 0),
clothing TEXT NOT NULL, clothing TEXT NOT NULL,
@ -57,8 +57,8 @@ CREATE TABLE event
CREATE TABLE assignment CREATE TABLE assignment
( (
eventId INTEGER REFERENCES event (id), eventId INTEGER REFERENCES event (id) ON DELETE CASCADE,
availabillityId INTEGER REFERENCES availabillity (id), availabillityId INTEGER REFERENCES availabillity (id) ON DELETE CASCADE,
function function NOT NULL, function function NOT NULL,
startTime TIME NOT NULL, startTime TIME NOT NULL,
endTime TIME NOT NULL, endTime TIME NOT NULL,
@ -74,8 +74,8 @@ CREATE TABLE vehicle
CREATE TABLE vehicleassignement CREATE TABLE vehicleassignement
( (
eventId INTEGER REFERENCES event (id), eventId INTEGER REFERENCES event (id) ON DELETE CASCADE,
vehicleId INTEGER REFERENCES vehicle (id), vehicleId INTEGER REFERENCES vehicle (id) ON DELETE CASCADE,
PRIMARY KEY (eventId, vehicleId) PRIMARY KEY (eventId, vehicleId)
); );
@ -93,7 +93,7 @@ CREATE UNLOGGED TABLE passwordReset
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL, token TEXT UNIQUE NOT NULL,
userId INTEGER NOT NULL REFERENCES user_ (id), userId INTEGER NOT NULL REFERENCES user_ (id) ON DELETE CASCADE,
expires TIMESTAMP NOT NULL expires TIMESTAMP NOT NULL
); );
@ -103,7 +103,7 @@ CREATE UNLOGGED TABLE registration
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL, token TEXT UNIQUE NOT NULL,
userId INTEGER NOT NULL REFERENCES user_ (id), userId INTEGER NOT NULL REFERENCES user_ (id) ON DELETE CASCADE,
expires TIMESTAMP NOT NULL expires TIMESTAMP NOT NULL
); );

View File

@ -41,6 +41,8 @@ pub fn init(cfg: &mut ServiceConfig) {
cfg.service(user::post_toggle::post); cfg.service(user::post_toggle::post);
cfg.service(user::get_changepassword::get); cfg.service(user::get_changepassword::get);
cfg.service(user::post_changepassword::post); cfg.service(user::post_changepassword::post);
cfg.service(user::get_register::get);
cfg.service(user::post_register::post);
cfg.service(availability::delete::delete); cfg.service(availability::delete::delete);
cfg.service(availability::get_new::get); cfg.service(availability::get_new::get);

View File

@ -1,30 +1,32 @@
use actix_identity::Identity;
use actix_web::{web, HttpResponse, Responder}; use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool; use sqlx::PgPool;
use crate::{endpoints::IdPath, models::{Role, User}}; use crate::{
endpoints::IdPath,
models::{Role, User}, utils::ApplicationError,
};
#[actix_web::delete("/users/{id}")] #[actix_web::delete("/users/{id}")]
pub async fn delete(user: Identity, pool: web::Data<PgPool>, path: web::Path<IdPath>) -> impl Responder { pub async fn delete(
let current_user = User::read_by_id(pool.get_ref(), user.id().unwrap().parse().unwrap()) user: web::ReqData<User>,
.await pool: web::Data<PgPool>,
.unwrap(); path: web::Path<IdPath>,
) -> Result<impl Responder, ApplicationError> {
if current_user.role != Role::AreaManager && current_user.role != Role::Admin { if user.role != Role::AreaManager && user.role != Role::Admin {
return HttpResponse::Unauthorized().finish(); return Err(ApplicationError::Unauthorized);
} }
if let Ok(user_in_db) = User::read_by_id(pool.get_ref(), path.id).await { let 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 {
return HttpResponse::Unauthorized().finish();
}
if user_in_db.locked { if user.role == Role::AreaManager && user.area_id != user_in_db.area_id {
if let Ok(_) = User::delete(pool.get_ref(), user_in_db.id).await { return Err(ApplicationError::Unauthorized);
return HttpResponse::Ok().finish();
}
}
} }
HttpResponse::BadRequest().finish() if user_in_db.locked {
User::delete(pool.get_ref(), user_in_db.id).await?;
return Ok(HttpResponse::Ok().finish());
}
Ok(HttpResponse::BadRequest().finish())
} }

View File

@ -4,7 +4,7 @@ use askama_actix::TemplateToResponse;
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use crate::models::Registration; use crate::{models::Registration, utils::ApplicationError};
use super::ResetPasswordTemplate; use super::ResetPasswordTemplate;
@ -18,14 +18,14 @@ pub async fn get(
user: Option<Identity>, user: Option<Identity>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
query: web::Query<TokenQuery>, query: web::Query<TokenQuery>,
) -> impl Responder { ) -> Result<impl Responder, ApplicationError> {
if user.is_some() { if user.is_some() {
return HttpResponse::Found() return Ok(HttpResponse::Found()
.insert_header((LOCATION, "/")) .insert_header((LOCATION, "/"))
.finish(); .finish());
} }
if let Ok(_) = Registration::does_token_exist(pool.get_ref(), &query.token).await { if let Some(_) = Registration::does_token_exist(pool.get_ref(), &query.token).await? {
let template = ResetPasswordTemplate { let template = ResetPasswordTemplate {
token: &query.token, token: &query.token,
title: "Brass - Registrierung", title: "Brass - Registrierung",
@ -35,8 +35,8 @@ pub async fn get(
submit_button_label: "Registrieren", submit_button_label: "Registrieren",
}; };
return template.to_response(); return Ok(template.to_response());
} }
HttpResponse::NotFound().finish() Ok(HttpResponse::NotFound().finish())
} }

View File

@ -22,7 +22,7 @@ pub async fn post_new(
user: web::ReqData<User>, user: web::ReqData<User>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
form: web::Form<NewUserForm>, form: web::Form<NewUserForm>,
mailer: web::ReqData<SmtpTransport>, mailer: web::Data<SmtpTransport>,
) -> Result<impl Responder, ApplicationError> { ) -> Result<impl Responder, ApplicationError> {
if user.role != Role::AreaManager && user.role != Role::Admin { if user.role != Role::AreaManager && user.role != Role::Admin {
return Err(ApplicationError::Unauthorized); return Err(ApplicationError::Unauthorized);
@ -48,9 +48,10 @@ pub async fn post_new(
.await?; .await?;
let registration = Registration::insert_new_for_user(pool.get_ref(), id).await?; let registration = Registration::insert_new_for_user(pool.get_ref(), id).await?;
let message = email::build_registration_message(&user, &registration.token); let new_user = User::read_by_id(pool.get_ref(), id).await?;
let message = email::build_registration_message(&new_user, &registration.token)?;
mailer.send(&message).unwrap(); mailer.send(&message)?;
Ok(HttpResponse::Found() Ok(HttpResponse::Found()
.insert_header((LOCATION, "/users")) .insert_header((LOCATION, "/users"))

View File

@ -23,7 +23,8 @@ async fn post(
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> Result<impl Responder, ApplicationError> { ) -> Result<impl Responder, ApplicationError> {
let is_dry = form.dry.unwrap_or(false); let is_dry = form.dry.unwrap_or(false);
let token = Registration::does_token_exist(pool.get_ref(), &form.token).await?; // TODO: flip unauthorized with not found or unwrap result in a other way
let token = Registration::does_token_exist(pool.get_ref(), &form.token).await?.ok_or(ApplicationError::Unauthorized)?;
if form.password.chars().count() > 256 { if form.password.chars().count() > 256 {
if is_dry { if is_dry {

View File

@ -52,6 +52,7 @@ where
&& request.path() != "/login" && request.path() != "/login"
&& request.path() != "/imprint" && request.path() != "/imprint"
&& !request.path().starts_with("/reset-password") && !request.path().starts_with("/reset-password")
&& !request.path().starts_with("/register")
&& !request.path().starts_with("/static") && !request.path().starts_with("/static")
{ {
let (request, _pl) = request.into_parts(); let (request, _pl) = request.into_parts();

View File

@ -1,6 +1,6 @@
use sqlx::{query, query_as, PgPool}; use sqlx::{query, query_as, PgPool};
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct Area { pub struct Area {
pub id: i32, pub id: i32,
pub name: String pub name: String
@ -9,7 +9,7 @@ pub struct Area {
impl Area { impl Area {
pub async fn create(pool: &PgPool, name: &str) -> anyhow::Result<i32> { pub async fn create(pool: &PgPool, name: &str) -> anyhow::Result<i32> {
let result = query!("INSERT INTO area (name) VALUES ($1) RETURNING id;", name).fetch_one(pool).await?; let result = query!("INSERT INTO area (name) VALUES ($1) RETURNING id;", name).fetch_one(pool).await?;
Ok(result.id) Ok(result.id)
} }

View File

@ -30,13 +30,13 @@ impl Registration {
Ok(inserted) Ok(inserted)
} }
pub async fn does_token_exist(pool: &PgPool, token: &str) -> Result<Registration> { pub async fn does_token_exist(pool: &PgPool, token: &str) -> Result<Option<Registration>> {
query_as!( query_as!(
Registration, Registration,
"SELECT * FROM registration WHERE token = $1 AND expires > NOW();", "SELECT * FROM registration WHERE token = $1 AND expires > NOW();",
token token
) )
.fetch_one(pool) .fetch_optional(pool)
.await .await
} }

View File

@ -3,7 +3,7 @@ use sqlx::PgPool;
use super::{Area, Function, Result, Role}; use super::{Area, Function, Result, Role};
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct User { pub struct User {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
@ -366,7 +366,7 @@ impl User {
Ok(()) Ok(())
} }
pub async fn delete(pool: &PgPool, id: i32) -> anyhow::Result<()> { pub async fn delete(pool: &PgPool, id: i32) -> Result<()> {
sqlx::query!("DELETE FROM user_ WHERE id = $1;", id) sqlx::query!("DELETE FROM user_ WHERE id = $1;", id)
.execute(pool) .execute(pool)
.await?; .await?;

View File

@ -9,6 +9,14 @@ pub enum ApplicationError {
Unauthorized, Unauthorized,
#[error("database error")] #[error("database error")]
Database(#[from] sqlx::Error), Database(#[from] sqlx::Error),
#[error("environment variable not present or not unicode")]
EnvVariable(#[from] std::env::VarError),
#[error("email address not conform")]
EmailAdress(#[from] lettre::address::AddressError),
#[error("email content not right")]
Email(#[from] lettre::error::Error),
#[error("email transport not working")]
EmailTransport(#[from] lettre::transport::smtp::Error),
} }
impl actix_web::error::ResponseError for ApplicationError { impl actix_web::error::ResponseError for ApplicationError {
@ -17,10 +25,24 @@ impl actix_web::error::ResponseError for ApplicationError {
ApplicationError::UnsupportedEnumValue { .. } => StatusCode::BAD_REQUEST, ApplicationError::UnsupportedEnumValue { .. } => StatusCode::BAD_REQUEST,
ApplicationError::Unauthorized { .. } => StatusCode::UNAUTHORIZED, ApplicationError::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
ApplicationError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::EnvVariable(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::EmailAdress(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::Email(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::EmailTransport(_) => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).body(self.to_string()) let mut response = HttpResponse::build(self.status_code());
match self {
ApplicationError::UnsupportedEnumValue { .. } => response.body(self.to_string()),
ApplicationError::Unauthorized { .. } => response.body(self.to_string()),
ApplicationError::Database(e) => response.body(format!("{self} - {e}")),
ApplicationError::EnvVariable(_) => response.body(self.to_string()),
ApplicationError::EmailAdress(e) => response.body(format!("{self} - {e}")),
ApplicationError::Email(e) => response.body(format!("{self} - {e}")),
ApplicationError::EmailTransport(e) => response.body(format!("{self} - {e}")),
}
} }
} }

View File

@ -1,13 +1,15 @@
use std::env; use std::env;
use lettre::{ use lettre::{
message::{header::ContentType, MultiPart, SinglePart}, message::{header::ContentType, Mailbox, MultiPart, SinglePart},
transport::smtp::{authentication::Credentials, extension::ClientId}, transport::smtp::{authentication::Credentials, extension::ClientId},
Message, SmtpTransport, Message, SmtpTransport,
}; };
use crate::models::User; use crate::models::User;
use super::ApplicationError;
pub fn get_mailer() -> anyhow::Result<SmtpTransport> { pub fn get_mailer() -> anyhow::Result<SmtpTransport> {
let server = &env::var("SMTP_SERVER")?; let server = &env::var("SMTP_SERVER")?;
let port = &env::var("SMTP_PORT")?.parse()?; let port = &env::var("SMTP_PORT")?.parse()?;
@ -79,14 +81,15 @@ Viele Grüße"##, user.name))
return message; return message;
} }
pub fn build_registration_message(user: &User, token: &str) -> Message { pub fn build_registration_message(user: &User, token: &str) -> Result<Message, ApplicationError> {
let hostname = env::var("HOSTNAME").unwrap(); let hostname = env::var("HOSTNAME")?;
let register_url = format!("https://{hostname}/register?token={token}"); let register_url = format!("https://{hostname}/register?token={token}");
println!("{user:?}");
let message = Message::builder() let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap()) .from("noreply <noreply@brasiwa-leipzig.de>".parse()?)
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap()) .reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?)
.to(format!("{} <{}>", user.name, user.email).parse().unwrap()) .to(Mailbox::new(Some(user.name.clone()), user.email.parse()?))
.subject("Brass: Registrierung deines Accounts") .subject("Brass: Registrierung deines Accounts")
.multipart( .multipart(
MultiPart::alternative() MultiPart::alternative()
@ -116,7 +119,7 @@ Viele Grüße"##, user.name))
<p>Viele Grüße</p>"##, user.name)) <p>Viele Grüße</p>"##, user.name))
)) ))
.unwrap(); ?;
return message; Ok(message)
} }

View File

@ -13,7 +13,7 @@
<div class="field"> <div class="field">
<label class="label" for="password">{{ new_password_label }}</label> <label class="label" for="password">{{ new_password_label }}</label>
<div class="control"> <div class="control">
<input class="input" hx-post="/reset-password?dry=true" hx-params="*" hx-trigger="keyup changed delay:500ms" <input class="input" hx-post="{{ endpoint }}?dry=true" hx-params="*" hx-trigger="keyup changed delay:500ms"
hx-target="#password-strength" hx-target-400="#password-strength" placeholder="**********" name="password" hx-target="#password-strength" hx-target-400="#password-strength" placeholder="**********" name="password"
type="password" required hx-swap="outerHTML" maxlength=256 type="password" required hx-swap="outerHTML" maxlength=256
hx-on:input="document.getElementById('password-strength').innerHTML = ''"> hx-on:input="document.getElementById('password-strength').innerHTML = ''">