Skip to content

Commit

Permalink
Merge pull request #173 from ZeusWPI/feature/admin-state-change
Browse files Browse the repository at this point in the history
Allow admins to change the state of users
  • Loading branch information
rien authored Feb 19, 2022
2 parents 2c044e5 + 87c2f20 commit 1b63254
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 40 deletions.
16 changes: 16 additions & 0 deletions src/controllers/users_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,22 @@ pub async fn set_admin<'r>(
})
}

#[post("/users/<username>/change_state", data = "<value>")]
pub async fn change_state<'r>(
username: String,
value: Api<ChangeStatus>,
_session: AdminSession,
db: DbConn,
) -> Result<impl Responder<'r, 'static>> {
let mut user = User::find_by_username(username, &db).await?;
user.state = value.into_inner().state;
let user = user.update(&db).await?;
Ok(Accepter {
html: Redirect::to(uri!(show_user(user.username))),
json: Custom(Status::NoContent, ()),
})
}

#[post("/users/<username>/approve")]
pub async fn set_approved<'r>(
username: String,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ fn assemble(rocket: Rocket<Build>) -> Rocket<Build> {
users_controller::show_user,
users_controller::list_users,
users_controller::update_user,
users_controller::change_state,
users_controller::set_admin,
users_controller::set_approved,
users_controller::forgot_password_get,
Expand Down
61 changes: 37 additions & 24 deletions src/models/user.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use self::schema::users;
use crate::errors::{InternalError, LoginError, Result, ZauthError};
use crate::errors::{self, InternalError, LoginError, ZauthError};
use crate::DbConn;
use diesel::{self, prelude::*};
use diesel_derive_enum::DbEnum;
Expand All @@ -11,11 +11,13 @@ use chrono::{NaiveDateTime, Utc};
use lettre::message::Mailbox;
use pwhash::bcrypt::{self, BcryptSetup};
use regex::Regex;
use rocket::serde::Serialize;
use rocket::{serde::Serialize, FromFormField};
use std::convert::TryFrom;
use validator::{Validate, ValidationError, ValidationErrors};

#[derive(DbEnum, Debug, Serialize, Clone, PartialEq)]
#[derive(
DbEnum, Debug, Deserialize, FromFormField, Serialize, Clone, PartialEq,
)]
pub enum UserState {
PendingApproval,
PendingMailConfirmation,
Expand Down Expand Up @@ -163,6 +165,11 @@ pub struct ChangeAdmin {
pub admin: bool,
}

#[derive(FromForm, Deserialize, Debug, Clone)]
pub struct ChangeStatus {
pub state: UserState,
}

#[derive(Validate, FromForm, Deserialize, Debug, Clone)]
pub struct ChangePassword {
#[validate(length(
Expand All @@ -173,7 +180,7 @@ pub struct ChangePassword {
}

impl User {
pub async fn all(db: &DbConn) -> Result<Vec<User>> {
pub async fn all(db: &DbConn) -> errors::Result<Vec<User>> {
let all_users =
db.run(move |conn| users::table.load::<User>(conn)).await?;
Ok(all_users)
Expand All @@ -186,7 +193,7 @@ impl User {
pub async fn find_by_username<'r>(
username: String,
db: &DbConn,
) -> Result<User> {
) -> errors::Result<User> {
db.run(move |conn| {
users::table
.filter(users::username.eq(username))
Expand All @@ -196,13 +203,16 @@ impl User {
.await
}

pub async fn find_by_email(email: String, db: &DbConn) -> Result<User> {
pub async fn find_by_email(
email: String,
db: &DbConn,
) -> errors::Result<User> {
let query = users::table.filter(users::email.eq(email));
db.run(move |conn| query.first(conn).map_err(ZauthError::from))
.await
}

pub async fn delete(self, db: &DbConn) -> Result<()> {
pub async fn delete(self, db: &DbConn) -> errors::Result<()> {
db.run(move |conn| {
diesel::delete(users::table.find(self.id))
.execute(conn)
Expand All @@ -215,7 +225,7 @@ impl User {
pub async fn find_by_password_token<'r>(
token: String,
db: &DbConn,
) -> Result<Option<User>> {
) -> errors::Result<Option<User>> {
let token = token.to_owned();
let result = db
.run(move |conn| {
Expand Down Expand Up @@ -248,7 +258,7 @@ impl User {
pub async fn find_by_email_token<'r>(
token: String,
db: &DbConn,
) -> Result<Option<User>> {
) -> errors::Result<Option<User>> {
let token = token.to_owned();
let result = db
.run(move |conn| {
Expand All @@ -266,7 +276,7 @@ impl User {
}
}

pub async fn find_by_pending(db: &DbConn) -> Result<Vec<User>> {
pub async fn find_by_pending(db: &DbConn) -> errors::Result<Vec<User>> {
let pending_users = db
.run(move |conn| {
users::table
Expand All @@ -281,7 +291,7 @@ impl User {
user: NewUser,
bcrypt_cost: u32,
db: &DbConn,
) -> Result<User> {
) -> errors::Result<User> {
user.validate()?;
let user = NewUserHashed {
username: user.username,
Expand Down Expand Up @@ -309,7 +319,7 @@ impl User {
user: NewUser,
conf: &Config,
db: &DbConn,
) -> Result<User> {
) -> errors::Result<User> {
user.validate()?;
if Self::pending_count(&db).await? >= conf.maximum_pending_users {
let mut err = ValidationErrors::new();
Expand Down Expand Up @@ -349,7 +359,7 @@ impl User {
.await
}

pub async fn approve(mut self, db: &DbConn) -> Result<User> {
pub async fn approve(mut self, db: &DbConn) -> errors::Result<User> {
if self.state != UserState::PendingApproval {
return Err(ZauthError::Unprocessable(String::from(
"user is not in the pending approval state",
Expand All @@ -359,7 +369,7 @@ impl User {
self.update(&db).await
}

pub async fn confirm_email(mut self, db: &DbConn) -> Result<User> {
pub async fn confirm_email(mut self, db: &DbConn) -> errors::Result<User> {
if self.state == UserState::PendingMailConfirmation {
self.state = UserState::PendingApproval;
}
Expand All @@ -375,7 +385,7 @@ impl User {
self.update(&db).await
}

pub fn change_with(&mut self, change: UserChange) -> Result<()> {
pub fn change_with(&mut self, change: UserChange) -> errors::Result<()> {
if let Some(email) = change.email {
self.email = email;
}
Expand All @@ -385,7 +395,7 @@ impl User {
Ok(())
}

pub async fn update(self, db: &DbConn) -> Result<Self> {
pub async fn update(self, db: &DbConn) -> errors::Result<Self> {
let id = self.id;
db.run(move |conn| {
conn.transaction(|| {
Expand All @@ -407,31 +417,34 @@ impl User {
change: ChangePassword,
conf: &Config,
db: &DbConn,
) -> Result<Self> {
) -> errors::Result<Self> {
change.validate()?;
self.hashed_password = hash(&change.password, conf.bcrypt_cost)?;
self.password_reset_token = None;
self.password_reset_expiry = None;
self.update(db).await
}

pub async fn reload(self, db: &DbConn) -> Result<User> {
pub async fn reload(self, db: &DbConn) -> errors::Result<User> {
Self::find(self.id, db).await
}

pub async fn update_last_login(mut self, db: &DbConn) -> Result<Self> {
pub async fn update_last_login(
mut self,
db: &DbConn,
) -> errors::Result<Self> {
self.last_login = Utc::now().naive_utc();
self.update(db).await
}

pub async fn find(id: i32, db: &DbConn) -> Result<User> {
pub async fn find(id: i32, db: &DbConn) -> errors::Result<User> {
db.run(move |conn| {
users::table.find(id).first(conn).map_err(ZauthError::from)
})
.await
}

pub async fn pending_count(db: &DbConn) -> Result<usize> {
pub async fn pending_count(db: &DbConn) -> errors::Result<usize> {
let count: i64 = db
.run(move |conn| {
users::table
Expand All @@ -447,7 +460,7 @@ impl User {
Ok(count as usize)
}

pub async fn last(db: &DbConn) -> Result<User> {
pub async fn last(db: &DbConn) -> errors::Result<User> {
db.run(move |conn| {
users::table
.order(users::id.desc())
Expand All @@ -461,7 +474,7 @@ impl User {
username: String,
password: String,
db: &DbConn,
) -> Result<User> {
) -> errors::Result<User> {
match Self::find_by_username(username, db).await {
Ok(user) if !verify(&password, &user.hashed_password) => {
Err(ZauthError::LoginError(LoginError::UsernamePasswordError))
Expand Down Expand Up @@ -506,7 +519,7 @@ fn verify(password: &str, hash: &str) -> bool {
impl TryFrom<&User> for Mailbox {
type Error = ZauthError;

fn try_from(value: &User) -> Result<Mailbox> {
fn try_from(value: &User) -> errors::Result<Mailbox> {
Ok(Mailbox::new(
Some(value.username.to_string()),
value.email.clone().parse().map_err(InternalError::from)?,
Expand Down
48 changes: 32 additions & 16 deletions templates/users/show.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<div class="profile-subtitle">
{% if user.admin %}
Zeus Admin
Zeus Admin
{% else %}
Zeus Member
{% endif %}
Expand All @@ -29,27 +29,27 @@
<input type="hidden" name="_method" value="put"/>

<div class="field">
<label class="label">Username</label>
<input class="input" name="username" type="text" placeholder="Username" value="{{ user.username }}" disabled />
</div>
<label class="label">Username</label>
<input class="input" name="username" type="text" placeholder="Username" value="{{ user.username }}" disabled />
</div>

<div class="field">
<label class="label">Full Name</label>
<input class="input" name="full_name" type="text" placeholder="Full Name" value="{{ user.full_name }}" disabled />
</div>
<label class="label">Full Name</label>
<input class="input" name="full_name" type="text" placeholder="Full Name" value="{{ user.full_name }}" disabled />
</div>

<div class="field">
<label class="label">Email</label>
<input class="input" name="email" type="email" placeholder="Email" value="{{ user.email }}" required />
</div>
<label class="label">Email</label>
<input class="input" name="email" type="email" placeholder="Email" value="{{ user.email }}" required />
</div>

<div class="field">
<label class="label">Password</label>
<input class="input" name="password" type="password" placeholder="Password" disabled />
</div>
<label class="label">Password</label>
<input class="input" name="password" type="password" placeholder="Password" disabled />
</div>

<div class="field">
<label class="label">SSH Keys</label>
<label class="label">SSH Keys</label>
<textarea class="textarea" name="ssh_key" placeholder="SSH Keys">{% match user.ssh_key %}{% when Some with (val) %}{{ val }}{% when None %}{% endmatch %}</textarea>
</div>

Expand All @@ -58,11 +58,27 @@
</div>
</div>

<!-- Change password -->
<div class="column is-5">
<div class="card card-content">
<!-- Change password -->
<div class="card card-content mb-4">
<a class="button is-primary" href="/users/forgot_password">Change password</a>
</div>

{% if current_user.admin %}
<!-- Change status -->
<div class="card card-content">
<form class="profile-edit-form" action="/users/{{ user.username }}/change_state" autocomplete="off" method="POST">
<div class="field">
<select class="select" name="state">
<option value="active" {% if user.state == UserState::Active %}selected{% endif %}>Active</option>
<option value="disabled" {% if user.state == UserState::Disabled %}selected{% endif %}>Disabled</option>
</select>
</div>

<button class="button is-primary" type="submit">Change state</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endblock content %}
49 changes: 49 additions & 0 deletions tests/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -879,3 +879,52 @@ async fn validate_on_admin_create() {
})
.await;
}

#[rocket::async_test]
async fn disable_user() {
common::as_admin(async move |http_client, db, _admin| {
let user = User::create(
NewUser {
username: String::from("somebody"),
password: String::from("once told me"),
full_name: String::from("zeus"),
email: String::from("[email protected]"),
ssh_key: Some(String::from("ssh-rsa nananananananaaa")),
not_a_robot: true,
},
common::BCRYPT_COST,
&db,
)
.await
.unwrap();

assert_eq!(
user.state,
UserState::Active,
"user should be active before state change"
);

let response = http_client
.post(format!("/users/{}/change_state", user.username))
.header(ContentType::Form)
.header(Accept::JSON)
.body("state=Disabled")
.dispatch()
.await;

assert_eq!(
response.status(),
Status::NoContent,
"admin should be able to disable user"
);

let user = user.reload(&db).await.expect("reload user");

assert_eq!(
user.state,
UserState::Disabled,
"user should be disabled after state change"
);
})
.await;
}

0 comments on commit 1b63254

Please sign in to comment.