diff --git a/backend/src/lib/domain/warren/models/user/requests/edit.rs b/backend/src/lib/domain/warren/models/user/requests/edit.rs new file mode 100644 index 0000000..1003d6b --- /dev/null +++ b/backend/src/lib/domain/warren/models/user/requests/edit.rs @@ -0,0 +1,60 @@ +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::warren::models::user::{UserEmail, UserName, UserPassword}; + +/// An admin request to edit an existing user +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EditUserRequest { + user_id: Uuid, + name: UserName, + email: UserEmail, + password: Option, + admin: bool, +} + +impl EditUserRequest { + pub fn new( + user_id: Uuid, + name: UserName, + email: UserEmail, + password: Option, + admin: bool, + ) -> Self { + Self { + user_id, + name, + email, + password, + admin, + } + } + + pub fn user_id(&self) -> &Uuid { + &self.user_id + } + + pub fn name(&self) -> &UserName { + &self.name + } + + pub fn email(&self) -> &UserEmail { + &self.email + } + + pub fn password(&self) -> Option<&UserPassword> { + self.password.as_ref() + } + + pub fn admin(&self) -> bool { + self.admin + } +} + +#[derive(Debug, Error)] +pub enum EditUserError { + #[error("There is no user with this id")] + NotFound, + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/user/requests/list_all.rs b/backend/src/lib/domain/warren/models/user/requests/list_all.rs new file mode 100644 index 0000000..01ccc58 --- /dev/null +++ b/backend/src/lib/domain/warren/models/user/requests/list_all.rs @@ -0,0 +1,64 @@ +use thiserror::Error; + +use crate::domain::warren::models::{ + user::User, + user_warren::{UserWarren, requests::ListUserWarrensError}, + warren::{FetchWarrensError, Warren}, +}; + +use super::ListUsersError; + +/// An admin request to list all users, user warrens and warrens +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ListAllUsersAndWarrensRequest {} + +impl ListAllUsersAndWarrensRequest { + pub fn new() -> Self { + Self {} + } +} + +#[derive(Debug, Error)] +pub enum ListAllUsersAndWarrensError { + #[error(transparent)] + ListUsers(#[from] ListUsersError), + #[error(transparent)] + ListUserWarrens(#[from] ListUserWarrensError), + #[error(transparent)] + FetchWarrens(#[from] FetchWarrensError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ListAllUsersAndWarrensResponse { + users: Vec, + user_warrens: Vec, + warrens: Vec, +} + +impl ListAllUsersAndWarrensResponse { + pub fn new(users: Vec, user_warrens: Vec, warrens: Vec) -> Self { + Self { + users, + user_warrens, + warrens, + } + } + + pub fn users(&self) -> &Vec { + &self.users + } + + pub fn user_warrens(&self) -> &Vec { + &self.user_warrens + } + + pub fn warrens(&self) -> &Vec { + &self.warrens + } + + pub fn unpack(self) -> (Vec, Vec, Vec) { + (self.users, self.user_warrens, self.warrens) + } +} diff --git a/backend/src/lib/domain/warren/models/user/requests/mod.rs b/backend/src/lib/domain/warren/models/user/requests/mod.rs index e93aab7..4e75ce1 100644 --- a/backend/src/lib/domain/warren/models/user/requests/mod.rs +++ b/backend/src/lib/domain/warren/models/user/requests/mod.rs @@ -1,13 +1,17 @@ mod create; mod delete; +mod edit; mod list; +mod list_all; mod login; mod register; mod verify_password; pub use create::*; pub use delete::*; +pub use edit::*; pub use list::*; +pub use list_all::*; pub use login::*; pub use register::*; pub use verify_password::*; diff --git a/backend/src/lib/domain/warren/models/user_warren/requests/fetch_user_warren.rs b/backend/src/lib/domain/warren/models/user_warren/requests/fetch_user_warren.rs new file mode 100644 index 0000000..d293587 --- /dev/null +++ b/backend/src/lib/domain/warren/models/user_warren/requests/fetch_user_warren.rs @@ -0,0 +1,30 @@ +use thiserror::Error; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FetchUserWarrenRequest { + user_id: Uuid, + warren_id: Uuid, +} + +impl FetchUserWarrenRequest { + pub fn new(user_id: Uuid, warren_id: Uuid) -> Self { + Self { user_id, warren_id } + } + + pub fn user_id(&self) -> &Uuid { + &self.user_id + } + + pub fn warren_id(&self) -> &Uuid { + &self.warren_id + } +} + +#[derive(Debug, Error)] +pub enum FetchUserWarrenError { + #[error("This user warren does not exist")] + NotFound, + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/user_warren/requests.rs b/backend/src/lib/domain/warren/models/user_warren/requests/fetch_user_warrens.rs similarity index 61% rename from backend/src/lib/domain/warren/models/user_warren/requests.rs rename to backend/src/lib/domain/warren/models/user_warren/requests/fetch_user_warrens.rs index c601c01..f7bedb1 100644 --- a/backend/src/lib/domain/warren/models/user_warren/requests.rs +++ b/backend/src/lib/domain/warren/models/user_warren/requests/fetch_user_warrens.rs @@ -42,31 +42,3 @@ pub enum ListWarrensError { #[error(transparent)] Unknown(#[from] anyhow::Error), } - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct FetchUserWarrenRequest { - user_id: Uuid, - warren_id: Uuid, -} - -impl FetchUserWarrenRequest { - pub fn new(user_id: Uuid, warren_id: Uuid) -> Self { - Self { user_id, warren_id } - } - - pub fn user_id(&self) -> &Uuid { - &self.user_id - } - - pub fn warren_id(&self) -> &Uuid { - &self.warren_id - } -} - -#[derive(Debug, Error)] -pub enum FetchUserWarrenError { - #[error("This user warren does not exist")] - NotFound, - #[error(transparent)] - Unknown(#[from] anyhow::Error), -} diff --git a/backend/src/lib/domain/warren/models/user_warren/requests/list_user_warrens.rs b/backend/src/lib/domain/warren/models/user_warren/requests/list_user_warrens.rs new file mode 100644 index 0000000..4bad3ce --- /dev/null +++ b/backend/src/lib/domain/warren/models/user_warren/requests/list_user_warrens.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +/// A request to list all user warrens (admin only) +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ListUserWarrensRequest {} + +impl ListUserWarrensRequest { + pub fn new() -> Self { + Self {} + } +} + +#[derive(Debug, Error)] +pub enum ListUserWarrensError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/user_warren/requests/mod.rs b/backend/src/lib/domain/warren/models/user_warren/requests/mod.rs new file mode 100644 index 0000000..6bb3363 --- /dev/null +++ b/backend/src/lib/domain/warren/models/user_warren/requests/mod.rs @@ -0,0 +1,6 @@ +mod fetch_user_warren; +mod fetch_user_warrens; +mod list_user_warrens; +pub use fetch_user_warren::*; +pub use fetch_user_warrens::*; +pub use list_user_warrens::*; diff --git a/backend/src/lib/domain/warren/ports/metrics.rs b/backend/src/lib/domain/warren/ports/metrics.rs index 655c057..5a5dca2 100644 --- a/backend/src/lib/domain/warren/ports/metrics.rs +++ b/backend/src/lib/domain/warren/ports/metrics.rs @@ -59,9 +59,15 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static { fn record_user_creation_success(&self) -> impl Future + Send; fn record_user_creation_failure(&self) -> impl Future + Send; + fn record_user_edit_success(&self) -> impl Future + Send; + fn record_user_edit_failure(&self) -> impl Future + Send; + fn record_user_list_success(&self) -> impl Future + Send; fn record_user_list_failure(&self) -> impl Future + Send; + fn record_list_all_users_and_warrens_success(&self) -> impl Future + Send; + fn record_list_all_users_and_warrens_failure(&self) -> impl Future + Send; + fn record_user_deletion_success(&self) -> impl Future + Send; fn record_user_deletion_failure(&self) -> impl Future + Send; diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index 9745e09..71d4a16 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -20,9 +20,10 @@ use super::models::{ FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, }, user::{ - CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, ListUsersError, - ListUsersRequest, LoginUserError, LoginUserRequest, LoginUserResponse, RegisterUserError, - RegisterUserRequest, User, + CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, + EditUserRequest, ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, + ListAllUsersAndWarrensResponse, ListUsersError, ListUsersRequest, LoginUserError, + LoginUserRequest, LoginUserResponse, RegisterUserError, RegisterUserRequest, User, }, user_warren::{ UserWarren, @@ -121,11 +122,17 @@ pub trait AuthService: Clone + Send + Sync + 'static { &self, request: LoginUserRequest, ) -> impl Future> + Send; + /// An action that creates a user (MUST REQUIRE ADMIN PRIVILEGES) fn create_user( &self, request: AuthRequest, ) -> impl Future>> + Send; + /// An action that edits a user (MUST REQUIRE ADMIN PRIVILEGES) + fn edit_user( + &self, + request: AuthRequest, + ) -> impl Future>> + Send; /// An action that deletes a user (MUST REQUIRE ADMIN PRIVILEGES) fn delete_user( &self, @@ -137,6 +144,15 @@ pub trait AuthService: Clone + Send + Sync + 'static { request: AuthRequest, ) -> impl Future, AuthError>> + Send; + /// An action that lists all users, user warrens and warrens (MUST REQUIRE ADMIN PRIVILEGES) + fn list_all_users_and_warrens( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> impl Future< + Output = Result>, + > + Send; + fn create_auth_session( &self, request: CreateAuthSessionRequest, diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index 72e37fe..696ace8 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::domain::warren::models::{ auth_session::requests::FetchAuthSessionResponse, file::{File, FilePath}, - user::{LoginUserResponse, User}, + user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User}, user_warren::UserWarren, warren::{ CreateWarrenDirectoryResponse, DeleteWarrenDirectoryResponse, DeleteWarrenFileResponse, @@ -74,12 +74,18 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static { fn user_registered(&self, user: &User) -> impl Future + Send; fn user_logged_in(&self, response: &LoginUserResponse) -> impl Future + Send; fn user_created(&self, creator: &User, created: &User) -> impl Future + Send; + fn user_edited(&self, editor: &User, edited: &User) -> impl Future + Send; fn user_deleted(&self, deleter: &User, user: &User) -> impl Future + Send; /// Lists all the users (admin action) /// /// * `user`: The user who requested the list /// * `users`: The users from the list fn users_listed(&self, user: &User, users: &Vec) -> impl Future + Send; + fn all_users_and_warrens_listed( + &self, + user: &User, + response: &ListAllUsersAndWarrensResponse, + ) -> impl Future + Send; fn auth_session_created(&self, user_id: &Uuid) -> impl Future + Send; fn auth_session_fetched( diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index 5c4b233..0777f4f 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -12,14 +12,16 @@ use crate::domain::warren::models::{ FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, }, user::{ - CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, ListUsersError, - ListUsersRequest, User, VerifyUserPasswordError, VerifyUserPasswordRequest, + CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, + EditUserRequest, ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, + ListAllUsersAndWarrensResponse, ListUsersError, ListUsersRequest, User, + VerifyUserPasswordError, VerifyUserPasswordRequest, }, user_warren::{ UserWarren, requests::{ FetchUserWarrenError, FetchUserWarrenRequest, FetchUserWarrensError, - FetchUserWarrensRequest, + FetchUserWarrensRequest, ListUserWarrensError, ListUserWarrensRequest, }, }, warren::{ @@ -27,6 +29,8 @@ use crate::domain::warren::models::{ }, }; +use super::WarrenService; + pub trait WarrenRepository: Clone + Send + Sync + 'static { fn list_warrens( &self, @@ -74,6 +78,11 @@ pub trait AuthRepository: Clone + Send + Sync + 'static { &self, request: CreateUserRequest, ) -> impl Future> + Send; + /// An action that edits a user (MUST REQUIRE ADMIN PRIVILEGES) + fn edit_user( + &self, + request: EditUserRequest, + ) -> impl Future> + Send; fn list_users( &self, request: ListUsersRequest, @@ -83,11 +92,23 @@ pub trait AuthRepository: Clone + Send + Sync + 'static { request: DeleteUserRequest, ) -> impl Future> + Send; + /// An action that lists all users, user warrens and warrens (MUST REQUIRE ADMIN PRIVILEGES) + fn list_all_users_and_warrens( + &self, + request: ListAllUsersAndWarrensRequest, + warren_service: &WS, + ) -> impl Future> + Send; + fn verify_user_password( &self, request: VerifyUserPasswordRequest, ) -> impl Future> + Send; + fn list_user_warrens( + &self, + request: ListUserWarrensRequest, + ) -> impl Future, ListUserWarrensError>> + Send; + fn create_auth_session( &self, request: CreateAuthSessionRequest, diff --git a/backend/src/lib/domain/warren/service/auth.rs b/backend/src/lib/domain/warren/service/auth.rs index 7bbf7bb..b172b0a 100644 --- a/backend/src/lib/domain/warren/service/auth.rs +++ b/backend/src/lib/domain/warren/service/auth.rs @@ -11,8 +11,10 @@ use crate::{ }, user::{ CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, - ListUsersError, ListUsersRequest, LoginUserError, LoginUserRequest, - LoginUserResponse, RegisterUserError, RegisterUserRequest, User, + EditUserError, EditUserRequest, ListAllUsersAndWarrensError, + ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse, ListUsersError, + ListUsersRequest, LoginUserError, LoginUserRequest, LoginUserResponse, + RegisterUserError, RegisterUserRequest, User, }, user_warren::{ UserWarren, @@ -166,6 +168,32 @@ where result.map_err(AuthError::Custom) } + async fn edit_user( + &self, + request: AuthRequest, + ) -> Result> { + let (session, request) = request.unpack(); + + let response = self + .fetch_auth_session(FetchAuthSessionRequest::new(session.session_id().clone())) + .await?; + + if !response.user().admin() { + return Err(AuthError::InsufficientPermissions); + } + + let result = self.repository.edit_user(request).await; + + if let Ok(user) = result.as_ref() { + self.metrics.record_user_edit_success().await; + self.notifier.user_edited(response.user(), user).await; + } else { + self.metrics.record_user_edit_failure().await; + } + + result.map_err(AuthError::Custom) + } + async fn delete_user( &self, request: AuthRequest, @@ -222,6 +250,42 @@ where result.map_err(AuthError::Custom) } + async fn list_all_users_and_warrens( + &self, + request: AuthRequest, + warren_service: &WS, + ) -> Result> { + let (session, request) = request.unpack(); + + let session_response = self + .fetch_auth_session(FetchAuthSessionRequest::new(session.session_id().clone())) + .await?; + + if !session_response.user().admin() { + return Err(AuthError::InsufficientPermissions); + } + + let result = self + .repository + .list_all_users_and_warrens(request, warren_service) + .await; + + if let Ok(response) = result.as_ref() { + self.metrics + .record_list_all_users_and_warrens_success() + .await; + self.notifier + .all_users_and_warrens_listed(session_response.user(), response) + .await; + } else { + self.metrics + .record_list_all_users_and_warrens_failure() + .await; + } + + result.map_err(AuthError::Custom) + } + async fn create_auth_session( &self, request: CreateAuthSessionRequest, diff --git a/backend/src/lib/inbound/http/handlers/admin/edit_user.rs b/backend/src/lib/inbound/http/handlers/admin/edit_user.rs new file mode 100644 index 0000000..205e6e5 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/admin/edit_user.rs @@ -0,0 +1,103 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::Deserialize; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::warren::{ + models::{ + auth_session::AuthRequest, + user::{ + EditUserRequest, UserEmail, UserEmailError, UserName, UserNameError, UserPassword, + UserPasswordError, + }, + }, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + handlers::{UserData, extractors::SessionIdHeader}, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, Error)] +pub(super) enum ParseEditUserHttpRequestError { + #[error(transparent)] + UserName(#[from] UserNameError), + #[error(transparent)] + UserEmail(#[from] UserEmailError), + #[error(transparent)] + UserPassword(#[from] UserPasswordError), +} + +impl From for ApiError { + fn from(value: ParseEditUserHttpRequestError) -> Self { + match value { + ParseEditUserHttpRequestError::UserName(err) => match err { + UserNameError::Empty => { + Self::BadRequest("The username must not be empty".to_string()) + } + }, + ParseEditUserHttpRequestError::UserEmail(err) => match err { + UserEmailError::Invalid => Self::BadRequest("The email is invalid".to_string()), + UserEmailError::Empty => { + Self::BadRequest("The email must not be empty".to_string()) + } + }, + ParseEditUserHttpRequestError::UserPassword(err) => Self::BadRequest( + match err { + UserPasswordError::Empty => "The provided password is empty", + // Best not give a potential bad actor any hints since this is the login and + // not the registration + UserPasswordError::LeadingWhitespace + | UserPasswordError::TrailingWhitespace + | UserPasswordError::TooShort + | UserPasswordError::TooLong => "", + } + .to_string(), + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct EditUserHttpRequestBody { + id: Uuid, + name: String, + email: String, + password: Option, + admin: bool, +} + +impl EditUserHttpRequestBody { + fn try_into_domain(self) -> Result { + let name = UserName::new(&self.name)?; + let email = UserEmail::new(&self.email)?; + let password = if let Some(password) = self.password.as_ref() { + Some(UserPassword::new(password)?) + } else { + None + }; + + Ok(EditUserRequest::new( + self.id, name, email, password, self.admin, + )) + } +} + +pub async fn edit_user( + State(state): State>, + SessionIdHeader(session): SessionIdHeader, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = request.try_into_domain()?; + + state + .auth_service + .edit_user(AuthRequest::new(session, domain_request)) + .await + .map(|user| ApiSuccess::new(StatusCode::CREATED, user.into())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/admin/list_all_users_and_warrens.rs b/backend/src/lib/inbound/http/handlers/admin/list_all_users_and_warrens.rs new file mode 100644 index 0000000..97dea8d --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/admin/list_all_users_and_warrens.rs @@ -0,0 +1,52 @@ +use axum::{extract::State, http::StatusCode}; +use serde::Serialize; + +use crate::{ + domain::warren::{ + models::{ + auth_session::AuthRequest, + user::{ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse}, + }, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + handlers::{UserData, UserWarrenData, WarrenData, extractors::SessionIdHeader}, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(super) struct ListAllUsersAndWarrensHttpResponseBody { + users: Vec, + user_warrens: Vec, + warrens: Vec, +} + +impl From for ListAllUsersAndWarrensHttpResponseBody { + fn from(value: ListAllUsersAndWarrensResponse) -> Self { + let (users, user_warrens, warrens) = value.unpack(); + + Self { + users: users.into_iter().map(Into::into).collect(), + user_warrens: user_warrens.into_iter().map(Into::into).collect(), + warrens: warrens.into_iter().map(Into::into).collect(), + } + } +} + +pub async fn list_all_users_and_warrens( + State(state): State>, + SessionIdHeader(session): SessionIdHeader, +) -> Result, ApiError> { + state + .auth_service + .list_all_users_and_warrens( + AuthRequest::new(session, ListAllUsersAndWarrensRequest::new()), + state.warren_service.as_ref(), + ) + .await + .map(|response| ApiSuccess::new(StatusCode::OK, response.into())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/admin/mod.rs b/backend/src/lib/inbound/http/handlers/admin/mod.rs index 75dcf14..6a8ff26 100644 --- a/backend/src/lib/inbound/http/handlers/admin/mod.rs +++ b/backend/src/lib/inbound/http/handlers/admin/mod.rs @@ -1,14 +1,18 @@ mod create_user; mod delete_user; +mod edit_user; +mod list_all_users_and_warrens; mod list_users; use create_user::create_user; use delete_user::delete_user; +use edit_user::edit_user; +use list_all_users_and_warrens::list_all_users_and_warrens; use list_users::list_users; use axum::{ Router, - routing::{delete, get, post}, + routing::{delete, get, patch, post}, }; use crate::{ @@ -18,7 +22,9 @@ use crate::{ pub fn routes() -> Router> { Router::new() + .route("/all", get(list_all_users_and_warrens)) .route("/users", get(list_users)) .route("/users", post(create_user)) + .route("/users", patch(edit_user)) .route("/users", delete(delete_user)) } diff --git a/backend/src/lib/inbound/http/handlers/mod.rs b/backend/src/lib/inbound/http/handlers/mod.rs index 9ed671c..1f15873 100644 --- a/backend/src/lib/inbound/http/handlers/mod.rs +++ b/backend/src/lib/inbound/http/handlers/mod.rs @@ -1,7 +1,7 @@ use serde::Serialize; use uuid::Uuid; -use crate::domain::warren::models::user::User; +use crate::domain::warren::models::{user::User, user_warren::UserWarren, warren::Warren}; pub mod admin; pub mod auth; @@ -28,3 +28,49 @@ impl From for UserData { } } } + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// A user warren that can be safely sent to the client +pub(super) struct UserWarrenData { + user_id: Uuid, + warren_id: Uuid, + can_create_children: bool, + can_list_files: bool, + can_read_files: bool, + can_modify_files: bool, + can_delete_files: bool, + can_delete_warren: bool, +} + +impl From for UserWarrenData { + fn from(value: UserWarren) -> Self { + Self { + user_id: *value.user_id(), + warren_id: *value.warren_id(), + can_create_children: value.can_create_children(), + can_list_files: value.can_list_files(), + can_read_files: value.can_read_files(), + can_modify_files: value.can_modify_files(), + can_delete_files: value.can_delete_files(), + can_delete_warren: value.can_delete_warren(), + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// A warren that can be safely sent to the client +pub(super) struct WarrenData { + id: Uuid, + name: String, +} + +impl From for WarrenData { + fn from(value: Warren) -> Self { + Self { + id: *value.id(), + name: value.name().to_string(), + } + } +} diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs index 0277bc0..a08eb2a 100644 --- a/backend/src/lib/outbound/metrics_debug_logger.rs +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -145,6 +145,13 @@ impl AuthMetrics for MetricsDebugLogger { tracing::debug!("[Metrics] User creation failed"); } + async fn record_user_edit_success(&self) { + tracing::debug!("[Metrics] User edit succeeded"); + } + async fn record_user_edit_failure(&self) { + tracing::debug!("[Metrics] User edit failed"); + } + async fn record_user_deletion_success(&self) { tracing::debug!("[Metrics] User deletion succeeded"); } @@ -152,13 +159,20 @@ impl AuthMetrics for MetricsDebugLogger { tracing::debug!("[Metrics] User deletion failed"); } - async fn record_user_list_success(&self) -> () { + async fn record_user_list_success(&self) { tracing::debug!("[Metrics] User list succeeded"); } - async fn record_user_list_failure(&self) -> () { + async fn record_user_list_failure(&self) { tracing::debug!("[Metrics] User list failed"); } + async fn record_list_all_users_and_warrens_success(&self) { + tracing::debug!("[Metrics] Users, warrens and user warrens list succeeded"); + } + async fn record_list_all_users_and_warrens_failure(&self) { + tracing::debug!("[Metrics] Users, warrens and user warrens list failed"); + } + async fn record_auth_session_creation_success(&self) { tracing::debug!("[Metrics] Auth session creation succeeded"); } @@ -180,10 +194,10 @@ impl AuthMetrics for MetricsDebugLogger { tracing::debug!("[Metrics] Auth warren list failed"); } - async fn record_auth_fetch_user_warrens_success(&self) -> () { + async fn record_auth_fetch_user_warrens_success(&self) { tracing::debug!("[Metrics] Auth user warren id fetch succeeded"); } - async fn record_auth_fetch_user_warrens_failure(&self) -> () { + async fn record_auth_fetch_user_warrens_failure(&self) { tracing::debug!("[Metrics] Auth user warren id fetch failed"); } diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 5a24d01..10a5909 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -4,7 +4,7 @@ use crate::domain::warren::{ models::{ auth_session::requests::FetchAuthSessionResponse, file::{File, FilePath}, - user::{LoginUserResponse, User}, + user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User}, user_warren::UserWarren, warren::{ CreateWarrenDirectoryResponse, DeleteWarrenDirectoryResponse, DeleteWarrenFileResponse, @@ -129,6 +129,14 @@ impl AuthNotifier for NotifierDebugLogger { ); } + async fn user_edited(&self, editor: &User, user: &User) { + tracing::debug!( + "[Notifier] Admin user {} edited user {}", + editor.name(), + user.id() + ); + } + async fn user_deleted(&self, deleter: &User, user: &User) { tracing::debug!( "[Notifier] Admin user {} deleted user {}", @@ -145,6 +153,20 @@ impl AuthNotifier for NotifierDebugLogger { ); } + async fn all_users_and_warrens_listed( + &self, + user: &User, + response: &ListAllUsersAndWarrensResponse, + ) { + tracing::debug!( + "[Notifier] Admin user {} listed {} user(s), {} warren(s) and {} user warren(s)", + user.name(), + response.users().len(), + response.warrens().len(), + response.user_warrens().len(), + ); + } + async fn user_logged_in(&self, response: &LoginUserResponse) { tracing::debug!("[Notifier] Logged in user {}", response.user().name()); } diff --git a/backend/src/lib/outbound/postgres.rs b/backend/src/lib/outbound/postgres.rs index 5f12e0f..8f7d158 100644 --- a/backend/src/lib/outbound/postgres.rs +++ b/backend/src/lib/outbound/postgres.rs @@ -25,22 +25,23 @@ use crate::domain::warren::{ }, }, user::{ - CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, ListUsersError, - ListUsersRequest, User, UserEmail, UserName, UserPassword, VerifyUserPasswordError, - VerifyUserPasswordRequest, + CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, + EditUserRequest, ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, + ListAllUsersAndWarrensResponse, ListUsersError, ListUsersRequest, User, UserEmail, + UserName, UserPassword, VerifyUserPasswordError, VerifyUserPasswordRequest, }, user_warren::{ UserWarren, requests::{ FetchUserWarrenError, FetchUserWarrenRequest, FetchUserWarrensError, - FetchUserWarrensRequest, + FetchUserWarrensRequest, ListUserWarrensError, ListUserWarrensRequest, }, }, warren::{ FetchWarrenError, FetchWarrenRequest, FetchWarrensError, FetchWarrensRequest, Warren, }, }, - ports::{AuthRepository, WarrenRepository}, + ports::{AuthRepository, WarrenRepository, WarrenService}, }; #[derive(Debug, Clone)] @@ -138,12 +139,8 @@ impl Postgres { password: &UserPassword, is_admin: bool, ) -> anyhow::Result { - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(password.as_str().as_bytes(), &salt) - .map_err(|_| anyhow!("Failed to hash password"))? - .to_string(); + let password_hash = + hash_password(password).map_err(|e| anyhow!("Failed to hash password: {e:?}"))?; let mut tx = connection.begin().await?; @@ -176,6 +173,73 @@ impl Postgres { Ok(user) } + async fn edit_user( + &self, + connection: &mut PgConnection, + id: &Uuid, + name: &UserName, + email: &UserEmail, + password: Option<&UserPassword>, + is_admin: bool, + ) -> anyhow::Result { + let password_hash = if let Some(password) = password { + Some(hash_password(password).map_err(|e| anyhow!("Failed to hash password: {e:?}"))?) + } else { + None + }; + + let mut tx = connection.begin().await?; + + let user: User = sqlx::query_as( + "UPDATE + users + SET + name = $2, + email = $3, + hash = COALESCE($4, hash), + admin = $5 + WHERE + id = $1 + RETURNING + * + ", + ) + .bind(id) + .bind(name) + .bind(email) + .bind(password_hash) + .bind(is_admin) + .fetch_one(&mut *tx) + .await?; + + self.delete_user_sessions(&mut *tx, id).await?; + + tx.commit().await?; + + Ok(user) + } + + async fn delete_user_sessions( + &self, + connection: &mut PgConnection, + user_id: &Uuid, + ) -> Result { + let rows_affected = sqlx::query( + " + DELETE FROM + auth_sessions + WHERE + user_id = $1 + ", + ) + .bind(user_id) + .execute(connection) + .await? + .rows_affected(); + + Ok(rows_affected) + } + async fn delete_user_from_database( &self, connection: &mut PgConnection, @@ -307,7 +371,7 @@ impl Postgres { &self, connection: &mut PgConnection, session_id: &AuthSessionId, - ) -> anyhow::Result { + ) -> Result { let session: AuthSession = sqlx::query_as( " SELECT @@ -330,7 +394,7 @@ impl Postgres { connection: &mut PgConnection, user_id: &Uuid, ) -> Result, sqlx::Error> { - let ids: Vec = sqlx::query_as( + let user_warrens: Vec = sqlx::query_as( " SELECT * @@ -344,7 +408,25 @@ impl Postgres { .fetch_all(connection) .await?; - Ok(ids) + Ok(user_warrens) + } + + async fn get_all_user_warrens( + &self, + connection: &mut PgConnection, + ) -> Result, sqlx::Error> { + let user_warrens: Vec = sqlx::query_as( + " + SELECT + * + FROM + user_warrens + ", + ) + .fetch_all(connection) + .await?; + + Ok(user_warrens) } async fn get_user_warren( @@ -379,6 +461,8 @@ impl Postgres { * FROM users + ORDER BY + created_at ASC ", ) .fetch_all(connection) @@ -453,6 +537,28 @@ impl AuthRepository for Postgres { Ok(user) } + async fn edit_user(&self, request: EditUserRequest) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let user = self + .edit_user( + &mut connection, + request.user_id(), + request.name(), + request.email(), + request.password(), + request.admin(), + ) + .await + .context(format!("Failed to edit user"))?; + + Ok(user) + } + async fn delete_user(&self, request: DeleteUserRequest) -> Result { let mut connection = self .pool @@ -528,7 +634,13 @@ impl AuthRepository for Postgres { let session = self .get_auth_session(&mut connection, request.session_id()) .await - .context("Failed to get auth session")?; + .map_err(|e| { + if is_not_found_error(&e) { + FetchAuthSessionError::NotFound + } else { + anyhow!("Failed to get auth session: {e:?}").into() + } + })?; let user = self .get_user_from_id(&mut connection, session.user_id()) .await @@ -547,12 +659,30 @@ impl AuthRepository for Postgres { .await .context("Failed to get a PostgreSQL connection")?; - let warren_ids = self + let user_warrens = self .get_user_warrens(&mut connection, request.user_id()) .await .context("Failed to get user warrens")?; - Ok(warren_ids) + Ok(user_warrens) + } + + async fn list_user_warrens( + &self, + _request: ListUserWarrensRequest, + ) -> Result, ListUserWarrensError> { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let user_warrens = self + .get_all_user_warrens(&mut connection) + .await + .context("Failed to get all user warrens")?; + + Ok(user_warrens) } async fn fetch_user_warren( @@ -590,8 +720,53 @@ impl AuthRepository for Postgres { Ok(users) } + + async fn list_all_users_and_warrens( + &self, + _request: ListAllUsersAndWarrensRequest, + warren_service: &WS, + ) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let users = self + .fetch_users(&mut connection) + .await + .context("Failed to fetch all users")?; + let user_warrens = self + .get_all_user_warrens(&mut connection) + .await + .context("Failed to fetch all user warrens")?; + let warrens = warren_service + .list_warrens(FetchWarrensRequest::new( + user_warrens + .iter() + .map(|uw| uw.warren_id().clone()) + .collect(), + )) + .await + .context("Failed to get warrens")?; + + Ok(ListAllUsersAndWarrensResponse::new( + users, + user_warrens, + warrens, + )) + } } fn is_not_found_error(err: &sqlx::Error) -> bool { matches!(err, sqlx::Error::RowNotFound) } + +fn hash_password(password: &UserPassword) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + + argon2 + .hash_password(password.as_str().as_bytes(), &salt) + .map(|h| h.to_string()) +} diff --git a/frontend/bun.lock b/frontend/bun.lock index 1ca1064..cc82c64 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -11,6 +11,7 @@ "@nuxt/test-utils": "3.19.2", "@pinia/nuxt": "^0.11.1", "@tailwindcss/vite": "^4.1.11", + "@vee-validate/yup": "^4.15.1", "@vee-validate/zod": "^4.15.1", "@vueuse/core": "^13.5.0", "byte-size": "^9.0.1", @@ -29,7 +30,7 @@ "vue": "^3.5.17", "vue-router": "^4.5.1", "vue-sonner": "^2.0.1", - "zod": "^4.0.5", + "yup": "^1.6.1", }, "devDependencies": { "@iconify-json/lucide": "^1.2.57", @@ -538,6 +539,8 @@ "@unhead/vue": ["@unhead/vue@2.0.12", "", { "dependencies": { "hookable": "^5.5.3", "unhead": "2.0.12" }, "peerDependencies": { "vue": ">=3.5.13" } }, "sha512-WFaiCVbBd39FK6Bx3GQskhgT9s45Vjx6dRQegYheVwU1AnF+FAfJVgWbrl21p6fRJcLAFp0xDz6wE18JYBM0eQ=="], + "@vee-validate/yup": ["@vee-validate/yup@4.15.1", "", { "dependencies": { "type-fest": "^4.8.3", "vee-validate": "4.15.1" }, "peerDependencies": { "yup": "^1.3.2" } }, "sha512-+u6lI1IZftjHphj+mTCPJRruwBBwv1IKKCI1EFm6ipQroAPibkS5M8UNX+yeVYG5++ix6m1rsv4/SJvJJQTWJg=="], + "@vee-validate/zod": ["@vee-validate/zod@4.15.1", "", { "dependencies": { "type-fest": "^4.8.3", "vee-validate": "4.15.1" }, "peerDependencies": { "zod": "^3.24.0" } }, "sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA=="], "@vercel/nft": ["@vercel/nft@0.29.4", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^10.4.5", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-6lLqMNX3TuycBPABycx7A9F1bHQR7kiQln6abjFbPrf5C/05qHM9M5E4PeTE59c7z8g6vHnx1Ioihb2AQl7BTA=="], @@ -1548,6 +1551,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "property-expr": ["property-expr@2.0.6", "", {}, "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="], + "protocols": ["protocols@2.0.2", "", {}, "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], @@ -1752,6 +1757,8 @@ "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + "tiny-case": ["tiny-case@1.0.3", "", {}, "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -1770,6 +1777,8 @@ "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "toposort": ["toposort@2.0.2", "", {}, "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -1928,6 +1937,8 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="], + "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="], @@ -2242,6 +2253,8 @@ "yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "yup/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], diff --git a/frontend/components/AppBreadcrumbs.vue b/frontend/components/AppBreadcrumbs.vue index 4cd94b0..3153a82 100644 --- a/frontend/components/AppBreadcrumbs.vue +++ b/frontend/components/AppBreadcrumbs.vue @@ -81,9 +81,14 @@ const warrenCrumbs = computed(() => {