From 7f2aac12e68a215e459c6ef66d8b85ffcdc1278c Mon Sep 17 00:00:00 2001 From: 409 <409dev@protonmail.com> Date: Sat, 19 Jul 2025 22:18:49 +0200 Subject: [PATCH] create users through admin page --- .../lib/domain/warren/models/user/requests.rs | 50 +++++++ .../src/lib/domain/warren/ports/metrics.rs | 3 + backend/src/lib/domain/warren/ports/mod.rs | 9 +- .../src/lib/domain/warren/ports/notifier.rs | 2 + .../src/lib/domain/warren/ports/repository.rs | 8 +- backend/src/lib/domain/warren/service/auth.rs | 34 ++++- backend/src/lib/inbound/http/errors.rs | 11 +- .../http/handlers/admin/create_user.rs | 95 ++++++++++++ .../lib/inbound/http/handlers/admin/mod.rs | 13 ++ .../http/handlers/auth/fetch_session.rs | 4 +- .../lib/inbound/http/handlers/auth/login.rs | 4 +- backend/src/lib/inbound/http/handlers/mod.rs | 5 +- backend/src/lib/inbound/http/mod.rs | 2 + .../src/lib/outbound/metrics_debug_logger.rs | 7 + .../src/lib/outbound/notifier_debug_logger.rs | 8 ++ backend/src/lib/outbound/postgres.rs | 6 +- frontend/bun.lock | 11 +- frontend/components/AppSidebar.vue | 4 + frontend/components/SidebarAdminMenu.vue | 53 +++++++ frontend/components/SidebarUser.vue | 2 +- .../components/admin/CreateUserDialog.vue | 132 +++++++++++++++++ .../components/admin/DeleteUserDialog.vue | 90 ++++++++++++ frontend/components/admin/UserListing.vue | 26 ++++ .../ui/alert-dialog/AlertDialog.vue | 14 ++ .../ui/alert-dialog/AlertDialogAction.vue | 17 +++ .../ui/alert-dialog/AlertDialogCancel.vue | 24 ++++ .../ui/alert-dialog/AlertDialogContent.vue | 41 ++++++ .../alert-dialog/AlertDialogDescription.vue | 23 +++ .../ui/alert-dialog/AlertDialogFooter.vue | 22 +++ .../ui/alert-dialog/AlertDialogHeader.vue | 17 +++ .../ui/alert-dialog/AlertDialogTitle.vue | 20 +++ .../ui/alert-dialog/AlertDialogTrigger.vue | 11 ++ frontend/components/ui/alert-dialog/index.ts | 9 ++ frontend/components/ui/checkbox/Checkbox.vue | 34 +++++ frontend/components/ui/checkbox/index.ts | 1 + frontend/components/ui/form/FormControl.vue | 17 +++ .../components/ui/form/FormDescription.vue | 21 +++ frontend/components/ui/form/FormItem.vue | 22 +++ frontend/components/ui/form/FormLabel.vue | 25 ++++ frontend/components/ui/form/FormMessage.vue | 22 +++ frontend/components/ui/form/index.ts | 7 + frontend/components/ui/form/injectionKeys.ts | 4 + frontend/components/ui/form/useFormField.ts | 30 ++++ frontend/components/ui/input/Input.vue | 7 + frontend/components/ui/label/Label.vue | 25 ++++ frontend/components/ui/label/index.ts | 1 + frontend/components/ui/switch/Switch.vue | 38 +++++ frontend/components/ui/switch/index.ts | 1 + frontend/layouts/admin.vue | 9 ++ frontend/lib/api/admin/createUser.ts | 43 ++++++ frontend/lib/api/auth/login.ts | 5 +- frontend/lib/schemas/admin.ts | 9 ++ frontend/lib/schemas/auth.ts | 23 +++ frontend/middleware/is-admin.ts | 9 ++ frontend/nuxt.config.ts | 6 + frontend/package.json | 5 +- frontend/pages/admin/index.vue | 76 ++++++++++ frontend/pages/admin/stats.vue | 9 ++ frontend/pages/admin/users.vue | 9 ++ frontend/pages/admin/warrens.vue | 9 ++ frontend/pages/index.vue | 4 - frontend/pages/login.vue | 115 ++++++++------- frontend/pages/register.vue | 135 ++++++++++-------- frontend/stores/admin.ts | 30 ++++ frontend/types/auth.ts | 5 +- 65 files changed, 1394 insertions(+), 139 deletions(-) create mode 100644 backend/src/lib/inbound/http/handlers/admin/create_user.rs create mode 100644 backend/src/lib/inbound/http/handlers/admin/mod.rs create mode 100644 frontend/components/SidebarAdminMenu.vue create mode 100644 frontend/components/admin/CreateUserDialog.vue create mode 100644 frontend/components/admin/DeleteUserDialog.vue create mode 100644 frontend/components/admin/UserListing.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialog.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialogAction.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialogCancel.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialogContent.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialogDescription.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialogFooter.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialogHeader.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialogTitle.vue create mode 100644 frontend/components/ui/alert-dialog/AlertDialogTrigger.vue create mode 100644 frontend/components/ui/alert-dialog/index.ts create mode 100644 frontend/components/ui/checkbox/Checkbox.vue create mode 100644 frontend/components/ui/checkbox/index.ts create mode 100644 frontend/components/ui/form/FormControl.vue create mode 100644 frontend/components/ui/form/FormDescription.vue create mode 100644 frontend/components/ui/form/FormItem.vue create mode 100644 frontend/components/ui/form/FormLabel.vue create mode 100644 frontend/components/ui/form/FormMessage.vue create mode 100644 frontend/components/ui/form/index.ts create mode 100644 frontend/components/ui/form/injectionKeys.ts create mode 100644 frontend/components/ui/form/useFormField.ts create mode 100644 frontend/components/ui/label/Label.vue create mode 100644 frontend/components/ui/label/index.ts create mode 100644 frontend/components/ui/switch/Switch.vue create mode 100644 frontend/components/ui/switch/index.ts create mode 100644 frontend/layouts/admin.vue create mode 100644 frontend/lib/api/admin/createUser.ts create mode 100644 frontend/lib/schemas/admin.ts create mode 100644 frontend/lib/schemas/auth.ts create mode 100644 frontend/middleware/is-admin.ts create mode 100644 frontend/pages/admin/index.vue create mode 100644 frontend/pages/admin/stats.vue create mode 100644 frontend/pages/admin/users.vue create mode 100644 frontend/pages/admin/warrens.vue create mode 100644 frontend/stores/admin.ts diff --git a/backend/src/lib/domain/warren/models/user/requests.rs b/backend/src/lib/domain/warren/models/user/requests.rs index a7e8d25..5e2c006 100644 --- a/backend/src/lib/domain/warren/models/user/requests.rs +++ b/backend/src/lib/domain/warren/models/user/requests.rs @@ -11,8 +11,16 @@ pub struct RegisterUserRequest { password: UserPassword, } +impl From for CreateUserRequest { + fn from(value: RegisterUserRequest) -> Self { + Self::new(value.name, value.email, value.password, false) + } +} + #[derive(Debug, Error)] pub enum RegisterUserError { + #[error(transparent)] + CreateUser(#[from] CreateUserError), #[error(transparent)] Unknown(#[from] anyhow::Error), } @@ -178,3 +186,45 @@ pub enum LoginUserError { #[error(transparent)] Unknown(#[from] anyhow::Error), } + +/// An admin request to create a new user +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateUserRequest { + name: UserName, + email: UserEmail, + password: UserPassword, + admin: bool, +} + +impl CreateUserRequest { + pub fn new(name: UserName, email: UserEmail, password: UserPassword, admin: bool) -> Self { + Self { + name, + email, + password, + admin, + } + } + + pub fn name(&self) -> &UserName { + &self.name + } + + pub fn email(&self) -> &UserEmail { + &self.email + } + + pub fn password(&self) -> &UserPassword { + &self.password + } + + pub fn admin(&self) -> bool { + self.admin + } +} + +#[derive(Debug, Error)] +pub enum CreateUserError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/ports/metrics.rs b/backend/src/lib/domain/warren/ports/metrics.rs index 2fa28f0..45a57fb 100644 --- a/backend/src/lib/domain/warren/ports/metrics.rs +++ b/backend/src/lib/domain/warren/ports/metrics.rs @@ -56,6 +56,9 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static { fn record_user_login_success(&self) -> impl Future + Send; fn record_user_login_failure(&self) -> impl Future + Send; + fn record_user_creation_success(&self) -> impl Future + Send; + fn record_user_creation_failure(&self) -> impl Future + Send; + fn record_auth_session_creation_success(&self) -> impl Future + Send; fn record_auth_session_creation_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 6372254..9bb5404 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -20,8 +20,8 @@ use super::models::{ FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, }, user::{ - LoginUserError, LoginUserRequest, LoginUserResponse, RegisterUserError, - RegisterUserRequest, User, + CreateUserError, CreateUserRequest, LoginUserError, LoginUserRequest, LoginUserResponse, + RegisterUserError, RegisterUserRequest, User, }, user_warren::{ UserWarren, @@ -120,6 +120,11 @@ 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; fn create_auth_session( &self, diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index aab309f..b64edc6 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -73,6 +73,8 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static { 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 auth_session_created(&self, user_id: &Uuid) -> impl Future + Send; fn auth_session_fetched( &self, diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index fec8fb8..6f8f260 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -12,7 +12,7 @@ use crate::domain::warren::models::{ FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, }, user::{ - RegisterUserError, RegisterUserRequest, User, VerifyUserPasswordError, + CreateUserError, CreateUserRequest, User, VerifyUserPasswordError, VerifyUserPasswordRequest, }, user_warren::{ @@ -70,10 +70,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static { } pub trait AuthRepository: Clone + Send + Sync + 'static { - fn register_user( + fn create_user( &self, - request: RegisterUserRequest, - ) -> impl Future> + Send; + request: CreateUserRequest, + ) -> impl Future> + Send; fn verify_user_password( &self, request: VerifyUserPasswordRequest, diff --git a/backend/src/lib/domain/warren/service/auth.rs b/backend/src/lib/domain/warren/service/auth.rs index 622455f..8beb1a6 100644 --- a/backend/src/lib/domain/warren/service/auth.rs +++ b/backend/src/lib/domain/warren/service/auth.rs @@ -10,8 +10,8 @@ use crate::{ }, }, user::{ - LoginUserError, LoginUserRequest, LoginUserResponse, RegisterUserError, - RegisterUserRequest, User, + CreateUserError, CreateUserRequest, LoginUserError, LoginUserRequest, + LoginUserResponse, RegisterUserError, RegisterUserRequest, User, }, user_warren::{ UserWarren, @@ -99,7 +99,7 @@ where N: AuthNotifier, { async fn register_user(&self, request: RegisterUserRequest) -> Result { - let result = self.repository.register_user(request).await; + let result = self.repository.create_user(request.into()).await; if let Ok(user) = result.as_ref() { self.metrics.record_user_registration_success().await; @@ -108,7 +108,7 @@ where self.metrics.record_user_registration_failure().await; } - result + result.map_err(Into::into) } async fn login_user( @@ -139,6 +139,32 @@ where result.map_err(Into::into) } + async fn create_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.create_user(request).await; + + if let Ok(user) = result.as_ref() { + self.metrics.record_user_creation_success().await; + self.notifier.user_created(response.user(), user).await; + } else { + self.metrics.record_user_creation_failure().await; + } + + result.map_err(AuthError::Custom) + } + async fn create_auth_session( &self, request: CreateAuthSessionRequest, diff --git a/backend/src/lib/inbound/http/errors.rs b/backend/src/lib/inbound/http/errors.rs index b8cab09..29e2d5c 100644 --- a/backend/src/lib/inbound/http/errors.rs +++ b/backend/src/lib/inbound/http/errors.rs @@ -2,7 +2,7 @@ use crate::{ domain::warren::models::{ auth_session::{AuthError, requests::FetchAuthSessionError}, file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError}, - user::{LoginUserError, RegisterUserError, VerifyUserPasswordError}, + user::{CreateUserError, LoginUserError, RegisterUserError, VerifyUserPasswordError}, user_warren::requests::FetchUserWarrenError, warren::{ CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError, @@ -136,6 +136,7 @@ impl From for ApiError { impl From for ApiError { fn from(value: RegisterUserError) -> Self { match value { + RegisterUserError::CreateUser(err) => err.into(), RegisterUserError::Unknown(error) => Self::InternalServerError(error.to_string()), } } @@ -197,3 +198,11 @@ impl From> for ApiError { } } } + +impl From for ApiError { + fn from(value: CreateUserError) -> Self { + match value { + CreateUserError::Unknown(err) => Self::InternalServerError(err.to_string()), + } + } +} diff --git a/backend/src/lib/inbound/http/handlers/admin/create_user.rs b/backend/src/lib/inbound/http/handlers/admin/create_user.rs new file mode 100644 index 0000000..32aeae8 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/admin/create_user.rs @@ -0,0 +1,95 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::Deserialize; +use thiserror::Error; + +use crate::{ + domain::warren::{ + models::{ + auth_session::AuthRequest, + user::{ + CreateUserRequest, 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 ParseCreateUserHttpRequestError { + #[error(transparent)] + UserName(#[from] UserNameError), + #[error(transparent)] + UserEmail(#[from] UserEmailError), + #[error(transparent)] + UserPassword(#[from] UserPasswordError), +} + +impl From for ApiError { + fn from(value: ParseCreateUserHttpRequestError) -> Self { + match value { + ParseCreateUserHttpRequestError::UserName(err) => match err { + UserNameError::Empty => { + Self::BadRequest("The username must not be empty".to_string()) + } + }, + ParseCreateUserHttpRequestError::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()) + } + }, + ParseCreateUserHttpRequestError::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 CreateUserHttpRequestBody { + name: String, + email: String, + password: String, + admin: bool, +} + +impl CreateUserHttpRequestBody { + fn try_into_domain(self) -> Result { + let name = UserName::new(&self.name)?; + let email = UserEmail::new(&self.email)?; + let password = UserPassword::new(&self.password)?; + + Ok(CreateUserRequest::new(name, email, password, self.admin)) + } +} + +pub async fn create_user( + State(state): State>, + SessionIdHeader(session): SessionIdHeader, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = request.try_into_domain()?; + + state + .auth_service + .create_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/mod.rs b/backend/src/lib/inbound/http/handlers/admin/mod.rs new file mode 100644 index 0000000..8478ac7 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/admin/mod.rs @@ -0,0 +1,13 @@ +mod create_user; +use create_user::create_user; + +use axum::{Router, routing::post}; + +use crate::{ + domain::warren::ports::{AuthService, WarrenService}, + inbound::http::AppState, +}; + +pub fn routes() -> Router> { + Router::new().route("/users", post(create_user)) +} diff --git a/backend/src/lib/inbound/http/handlers/auth/fetch_session.rs b/backend/src/lib/inbound/http/handlers/auth/fetch_session.rs index 0a099d4..e113883 100644 --- a/backend/src/lib/inbound/http/handlers/auth/fetch_session.rs +++ b/backend/src/lib/inbound/http/handlers/auth/fetch_session.rs @@ -14,7 +14,7 @@ use crate::{ }, inbound::http::{ AppState, - handlers::SessionUser, + handlers::UserData, responses::{ApiError, ApiSuccess}, }, }; @@ -22,7 +22,7 @@ use crate::{ #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct FetchSessionResponseBody { - user: SessionUser, + user: UserData, expires_at: i64, } diff --git a/backend/src/lib/inbound/http/handlers/auth/login.rs b/backend/src/lib/inbound/http/handlers/auth/login.rs index 398741f..319785b 100644 --- a/backend/src/lib/inbound/http/handlers/auth/login.rs +++ b/backend/src/lib/inbound/http/handlers/auth/login.rs @@ -12,7 +12,7 @@ use crate::{ }, inbound::http::{ AppState, - handlers::SessionUser, + handlers::UserData, responses::{ApiError, ApiSuccess}, }, }; @@ -70,7 +70,7 @@ impl LoginUserHttpRequestBody { #[derive(Debug, Clone, PartialEq, Serialize)] pub struct LoginResponseBody { token: String, - user: SessionUser, + user: UserData, expires_at: i64, } diff --git a/backend/src/lib/inbound/http/handlers/mod.rs b/backend/src/lib/inbound/http/handlers/mod.rs index 42fccd0..98c4450 100644 --- a/backend/src/lib/inbound/http/handlers/mod.rs +++ b/backend/src/lib/inbound/http/handlers/mod.rs @@ -3,6 +3,7 @@ use uuid::Uuid; use crate::domain::warren::models::user::User; +pub mod admin; pub mod auth; pub mod extractors; pub mod warrens; @@ -10,14 +11,14 @@ pub mod warrens; #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] /// A session user that can be safely sent to the client -pub struct SessionUser { +pub(super) struct UserData { id: Uuid, name: String, email: String, admin: bool, } -impl From for SessionUser { +impl From for UserData { fn from(value: User) -> Self { Self { id: *value.id(), diff --git a/backend/src/lib/inbound/http/mod.rs b/backend/src/lib/inbound/http/mod.rs index 51a009a..69d5a6e 100644 --- a/backend/src/lib/inbound/http/mod.rs +++ b/backend/src/lib/inbound/http/mod.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use anyhow::Context; use axum::{Router, http::HeaderValue}; +use handlers::admin; use handlers::auth; use handlers::warrens; use tokio::net::TcpListener; @@ -126,4 +127,5 @@ fn api_routes() -> Router> Router::new() .nest("/auth", auth::routes()) .nest("/warrens", warrens::routes()) + .nest("/admin", admin::routes()) } diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs index c94b723..debb2c9 100644 --- a/backend/src/lib/outbound/metrics_debug_logger.rs +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -138,6 +138,13 @@ impl AuthMetrics for MetricsDebugLogger { tracing::debug!("[Metrics] User login failed"); } + async fn record_user_creation_success(&self) { + tracing::debug!("[Metrics] User creation succeeded"); + } + async fn record_user_creation_failure(&self) { + tracing::debug!("[Metrics] User creation failed"); + } + async fn record_auth_session_creation_success(&self) { tracing::debug!("[Metrics] Auth session creation succeeded"); } diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 86a1d7f..bf1d917 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -121,6 +121,14 @@ impl AuthNotifier for NotifierDebugLogger { tracing::debug!("[Notifier] Registered user {}", user.name()); } + async fn user_created(&self, creator: &User, created: &User) { + tracing::debug!( + "[Notifier] Admin user {} created user {}", + creator.name(), + created.name() + ); + } + 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 9fbf489..de9e0c3 100644 --- a/backend/src/lib/outbound/postgres.rs +++ b/backend/src/lib/outbound/postgres.rs @@ -25,7 +25,7 @@ use crate::domain::warren::{ }, }, user::{ - RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword, + CreateUserError, CreateUserRequest, User, UserEmail, UserName, UserPassword, VerifyUserPasswordError, VerifyUserPasswordRequest, }, user_warren::{ @@ -394,7 +394,7 @@ impl WarrenRepository for Postgres { } impl AuthRepository for Postgres { - async fn register_user(&self, request: RegisterUserRequest) -> Result { + async fn create_user(&self, request: CreateUserRequest) -> Result { let mut connection = self .pool .acquire() @@ -407,7 +407,7 @@ impl AuthRepository for Postgres { request.name(), request.email(), request.password(), - false, + request.admin(), ) .await .context(format!("Failed to create user"))?; diff --git a/frontend/bun.lock b/frontend/bun.lock index a73d497..1ca1064 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/zod": "^4.15.1", "@vueuse/core": "^13.5.0", "byte-size": "^9.0.1", "class-variance-authority": "^0.7.1", @@ -24,9 +25,11 @@ "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.5", + "vee-validate": "^4.15.1", "vue": "^3.5.17", "vue-router": "^4.5.1", "vue-sonner": "^2.0.1", + "zod": "^4.0.5", }, "devDependencies": { "@iconify-json/lucide": "^1.2.57", @@ -535,6 +538,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/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=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], @@ -1841,6 +1846,8 @@ "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], + "vee-validate": ["vee-validate@4.15.1", "", { "dependencies": { "@vue/devtools-api": "^7.5.2", "type-fest": "^4.8.3" }, "peerDependencies": { "vue": "^3.4.26" } }, "sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg=="], + "vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], "vite-dev-rpc": ["vite-dev-rpc@1.1.0", "", { "dependencies": { "birpc": "^2.4.0", "vite-hot-client": "^2.1.0" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0" } }, "sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A=="], @@ -1923,7 +1930,7 @@ "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@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1963,6 +1970,8 @@ "@netlify/zip-it-and-ship-it/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + "@netlify/zip-it-and-ship-it/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@nodelib/fs.scandir/@nodelib/fs.stat": ["@nodelib/fs.stat@4.0.0", "", {}, "sha512-ctr6bByzksKRCV0bavi8WoQevU6plSp2IkllIsEqaiKe2mwNNnaluhnRhcsgGZHrrHk57B3lf95MkLMO3STYcg=="], "@nuxt/cli/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], diff --git a/frontend/components/AppSidebar.vue b/frontend/components/AppSidebar.vue index 60c6a3c..988e220 100644 --- a/frontend/components/AppSidebar.vue +++ b/frontend/components/AppSidebar.vue @@ -61,6 +61,9 @@ async function selectWarren(id: string) { + diff --git a/frontend/components/SidebarAdminMenu.vue b/frontend/components/SidebarAdminMenu.vue new file mode 100644 index 0000000..42080aa --- /dev/null +++ b/frontend/components/SidebarAdminMenu.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/components/SidebarUser.vue b/frontend/components/SidebarUser.vue index c99d207..03b13ee 100644 --- a/frontend/components/SidebarUser.vue +++ b/frontend/components/SidebarUser.vue @@ -32,7 +32,7 @@ const AVATAR = diff --git a/frontend/components/admin/CreateUserDialog.vue b/frontend/components/admin/CreateUserDialog.vue new file mode 100644 index 0000000..9bf3a2d --- /dev/null +++ b/frontend/components/admin/CreateUserDialog.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/components/admin/DeleteUserDialog.vue b/frontend/components/admin/DeleteUserDialog.vue new file mode 100644 index 0000000..19aa9d8 --- /dev/null +++ b/frontend/components/admin/DeleteUserDialog.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/components/admin/UserListing.vue b/frontend/components/admin/UserListing.vue new file mode 100644 index 0000000..b22decc --- /dev/null +++ b/frontend/components/admin/UserListing.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialog.vue b/frontend/components/ui/alert-dialog/AlertDialog.vue new file mode 100644 index 0000000..e4fc7b8 --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialog.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialogAction.vue b/frontend/components/ui/alert-dialog/AlertDialogAction.vue new file mode 100644 index 0000000..ee53328 --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialogAction.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialogCancel.vue b/frontend/components/ui/alert-dialog/AlertDialogCancel.vue new file mode 100644 index 0000000..f9cba4e --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialogCancel.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialogContent.vue b/frontend/components/ui/alert-dialog/AlertDialogContent.vue new file mode 100644 index 0000000..ab18b73 --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialogContent.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialogDescription.vue b/frontend/components/ui/alert-dialog/AlertDialogDescription.vue new file mode 100644 index 0000000..ab09406 --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialogFooter.vue b/frontend/components/ui/alert-dialog/AlertDialogFooter.vue new file mode 100644 index 0000000..b6dde32 --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialogFooter.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialogHeader.vue b/frontend/components/ui/alert-dialog/AlertDialogHeader.vue new file mode 100644 index 0000000..22cff27 --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialogTitle.vue b/frontend/components/ui/alert-dialog/AlertDialogTitle.vue new file mode 100644 index 0000000..c36a797 --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialogTitle.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/components/ui/alert-dialog/AlertDialogTrigger.vue b/frontend/components/ui/alert-dialog/AlertDialogTrigger.vue new file mode 100644 index 0000000..4199286 --- /dev/null +++ b/frontend/components/ui/alert-dialog/AlertDialogTrigger.vue @@ -0,0 +1,11 @@ + + + diff --git a/frontend/components/ui/alert-dialog/index.ts b/frontend/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..448d519 --- /dev/null +++ b/frontend/components/ui/alert-dialog/index.ts @@ -0,0 +1,9 @@ +export { default as AlertDialog } from './AlertDialog.vue' +export { default as AlertDialogAction } from './AlertDialogAction.vue' +export { default as AlertDialogCancel } from './AlertDialogCancel.vue' +export { default as AlertDialogContent } from './AlertDialogContent.vue' +export { default as AlertDialogDescription } from './AlertDialogDescription.vue' +export { default as AlertDialogFooter } from './AlertDialogFooter.vue' +export { default as AlertDialogHeader } from './AlertDialogHeader.vue' +export { default as AlertDialogTitle } from './AlertDialogTitle.vue' +export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue' diff --git a/frontend/components/ui/checkbox/Checkbox.vue b/frontend/components/ui/checkbox/Checkbox.vue new file mode 100644 index 0000000..43a1c81 --- /dev/null +++ b/frontend/components/ui/checkbox/Checkbox.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/components/ui/checkbox/index.ts b/frontend/components/ui/checkbox/index.ts new file mode 100644 index 0000000..8c28c28 --- /dev/null +++ b/frontend/components/ui/checkbox/index.ts @@ -0,0 +1 @@ +export { default as Checkbox } from './Checkbox.vue' diff --git a/frontend/components/ui/form/FormControl.vue b/frontend/components/ui/form/FormControl.vue new file mode 100644 index 0000000..884fcb0 --- /dev/null +++ b/frontend/components/ui/form/FormControl.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/form/FormDescription.vue b/frontend/components/ui/form/FormDescription.vue new file mode 100644 index 0000000..f7a9f83 --- /dev/null +++ b/frontend/components/ui/form/FormDescription.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/components/ui/form/FormItem.vue b/frontend/components/ui/form/FormItem.vue new file mode 100644 index 0000000..10ec203 --- /dev/null +++ b/frontend/components/ui/form/FormItem.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/components/ui/form/FormLabel.vue b/frontend/components/ui/form/FormLabel.vue new file mode 100644 index 0000000..a7d69bb --- /dev/null +++ b/frontend/components/ui/form/FormLabel.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/components/ui/form/FormMessage.vue b/frontend/components/ui/form/FormMessage.vue new file mode 100644 index 0000000..8b34b09 --- /dev/null +++ b/frontend/components/ui/form/FormMessage.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/components/ui/form/index.ts b/frontend/components/ui/form/index.ts new file mode 100644 index 0000000..1a3be11 --- /dev/null +++ b/frontend/components/ui/form/index.ts @@ -0,0 +1,7 @@ +export { default as FormControl } from './FormControl.vue' +export { default as FormDescription } from './FormDescription.vue' +export { default as FormItem } from './FormItem.vue' +export { default as FormLabel } from './FormLabel.vue' +export { default as FormMessage } from './FormMessage.vue' +export { FORM_ITEM_INJECTION_KEY } from './injectionKeys' +export { Form, Field as FormField, FieldArray as FormFieldArray } from 'vee-validate' diff --git a/frontend/components/ui/form/injectionKeys.ts b/frontend/components/ui/form/injectionKeys.ts new file mode 100644 index 0000000..b972d36 --- /dev/null +++ b/frontend/components/ui/form/injectionKeys.ts @@ -0,0 +1,4 @@ +import type { InjectionKey } from 'vue' + +export const FORM_ITEM_INJECTION_KEY + = Symbol() as InjectionKey diff --git a/frontend/components/ui/form/useFormField.ts b/frontend/components/ui/form/useFormField.ts new file mode 100644 index 0000000..ed30a8a --- /dev/null +++ b/frontend/components/ui/form/useFormField.ts @@ -0,0 +1,30 @@ +import { FieldContextKey, useFieldError, useIsFieldDirty, useIsFieldTouched, useIsFieldValid } from 'vee-validate' +import { inject } from 'vue' +import { FORM_ITEM_INJECTION_KEY } from './injectionKeys' + +export function useFormField() { + const fieldContext = inject(FieldContextKey) + const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY) + + if (!fieldContext) + throw new Error('useFormField should be used within ') + + const { name } = fieldContext + const id = fieldItemContext + + const fieldState = { + valid: useIsFieldValid(name), + isDirty: useIsFieldDirty(name), + isTouched: useIsFieldTouched(name), + error: useFieldError(name), + } + + return { + id, + name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} diff --git a/frontend/components/ui/input/Input.vue b/frontend/components/ui/input/Input.vue index b82c9d0..97543c2 100644 --- a/frontend/components/ui/input/Input.vue +++ b/frontend/components/ui/input/Input.vue @@ -3,6 +3,12 @@ import type { HTMLAttributes } from 'vue' import { useVModel } from '@vueuse/core' import { cn } from '@/lib/utils' +const domRef = ref(); + +defineExpose({ + domRef, +}); + const props = defineProps<{ defaultValue?: string | number modelValue?: string | number @@ -21,6 +27,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {