M7: admin — user management and server settings
Adds an admin-only panel (topbar "Admin" button) with two tabs:
Users (create/edit/delete accounts, assign folders+modes, set admin
rights, enable/disable) and Settings (allow_writable_shares).
Backend
- Schema v2: new `users.active` column (disabled accounts cannot sign in;
their sessions are rejected). `User` gains `active`.
- Admin DB helpers: list/create/update/delete users, replace a user's
roots, count active admins, and `set_allow_writable_shares`.
- New `AdminUser` extractor (auth user + is_admin check → 403 otherwise).
- API: `GET/POST /api/admin/users`, `PUT/DELETE /api/admin/users/{id}`,
`GET/PUT /api/admin/settings`.
- Validation: unique name, min 8-char password, each root path must exist
as a directory inside the server root (reuses fs::resolve_root, so path
escapes are rejected), mode must be rw/ro.
- Lockout guards: an admin cannot demote/disable/delete themselves, and the
last active admin cannot be demoted, disabled, or deleted.
Frontend
- `AdminView` with Users/Settings tabs, shown in place of the browser when
an admin clicks the topbar "Admin" toggle (hidden for non-admins).
- Users tab: user list with admin/disabled badges, "(you)" marker, Edit and
Delete (self-delete disabled), plus a create/edit form (name, optional
password reset, admin + active checkboxes, and a folder editor to add
paths with per-folder read-write/read-only mode).
- Settings tab: allow_writable_shares toggle (the same gate the share
dialog uses), with save.
Verified in browser (app tab only): create/edit/delete users, root-mode
change, settings save reflected in /me, non-admin sees no Admin button, and
a read-only user is blocked from writes. All configs compile clean; the
embedded release binary serves the new admin UI.
Co-Authored-By: Qwen3.8 27bAserver/src/api/admin.rs
@@ -0,0 +1,315 @@
//! Admin API (milestone 7): user management and server settings.
//! All routes require an admin session (via [`AdminUser`]).
use std::sync::Arc;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::api::common::AdminUser;
use crate::auth;
use crate::db::Db;
use crate::error::{ApiError, AppState};
use crate::fs;
// ---------------------------------------------------------------------------
// Bodies
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct RootBody {
/// Path relative to the server root; "." means the whole root.
path: String,
/// "rw" or "ro".
#[serde(default = "default_rw")]
mode: String,
}
fn default_rw() -> String {
"rw".to_string()
}
#[derive(Deserialize)]
pub struct CreateUserBody {
name: String,
password: String,
#[serde(default)]
is_admin: bool,
#[serde(default)]
roots: Vec<RootBody>,
}
#[derive(Deserialize)]
pub struct UpdateUserBody {
#[serde(default)]
password: Option<String>,
#[serde(default)]
is_admin: Option<bool>,
#[serde(default)]
active: Option<bool>,
#[serde(default)]
roots: Option<Vec<RootBody>>,
}
#[derive(Deserialize)]
pub struct SettingsBody {
allow_writable_shares: bool,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Display name for a root path (folder name, or the server root's name for ".").
fn display_name(state_root: &std::path::Path, rel: &str) -> String {
let name = if rel == "." {
state_root.file_name()
} else {
std::path::Path::new(rel)
.file_name()
.filter(|_| !std::path::Path::new(rel).as_os_str().is_empty())
};
name.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| rel.to_string())
}
fn root_json(state: &AppState, r: &crate::db::RootRow) -> serde_json::Value {
serde_json::json!({
"id": r.id,
"name": display_name(&state.root, &r.path),
"path": r.path,
"mode": r.mode,
})
}
async fn user_json(db: &Db, state: &AppState, user: &crate::db::User) -> serde_json::Value {
let roots = db.user_roots(user.id).await;
serde_json::json!({
"id": user.id,
"name": user.name,
"is_admin": user.is_admin,
"active": user.active,
"roots": roots.iter().map(|r| root_json(state, r)).collect::<Vec<_>>(),
})
}
/// Validate each requested root path (must exist, be a directory, and stay
/// inside the server root) and its mode. Returns the (path, mode) pairs.
async fn validate_roots(
state: &AppState,
roots: &[RootBody],
) -> Result<Vec<(String, String)>, ApiError> {
let mut out = Vec::new();
for r in roots {
let path = if r.path.trim().is_empty() {
".".to_string()
} else {
r.path.trim().to_string()
};
if r.mode != "rw" && r.mode != "ro" {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"mode must be 'rw' or 'ro'",
));
}
let server_root = state.root.clone();
let path2 = path.clone();
tokio::task::spawn_blocking(move || fs::resolve_root(&server_root, &path2))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?
.map_err(|e| {
ApiError::new(
StatusCode::BAD_REQUEST,
format!("root path '{path}': {e}"),
)
})?;
out.push((path, r.mode.clone()));
}
Ok(out)
}
fn validate_name(name: &str) -> Result<(), ApiError> {
let n = name.trim();
if n.is_empty() || n.len() > 64 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"name must be 1–64 characters",
));
}
Ok(())
}
fn validate_password(pw: &str) -> Result<(), ApiError> {
if pw.len() < 8 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"password must be at least 8 characters",
));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// GET /api/admin/users — list all users with their roots.
pub async fn list_users(
State(state): State<Arc<AppState>>,
_admin: AdminUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let users = state.db.all_users().await;
let mut values = Vec::with_capacity(users.len());
for u in &users {
values.push(user_json(&state.db, &state, u).await);
}
Ok(Json(serde_json::json!(values)))
}
/// POST /api/admin/users — create a user.
pub async fn create_user(
State(state): State<Arc<AppState>>,
_admin: AdminUser,
Json(body): Json<CreateUserBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
let name = body.name.trim().to_string();
validate_name(&name)?;
validate_password(&body.password)?;
if state.db.find_user_by_name(&name).await.is_some() {
return Err(ApiError::new(
StatusCode::CONFLICT,
"a user with that name already exists",
));
}
let roots = validate_roots(&state, &body.roots).await?;
let pass_hash = auth::hash_password(&body.password)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}")))?;
let user = state
.db
.create_user(&name, &pass_hash, body.is_admin, &roots)
.await?;
Ok(Json(user_json(&state.db, &state, &user).await))
}
/// PUT /api/admin/users/{id} — update a user (password / is_admin / active /
/// roots; all optional).
pub async fn update_user(
State(state): State<Arc<AppState>>,
admin: AdminUser,
AxumPath(id): AxumPath<i64>,
Json(body): Json<UpdateUserBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
let target = state
.db
.find_user_by_id(id)
.await
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
// Lockout guards: an admin cannot demote, disable, or delete themselves.
if id == admin.user.id {
if body.is_admin == Some(false) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"you cannot remove your own admin rights",
));
}
if body.active == Some(false) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"you cannot disable your own account",
));
}
}
// Never allow dropping to zero active admins.
let demoting = id != admin.user.id
&& body.is_admin == Some(false)
&& target.is_admin;
let disabling = id != admin.user.id && body.active == Some(false) && target.active;
if (demoting || disabling) && state.db.count_admins().await <= 1 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"cannot remove the last active admin",
));
}
if let Some(pw) = &body.password {
validate_password(pw)?;
let hash = auth::hash_password(pw)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}")))?;
state.db.update_user_password(id, &hash).await?;
}
if let Some(is_admin) = body.is_admin {
state.db.set_user_admin(id, is_admin).await?;
}
if let Some(active) = body.active {
state.db.set_user_active(id, active).await?;
}
if let Some(roots) = &body.roots {
let pairs = validate_roots(&state, roots).await?;
state.db.set_user_roots(id, &pairs).await?;
}
let updated = state
.db
.find_user_by_id(id)
.await
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
Ok(Json(user_json(&state.db, &state, &updated).await))
}
/// DELETE /api/admin/users/{id} — delete a user (not yourself).
pub async fn delete_user(
State(state): State<Arc<AppState>>,
admin: AdminUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<serde_json::Value>, ApiError> {
if id == admin.user.id {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"you cannot delete your own account",
));
}
let target = state
.db
.find_user_by_id(id)
.await
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
if target.is_admin && target.active && state.db.count_admins().await <= 1 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"cannot delete the last active admin",
));
}
if !state.db.delete_user(id).await {
return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
}
Ok(Json(serde_json::json!({ "ok": true })))
}
/// GET /api/admin/settings
pub async fn get_settings(
State(state): State<Arc<AppState>>,
_admin: AdminUser,
) -> Result<Json<serde_json::Value>, ApiError> {
Ok(Json(serde_json::json!({
"allow_writable_shares": state.db.allow_writable_shares().await,
})))
}
/// PUT /api/admin/settings
pub async fn update_settings(
State(state): State<Arc<AppState>>,
_admin: AdminUser,
Json(body): Json<SettingsBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
state
.db
.set_allow_writable_shares(body.allow_writable_shares)
.await?;
Ok(Json(serde_json::json!({
"allow_writable_shares": body.allow_writable_shares,
})))
}
Mserver/src/api/common.rs
@@ -45,6 +45,7 @@ where
id: share.creator_id,
name: "shared".to_string(),
is_admin: false,
active: true,
};
Ok(AuthUser {
user,
@@ -121,3 +122,25 @@ impl HasState for AppState {
self
}
}
/// Extractor for admin-only routes: a signed-in user who is an admin.
pub struct AdminUser {
pub user: User,
}
impl<S> FromRequestParts<S> for AdminUser
where
S: HasState + Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
if !auth.user.is_admin {
return Err(ApiError::new(StatusCode::FORBIDDEN, "admin only"));
}
Ok(AdminUser {
user: auth.user,
})
}
}
Mserver/src/api/mod.rs
@@ -5,6 +5,7 @@ use axum::Router;
use crate::error::AppState;
mod admin;
mod auth;
mod common;
mod files;
@@ -27,6 +28,12 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/shares", post(shares::create))
.route("/api/shares/{id}", delete(shares::delete))
.route("/api/share/{token}", get(shares::resolve))
.route("/api/admin/users", get(admin::list_users))
.route("/api/admin/users", post(admin::create_user))
.route("/api/admin/users/{id}", put(admin::update_user))
.route("/api/admin/users/{id}", delete(admin::delete_user))
.route("/api/admin/settings", get(admin::get_settings))
.route("/api/admin/settings", put(admin::update_settings))
.fallback(spa::fallback)
.with_state(state)
}
Mserver/src/db.rs
@@ -3,13 +3,15 @@ use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension};
const SCHEMA_VERSION: i64 = 1;
const SCHEMA_VERSION: i64 = 2;
#[derive(Debug, Clone)]
pub struct User {
pub id: i64,
pub name: String,
pub is_admin: bool,
/// Disabled users cannot sign in and their sessions are rejected.
pub active: bool,
}
#[derive(Debug, Clone)]
@@ -85,11 +87,18 @@ impl Db {
if version < 1 {
conn.execute_batch(SCHEMA_V1)?;
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
}
if version < 2 {
// User management (M7): a disabled flag so admins can suspend
// accounts without deleting them.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
)?;
Ok(())
}
@@ -121,14 +130,15 @@ impl Db {
id: user_id,
name: name.to_string(),
is_admin: true,
active: true,
})
}
pub async fn verify_password(&self, name: &str, password: &str) -> Option<User> {
let c = self.0.lock().await;
let row: Option<(i64, String, bool, String)> = c
let row: Option<(i64, String, bool, String, bool)> = c
.query_row(
"SELECT id, name, is_admin != 0, pass_hash FROM users WHERE name = ?1",
"SELECT id, name, is_admin != 0, pass_hash, active != 0 FROM users WHERE name = ?1",
[name],
|r| {
Ok((
@@ -136,17 +146,22 @@ impl Db {
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
))
},
)
.optional()
.ok()
.flatten();
let (id, name, is_admin, hash) = row?;
let (id, name, is_admin, hash, active) = row?;
if !active {
return None;
}
crate::auth::verify_password(password, &hash).then_some(User {
id,
name,
is_admin,
active,
})
}
@@ -171,15 +186,16 @@ impl Db {
pub async fn session_user(&self, token: &str) -> Option<User> {
let c = self.0.lock().await;
c.query_row(
"SELECT u.id, u.name, u.is_admin != 0
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?1",
WHERE s.token = ?1 AND u.active = 1",
[token],
|r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
},
)
@@ -210,6 +226,161 @@ impl Db {
out
}
// ---------- admin: user management (M7) ----------
pub async fn all_users(&self) -> Vec<User> {
let c = self.0.lock().await;
let mut out = Vec::new();
if let Ok(mut stmt) = c.prepare(
"SELECT id, name, is_admin != 0, active != 0 FROM users ORDER BY id",
) {
if let Ok(rows) = stmt.query_map([], |r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
}) {
out.extend(rows.flatten());
}
}
out
}
pub async fn find_user_by_id(&self, id: i64) -> Option<User> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0 FROM users WHERE id = ?1",
[id],
|r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
},
)
.ok()
}
pub async fn find_user_by_name(&self, name: &str) -> Option<User> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0 FROM users WHERE name = ?1",
[name],
|r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
},
)
.ok()
}
pub async fn count_admins(&self) -> i64 {
let c = self.0.lock().await;
c.query_row(
"SELECT COUNT(*) FROM users WHERE is_admin = 1 AND active = 1",
[],
|r| r.get(0),
)
.unwrap_or(0)
}
/// Create a user with the given roots (path, mode) pairs.
pub async fn create_user(
&self,
name: &str,
pass_hash: &str,
is_admin: bool,
roots: &[(String, String)],
) -> Result<User, rusqlite::Error> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
tx.execute(
"INSERT INTO users (name, pass_hash, is_admin, active, created_at)
VALUES (?1, ?2, ?3, 1, ?4)",
params![name, pass_hash, is_admin as i64, now()],
)?;
let user_id = tx.last_insert_rowid();
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![user_id, path, mode],
)?;
}
tx.commit()?;
Ok(User {
id: user_id,
name: name.to_string(),
is_admin,
active: true,
})
}
pub async fn update_user_password(
&self,
id: i64,
pass_hash: &str,
) -> Result<(), rusqlite::Error> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET pass_hash = ?1 WHERE id = ?2",
params![pass_hash, id],
)?;
Ok(())
}
pub async fn set_user_admin(&self, id: i64, is_admin: bool) -> Result<(), rusqlite::Error> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET is_admin = ?1 WHERE id = ?2",
params![is_admin as i64, id],
)?;
Ok(())
}
pub async fn set_user_active(&self, id: i64, active: bool) -> Result<(), rusqlite::Error> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET active = ?1 WHERE id = ?2",
params![active as i64, id],
)?;
Ok(())
}
pub async fn delete_user(&self, id: i64) -> bool {
let c = self.0.lock().await;
c.execute("DELETE FROM users WHERE id = ?1", [id])
.ok()
.map(|n| n > 0)
.unwrap_or(false)
}
/// Replace a user's roots with the given (path, mode) pairs.
pub async fn set_user_roots(
&self,
user_id: i64,
roots: &[(String, String)],
) -> Result<(), rusqlite::Error> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
tx.execute("DELETE FROM user_roots WHERE user_id = ?1", [user_id])?;
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![user_id, path, mode],
)?;
}
tx.commit()?;
Ok(())
}
// ---------- shares ----------
pub async fn create_share(
@@ -277,7 +448,6 @@ impl Db {
.ok()
}
#[allow(dead_code)]
pub async fn set_setting(&self, key: &str, value: &str) -> Result<(), rusqlite::Error> {
let c = self.0.lock().await;
c.execute(
@@ -293,6 +463,14 @@ impl Db {
pub async fn allow_writable_shares(&self) -> bool {
self.get_setting("allow_writable_shares").await.as_deref() == Some("1")
}
pub async fn set_allow_writable_shares(
&self,
v: bool,
) -> Result<(), rusqlite::Error> {
self.set_setting("allow_writable_shares", if v { "1" } else { "0" })
.await
}
}
const SHARE_COLS: &str =
Mweb/app.css
@@ -945,3 +945,210 @@ button:disabled {
width: auto;
min-width: 140px;
}
/* ------------------------------------------------------------------ */
/* Admin (milestone 7) */
/* ------------------------------------------------------------------ */
.admin-view {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.admin-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
}
.admin-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 8px 14px;
color: var(--muted);
font-weight: 600;
}
.admin-tab:hover {
color: var(--text);
border-color: var(--muted);
}
.admin-tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.admin-section-title {
margin: 0 0 14px;
font-size: 15px;
}
.users-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.users-header .admin-section-title {
margin-bottom: 0;
}
.users-header .btn-primary {
width: auto;
}
.user-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.user-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
}
.user-row-main {
min-width: 0;
}
.user-row-name {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
flex-wrap: wrap;
}
.user-row-meta {
font-size: 12px;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge-off {
background: color-mix(in srgb, var(--danger) 15%, var(--panel));
border-color: var(--danger);
color: var(--danger);
}
.setting-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
}
.setting-row input {
width: auto;
margin-top: 2px;
}
.setting-label {
display: block;
font-weight: 600;
font-size: 14px;
}
.setting-desc {
display: block;
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
/* User create/edit form */
.user-form {
width: 520px;
}
.field-input {
width: 100%;
box-sizing: border-box;
}
.field-hint {
font-size: 12px;
margin: -2px 0 8px;
}
.check-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
margin-bottom: 12px;
cursor: pointer;
}
.check-row input {
width: auto;
}
.form-error {
color: var(--danger);
font-size: 13px;
margin: 4px 0 8px;
}
.roots-editor {
display: flex;
flex-direction: column;
gap: 6px;
}
.root-draft {
display: flex;
align-items: center;
gap: 8px;
}
.root-draft-path {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.root-draft-mode {
width: auto;
padding: 6px 8px;
}
.root-add {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* Active state for the topbar "Admin" toggle */
.btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
Mweb/src/api.rs
@@ -621,6 +621,119 @@ pub fn resolve_share(
request("GET", format!("/api/share/{token}"), None::<()>)
}
// ---------------------------------------------------------------------------
// Admin (milestone 7): user management + settings
// ---------------------------------------------------------------------------
#[derive(Deserialize, Clone)]
pub struct AdminUser {
pub id: i64,
pub name: String,
pub is_admin: bool,
pub active: bool,
pub roots: Vec<RootInfo>,
}
#[derive(Serialize)]
struct AdminRootBody {
path: String,
mode: String,
}
#[derive(Serialize)]
struct CreateAdminUserBody {
name: String,
password: String,
is_admin: bool,
roots: Vec<AdminRootBody>,
}
#[derive(Serialize)]
struct UpdateAdminUserBody {
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
is_admin: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
roots: Option<Vec<AdminRootBody>>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct AdminSettings {
pub allow_writable_shares: bool,
}
fn roots_to_bodies(roots: &[(String, String)]) -> Vec<AdminRootBody> {
roots
.iter()
.map(|(path, mode)| AdminRootBody {
path: path.clone(),
mode: mode.clone(),
})
.collect()
}
pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
request("GET", "/api/admin/users".to_string(), None::<()>)
}
pub fn create_admin_user(
name: &str,
password: &str,
is_admin: bool,
roots: &[(String, String)],
) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
request(
"POST",
"/api/admin/users".to_string(),
Some(CreateAdminUserBody {
name: name.to_string(),
password: password.to_string(),
is_admin,
roots: roots_to_bodies(roots),
}),
)
}
pub fn update_admin_user(
id: i64,
password: Option<String>,
is_admin: Option<bool>,
active: Option<bool>,
roots: Option<Vec<(String, String)>>,
) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
request(
"PUT",
format!("/api/admin/users/{id}"),
Some(UpdateAdminUserBody {
password,
is_admin,
active,
roots: roots.map(|r| roots_to_bodies(&r)),
}),
)
}
pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("DELETE", format!("/api/admin/users/{id}"))
}
pub fn get_admin_settings() -> impl std::future::Future<Output = Result<AdminSettings, ApiError>> {
request("GET", "/api/admin/settings".to_string(), None::<()>)
}
pub fn update_admin_settings(
allow_writable_shares: bool,
) -> impl std::future::Future<Output = Result<AdminSettings, ApiError>> {
request(
"PUT",
"/api/admin/settings".to_string(),
Some(AdminSettings { allow_writable_shares }),
)
}
// ---------------------------------------------------------------------------
// File picker (imperative, one at a time)
// ---------------------------------------------------------------------------
Aweb/src/views/admin.rs
@@ -0,0 +1,620 @@
//! Admin UI (milestone 7): user management and server settings.
//! Rendered in place of the browser when an admin opens the "Admin" panel.
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me};
use crate::components::toast::{show, ToastMsg};
// ---------------------------------------------------------------------------
// Top-level admin view with tabs
// ---------------------------------------------------------------------------
#[component]
pub fn AdminView(me: ReadSignal<Option<Me>>) -> impl IntoView {
let (tab, set_tab) = signal("users".to_string());
view! {
<div class="admin-view">
<div class="admin-tabs">
<button
class="admin-tab"
class:active=move || tab.get() == "users"
on:click=move |_| set_tab.set("users".to_string())
>
"Users"
</button>
<button
class="admin-tab"
class:active=move || tab.get() == "settings"
on:click=move |_| set_tab.set("settings".to_string())
>
"Settings"
</button>
</div>
<div class="admin-body">
{move || {
if tab.get() == "users" {
view! { <UsersTab me=me/> }
.into_view()
.into_any()
} else {
view! { <SettingsTab/> }
.into_view()
.into_any()
}
}}
</div>
</div>
}
}
// ---------------------------------------------------------------------------
// Settings tab
// ---------------------------------------------------------------------------
#[component]
fn SettingsTab() -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (value, set_value) = signal(Option::<bool>::None);
let (busy, set_busy) = signal(false);
{
let set = set_value;
let toast2 = toast;
spawn_local(async move {
match api::get_admin_settings().await {
Ok(s) => set.set(Some(s.allow_writable_shares)),
Err(e) => show(toast2, e.to_string()),
}
});
}
let save = move |_| {
if busy.get() {
return;
}
let Some(v) = value.get() else { return };
set_busy.set(true);
let toast2 = toast;
spawn_local(async move {
match api::update_admin_settings(v).await {
Ok(_) => show(toast2, "Settings saved".to_string()),
Err(e) => show(toast2, format!("Could not save settings: {e}")),
}
set_busy.set(false);
});
};
view! {
<div class="settings-tab">
<h3 class="admin-section-title">"Server settings"</h3>
{move || match value.get() {
None => view! {
<p class="muted">"Loading…"</p>
}
.into_view()
.into_any(),
Some(v) => view! {
<label class="setting-row">
<input
type="checkbox"
checked=v
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_value.set(Some(t.checked()));
}
}
/>
<span>
<span class="setting-label">"Allow writable shares"</span>
<span class="setting-desc">
"Let users create read-write share links. Off by default."
</span>
</span>
</label>
<div class="modal-actions">
<button class="btn btn-primary" disabled=move || busy.get() on:click=save>
{move || if busy.get() { "Saving…" } else { "Save settings" }}
</button>
</div>
}
.into_view()
.into_any(),
}}
</div>
}
}
// ---------------------------------------------------------------------------
// Users tab
// ---------------------------------------------------------------------------
/// What the user form is editing: a brand-new user, or an existing one.
#[derive(Clone)]
enum Editing {
New,
Edit(AdminUser),
}
#[component]
fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
let (editing, set_editing) = signal(Option::<Editing>::None);
let self_id = move || me.get().and_then(|m| m.user).map(|u| u.id);
let reload = move || {
let set = set_users;
let toast2 = toast;
spawn_local(async move {
match api::list_admin_users().await {
Ok(u) => set.set(Some(u)),
Err(e) => show(toast2, e.to_string()),
}
});
};
reload();
let delete = move |u: AdminUser| {
let is_self = self_id() == Some(u.id);
if is_self {
return;
}
let Some(win) = web_sys::window() else { return };
let ok = win
.confirm_with_message(&format!("Delete user “{}”? This cannot be undone.", u.name))
.unwrap_or(false);
if !ok {
return;
}
let id = u.id;
let read = users;
let write = set_users;
let toast2 = toast;
spawn_local(async move {
if api::delete_admin_user(id).await.is_ok() {
if let Some(mut cur) = read.get() {
cur.retain(|x| x.id != id);
write.set(Some(cur));
}
} else {
show(toast2, "Could not delete user".to_string());
}
});
};
view! {
<div class="users-tab">
<div class="users-header">
<h3 class="admin-section-title">"Users"</h3>
<button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
"New user"
</button>
</div>
{move || match users.get() {
None => view! {
<p class="muted">"Loading…"</p>
}
.into_view()
.into_any(),
Some(ref list) if list.is_empty() => view! {
<p class="muted">"No users."
</p>
}
.into_view()
.into_any(),
Some(ref list) => {
let sid = self_id();
let rows = list
.iter()
.map(|u| {
let is_self = sid == Some(u.id);
let roots_desc = if u.roots.is_empty() {
"(no folders)".to_string()
} else {
u.roots
.iter()
.map(|r| format!("{} ({})", r.path, r.mode))
.collect::<Vec<_>>()
.join(", ")
};
let u_edit = u.clone();
let u_del = u.clone();
let del = delete;
let set_edit = set_editing;
view! {
<div class="user-row">
<div class="user-row-main">
<div class="user-row-name">
{if u.is_admin { "👑 " } else { "" }}
{u.name.clone()}
{if is_self {
view! { <span class="muted">" (you)"</span> }
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
}}
{if u.is_admin {
view! { <span class="badge">"admin"</span> }
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
}}
{if !u.active {
view! { <span class="badge badge-off">"disabled"</span> }
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
}}
</div>
<div class="user-row-meta muted">{roots_desc.clone()}</div>
</div>
<div class="user-row-actions">
<button
class="btn btn-sm"
on:click=move |_| set_edit.set(Some(Editing::Edit(u_edit.clone())))
>
"Edit"
</button>
<button
class="btn btn-sm btn-danger"
disabled=is_self
title=if is_self { "You cannot delete yourself" } else { "Delete user" }
on:click=move |_| del(u_del.clone())
>
"Delete"
</button>
</div>
</div>
}
})
.collect::<Vec<_>>();
view! {
<div class="user-list">{rows}</div>
}
}
.into_view()
.into_any(),
}}
{move || match editing.get() {
Some(ref e) => {
let existing = match e {
Editing::Edit(u) => Some(u.clone()),
Editing::New => None,
};
let sid = self_id();
let set = set_editing;
view! {
<UserForm
existing=existing
self_id=sid
close=Callback::new(move |_| set.set(None))
on_saved=Callback::new(move |_| reload())
/>
}
.into_view()
.into_any()
}
None => view! {}.into_any(),
}}
</div>
}
}
// ---------------------------------------------------------------------------
// User create / edit form
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct RootDraft {
path: String,
mode: String,
}
#[component]
fn UserForm(
existing: Option<AdminUser>,
self_id: Option<i64>,
close: Callback<()>,
on_saved: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let is_new = existing.is_none();
let is_self = existing.as_ref().map(|u| self_id == Some(u.id)).unwrap_or(false);
let original_name = existing.as_ref().map(|u| u.name.clone()).unwrap_or_default();
let (name, set_name) = signal(existing.as_ref().map(|u| u.name.clone()).unwrap_or_default());
let (password, set_password) = signal(String::new());
let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
let mut roots0: Vec<RootDraft> = existing
.as_ref()
.map(|u| {
u.roots
.iter()
.map(|r| RootDraft {
path: r.path.clone(),
mode: r.mode.clone(),
})
.collect()
})
.unwrap_or_default();
if is_new && roots0.is_empty() {
roots0.push(RootDraft {
path: ".".to_string(),
mode: "rw".to_string(),
});
}
let (roots, set_roots) = signal(roots0);
let (new_path, set_new_path) = signal(String::new());
let (busy, set_busy) = signal(false);
let (error, set_error) = signal(Option::<String>::None);
let name_readonly = !is_new;
let add_root = move |_| {
let p = new_path.get().trim().to_string();
let path = if p.is_empty() { ".".to_string() } else { p };
let mut v = roots.get();
// Avoid duplicate paths.
if v.iter().any(|r| r.path == path) {
set_error.set(Some("That folder is already in the list.".to_string()));
return;
}
v.push(RootDraft {
path: path.clone(),
mode: "rw".to_string(),
});
set_roots.set(v);
set_new_path.set(String::new());
set_error.set(None);
};
let save = move |_| {
if busy.get() {
return;
}
let n = name.get().trim().to_string();
let pw = password.get();
let adm = is_admin.get();
let act = active.get();
let pairs: Vec<(String, String)> = roots
.get()
.iter()
.map(|r| (r.path.clone(), r.mode.clone()))
.collect();
// Client-side validation.
if n.is_empty() {
set_error.set(Some("A name is required.".to_string()));
return;
}
if is_new && pw.len() < 8 {
set_error.set(Some("Password must be at least 8 characters.".to_string()));
return;
}
if !is_new && pw.is_empty() {
// keep password unchanged
} else if pw.len() < 8 {
set_error.set(Some("Password must be at least 8 characters.".to_string()));
return;
}
if pairs.is_empty() {
set_error.set(Some("Give the user at least one folder.".to_string()));
return;
}
set_busy.set(true);
set_error.set(None);
let toast2 = toast;
let on_saved2 = on_saved;
let close2 = close;
let existing_id = existing.as_ref().map(|u| u.id);
spawn_local(async move {
let result = if is_new {
api::create_admin_user(&n, &pw, adm, &pairs).await
} else {
let id = existing_id.unwrap_or(0);
let pw_opt = if pw.is_empty() { None } else { Some(pw.clone()) };
api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
};
match result {
Ok(_) => {
show(toast2, if is_new { "User created".to_string() } else { "User updated".to_string() });
on_saved2.run(());
close2.run(());
}
Err(e) => {
show(toast2, format!("Could not save user: {e}"));
set_busy.set(false);
}
}
});
};
view! {
<div class="modal-overlay" on:click=move |_| close.run(())>
<div
class="modal user-form"
on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
>
<h2 class="modal-title">{if is_new { "New user".to_string() } else { format!("Edit “{}”", original_name) }}</h2>
{move || {
let n0 = name.get();
view! {
<label class="field">
<span class="field-label">"Name"</span>
<input
class="field-input"
value=n0.clone()
disabled=name_readonly
placeholder="e.g. alice"
on:input=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_name.set(t.value());
}
}
/>
</label>
}
}}
<label class="field">
<span class="field-label">
{if is_new { "Password" } else { "New password (leave blank to keep)" }}
</span>
<input
class="field-input"
type="password"
value=move || password.get()
placeholder="at least 8 characters"
on:input=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_password.set(t.value());
}
}
/>
</label>
<label class="check-row">
<input
type="checkbox"
checked=move || is_admin.get()
disabled=is_self
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_is_admin.set(t.checked());
}
}
/>
"Administrator"
</label>
{move || if !is_new {
view! {
<label class="check-row">
<input
type="checkbox"
checked=move || active.get()
disabled=is_self
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_active.set(t.checked());
}
}
/>
"Active (can sign in)"
</label>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}}
<div class="field">
<span class="field-label">"Folders"</span>
<p class="muted field-hint">"Paths are relative to the server root. “.” is the whole root.”"</p>
<div class="roots-editor">
{move || {
let set_roots_c = set_roots;
let roots_c = roots;
roots
.get()
.iter()
.enumerate()
.map(|(i, r)| {
let rw_selected = r.mode == "rw";
let ro_selected = r.mode == "ro";
view! {
<div class="root-draft">
<span class="root-draft-path">{r.path.clone()}</span>
<select
class="root-draft-mode"
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
{
let val = t.value();
let mut v = roots_c.get();
if i < v.len() {
v[i].mode = val;
}
set_roots_c.set(v);
}
}
>
<option value="rw" selected=rw_selected>"read-write"</option>
<option value="ro" selected=ro_selected>"read-only"</option>
</select>
<button
class="btn btn-sm"
title="Remove folder"
on:click=move |_| {
let mut v = roots_c.get();
if i < v.len() {
v.remove(i);
}
set_roots_c.set(v);
}
>
"✕"
</button>
</div>
}
})
.collect::<Vec<_>>()
}}
<div class="root-add">
<input
class="field-input"
value=move || new_path.get()
placeholder="e.g. docs or ."
on:input=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_new_path.set(t.value());
}
}
/>
<button class="btn" on:click=add_root>"Add"</button>
</div>
</div>
</div>
{move || match error.get() {
Some(ref e) => view! {
<p class="form-error">{e.clone()}</p>
}
.into_view()
.into_any(),
None => view! {}.into_any(),
}}
<div class="modal-actions">
<button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
<button
class="btn btn-primary"
disabled=move || busy.get()
on:click=save
>
{move || if busy.get() { "Saving…" } else { if is_new { "Create user" } else { "Save" } }}
</button>
</div>
</div>
</div>
}
}
Mweb/src/views/mod.rs
@@ -1,3 +1,4 @@
pub mod admin;
pub mod browser;
pub mod dialogs;
pub mod login;
Mweb/src/views/shell.rs
@@ -4,6 +4,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::components::toast::{provide_toast, ToastView};
use crate::router::Location;
use crate::views::admin::AdminView;
use crate::views::browser::Browser;
use crate::views::shares::SharesPanel;
@@ -16,6 +17,7 @@ pub fn ShellView(
) -> impl IntoView {
let toast = provide_toast();
let (show_shares, set_show_shares) = signal(false);
let (show_admin, set_show_admin) = signal(false);
let on_logout = move |_| {
spawn_local(async move {
@@ -49,12 +51,36 @@ pub fn ShellView(
.into_any(),
)
}}
<Show when=move || me.get().and_then(|m| m.user).map(|u| u.is_admin).unwrap_or(false)>
<button
class="btn"
class:active=move || show_admin.get()
on:click=move |_| set_show_admin.update(|v| *v = !*v)
>
"Admin"
</button>
</Show>
<button class="btn" on:click=move |_| set_show_shares.set(true)>"Shares"</button>
<button class="btn" on:click=on_logout>"Log out"</button>
</div>
</header>
<main class="content">
<Browser me=me loc=loc/>
{move || {
let is_admin = me
.get()
.and_then(|m| m.user)
.map(|u| u.is_admin)
.unwrap_or(false);
if show_admin.get() && is_admin {
view! { <AdminView me=me/> }
.into_view()
.into_any()
} else {
view! { <Browser me=me loc=loc/> }
.into_view()
.into_any()
}
}}
</main>
{move || if show_shares.get() {
view! {