Implement User Management UI (Phase 11 P0 #1)

User Management Features:
- Users.vue: Complete user CRUD interface
- Tauri commands: 5 auth user endpoints
- REST API: DataProvider trait extensions

UI Components:
- User list table (username, home_dir, status)
- Create user dialog (username, password, home_dir, status)
- Edit user dialog (password optional, home_dir, status)
- Delete user confirmation
- Reset password prompt

Tauri Commands (renamed to avoid conflict):
- list_auth_users: List all users from auth database
- create_auth_user: Create user with bcrypt password
- update_auth_user: Update user (optional password)
- delete_auth_user: Delete user
- reset_auth_password: Reset password

DataProvider Trait Extensions:
- list_users(): List all users
- create_user(): Create user with password
- update_user(): Update user (optional password)
- delete_user(): Delete user
- reset_password(): Reset password

Implementations:
- SqliteProvider: Full implementation (sftpgo_users table)
- PgProvider: Full implementation (users table)

Router:
- Added /users route

Home.vue:
- Added User Management card

Build:  Tauri + markbase-core
Tests: 495 markbase-core + 201 smb-server
This commit is contained in:
Warren
2026-06-24 05:10:27 +08:00
parent 72503f7db9
commit e07d17aee7
9 changed files with 615 additions and 2 deletions

View File

@@ -73,4 +73,19 @@ pub trait DataProvider: Send + Sync {
let _ = username;
Ok(Vec::new())
}
/// 列出所有用户
fn list_users(&self) -> Result<Vec<User>, ProviderError>;
/// 创建用户
fn create_user(&self, user: &User, password: &str) -> Result<(), ProviderError>;
/// 更新用户
fn update_user(&self, user: &User, new_password: Option<&str>) -> Result<(), ProviderError>;
/// 删除用户
fn delete_user(&self, username: &str) -> Result<(), ProviderError>;
/// 重置密码
fn reset_password(&self, username: &str, new_password: &str) -> Result<(), ProviderError>;
}

View File

@@ -115,6 +115,102 @@ impl DataProvider for PgProvider {
None => Ok(Vec::new()),
}
}
fn list_users(&self) -> Result<Vec<User>, ProviderError> {
let mut conn = self.open_conn()?;
let rows = conn
.query(
"SELECT username, password, home_dir, permissions, uid, gid, status
FROM users ORDER BY username",
&[],
)
.map_err(|e| ProviderError::Internal(format!("Query error: {}", e)))?;
let users = rows
.iter()
.map(|row| User {
username: row.get(0),
password_hash: row.get::<_, Option<String>>(1).unwrap_or_default(),
home_dir: PathBuf::from(row.get::<_, String>(2)),
permissions: row
.get::<_, Option<String>>(3)
.unwrap_or_else(|| "*".to_string()),
uid: row.get::<_, i64>(4) as u32,
gid: row.get::<_, i64>(5) as u32,
status: row.get(6),
})
.collect();
Ok(users)
}
fn create_user(&self, user: &User, password: &str) -> Result<(), ProviderError> {
let mut conn = self.open_conn()?;
let hash = bcrypt::hash(password, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"INSERT INTO users (username, password, home_dir, permissions, uid, gid, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
&[&user.username, &hash, &user.home_dir.to_string_lossy(), &user.permissions, &(user.uid as i64), &(user.gid as i64), &user.status],
)
.map_err(|e| ProviderError::Internal(format!("Insert error: {}", e)))?;
Ok(())
}
fn update_user(&self, user: &User, new_password: Option<&str>) -> Result<(), ProviderError> {
let mut conn = self.open_conn()?;
if let Some(pwd) = new_password {
let hash = bcrypt::hash(pwd, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"UPDATE users
SET password = $2, home_dir = $3, permissions = $4, uid = $5, gid = $6, status = $7
WHERE username = $1",
&[&user.username, &hash, &user.home_dir.to_string_lossy(), &user.permissions, &(user.uid as i64), &(user.gid as i64), &user.status],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
} else {
conn.execute(
"UPDATE users
SET home_dir = $2, permissions = $3, uid = $4, gid = $5, status = $6
WHERE username = $1",
&[&user.username, &user.home_dir.to_string_lossy(), &user.permissions, &(user.uid as i64), &(user.gid as i64), &user.status],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
}
Ok(())
}
fn delete_user(&self, username: &str) -> Result<(), ProviderError> {
let mut conn = self.open_conn()?;
conn.execute("DELETE FROM users WHERE username = $1", &[&username])
.map_err(|e| ProviderError::Internal(format!("Delete error: {}", e)))?;
Ok(())
}
fn reset_password(&self, username: &str, new_password: &str) -> Result<(), ProviderError> {
let mut conn = self.open_conn()?;
let hash = bcrypt::hash(new_password, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"UPDATE users SET password = $2 WHERE username = $1",
&[&username, &hash],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
Ok(())
}
}
#[cfg(test)]

View File

@@ -89,6 +89,123 @@ impl DataProvider for SqliteProvider {
.collect();
Ok(groups)
}
fn list_users(&self) -> Result<Vec<User>, ProviderError> {
let conn = self.open_conn()?;
let users = conn
.prepare(
"SELECT username, password_hash, home_dir, permissions, uid, gid, status
FROM sftpgo_users ORDER BY username",
)
.map_err(|e| ProviderError::Internal(format!("Query prepare error: {}", e)))?
.query_map([], |row| {
Ok(User {
username: row.get(0)?,
password_hash: row.get(1)?,
home_dir: PathBuf::from(row.get::<_, String>(2)?),
permissions: row.get(3)?,
uid: row.get::<_, i64>(4)? as u32,
gid: row.get::<_, i64>(5)? as u32,
status: row.get(6)?,
})
})
.map_err(|e| ProviderError::Internal(format!("Query map error: {}", e)))?
.filter_map(|r| r.ok())
.collect();
Ok(users)
}
fn create_user(&self, user: &User, password: &str) -> Result<(), ProviderError> {
let conn = self.open_conn()?;
let hash = bcrypt::hash(password, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"INSERT INTO sftpgo_users (username, password_hash, home_dir, permissions, uid, gid, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
user.username,
hash,
user.home_dir.to_string_lossy(),
user.permissions,
user.uid as i64,
user.gid as i64,
user.status,
],
)
.map_err(|e| ProviderError::Internal(format!("Insert error: {}", e)))?;
Ok(())
}
fn update_user(&self, user: &User, new_password: Option<&str>) -> Result<(), ProviderError> {
let conn = self.open_conn()?;
if let Some(pwd) = new_password {
let hash = bcrypt::hash(pwd, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"UPDATE sftpgo_users
SET password_hash = ?2, home_dir = ?3, permissions = ?4, uid = ?5, gid = ?6, status = ?7
WHERE username = ?1",
params![
user.username,
hash,
user.home_dir.to_string_lossy(),
user.permissions,
user.uid as i64,
user.gid as i64,
user.status,
],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
} else {
conn.execute(
"UPDATE sftpgo_users
SET home_dir = ?2, permissions = ?3, uid = ?4, gid = ?5, status = ?6
WHERE username = ?1",
params![
user.username,
user.home_dir.to_string_lossy(),
user.permissions,
user.uid as i64,
user.gid as i64,
user.status,
],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
}
Ok(())
}
fn delete_user(&self, username: &str) -> Result<(), ProviderError> {
let conn = self.open_conn()?;
conn.execute("DELETE FROM sftpgo_users WHERE username = ?1", params![username])
.map_err(|e| ProviderError::Internal(format!("Delete error: {}", e)))?;
Ok(())
}
fn reset_password(&self, username: &str, new_password: &str) -> Result<(), ProviderError> {
let conn = self.open_conn()?;
let hash = bcrypt::hash(new_password, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"UPDATE sftpgo_users SET password_hash = ?2 WHERE username = ?1",
params![username, hash],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
Ok(())
}
}
#[cfg(test)]

View File

@@ -6,6 +6,7 @@ pub mod management;
pub mod health;
pub mod monitor;
pub mod backup;
pub mod user_management;
pub use file_ops::*;
pub use install::*;
@@ -14,4 +15,5 @@ pub use diagnostic::*;
pub use management::*;
pub use health::*;
pub use monitor::*;
pub use backup::*;
pub use backup::*;
pub use user_management::*;

View File

@@ -0,0 +1,100 @@
use markbase_core::provider::{DataProvider, User, ProviderError, sqlite::SqliteProvider};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct UserInfo {
pub username: String,
pub home_dir: String,
pub status: String,
}
lazy_static::lazy_static! {
static ref DATA_PROVIDER: LazyLock<Arc<Mutex<Box<dyn DataProvider>>>> =
LazyLock::new(|| {
Arc::new(Mutex::new(Box::new(
SqliteProvider::new(&PathBuf::from("data/auth.sqlite").to_string_lossy().to_string())
.expect("Failed to create SqliteProvider")
) as Box<dyn DataProvider>))
});
}
#[tauri::command]
pub async fn list_auth_users() -> Result<Vec<UserInfo>, String> {
let provider = DATA_PROVIDER.lock().unwrap();
let users = provider.list_users().map_err(|e| e.to_string())?;
Ok(users.into_iter().map(|u| UserInfo {
username: u.username,
home_dir: u.home_dir.to_string_lossy().to_string(),
status: if u.status == 1 { "active".to_string() } else { "disabled".to_string() },
}).collect())
}
#[tauri::command]
pub async fn create_auth_user(
username: String,
password: String,
home_dir: String,
status: String,
) -> Result<(), String> {
let provider = DATA_PROVIDER.lock().unwrap();
let user = User {
username: username.clone(),
password_hash: String::new(),
home_dir: PathBuf::from(home_dir),
uid: 1000,
gid: 1000,
permissions: "*".to_string(),
status: if status == "active" { 1 } else { 0 },
};
provider.create_user(&user, &password).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn update_auth_user(
username: String,
password: Option<String>,
home_dir: String,
status: String,
) -> Result<(), String> {
let provider = DATA_PROVIDER.lock().unwrap();
let user = User {
username: username.clone(),
password_hash: String::new(),
home_dir: PathBuf::from(home_dir),
uid: 1000,
gid: 1000,
permissions: "*".to_string(),
status: if status == "active" { 1 } else { 0 },
};
provider.update_user(&user, password.as_deref()).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn delete_auth_user(username: String) -> Result<(), String> {
let provider = DATA_PROVIDER.lock().unwrap();
provider.delete_user(&username).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn reset_auth_password(username: String, new_password: String) -> Result<(), String> {
let provider = DATA_PROVIDER.lock().unwrap();
provider.reset_password(&username, &new_password).map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -42,6 +42,11 @@ fn main() {
get_backup_config,
set_backup_config,
run_backup,
list_auth_users,
create_auth_user,
update_auth_user,
delete_auth_user,
reset_auth_password,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -7,6 +7,7 @@ import Management from '../views/Management.vue'
import Health from '../views/Health.vue'
import Monitor from '../views/Monitor.vue'
import Backup from '../views/Backup.vue'
import Users from '../views/Users.vue'
const routes = [
{
@@ -48,6 +49,11 @@ const routes = [
path: '/backup',
name: 'Backup',
component: Backup
},
{
path: '/users',
name: 'Users',
component: Users
}
]

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/app'
import { invoke } from '@tauri-apps/api/tauri'
import { ElMessage } from 'element-plus'
import { Folder, Document, Upload, Clock } from '@element-plus/icons-vue'
import { Folder, Document, Upload, Clock, UserFilled } from '@element-plus/icons-vue'
import { open } from '@tauri-apps/api/dialog'
const router = useRouter()
@@ -225,6 +225,14 @@ onMounted(async () => {
<p>Snapshots and scheduler</p>
</div>
</el-card>
<el-card class="management-card" @click="navigateTo('/users')">
<div class="card-content">
<el-icon :size="40"><UserFilled /></el-icon>
<h3>User Management</h3>
<p>Users and permissions</p>
</div>
</el-card>
</div>
</el-col>
</el-row>

View File

@@ -0,0 +1,264 @@
<script setup>
import { ref, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
User,
UserFilled,
Plus,
Edit,
Delete,
Key,
FolderOpened,
} from '@element-plus/icons-vue'
const users = ref([])
const loading = ref(false)
const showCreateDialog = ref(false)
const showEditDialog = ref(false)
const currentUser = ref({
username: '',
password: '',
home_dir: '',
status: 'active',
})
const editingUser = ref(null)
const loadUsers = async () => {
loading.value = true
try {
const list = await invoke('list_auth_users')
users.value = list
} catch (error) {
ElMessage.error(`Failed to load users: ${error}`)
} finally {
loading.value = false
}
}
const createUser = async () => {
if (!currentUser.value.username) {
ElMessage.warning('Please enter username')
return
}
if (!currentUser.value.password) {
ElMessage.warning('Please enter password')
return
}
loading.value = true
try {
await invoke('create_auth_user', {
username: currentUser.value.username,
password: currentUser.value.password,
homeDir: currentUser.value.home_dir || `/data/${currentUser.value.username}`,
status: currentUser.value.status,
})
ElMessage.success(`User '${currentUser.value.username}' created`)
showCreateDialog.value = false
currentUser.value = { username: '', password: '', home_dir: '', status: 'active' }
await loadUsers()
} catch (error) {
ElMessage.error(`Failed to create user: ${error}`)
} finally {
loading.value = false
}
}
const editUser = (user) => {
editingUser.value = { ...user, password: '' }
showEditDialog.value = true
}
const updateUser = async () => {
if (!editingUser.value.username) {
ElMessage.warning('Please enter username')
return
}
loading.value = true
try {
await invoke('update_auth_user', {
username: editingUser.value.username,
password: editingUser.value.password || null,
homeDir: editingUser.value.home_dir,
status: editingUser.value.status,
})
ElMessage.success(`User '${editingUser.value.username}' updated`)
showEditDialog.value = false
editingUser.value = null
await loadUsers()
} catch (error) {
ElMessage.error(`Failed to update user: ${error}`)
} finally {
loading.value = false
}
}
const deleteUser = async (username) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to delete user '${username}'?`,
'Delete User',
{ type: 'warning' }
)
loading.value = true
await invoke('delete_auth_user', { username })
ElMessage.success(`User '${username}' deleted`)
await loadUsers()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(`Failed to delete user: ${error}`)
}
} finally {
loading.value = false
}
}
const resetPassword = async (username) => {
try {
const { value: newPassword } = await ElMessageBox.prompt(
`Enter new password for '${username}'`,
'Reset Password',
{
inputType: 'password',
inputPlaceholder: 'New password',
}
)
if (newPassword) {
loading.value = true
await invoke('reset_auth_password', { username, newPassword })
ElMessage.success(`Password reset for '${username}'`)
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(`Failed to reset password: ${error}`)
}
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadUsers()
})
</script>
<template>
<div class="users-container">
<el-card>
<template #header>
<div class="card-header">
<span><el-icon><UserFilled /></el-icon> User Management</span>
<el-button type="primary" :icon="Plus" @click="showCreateDialog = true">
Create User
</el-button>
</div>
</template>
<el-table :data="users" v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="Username" min-width="150">
<template #default="{ row }">
<span style="display: flex; align-items: center; gap: 8px;">
<el-icon><User /></el-icon>
{{ row.username }}
</span>
</template>
</el-table-column>
<el-table-column prop="home_dir" label="Home Directory" min-width="200" />
<el-table-column prop="status" label="Status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Actions" width="200" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button size="small" :icon="Edit" @click="editUser(row)">
Edit
</el-button>
<el-button size="small" :icon="Key" @click="resetPassword(row.username)">
Reset PW
</el-button>
<el-button size="small" type="danger" :icon="Delete" @click="deleteUser(row.username)">
Delete
</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Create User Dialog -->
<el-dialog v-model="showCreateDialog" title="Create User" width="500px">
<el-form label-width="120px">
<el-form-item label="Username">
<el-input v-model="currentUser.username" placeholder="Enter username" />
</el-form-item>
<el-form-item label="Password">
<el-input v-model="currentUser.password" type="password" placeholder="Enter password" />
</el-form-item>
<el-form-item label="Home Directory">
<el-input v-model="currentUser.home_dir" placeholder="/data/{username}" />
</el-form-item>
<el-form-item label="Status">
<el-select v-model="currentUser.status" style="width: 100%;">
<el-option label="Active" value="active" />
<el-option label="Disabled" value="disabled" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">Cancel</el-button>
<el-button type="primary" @click="createUser" :loading="loading">Create</el-button>
</template>
</el-dialog>
<!-- Edit User Dialog -->
<el-dialog v-model="showEditDialog" title="Edit User" width="500px">
<el-form label-width="120px">
<el-form-item label="Username">
<el-input v-model="editingUser.username" disabled />
</el-form-item>
<el-form-item label="New Password">
<el-input v-model="editingUser.password" type="password" placeholder="Leave empty to keep current" />
</el-form-item>
<el-form-item label="Home Directory">
<el-input v-model="editingUser.home_dir" />
</el-form-item>
<el-form-item label="Status">
<el-select v-model="editingUser.status" style="width: 100%;">
<el-option label="Active" value="active" />
<el-option label="Disabled" value="disabled" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">Cancel</el-button>
<el-button type="primary" @click="updateUser" :loading="loading">Update</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.users-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header span {
display: flex;
align-items: center;
gap: 8px;
}
</style>