create users through admin page
This commit is contained in:
@@ -11,8 +11,16 @@ pub struct RegisterUserRequest {
|
|||||||
password: UserPassword,
|
password: UserPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<RegisterUserRequest> for CreateUserRequest {
|
||||||
|
fn from(value: RegisterUserRequest) -> Self {
|
||||||
|
Self::new(value.name, value.email, value.password, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RegisterUserError {
|
pub enum RegisterUserError {
|
||||||
|
#[error(transparent)]
|
||||||
|
CreateUser(#[from] CreateUserError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Unknown(#[from] anyhow::Error),
|
Unknown(#[from] anyhow::Error),
|
||||||
}
|
}
|
||||||
@@ -178,3 +186,45 @@ pub enum LoginUserError {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Unknown(#[from] anyhow::Error),
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static {
|
|||||||
fn record_user_login_success(&self) -> impl Future<Output = ()> + Send;
|
fn record_user_login_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
fn record_user_login_failure(&self) -> impl Future<Output = ()> + Send;
|
fn record_user_login_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn record_user_creation_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_user_creation_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
fn record_auth_session_creation_success(&self) -> impl Future<Output = ()> + Send;
|
fn record_auth_session_creation_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
fn record_auth_session_creation_failure(&self) -> impl Future<Output = ()> + Send;
|
fn record_auth_session_creation_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ use super::models::{
|
|||||||
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
|
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
|
||||||
},
|
},
|
||||||
user::{
|
user::{
|
||||||
LoginUserError, LoginUserRequest, LoginUserResponse, RegisterUserError,
|
CreateUserError, CreateUserRequest, LoginUserError, LoginUserRequest, LoginUserResponse,
|
||||||
RegisterUserRequest, User,
|
RegisterUserError, RegisterUserRequest, User,
|
||||||
},
|
},
|
||||||
user_warren::{
|
user_warren::{
|
||||||
UserWarren,
|
UserWarren,
|
||||||
@@ -120,6 +120,11 @@ pub trait AuthService: Clone + Send + Sync + 'static {
|
|||||||
&self,
|
&self,
|
||||||
request: LoginUserRequest,
|
request: LoginUserRequest,
|
||||||
) -> impl Future<Output = Result<LoginUserResponse, LoginUserError>> + Send;
|
) -> impl Future<Output = Result<LoginUserResponse, LoginUserError>> + Send;
|
||||||
|
/// An action that creates a user (MUST REQUIRE ADMIN PRIVILEGES)
|
||||||
|
fn create_user(
|
||||||
|
&self,
|
||||||
|
request: AuthRequest<CreateUserRequest>,
|
||||||
|
) -> impl Future<Output = Result<User, AuthError<CreateUserError>>> + Send;
|
||||||
|
|
||||||
fn create_auth_session(
|
fn create_auth_session(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
|
|||||||
pub trait AuthNotifier: Clone + Send + Sync + 'static {
|
pub trait AuthNotifier: Clone + Send + Sync + 'static {
|
||||||
fn user_registered(&self, user: &User) -> impl Future<Output = ()> + Send;
|
fn user_registered(&self, user: &User) -> impl Future<Output = ()> + Send;
|
||||||
fn user_logged_in(&self, response: &LoginUserResponse) -> impl Future<Output = ()> + Send;
|
fn user_logged_in(&self, response: &LoginUserResponse) -> impl Future<Output = ()> + Send;
|
||||||
|
fn user_created(&self, creator: &User, created: &User) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
fn auth_session_created(&self, user_id: &Uuid) -> impl Future<Output = ()> + Send;
|
fn auth_session_created(&self, user_id: &Uuid) -> impl Future<Output = ()> + Send;
|
||||||
fn auth_session_fetched(
|
fn auth_session_fetched(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::domain::warren::models::{
|
|||||||
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
|
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
|
||||||
},
|
},
|
||||||
user::{
|
user::{
|
||||||
RegisterUserError, RegisterUserRequest, User, VerifyUserPasswordError,
|
CreateUserError, CreateUserRequest, User, VerifyUserPasswordError,
|
||||||
VerifyUserPasswordRequest,
|
VerifyUserPasswordRequest,
|
||||||
},
|
},
|
||||||
user_warren::{
|
user_warren::{
|
||||||
@@ -70,10 +70,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait AuthRepository: Clone + Send + Sync + 'static {
|
pub trait AuthRepository: Clone + Send + Sync + 'static {
|
||||||
fn register_user(
|
fn create_user(
|
||||||
&self,
|
&self,
|
||||||
request: RegisterUserRequest,
|
request: CreateUserRequest,
|
||||||
) -> impl Future<Output = Result<User, RegisterUserError>> + Send;
|
) -> impl Future<Output = Result<User, CreateUserError>> + Send;
|
||||||
fn verify_user_password(
|
fn verify_user_password(
|
||||||
&self,
|
&self,
|
||||||
request: VerifyUserPasswordRequest,
|
request: VerifyUserPasswordRequest,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
user::{
|
user::{
|
||||||
LoginUserError, LoginUserRequest, LoginUserResponse, RegisterUserError,
|
CreateUserError, CreateUserRequest, LoginUserError, LoginUserRequest,
|
||||||
RegisterUserRequest, User,
|
LoginUserResponse, RegisterUserError, RegisterUserRequest, User,
|
||||||
},
|
},
|
||||||
user_warren::{
|
user_warren::{
|
||||||
UserWarren,
|
UserWarren,
|
||||||
@@ -99,7 +99,7 @@ where
|
|||||||
N: AuthNotifier,
|
N: AuthNotifier,
|
||||||
{
|
{
|
||||||
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
|
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
|
||||||
let result = self.repository.register_user(request).await;
|
let result = self.repository.create_user(request.into()).await;
|
||||||
|
|
||||||
if let Ok(user) = result.as_ref() {
|
if let Ok(user) = result.as_ref() {
|
||||||
self.metrics.record_user_registration_success().await;
|
self.metrics.record_user_registration_success().await;
|
||||||
@@ -108,7 +108,7 @@ where
|
|||||||
self.metrics.record_user_registration_failure().await;
|
self.metrics.record_user_registration_failure().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login_user(
|
async fn login_user(
|
||||||
@@ -139,6 +139,32 @@ where
|
|||||||
result.map_err(Into::into)
|
result.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_user(
|
||||||
|
&self,
|
||||||
|
request: AuthRequest<CreateUserRequest>,
|
||||||
|
) -> Result<User, AuthError<CreateUserError>> {
|
||||||
|
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(
|
async fn create_auth_session(
|
||||||
&self,
|
&self,
|
||||||
request: CreateAuthSessionRequest,
|
request: CreateAuthSessionRequest,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
domain::warren::models::{
|
domain::warren::models::{
|
||||||
auth_session::{AuthError, requests::FetchAuthSessionError},
|
auth_session::{AuthError, requests::FetchAuthSessionError},
|
||||||
file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError},
|
file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError},
|
||||||
user::{LoginUserError, RegisterUserError, VerifyUserPasswordError},
|
user::{CreateUserError, LoginUserError, RegisterUserError, VerifyUserPasswordError},
|
||||||
user_warren::requests::FetchUserWarrenError,
|
user_warren::requests::FetchUserWarrenError,
|
||||||
warren::{
|
warren::{
|
||||||
CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError,
|
CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError,
|
||||||
@@ -136,6 +136,7 @@ impl From<UploadWarrenFilesError> for ApiError {
|
|||||||
impl From<RegisterUserError> for ApiError {
|
impl From<RegisterUserError> for ApiError {
|
||||||
fn from(value: RegisterUserError) -> Self {
|
fn from(value: RegisterUserError) -> Self {
|
||||||
match value {
|
match value {
|
||||||
|
RegisterUserError::CreateUser(err) => err.into(),
|
||||||
RegisterUserError::Unknown(error) => Self::InternalServerError(error.to_string()),
|
RegisterUserError::Unknown(error) => Self::InternalServerError(error.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,3 +198,11 @@ impl<T: std::error::Error> From<AuthError<T>> for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<CreateUserError> for ApiError {
|
||||||
|
fn from(value: CreateUserError) -> Self {
|
||||||
|
match value {
|
||||||
|
CreateUserError::Unknown(err) => Self::InternalServerError(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
95
backend/src/lib/inbound/http/handlers/admin/create_user.rs
Normal file
95
backend/src/lib/inbound/http/handlers/admin/create_user.rs
Normal file
@@ -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<ParseCreateUserHttpRequestError> 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<CreateUserRequest, ParseCreateUserHttpRequestError> {
|
||||||
|
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<WS: WarrenService, AS: AuthService>(
|
||||||
|
State(state): State<AppState<WS, AS>>,
|
||||||
|
SessionIdHeader(session): SessionIdHeader,
|
||||||
|
Json(request): Json<CreateUserHttpRequestBody>,
|
||||||
|
) -> Result<ApiSuccess<UserData>, 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)
|
||||||
|
}
|
||||||
13
backend/src/lib/inbound/http/handlers/admin/mod.rs
Normal file
13
backend/src/lib/inbound/http/handlers/admin/mod.rs
Normal file
@@ -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<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
|
||||||
|
Router::new().route("/users", post(create_user))
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
inbound::http::{
|
inbound::http::{
|
||||||
AppState,
|
AppState,
|
||||||
handlers::SessionUser,
|
handlers::UserData,
|
||||||
responses::{ApiError, ApiSuccess},
|
responses::{ApiError, ApiSuccess},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -22,7 +22,7 @@ use crate::{
|
|||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FetchSessionResponseBody {
|
pub struct FetchSessionResponseBody {
|
||||||
user: SessionUser,
|
user: UserData,
|
||||||
expires_at: i64,
|
expires_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
inbound::http::{
|
inbound::http::{
|
||||||
AppState,
|
AppState,
|
||||||
handlers::SessionUser,
|
handlers::UserData,
|
||||||
responses::{ApiError, ApiSuccess},
|
responses::{ApiError, ApiSuccess},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -70,7 +70,7 @@ impl LoginUserHttpRequestBody {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
pub struct LoginResponseBody {
|
pub struct LoginResponseBody {
|
||||||
token: String,
|
token: String,
|
||||||
user: SessionUser,
|
user: UserData,
|
||||||
expires_at: i64,
|
expires_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::domain::warren::models::user::User;
|
use crate::domain::warren::models::user::User;
|
||||||
|
|
||||||
|
pub mod admin;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod extractors;
|
pub mod extractors;
|
||||||
pub mod warrens;
|
pub mod warrens;
|
||||||
@@ -10,14 +11,14 @@ pub mod warrens;
|
|||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
/// A session user that can be safely sent to the client
|
/// A session user that can be safely sent to the client
|
||||||
pub struct SessionUser {
|
pub(super) struct UserData {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
email: String,
|
email: String,
|
||||||
admin: bool,
|
admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<User> for SessionUser {
|
impl From<User> for UserData {
|
||||||
fn from(value: User) -> Self {
|
fn from(value: User) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: *value.id(),
|
id: *value.id(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{Router, http::HeaderValue};
|
use axum::{Router, http::HeaderValue};
|
||||||
|
use handlers::admin;
|
||||||
use handlers::auth;
|
use handlers::auth;
|
||||||
use handlers::warrens;
|
use handlers::warrens;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -126,4 +127,5 @@ fn api_routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>>
|
|||||||
Router::new()
|
Router::new()
|
||||||
.nest("/auth", auth::routes())
|
.nest("/auth", auth::routes())
|
||||||
.nest("/warrens", warrens::routes())
|
.nest("/warrens", warrens::routes())
|
||||||
|
.nest("/admin", admin::routes())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@ impl AuthMetrics for MetricsDebugLogger {
|
|||||||
tracing::debug!("[Metrics] User login failed");
|
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) {
|
async fn record_auth_session_creation_success(&self) {
|
||||||
tracing::debug!("[Metrics] Auth session creation succeeded");
|
tracing::debug!("[Metrics] Auth session creation succeeded");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ impl AuthNotifier for NotifierDebugLogger {
|
|||||||
tracing::debug!("[Notifier] Registered user {}", user.name());
|
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) {
|
async fn user_logged_in(&self, response: &LoginUserResponse) {
|
||||||
tracing::debug!("[Notifier] Logged in user {}", response.user().name());
|
tracing::debug!("[Notifier] Logged in user {}", response.user().name());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use crate::domain::warren::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
user::{
|
user::{
|
||||||
RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword,
|
CreateUserError, CreateUserRequest, User, UserEmail, UserName, UserPassword,
|
||||||
VerifyUserPasswordError, VerifyUserPasswordRequest,
|
VerifyUserPasswordError, VerifyUserPasswordRequest,
|
||||||
},
|
},
|
||||||
user_warren::{
|
user_warren::{
|
||||||
@@ -394,7 +394,7 @@ impl WarrenRepository for Postgres {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AuthRepository for Postgres {
|
impl AuthRepository for Postgres {
|
||||||
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
|
async fn create_user(&self, request: CreateUserRequest) -> Result<User, CreateUserError> {
|
||||||
let mut connection = self
|
let mut connection = self
|
||||||
.pool
|
.pool
|
||||||
.acquire()
|
.acquire()
|
||||||
@@ -407,7 +407,7 @@ impl AuthRepository for Postgres {
|
|||||||
request.name(),
|
request.name(),
|
||||||
request.email(),
|
request.email(),
|
||||||
request.password(),
|
request.password(),
|
||||||
false,
|
request.admin(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context(format!("Failed to create user"))?;
|
.context(format!("Failed to create user"))?;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"@nuxt/test-utils": "3.19.2",
|
"@nuxt/test-utils": "3.19.2",
|
||||||
"@pinia/nuxt": "^0.11.1",
|
"@pinia/nuxt": "^0.11.1",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.5.0",
|
||||||
"byte-size": "^9.0.1",
|
"byte-size": "^9.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -24,9 +25,11 @@
|
|||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue-sonner": "^2.0.1",
|
"vue-sonner": "^2.0.1",
|
||||||
|
"zod": "^4.0.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.57",
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"@nuxt/cli/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ async function selectWarren(id: string) {
|
|||||||
<SidebarMenuSubButton
|
<SidebarMenuSubButton
|
||||||
:tooltip="warren.name"
|
:tooltip="warren.name"
|
||||||
:is-active="
|
:is-active="
|
||||||
|
route.path.startsWith(
|
||||||
|
'/warrens/files'
|
||||||
|
) &&
|
||||||
store.current != null &&
|
store.current != null &&
|
||||||
store.current.warrenId === id
|
store.current.warrenId === id
|
||||||
"
|
"
|
||||||
@@ -78,6 +81,7 @@ async function selectWarren(id: string) {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
<SidebarAdminMenu v-if="session != null && session.user.admin" />
|
||||||
<SidebarUser v-if="session != null" :user="session.user" />
|
<SidebarUser v-if="session != null" :user="session.user" />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
53
frontend/components/SidebarAdminMenu.vue
Normal file
53
frontend/components/SidebarAdminMenu.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuButton,
|
||||||
|
} from '@/components/ui/sidebar';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<NuxtLink to="/admin" as-child>
|
||||||
|
<SidebarMenuButton
|
||||||
|
:is-active="route.path === '/admin'"
|
||||||
|
tooltip="Admin"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:settings" />
|
||||||
|
<span>Administration</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</NuxtLink>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
<SidebarMenuSubItem>
|
||||||
|
<NuxtLink to="/admin/users" as-child>
|
||||||
|
<SidebarMenuSubButton
|
||||||
|
:is-active="route.path === '/admin/users'"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:users" />
|
||||||
|
<span>Users</span>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/admin/warrens" as-child>
|
||||||
|
<SidebarMenuSubButton
|
||||||
|
:is-active="route.path === '/admin/warrens'"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:folder-tree" />
|
||||||
|
<span>Warrens</span>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</NuxtLink>
|
||||||
|
<!-- <NuxtLink to="/admin/stats" as-child>
|
||||||
|
<SidebarMenuSubButton
|
||||||
|
:is-active="route.path === '/admin/stats'"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:bar-chart-3" />
|
||||||
|
<span>Stats</span>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</NuxtLink> -->
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</template>
|
||||||
@@ -32,7 +32,7 @@ const AVATAR =
|
|||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
size="lg"
|
size="lg"
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
tooltip="Settings"
|
tooltip="User"
|
||||||
>
|
>
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
<Avatar class="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage :src="AVATAR" />
|
<AvatarImage :src="AVATAR" />
|
||||||
|
|||||||
132
frontend/components/admin/CreateUserDialog.vue
Normal file
132
frontend/components/admin/CreateUserDialog.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useForm } from 'vee-validate';
|
||||||
|
import { createUserSchema } from '~/lib/schemas/admin';
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod';
|
||||||
|
import type z from 'zod';
|
||||||
|
import { createUser } from '~/lib/api/admin/createUser';
|
||||||
|
|
||||||
|
const adminStore = useAdminStore();
|
||||||
|
const creating = ref(false);
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
adminStore.closeCreateUserDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(createUserSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(
|
||||||
|
async (values: z.output<typeof createUserSchema>) => {
|
||||||
|
if (creating.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
creating.value = true;
|
||||||
|
|
||||||
|
const result = await createUser(values);
|
||||||
|
|
||||||
|
creating.value = false;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
adminStore.closeCreateUserDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="adminStore.createUserDialog != null">
|
||||||
|
<DialogTrigger><slot /></DialogTrigger>
|
||||||
|
<DialogContent @escape-key-down="cancel">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create user</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a username, email and password to create a new user
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="create-user-form"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
@submit.prevent="onSubmit"
|
||||||
|
>
|
||||||
|
<FormField v-slot="{ componentField }" name="name">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
v-bind="componentField"
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="confused-cat"
|
||||||
|
autocomplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-protonpass-ignore
|
||||||
|
data-bwignore
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<FormMessage />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="email">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
v-bind="componentField"
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="confusedcat@example.com"
|
||||||
|
autocomplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-protonpass-ignore
|
||||||
|
data-bwignore
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="password">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
v-bind="componentField"
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-protonpass-ignore
|
||||||
|
data-bwignore
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<FormMessage />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="admin">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Admin</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
id="admin"
|
||||||
|
:model-value="value"
|
||||||
|
@update:model-value="handleChange"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<FormMessage />
|
||||||
|
</FormField>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="cancel">Cancel</Button>
|
||||||
|
<Button type="submit" form="create-user-form">Create</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
90
frontend/components/admin/DeleteUserDialog.vue
Normal file
90
frontend/components/admin/DeleteUserDialog.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import type { AuthUser } from '~/types/auth';
|
||||||
|
|
||||||
|
const adminStore = useAdminStore();
|
||||||
|
// We'll only update this value if there is a user to prevent layout shifts on close
|
||||||
|
const user = ref<AuthUser>();
|
||||||
|
const confirmEmailInput = ref<InstanceType<typeof Input>>();
|
||||||
|
const confirmEmail = ref<string>('');
|
||||||
|
|
||||||
|
const emailMatches = computed(
|
||||||
|
() => user.value != null && user.value.email === confirmEmail.value
|
||||||
|
);
|
||||||
|
|
||||||
|
adminStore.$subscribe(async (_mutation, state) => {
|
||||||
|
if (state.deleteUserDialog != null) {
|
||||||
|
user.value = state.deleteUserDialog.user;
|
||||||
|
setTimeout(() => confirmEmailInput.value?.domRef?.focus(), 25);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
adminStore.clearDeleteUserDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialog :open="adminStore.deleteUserDialog != null">
|
||||||
|
<AlertDialogTrigger as-child>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent @escape-key-down="cancel">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription class="space-y-1">
|
||||||
|
<p ref="test">
|
||||||
|
This action cannot be undone. This will permanently
|
||||||
|
delete the user and remove their data from the database
|
||||||
|
</p>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlterDialogContent v-if="user != null">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<AdminUserListing :user />
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<p
|
||||||
|
:class="[
|
||||||
|
'tight text-sm',
|
||||||
|
emailMatches
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: 'text-destructive-foreground',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Enter their email address to continue
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
ref="confirmEmailInput"
|
||||||
|
v-model="confirmEmail"
|
||||||
|
type="text"
|
||||||
|
:placeholder="user.email"
|
||||||
|
autocomplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-protonpass-ignore
|
||||||
|
data-bwignore
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlterDialogContent>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel @click="cancel">Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction :disabled="!emailMatches" @click="submit"
|
||||||
|
>Delete</AlertDialogAction
|
||||||
|
>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</template>
|
||||||
26
frontend/components/admin/UserListing.vue
Normal file
26
frontend/components/admin/UserListing.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AuthUser } from '~/types/auth';
|
||||||
|
|
||||||
|
const { user } = defineProps<{
|
||||||
|
user: AuthUser;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// TODO: Remove once this is a field on the users
|
||||||
|
const AVATAR =
|
||||||
|
'https://cdn.discordapp.com/avatars/285424924049276939/0368b00056c416cae689ab1434c0aac0.webp';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="group/user flex flex-row items-center justify-between gap-4">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage :src="AVATAR" />
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex grow flex-col leading-4">
|
||||||
|
<span class="text-sm font-medium">{{ user.name }}</span>
|
||||||
|
<span class="text-muted-foreground text-xs">{{ user.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="opacity-0 transition-all group-hover/user:opacity-100">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
frontend/components/ui/alert-dialog/AlertDialog.vue
Normal file
14
frontend/components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogProps>()
|
||||||
|
const emits = defineEmits<AlertDialogEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</template>
|
||||||
17
frontend/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
17
frontend/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogAction>
|
||||||
|
</template>
|
||||||
24
frontend/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
24
frontend/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogCancel
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'mt-2 sm:mt-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogCancel>
|
||||||
|
</template>
|
||||||
41
frontend/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
41
frontend/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
AlertDialogContent,
|
||||||
|
type AlertDialogContentEmits,
|
||||||
|
type AlertDialogContentProps,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<AlertDialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
|
||||||
|
/>
|
||||||
|
<AlertDialogContent
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
AlertDialogDescription,
|
||||||
|
type AlertDialogDescriptionProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogDescription
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
22
frontend/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
17
frontend/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
20
frontend/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
20
frontend/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTitle
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-lg font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</template>
|
||||||
11
frontend/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
11
frontend/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { AlertDialogTrigger, type AlertDialogTriggerProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</template>
|
||||||
9
frontend/components/ui/alert-dialog/index.ts
Normal file
9
frontend/components/ui/alert-dialog/index.ts
Normal file
@@ -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'
|
||||||
34
frontend/components/ui/checkbox/Checkbox.vue
Normal file
34
frontend/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Check } from 'lucide-vue-next'
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
data-slot="checkbox"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
class="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<Check class="size-3.5" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
1
frontend/components/ui/checkbox/index.ts
Normal file
1
frontend/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from './Checkbox.vue'
|
||||||
17
frontend/components/ui/form/FormControl.vue
Normal file
17
frontend/components/ui/form/FormControl.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Slot } from 'reka-ui'
|
||||||
|
import { useFormField } from './useFormField'
|
||||||
|
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Slot
|
||||||
|
:id="formItemId"
|
||||||
|
data-slot="form-control"
|
||||||
|
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Slot>
|
||||||
|
</template>
|
||||||
21
frontend/components/ui/form/FormDescription.vue
Normal file
21
frontend/components/ui/form/FormDescription.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useFormField } from './useFormField'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
:id="formDescriptionId"
|
||||||
|
data-slot="form-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/form/FormItem.vue
Normal file
22
frontend/components/ui/form/FormItem.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useId } from 'reka-ui'
|
||||||
|
import { type HTMLAttributes, provide } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const id = useId()
|
||||||
|
provide(FORM_ITEM_INJECTION_KEY, id)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
:class="cn('grid gap-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
25
frontend/components/ui/form/FormLabel.vue
Normal file
25
frontend/components/ui/form/FormLabel.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { LabelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useFormField } from './useFormField'
|
||||||
|
|
||||||
|
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
:data-error="!!error"
|
||||||
|
:class="cn(
|
||||||
|
'data-[error=true]:text-destructive-foreground',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
:for="formItemId"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/form/FormMessage.vue
Normal file
22
frontend/components/ui/form/FormMessage.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ErrorMessage } from 'vee-validate'
|
||||||
|
import { type HTMLAttributes, toValue } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useFormField } from './useFormField'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { name, formMessageId } = useFormField()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ErrorMessage
|
||||||
|
:id="formMessageId"
|
||||||
|
data-slot="form-message"
|
||||||
|
as="p"
|
||||||
|
:name="toValue(name)"
|
||||||
|
:class="cn('text-destructive-foreground text-sm', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
7
frontend/components/ui/form/index.ts
Normal file
7
frontend/components/ui/form/index.ts
Normal file
@@ -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'
|
||||||
4
frontend/components/ui/form/injectionKeys.ts
Normal file
4
frontend/components/ui/form/injectionKeys.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { InjectionKey } from 'vue'
|
||||||
|
|
||||||
|
export const FORM_ITEM_INJECTION_KEY
|
||||||
|
= Symbol() as InjectionKey<string>
|
||||||
30
frontend/components/ui/form/useFormField.ts
Normal file
30
frontend/components/ui/form/useFormField.ts
Normal file
@@ -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 <FormField>')
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@ import type { HTMLAttributes } from 'vue'
|
|||||||
import { useVModel } from '@vueuse/core'
|
import { useVModel } from '@vueuse/core'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const domRef = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
domRef,
|
||||||
|
});
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
defaultValue?: string | number
|
defaultValue?: string | number
|
||||||
modelValue?: string | number
|
modelValue?: string | number
|
||||||
@@ -21,6 +27,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
|
ref="domRef"
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
|
|||||||
25
frontend/components/ui/label/Label.vue
Normal file
25
frontend/components/ui/label/Label.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Label, type LabelProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="label"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
1
frontend/components/ui/label/index.ts
Normal file
1
frontend/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from './Label.vue'
|
||||||
38
frontend/components/ui/switch/Switch.vue
Normal file
38
frontend/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
SwitchRoot,
|
||||||
|
type SwitchRootEmits,
|
||||||
|
type SwitchRootProps,
|
||||||
|
SwitchThumb,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const emits = defineEmits<SwitchRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SwitchRoot
|
||||||
|
data-slot="switch"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<SwitchThumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
|
||||||
|
>
|
||||||
|
<slot name="thumb" />
|
||||||
|
</SwitchThumb>
|
||||||
|
</SwitchRoot>
|
||||||
|
</template>
|
||||||
1
frontend/components/ui/switch/index.ts
Normal file
1
frontend/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Switch } from './Switch.vue'
|
||||||
9
frontend/layouts/admin.vue
Normal file
9
frontend/layouts/admin.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<AdminCreateUserDialog />
|
||||||
|
<AdminDeleteUserDialog />
|
||||||
|
<slot />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
43
frontend/lib/api/admin/createUser.ts
Normal file
43
frontend/lib/api/admin/createUser.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import type { ApiResponse } from '~/types/api';
|
||||||
|
import type { AuthUser, AuthUserFields } from '~/types/auth';
|
||||||
|
import { getApiHeaders } from '..';
|
||||||
|
|
||||||
|
/** Admin function to create a new user */
|
||||||
|
export async function createUser(
|
||||||
|
user: AuthUserFields & { password: string }
|
||||||
|
): Promise<{ success: true; user: AuthUser } | { success: false }> {
|
||||||
|
const { data, error } = await useFetch<ApiResponse<AuthUser>>(
|
||||||
|
getApiUrl('admin/users'),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: getApiHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
password: user.password,
|
||||||
|
admin: user.admin,
|
||||||
|
}),
|
||||||
|
responseType: 'json',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.value == null) {
|
||||||
|
toast.error('Create user', {
|
||||||
|
description: error.value?.data ?? 'Failed to create user',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Create user', {
|
||||||
|
description: 'Successfully created user',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: data.value.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import type { ApiResponse } from '~/types/api';
|
import type { ApiResponse } from '~/types/api';
|
||||||
import type { AuthUser } from '~/types/auth';
|
import type { AuthUser } from '~/types/auth';
|
||||||
|
import { getApiHeaders } from '..';
|
||||||
|
|
||||||
export async function loginUser(
|
export async function loginUser(
|
||||||
email: string,
|
email: string,
|
||||||
@@ -10,9 +11,7 @@ export async function loginUser(
|
|||||||
ApiResponse<{ token: string; user: AuthUser; expiresAt: number }>
|
ApiResponse<{ token: string; user: AuthUser; expiresAt: number }>
|
||||||
>(getApiUrl('auth/login'), {
|
>(getApiUrl('auth/login'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: getApiHeaders(false),
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
|
|||||||
9
frontend/lib/schemas/admin.ts
Normal file
9
frontend/lib/schemas/admin.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
import { registerSchema } from './auth';
|
||||||
|
|
||||||
|
export const createUserSchema = registerSchema.extend({
|
||||||
|
admin: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.prefault(() => false),
|
||||||
|
});
|
||||||
23
frontend/lib/schemas/auth.ts
Normal file
23
frontend/lib/schemas/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
name: z.string('This field is required').trim().min(1),
|
||||||
|
email: z
|
||||||
|
.email({
|
||||||
|
error: 'This field is required',
|
||||||
|
pattern: z.regexes.rfc5322Email,
|
||||||
|
})
|
||||||
|
.trim(),
|
||||||
|
password: z.string('This field is required').trim().min(12).max(32),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.email({
|
||||||
|
error: 'This field is required',
|
||||||
|
pattern: z.regexes.rfc5322Email,
|
||||||
|
})
|
||||||
|
.trim(),
|
||||||
|
// Don't include the min and max here to let bad actors waste their time
|
||||||
|
password: z.string('This field is required').trim(),
|
||||||
|
});
|
||||||
9
frontend/middleware/is-admin.ts
Normal file
9
frontend/middleware/is-admin.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export default defineNuxtRouteMiddleware((_to, _from) => {
|
||||||
|
const session = useAuthSession().value;
|
||||||
|
|
||||||
|
if (session == null || !session.user.admin) {
|
||||||
|
return navigateTo({
|
||||||
|
path: 'login',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -62,4 +62,10 @@ export default defineNuxtConfig({
|
|||||||
cookiesSameSite: 'strict',
|
cookiesSameSite: 'strict',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fonts: {
|
||||||
|
defaults: {
|
||||||
|
weights: [400, 500, 600, 700, 800, 900],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@nuxt/test-utils": "3.19.2",
|
"@nuxt/test-utils": "3.19.2",
|
||||||
"@pinia/nuxt": "^0.11.1",
|
"@pinia/nuxt": "^0.11.1",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.5.0",
|
||||||
"byte-size": "^9.0.1",
|
"byte-size": "^9.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -31,9 +32,11 @@
|
|||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue-sonner": "^2.0.1"
|
"vue-sonner": "^2.0.1",
|
||||||
|
"zod": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.57",
|
"@iconify-json/lucide": "^1.2.57",
|
||||||
|
|||||||
76
frontend/pages/admin/index.vue
Normal file
76
frontend/pages/admin/index.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AuthUser } from '~/types/auth';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['is-admin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = useAuthSession();
|
||||||
|
const adminStore = useAdminStore();
|
||||||
|
|
||||||
|
const users: AuthUser[] = [
|
||||||
|
{
|
||||||
|
id: '5a307466-bf2e-4cf2-9b11-61f024e8fa71',
|
||||||
|
name: '409',
|
||||||
|
email: '409dev@protonmail.com',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '99132ce4-045c-4d4b-b957-61f5e99e708b',
|
||||||
|
name: 'test-user',
|
||||||
|
email: 'test@user.com',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle
|
||||||
|
><NuxtLink to="/admin/users">Users</NuxtLink></CardTitle
|
||||||
|
>
|
||||||
|
<CardDescription
|
||||||
|
>Add users or modify existing users' permissions or
|
||||||
|
warrens</CardDescription
|
||||||
|
>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea class="max-h-96">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<AdminUserListing
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
:user
|
||||||
|
class="group/user flex flex-row items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
class="m-1"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
:disabled="session?.user.id === user.id"
|
||||||
|
@click="
|
||||||
|
() =>
|
||||||
|
adminStore.openDeleteUserDialog(
|
||||||
|
user
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:trash-2" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</AdminUserListing>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<div class="mt-4 flex flex-row">
|
||||||
|
<Button @click="adminStore.openCreateUserDialog"
|
||||||
|
>Create user</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
9
frontend/pages/admin/stats.vue
Normal file
9
frontend/pages/admin/stats.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['is-admin'],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p>/admin/stats</p>
|
||||||
|
</template>
|
||||||
9
frontend/pages/admin/users.vue
Normal file
9
frontend/pages/admin/users.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['is-admin'],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p>/admin/users</p>
|
||||||
|
</template>
|
||||||
9
frontend/pages/admin/warrens.vue
Normal file
9
frontend/pages/admin/warrens.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['is-admin'],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p>/admin/warrens</p>
|
||||||
|
</template>
|
||||||
@@ -3,7 +3,3 @@ definePageMeta({
|
|||||||
middleware: ['authenticated'],
|
middleware: ['authenticated'],
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<p>/</p>
|
|
||||||
</template>
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod';
|
||||||
|
import { useForm } from 'vee-validate';
|
||||||
|
import type z from 'zod';
|
||||||
import { loginUser } from '~/lib/api/auth/login';
|
import { loginUser } from '~/lib/api/auth/login';
|
||||||
|
import { loginSchema } from '~/lib/schemas/auth';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'auth',
|
layout: 'auth',
|
||||||
@@ -20,34 +24,28 @@ useHead({
|
|||||||
// TODO: Get this from the backend
|
// TODO: Get this from the backend
|
||||||
const OPEN_ID = false;
|
const OPEN_ID = false;
|
||||||
const loggingIn = ref(false);
|
const loggingIn = ref(false);
|
||||||
const email = ref('');
|
|
||||||
const password = ref('');
|
|
||||||
|
|
||||||
const inputValid = computed(
|
const form = useForm({
|
||||||
() => email.value.trim().length > 0 && password.value.trim().length > 0
|
validationSchema: toTypedSchema(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(
|
||||||
|
async (values: z.output<typeof loginSchema>) => {
|
||||||
|
if (loggingIn.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loggingIn.value = true;
|
||||||
|
|
||||||
|
const { success } = await loginUser(values.email, values.password);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await navigateTo({ path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
loggingIn.value = false;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!inputValid.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loggingIn.value = true;
|
|
||||||
|
|
||||||
const { success } = await loginUser(email.value, password.value);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
await navigateTo({ path: '/' });
|
|
||||||
}
|
|
||||||
|
|
||||||
loggingIn.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,33 +56,48 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
Enter your email and password to log in to your account.
|
Enter your email and password to log in to your account.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid gap-4">
|
|
||||||
<div class="grid gap-2">
|
<CardContent>
|
||||||
<Label for="email">Email</Label>
|
<form id="login-form" class="grid gap-4" @submit.prevent="onSubmit">
|
||||||
<Input
|
<FormField v-slot="{ componentField }" name="email">
|
||||||
id="email"
|
<FormItem>
|
||||||
v-model="email"
|
<FormLabel>Email</FormLabel>
|
||||||
type="email"
|
<FormControl>
|
||||||
placeholder="your@email.com"
|
<Input
|
||||||
autocomplete="off"
|
v-bind="componentField"
|
||||||
required
|
id="email"
|
||||||
@keydown="onKeyDown"
|
type="email"
|
||||||
/>
|
placeholder="name@example.com"
|
||||||
</div>
|
autocomplete="off"
|
||||||
<div class="grid gap-2">
|
/>
|
||||||
<Label for="password">Password</Label>
|
</FormControl>
|
||||||
<Input
|
<FormMessage />
|
||||||
id="password"
|
</FormItem>
|
||||||
v-model="password"
|
</FormField>
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
<FormField v-slot="{ componentField }" name="password">
|
||||||
required
|
<FormItem>
|
||||||
@keydown="onKeyDown"
|
<FormLabel>Password</FormLabel>
|
||||||
/>
|
<FormControl>
|
||||||
</div>
|
<Input
|
||||||
|
v-bind="componentField"
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<FormMessage />
|
||||||
|
</FormField>
|
||||||
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter class="flex-col gap-2">
|
<CardFooter class="flex-col gap-2">
|
||||||
<Button class="w-full" :disabled="!inputValid" @click="submit"
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="w-full"
|
||||||
|
form="login-form"
|
||||||
|
:disabled="loggingIn"
|
||||||
>Log in</Button
|
>Log in</Button
|
||||||
>
|
>
|
||||||
<Button class="w-full" variant="outline" :disabled="!OPEN_ID"
|
<Button class="w-full" variant="outline" :disabled="!OPEN_ID"
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod';
|
||||||
|
import { useForm } from 'vee-validate';
|
||||||
|
import type z from 'zod';
|
||||||
import { registerUser } from '~/lib/api/auth/register';
|
import { registerUser } from '~/lib/api/auth/register';
|
||||||
|
import { registerSchema } from '~/lib/schemas/auth';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'auth',
|
layout: 'auth',
|
||||||
@@ -18,33 +22,29 @@ useHead({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const registering = ref(false);
|
const registering = ref(false);
|
||||||
const username = ref('');
|
|
||||||
const email = ref('');
|
|
||||||
const password = ref('');
|
|
||||||
|
|
||||||
const allFieldsValid = computed(
|
const form = useForm({
|
||||||
() =>
|
validationSchema: toTypedSchema(registerSchema),
|
||||||
username.value.trim().length > 0 &&
|
});
|
||||||
email.value.trim().length > 0 &&
|
|
||||||
password.value.trim().length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
async function submit() {
|
const onSubmit = form.handleSubmit(
|
||||||
registering.value = true;
|
async (values: z.output<typeof registerSchema>) => {
|
||||||
|
registering.value = true;
|
||||||
|
|
||||||
const { success } = await registerUser(
|
const { success } = await registerUser(
|
||||||
username.value,
|
values.name,
|
||||||
email.value,
|
values.email,
|
||||||
password.value
|
values.password
|
||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await navigateTo({ path: '/login' });
|
await navigateTo({ path: '/login' });
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registering.value = false;
|
||||||
}
|
}
|
||||||
|
);
|
||||||
registering.value = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -53,45 +53,66 @@ async function submit() {
|
|||||||
<CardTitle class="text-2xl">Register</CardTitle>
|
<CardTitle class="text-2xl">Register</CardTitle>
|
||||||
<CardDescription>Create a new user account</CardDescription>
|
<CardDescription>Create a new user account</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid gap-4">
|
<CardContent>
|
||||||
<div class="grid gap-2">
|
<form
|
||||||
<Label for="username">Username</Label>
|
id="register-form"
|
||||||
<Input
|
class="grid gap-2"
|
||||||
id="username"
|
@submit.prevent="onSubmit"
|
||||||
v-model="username"
|
>
|
||||||
type="username"
|
<FormField v-slot="{ componentField }" name="name">
|
||||||
placeholder="409"
|
<FormItem>
|
||||||
autocomplete="off"
|
<FormLabel>Username</FormLabel>
|
||||||
required
|
<FormControl>
|
||||||
/>
|
<Input
|
||||||
</div>
|
v-bind="componentField"
|
||||||
<div class="grid gap-2">
|
id="username"
|
||||||
<Label for="email">Email</Label>
|
type="text"
|
||||||
<Input
|
placeholder="confused-cat"
|
||||||
id="email"
|
autocomplete="off"
|
||||||
v-model="email"
|
/>
|
||||||
type="email"
|
</FormControl>
|
||||||
placeholder="your@email.com"
|
</FormItem>
|
||||||
autocomplete="off"
|
<FormMessage />
|
||||||
required
|
</FormField>
|
||||||
/>
|
|
||||||
</div>
|
<FormField v-slot="{ componentField }" name="email">
|
||||||
<div class="grid gap-2">
|
<FormItem>
|
||||||
<Label for="password">Password</Label>
|
<FormLabel>Email</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
id="password"
|
<Input
|
||||||
v-model="password"
|
v-bind="componentField"
|
||||||
type="password"
|
id="email"
|
||||||
autocomplete="off"
|
type="email"
|
||||||
required
|
placeholder="confusedcat@example.com"
|
||||||
/>
|
autocomplete="off"
|
||||||
</div>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="password">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
v-bind="componentField"
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<FormMessage />
|
||||||
|
</FormField>
|
||||||
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter class="flex-col gap-2">
|
<CardFooter class="flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="!allFieldsValid || registering"
|
form="register-form"
|
||||||
@click="submit"
|
:disabled="registering"
|
||||||
>Register</Button
|
>Register</Button
|
||||||
>
|
>
|
||||||
<NuxtLink to="/login" class="w-full">
|
<NuxtLink to="/login" class="w-full">
|
||||||
|
|||||||
30
frontend/stores/admin.ts
Normal file
30
frontend/stores/admin.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { AuthUser, AuthUserFields } from '~/types/auth';
|
||||||
|
|
||||||
|
export const useAdminStore = defineStore('admin', {
|
||||||
|
state: () => ({
|
||||||
|
createUserDialog: null as { user: AuthUserFields } | null,
|
||||||
|
deleteUserDialog: null as { user: AuthUser } | null,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
openCreateUserDialog() {
|
||||||
|
this.createUserDialog = {
|
||||||
|
user: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
closeCreateUserDialog() {
|
||||||
|
this.createUserDialog = null;
|
||||||
|
},
|
||||||
|
openDeleteUserDialog(user: AuthUser) {
|
||||||
|
this.deleteUserDialog = {
|
||||||
|
user: user,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clearDeleteUserDialog() {
|
||||||
|
this.deleteUserDialog = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,8 +7,11 @@ export interface AuthSession {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser extends AuthUserFields {
|
||||||
id: string;
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUserFields {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user