Argon2id password hashing, JWT access tokens with rotating HttpOnly refresh cookies and reuse detection, login rate limiting, auth audit log, bootstrap admin, admin-only user CRUD and password reset, OpenAPI spec. Vue login page, auth store with automatic token refresh, route guards, sidebar shell with toasts and placeholder pages, user management page. All tests green: 40 backend, 12 Vitest, 4 Playwright. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
84 lines
2.6 KiB
Rust
84 lines
2.6 KiB
Rust
use std::sync::Arc;
|
|
|
|
use chrono::Utc;
|
|
use domain::ports::{PasswordHasher, UserRepository};
|
|
use domain::user::{validate_email, validate_password, NewUser, Role, User, UserUpdate};
|
|
use domain::DomainError;
|
|
use uuid::Uuid;
|
|
|
|
pub struct UserService {
|
|
users: Arc<dyn UserRepository>,
|
|
hasher: Arc<dyn PasswordHasher>,
|
|
}
|
|
|
|
impl UserService {
|
|
pub fn new(users: Arc<dyn UserRepository>, hasher: Arc<dyn PasswordHasher>) -> Self {
|
|
Self { users, hasher }
|
|
}
|
|
|
|
pub async fn list(&self) -> Result<Vec<User>, DomainError> {
|
|
self.users.list().await
|
|
}
|
|
|
|
pub async fn get(&self, id: Uuid) -> Result<User, DomainError> {
|
|
self.users
|
|
.find_by_id(id)
|
|
.await?
|
|
.ok_or(DomainError::NotFound)
|
|
}
|
|
|
|
pub async fn create(&self, new: NewUser) -> Result<User, DomainError> {
|
|
let email = new.email.trim().to_lowercase();
|
|
validate_email(&email)?;
|
|
validate_password(&new.password)?;
|
|
if self.users.find_by_email(&email).await?.is_some() {
|
|
return Err(DomainError::EmailTaken);
|
|
}
|
|
let user = User {
|
|
id: Uuid::new_v4(),
|
|
email,
|
|
display_name: new.display_name.trim().to_string(),
|
|
password_hash: self.hasher.hash(&new.password)?,
|
|
role: new.role,
|
|
is_active: true,
|
|
created_at: Utc::now(),
|
|
};
|
|
self.users.insert(&user).await?;
|
|
Ok(user)
|
|
}
|
|
|
|
pub async fn update(&self, id: Uuid, update: UserUpdate) -> Result<User, DomainError> {
|
|
let current = self.get(id).await?;
|
|
let loses_admin = current.is_admin()
|
|
&& current.is_active
|
|
&& (update.role == Some(Role::User) || update.is_active == Some(false));
|
|
if loses_admin && self.users.count_active_admins().await? <= 1 {
|
|
return Err(DomainError::LastAdmin);
|
|
}
|
|
self.users.update(id, &update).await
|
|
}
|
|
|
|
pub async fn reset_password(&self, id: Uuid, password: &str) -> Result<(), DomainError> {
|
|
validate_password(password)?;
|
|
self.get(id).await?;
|
|
self.users
|
|
.set_password_hash(id, &self.hasher.hash(password)?)
|
|
.await
|
|
}
|
|
|
|
/// Create the initial admin if the user table is empty. Returns true if created.
|
|
pub async fn bootstrap_admin(&self, email: &str, password: &str) -> Result<bool, DomainError> {
|
|
if self.users.count().await? > 0 {
|
|
return Ok(false);
|
|
}
|
|
self.create(NewUser {
|
|
email: email.into(),
|
|
display_name: "Administrator".into(),
|
|
password: password.into(),
|
|
role: Role::Admin,
|
|
})
|
|
.await?;
|
|
Ok(true)
|
|
}
|
|
}
|