actix-web-admin 0.1.1

A powerful admin panel library for Actix-web 4 applications
Documentation
use async_trait::async_trait;
use std::path::PathBuf;
use std::sync::Mutex;

use crate::auth::store::{self, AuthError, User};

pub struct JsonUserStore {
    path: PathBuf,
    users: Mutex<Vec<User>>,
}

impl JsonUserStore {
    pub fn new(path: impl Into<PathBuf>) -> Self {
        let path = path.into();
        let users = if path.exists() {
            let data = std::fs::read_to_string(&path)
                .unwrap_or_else(|_| "[]".to_string());
            serde_json::from_str(&data).unwrap_or_default()
        } else {
            Vec::new()
        };
        JsonUserStore {
            path,
            users: Mutex::new(users),
        }
    }

    fn save(&self) -> Result<(), AuthError> {
        let users = self.users.lock().map_err(|e| {
            AuthError::Storage(format!("lock error: {}", e))
        })?;
        let data = serde_json::to_string_pretty(&*users)
            .map_err(|e| AuthError::Storage(e.to_string()))?;
        std::fs::write(&self.path, data)
            .map_err(|e| AuthError::Storage(e.to_string()))?;
        Ok(())
    }

    fn find_by_username_locked(&self, username: &str) -> Result<Option<User>, AuthError> {
        let users = self.users.lock().map_err(|e| {
            AuthError::Storage(format!("lock error: {}", e))
        })?;
        Ok(users.iter().find(|u| u.username == username).cloned())
    }

    fn find_by_email_locked(&self, email: &str) -> Result<Option<User>, AuthError> {
        let users = self.users.lock().map_err(|e| {
            AuthError::Storage(format!("lock error: {}", e))
        })?;
        Ok(users.iter().find(|u| u.email == email).cloned())
    }
}

#[async_trait]
impl store::UserStore for JsonUserStore {
    async fn find_by_username(&self, username: &str) -> Result<Option<User>, AuthError> {
        self.find_by_username_locked(username)
    }

    async fn find_by_email(&self, email: &str) -> Result<Option<User>, AuthError> {
        self.find_by_email_locked(email)
    }

    async fn create_user(
        &self,
        username: &str,
        email: &str,
        name: &str,
        password: &str,
        is_superuser: bool,
    ) -> Result<User, AuthError> {
        {
            let users = self.users.lock().map_err(|e| {
                AuthError::Storage(format!("lock error: {}", e))
            })?;
            if users.iter().any(|u| u.username == username) {
                return Err(AuthError::DuplicateUsername);
            }
            if users.iter().any(|u| u.email == email) {
                return Err(AuthError::DuplicateEmail);
            }
        }

        let password_hash = store::hash_password(password)?;
        let user = User {
            id: store::generate_id(),
            username: username.to_string(),
            email: email.to_string(),
            name: name.to_string(),
            password_hash,
            is_superuser,
            created_at: store::timestamp(),
        };

        {
            let mut users = self.users.lock().map_err(|e| {
                AuthError::Storage(format!("lock error: {}", e))
            })?;
            users.push(user.clone());
        }
        self.save()?;
        Ok(user)
    }

    async fn delete_user(&self, username: &str) -> Result<(), AuthError> {
        let mut users = self.users.lock().map_err(|e| {
            AuthError::Storage(format!("lock error: {}", e))
        })?;
        let len_before = users.len();
        users.retain(|u| u.username != username);
        if users.len() == len_before {
            return Err(AuthError::NotFound);
        }
        drop(users);
        self.save()?;
        Ok(())
    }
}

impl JsonUserStore {
    pub fn all_users(&self) -> Result<Vec<User>, AuthError> {
        let users = self.users.lock().map_err(|e| {
            AuthError::Storage(format!("lock error: {}", e))
        })?;
        Ok(users.clone())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::store::UserStore;

    #[tokio::test]
    async fn test_create_and_find() {
        let dir = std::env::temp_dir();
        let path = dir.join("test_users3.json");
        let _ = std::fs::remove_file(&path);

        let store = JsonUserStore::new(&path);
        let user = store
            .create_user("alice", "alice@example.com", "Alice", "p4ss", true)
            .await
            .unwrap();
        assert_eq!(user.username, "alice");
        assert_eq!(user.email, "alice@example.com");
        assert_eq!(user.name, "Alice");
        assert!(user.is_superuser);

        let found = store.find_by_username("alice").await.unwrap();
        assert!(found.is_some());
        assert_eq!(found.unwrap().email, "alice@example.com");

        let found = store.find_by_email("alice@example.com").await.unwrap();
        assert!(found.is_some());
        assert_eq!(found.unwrap().username, "alice");

        let _ = std::fs::remove_file(&path);
    }

    #[tokio::test]
    async fn test_duplicate_username() {
        let dir = std::env::temp_dir();
        let path = dir.join("test_users_dup3.json");
        let _ = std::fs::remove_file(&path);

        let store = JsonUserStore::new(&path);
        store
            .create_user("bob", "bob@test.com", "Bob", "pass1", false)
            .await
            .unwrap();
        let result = store
            .create_user("bob", "other@test.com", "Other", "pass2", false)
            .await;
        assert!(matches!(result, Err(AuthError::DuplicateUsername)));

        let _ = std::fs::remove_file(&path);
    }

    #[tokio::test]
    async fn test_duplicate_email() {
        let dir = std::env::temp_dir();
        let path = dir.join("test_users_dup_email.json");
        let _ = std::fs::remove_file(&path);

        let store = JsonUserStore::new(&path);
        store
            .create_user("user1", "same@test.com", "User1", "pass1", false)
            .await
            .unwrap();
        let result = store
            .create_user("user2", "same@test.com", "User2", "pass2", false)
            .await;
        assert!(matches!(result, Err(AuthError::DuplicateEmail)));

        let _ = std::fs::remove_file(&path);
    }

    #[tokio::test]
    async fn test_delete_user() {
        let dir = std::env::temp_dir();
        let path = dir.join("test_users_delete.json");
        let _ = std::fs::remove_file(&path);

        let store = JsonUserStore::new(&path);
        store
            .create_user("dave", "dave@test.com", "Dave", "pass", false)
            .await
            .unwrap();

        let result = store.delete_user("dave").await;
        assert!(result.is_ok(), "should delete existing user");

        let found = store.find_by_username("dave").await.unwrap();
        assert!(found.is_none(), "deleted user should not be found");

        let result = store.delete_user("nonexistent").await;
        assert!(matches!(result, Err(AuthError::NotFound)));

        let _ = std::fs::remove_file(&path);
    }

    #[tokio::test]
    async fn test_persistence() {
        let dir = std::env::temp_dir();
        let path = dir.join("test_users_persist3.json");
        let _ = std::fs::remove_file(&path);

        {
            let store = JsonUserStore::new(&path);
            store
                .create_user("carol", "carol@test.com", "Carol", "secret", false)
                .await
                .unwrap();
        }

        {
            let store = JsonUserStore::new(&path);
            let found = store.find_by_username("carol").await.unwrap();
            assert!(found.is_some(), "user should persist to disk");
            assert_eq!(found.unwrap().email, "carol@test.com");
        }

        let _ = std::fs::remove_file(&path);
    }
}