mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(users): Track 5 user management — admin panel, custom roles, banning
Adds admin-only user management API and UI: - DB: UserSummary, RoleRecord structs; 10 new auth-DB helpers (list_users, create_user, get_user_by_uid, set_user_status, assign_role, remove_role, list_roles, create_custom_role, invalidate_user_sessions, get_user_id_by_uid) with 4 tests - Server: 5 admin routes (/api/admin/users, /api/admin/users/:uid/status, /api/admin/users/:uid/roles, /api/admin/roles) with 7 handlers + 2 tests - Frontend: 7 admin API helpers in api.js; AdminView two-tab panel (Users/Roles) with ban/unban, create user, create custom role forms - Role bitmask: guest=1, user=2, admin=4, owner=8; custom roles bit>=4 - Ban invalidates all active sessions; only-owner guard prevents last owner removal 169 tests green, frontend builds clean.
This commit is contained in:
commit
2c3d3eb86e
5 changed files with 713 additions and 22 deletions
31
NEXT.md
31
NEXT.md
|
|
@ -101,10 +101,20 @@ guest=1, user=2, admin=4, owner=8. All tests green.
|
|||
|
||||
---
|
||||
|
||||
### 5. User management
|
||||
### ~~5. User management~~ ✅ Done
|
||||
|
||||
**What:** Registration flow, custom role creation, admin user panel, banning users.
|
||||
Depends on Track 4.
|
||||
**Implemented:** `crates/archivr-core/src/database.rs`: `UserSummary` and `RoleRecord` structs;
|
||||
10 new pub functions (`invalidate_user_sessions`, `get_user_id_by_uid`, `list_users`,
|
||||
`get_user_by_uid`, `create_user`, `set_user_status`, `assign_role`, `remove_role`, `list_roles`,
|
||||
`create_custom_role`). `crates/archivr-server/src/routes.rs`: 5 admin routes
|
||||
(`GET|POST /api/admin/users`, `PATCH /api/admin/users/:uid/status`,
|
||||
`POST|DELETE /api/admin/users/:uid/roles`, `GET|POST /api/admin/roles`); 7 handler functions;
|
||||
request body structs. `frontend/src/api.js`: 7 admin helpers (`listAdminUsers`,
|
||||
`createAdminUser`, `setUserStatus`, `assignRole`, `removeRole`, `listRoles`, `createRole`).
|
||||
`frontend/src/components/AdminView.jsx`: two-tab admin panel (Users / Roles) with ban/unban,
|
||||
create user form, create custom role form. Role bitmask: guest=1, user=2, admin=4, owner=8;
|
||||
custom roles get bit_position≥4. Ban invalidates all sessions. Only-owner guard on role removal.
|
||||
169 tests green.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -186,19 +196,12 @@ and a corresponding downloader module. Consider `rclone` as a shell-out strategy
|
|||
|
||||
## What to Do First
|
||||
|
||||
Tracks 1, 2, and 4 are complete. Track 3 (async capture jobs) is the next priority.
|
||||
Tracks 1, 2, 3, 4, and 5 are complete. Track 6 (permissions & visibility) is the next priority.
|
||||
|
||||
Open the next thread with:
|
||||
|
||||
```text
|
||||
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 3: async capture jobs.
|
||||
Create a task-level implementation plan first, then wait for approval.
|
||||
```
|
||||
|
||||
For Track 5 (user management), begin only after Track 4 is verified in production:
|
||||
|
||||
```text
|
||||
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 5: user management
|
||||
(registration flow, custom roles, admin panel). Create a task-level implementation plan
|
||||
first, then wait for approval.
|
||||
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 6: permissions and
|
||||
visibility — the collection model. Create a task-level implementation plan first, then wait
|
||||
for approval.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -109,6 +109,27 @@ pub struct CaptureJobRecord {
|
|||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UserSummary {
|
||||
pub user_uid: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub role_slugs: Vec<String>,
|
||||
pub role_bits: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct RoleRecord {
|
||||
pub role_uid: String,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub level: i64,
|
||||
pub bit_position: i64,
|
||||
pub is_builtin: bool,
|
||||
}
|
||||
|
||||
pub fn database_path(archive_path: &Path) -> PathBuf {
|
||||
archive_path.join(DATABASE_FILE_NAME)
|
||||
}
|
||||
|
|
@ -577,6 +598,225 @@ pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result<Vec<ApiTokenR
|
|||
Ok(records)
|
||||
}
|
||||
|
||||
/// Deletes all sessions for a user. Called on ban or role change.
|
||||
pub fn invalidate_user_sessions(conn: &Connection, user_id: i64) -> Result<usize> {
|
||||
let n = conn.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Returns the integer id for a user_uid, or None if not found.
|
||||
pub fn get_user_id_by_uid(conn: &Connection, user_uid: &str) -> Result<Option<i64>> {
|
||||
conn.query_row("SELECT id FROM users WHERE user_uid = ?1", [user_uid], |r| r.get(0))
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Lists all users with their assigned roles and computed role_bits.
|
||||
pub fn list_users(conn: &Connection) -> Result<Vec<UserSummary>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_uid, username, email, status, created_at FROM users ORDER BY created_at ASC",
|
||||
)?;
|
||||
let rows: Vec<(i64, String, String, Option<String>, String, String)> = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, user_uid, username, email, status, created_at)| {
|
||||
let role_bits = compute_role_bits(conn, id)?;
|
||||
let mut rs = conn.prepare(
|
||||
"SELECT r.slug FROM user_roles ur JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?1 ORDER BY r.level, r.bit_position",
|
||||
)?;
|
||||
let role_slugs: Vec<String> =
|
||||
rs.query_map([id], |r| r.get(0))?.collect::<Result<_, _>>()?;
|
||||
Ok(UserSummary { user_uid, username, email, status, created_at, role_slugs, role_bits })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Gets a single user by user_uid with roles and role_bits.
|
||||
pub fn get_user_by_uid(conn: &Connection, user_uid: &str) -> Result<Option<UserSummary>> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT id, user_uid, username, email, status, created_at FROM users WHERE user_uid = ?1",
|
||||
[user_uid],
|
||||
|r| Ok((r.get::<_, i64>(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
|
||||
)
|
||||
.optional()?;
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some((id, user_uid, username, email, status, created_at)) => {
|
||||
let role_bits = compute_role_bits(conn, id)?;
|
||||
let mut rs = conn.prepare(
|
||||
"SELECT r.slug FROM user_roles ur JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?1 ORDER BY r.level, r.bit_position",
|
||||
)?;
|
||||
let role_slugs: Vec<String> =
|
||||
rs.query_map([id], |r| r.get(0))?.collect::<Result<_, _>>()?;
|
||||
Ok(Some(UserSummary { user_uid, username, email, status, created_at, role_slugs, role_bits }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new user (admin-created account) and assigns the 'user' role.
|
||||
/// Returns the new user_uid.
|
||||
pub fn create_user(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
email: Option<&str>,
|
||||
password_hash: &str,
|
||||
created_by_user_id: i64,
|
||||
) -> Result<String> {
|
||||
let user_uid = public_id("usr");
|
||||
conn.execute(
|
||||
"INSERT INTO users (user_uid, username, email, password_hash, status, role, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, 'active', 'user', ?5)",
|
||||
params![user_uid, username, email, password_hash, now_timestamp()],
|
||||
)?;
|
||||
let user_id = conn.last_insert_rowid();
|
||||
let role_id: i64 = conn.query_row(
|
||||
"SELECT id FROM roles WHERE slug = 'user'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![user_id, role_id, now_timestamp(), created_by_user_id],
|
||||
)?;
|
||||
Ok(user_uid)
|
||||
}
|
||||
|
||||
/// Sets a user's status ('active' | 'disabled'). Invalidates sessions when disabling.
|
||||
/// Returns true if the user was found.
|
||||
pub fn set_user_status(conn: &Connection, user_uid: &str, status: &str) -> Result<bool> {
|
||||
if status == "disabled" {
|
||||
let id: Option<i64> = conn
|
||||
.query_row("SELECT id FROM users WHERE user_uid = ?1", [user_uid], |r| r.get(0))
|
||||
.optional()?;
|
||||
if let Some(id) = id {
|
||||
invalidate_user_sessions(conn, id)?;
|
||||
}
|
||||
}
|
||||
let n = conn.execute(
|
||||
"UPDATE users SET status = ?1 WHERE user_uid = ?2",
|
||||
params![status, user_uid],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Assigns a role to a user (cumulative: also ensures 'user' for any non-guest role,
|
||||
/// and 'admin' for 'owner'). Invalidates the user's sessions so changes take effect on re-login.
|
||||
pub fn assign_role(
|
||||
conn: &Connection,
|
||||
target_user_id: i64,
|
||||
role_slug: &str,
|
||||
assigned_by_user_id: i64,
|
||||
) -> Result<()> {
|
||||
let role_id: i64 = conn
|
||||
.query_row("SELECT id FROM roles WHERE slug = ?1", [role_slug], |r| r.get(0))
|
||||
.map_err(|_| anyhow::anyhow!("role '{}' not found", role_slug))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![target_user_id, role_id, now_timestamp(), assigned_by_user_id],
|
||||
)?;
|
||||
// Cumulative: ensure 'user' whenever any non-guest role is assigned
|
||||
if role_slug != "user" && role_slug != "guest" {
|
||||
let uid: i64 = conn.query_row("SELECT id FROM roles WHERE slug = 'user'", [], |r| r.get(0))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![target_user_id, uid, now_timestamp(), assigned_by_user_id],
|
||||
)?;
|
||||
}
|
||||
// Also ensure 'admin' when assigning 'owner'
|
||||
if role_slug == "owner" {
|
||||
let aid: i64 = conn.query_row("SELECT id FROM roles WHERE slug = 'admin'", [], |r| r.get(0))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![target_user_id, aid, now_timestamp(), assigned_by_user_id],
|
||||
)?;
|
||||
}
|
||||
invalidate_user_sessions(conn, target_user_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a role from a user. Guards: can't remove the only owner's 'owner' role.
|
||||
/// Invalidates the user's sessions.
|
||||
pub fn remove_role(conn: &Connection, target_user_id: i64, role_slug: &str) -> Result<()> {
|
||||
if role_slug == "owner" {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE r.slug = 'owner'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if count <= 1 {
|
||||
anyhow::bail!("cannot remove the last owner");
|
||||
}
|
||||
}
|
||||
let role_id: i64 = conn
|
||||
.query_row("SELECT id FROM roles WHERE slug = ?1", [role_slug], |r| r.get(0))
|
||||
.map_err(|_| anyhow::anyhow!("role '{}' not found", role_slug))?;
|
||||
conn.execute(
|
||||
"DELETE FROM user_roles WHERE user_id = ?1 AND role_id = ?2",
|
||||
params![target_user_id, role_id],
|
||||
)?;
|
||||
invalidate_user_sessions(conn, target_user_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists all roles ordered by level then bit_position.
|
||||
pub fn list_roles(conn: &Connection) -> Result<Vec<RoleRecord>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT role_uid, slug, name, level, bit_position, is_builtin FROM roles
|
||||
ORDER BY level, bit_position",
|
||||
)?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok(RoleRecord {
|
||||
role_uid: r.get(0)?,
|
||||
slug: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
level: r.get(3)?,
|
||||
bit_position: r.get(4)?,
|
||||
is_builtin: r.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Creates a new custom role (level=2, bit_position = max existing + 1, min 4).
|
||||
/// Returns the created RoleRecord.
|
||||
pub fn create_custom_role(conn: &Connection, slug: &str, name: &str) -> Result<RoleRecord> {
|
||||
if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||
anyhow::bail!("role slug must be non-empty and contain only ASCII letters, digits, or hyphens");
|
||||
}
|
||||
let next_bit: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(bit_position) + 1, 4) FROM roles WHERE bit_position >= 4",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if next_bit >= 32 {
|
||||
anyhow::bail!("maximum number of custom roles reached");
|
||||
}
|
||||
let role_uid = public_id("role");
|
||||
conn.execute(
|
||||
"INSERT INTO roles (role_uid, slug, name, level, bit_position, is_builtin)
|
||||
VALUES (?1, ?2, ?3, 2, ?4, 0)",
|
||||
params![role_uid, slug, name, next_bit],
|
||||
)?;
|
||||
Ok(RoleRecord {
|
||||
role_uid,
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
level: 2,
|
||||
bit_position: next_bit,
|
||||
is_builtin: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ensure_default_user(conn: &Connection) -> Result<i64> {
|
||||
if let Some(id) = conn
|
||||
.query_row(
|
||||
|
|
@ -1688,4 +1928,63 @@ mod tests {
|
|||
assert_eq!(job.status, "failed");
|
||||
assert!(job.error_text.as_deref().unwrap().contains("interrupted"));
|
||||
}
|
||||
|
||||
fn make_auth_conn_for_mgmt() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
initialize_auth_schema(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_create_and_list() {
|
||||
let conn = make_auth_conn_for_mgmt();
|
||||
let owner_id = create_owner(&conn, "owner", "hash").unwrap();
|
||||
let uid = create_user(&conn, "alice", Some("alice@example.com"), "hash2", owner_id).unwrap();
|
||||
let users = list_users(&conn).unwrap();
|
||||
assert_eq!(users.len(), 2);
|
||||
let alice = users.iter().find(|u| u.username == "alice").unwrap();
|
||||
assert_eq!(alice.user_uid, uid);
|
||||
assert_eq!(alice.status, "active");
|
||||
assert!(alice.role_slugs.contains(&"user".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_status_disables_user_and_kills_sessions() {
|
||||
let conn = make_auth_conn_for_mgmt();
|
||||
let owner_id = create_owner(&conn, "owner", "hash").unwrap();
|
||||
let uid = create_user(&conn, "bob", None, "hash", owner_id).unwrap();
|
||||
let bob_id: i64 = conn.query_row("SELECT id FROM users WHERE user_uid = ?1", [&uid], |r| r.get(0)).unwrap();
|
||||
create_session(&conn, bob_id, 3, None).unwrap();
|
||||
set_user_status(&conn, &uid, "disabled").unwrap();
|
||||
let sess_count: i64 = conn.query_row("SELECT COUNT(*) FROM sessions WHERE user_id = ?1", [bob_id], |r| r.get(0)).unwrap();
|
||||
assert_eq!(sess_count, 0, "sessions should be cleared on disable");
|
||||
let u = get_user_by_uid(&conn, &uid).unwrap().unwrap();
|
||||
assert_eq!(u.status, "disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_and_remove_role() {
|
||||
let conn = make_auth_conn_for_mgmt();
|
||||
let owner_id = create_owner(&conn, "owner", "hash").unwrap();
|
||||
let uid = create_user(&conn, "carol", None, "hash", owner_id).unwrap();
|
||||
let carol_id = get_user_id_by_uid(&conn, &uid).unwrap().unwrap();
|
||||
let bits_before = compute_role_bits(&conn, carol_id).unwrap();
|
||||
assign_role(&conn, carol_id, "admin", owner_id).unwrap();
|
||||
let bits_after = compute_role_bits(&conn, carol_id).unwrap();
|
||||
assert!(bits_after & 4 != 0, "admin bit should be set");
|
||||
assert!(bits_after > bits_before);
|
||||
remove_role(&conn, carol_id, "admin").unwrap();
|
||||
let bits_final = compute_role_bits(&conn, carol_id).unwrap();
|
||||
assert!(bits_final & 4 == 0, "admin bit should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_role_gets_next_bit_position() {
|
||||
let conn = make_auth_conn_for_mgmt();
|
||||
let r1 = create_custom_role(&conn, "moderator", "Moderator").unwrap();
|
||||
assert_eq!(r1.bit_position, 4);
|
||||
let r2 = create_custom_role(&conn, "helper", "Helper").unwrap();
|
||||
assert_eq!(r2.bit_position, 5);
|
||||
assert_eq!(r2.level, 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,11 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
|
|||
.route("/api/auth/me", axum::routing::get(auth_me))
|
||||
.route("/api/auth/tokens", axum::routing::get(list_tokens).post(create_token))
|
||||
.route("/api/auth/tokens/:token_uid", axum::routing::delete(delete_token))
|
||||
.route("/api/admin/users", get(admin_list_users).post(admin_create_user))
|
||||
.route("/api/admin/users/:uid/status", axum::routing::patch(admin_set_user_status))
|
||||
.route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role))
|
||||
.route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role))
|
||||
.route("/api/admin/roles", get(admin_list_roles).post(admin_create_role))
|
||||
.nest_service("/assets", ServeDir::new(static_dir.join("assets")))
|
||||
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
|
||||
|
|
@ -590,6 +595,109 @@ async fn delete_token(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AdminCreateUserBody { username: String, password: String, email: Option<String> }
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AdminSetStatusBody { status: String }
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AdminAssignRoleBody { role_slug: String }
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AdminCreateRoleBody { slug: String, name: String }
|
||||
|
||||
async fn admin_list_users(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Json<Vec<database::UserSummary>>, ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||
Ok(Json(database::list_users(&conn)?))
|
||||
}
|
||||
|
||||
async fn admin_create_user(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<AdminCreateUserBody>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
let (caller_id, _) = auth_user.require_auth()?;
|
||||
if body.username.trim().is_empty() || body.password.len() < 8 {
|
||||
return Err(ApiError::bad_request("username required, password >= 8 chars"));
|
||||
}
|
||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||
let hash = auth::hash_password(&body.password).map_err(ApiError::from)?;
|
||||
let uid = database::create_user(&conn, &body.username, body.email.as_deref(), &hash, caller_id)?;
|
||||
Ok((StatusCode::CREATED, Json(serde_json::json!({ "user_uid": uid, "username": body.username }))))
|
||||
}
|
||||
|
||||
async fn admin_set_user_status(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(uid): Path<String>,
|
||||
Json(body): Json<AdminSetStatusBody>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
if body.status != "active" && body.status != "disabled" {
|
||||
return Err(ApiError::bad_request("status must be 'active' or 'disabled'"));
|
||||
}
|
||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||
if !database::set_user_status(&conn, &uid, &body.status)? {
|
||||
return Err(ApiError::not_found("user not found"));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "user_uid": uid, "status": body.status })))
|
||||
}
|
||||
|
||||
async fn admin_assign_role(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(uid): Path<String>,
|
||||
Json(body): Json<AdminAssignRoleBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
let (caller_id, _) = auth_user.require_auth()?;
|
||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||
let target_id = database::get_user_id_by_uid(&conn, &uid)?
|
||||
.ok_or_else(|| ApiError::not_found("user not found"))?;
|
||||
database::assign_role(&conn, target_id, &body.role_slug, caller_id)?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn admin_remove_role(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path((uid, role_slug)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||
let target_id = database::get_user_id_by_uid(&conn, &uid)?
|
||||
.ok_or_else(|| ApiError::not_found("user not found"))?;
|
||||
database::remove_role(&conn, target_id, &role_slug)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn admin_list_roles(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Json<Vec<database::RoleRecord>>, ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||
Ok(Json(database::list_roles(&conn)?))
|
||||
}
|
||||
|
||||
async fn admin_create_role(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<AdminCreateRoleBody>,
|
||||
) -> Result<(StatusCode, Json<database::RoleRecord>), ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||
let role = database::create_custom_role(&conn, &body.slug, &body.name)
|
||||
.map_err(ApiError::from)?;
|
||||
Ok((StatusCode::CREATED, Json(role)))
|
||||
}
|
||||
|
||||
fn mounted_archive<'a>(
|
||||
state: &'a AppState,
|
||||
archive_id: &str,
|
||||
|
|
@ -1507,4 +1615,26 @@ mod tests {
|
|||
assert!(json["job_uid"].as_str().is_some(), "response must have job_uid");
|
||||
assert_eq!(json["status"], "pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_users_requires_admin_role() {
|
||||
let (test_app, _dir) = make_test_app();
|
||||
let response = test_app.oneshot(
|
||||
Request::builder().uri("/api/admin/users").body(Body::empty()).unwrap()
|
||||
).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_list_users_returns_ok_for_admin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let response = app(registry, auth_path).oneshot(
|
||||
Request::builder().uri("/api/admin/users")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::empty()).unwrap()
|
||||
).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,62 @@ export async function fetchMe() {
|
|||
return r.json();
|
||||
}
|
||||
|
||||
// ── Admin helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listAdminUsers() {
|
||||
return getJson('/api/admin/users');
|
||||
}
|
||||
|
||||
export async function createAdminUser(username, password, email) {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, email: email || undefined }),
|
||||
});
|
||||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function setUserStatus(userUid, status) {
|
||||
const res = await fetch(`/api/admin/users/${userUid}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function assignRole(userUid, roleSlug) {
|
||||
const res = await fetch(`/api/admin/users/${userUid}/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role_slug: roleSlug }),
|
||||
});
|
||||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
}
|
||||
|
||||
export async function removeRole(userUid, roleSlug) {
|
||||
const res = await fetch(`/api/admin/users/${userUid}/roles/${encodeURIComponent(roleSlug)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok && res.status !== 204) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
}
|
||||
|
||||
export async function listRoles() {
|
||||
return getJson('/api/admin/roles');
|
||||
}
|
||||
|
||||
export async function createRole(slug, name) {
|
||||
const res = await fetch('/api/admin/roles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, name }),
|
||||
});
|
||||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── 401 interceptor ───────────────────────────────────────────────────────────
|
||||
const _origFetch = window.fetch;
|
||||
window.fetch = async (...args) => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,218 @@
|
|||
import { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import { AuthContext } from '../App.jsx'
|
||||
import {
|
||||
listAdminUsers, createAdminUser, setUserStatus, assignRole, removeRole,
|
||||
listRoles, createRole
|
||||
} from '../api.js'
|
||||
|
||||
const ROLE_ADMIN = 4
|
||||
|
||||
export default function AdminView({ archives }) {
|
||||
const { currentUser } = useContext(AuthContext) ?? {}
|
||||
const isAdmin = currentUser && (currentUser.role_bits & ROLE_ADMIN) !== 0
|
||||
|
||||
const [tab, setTab] = useState('users')
|
||||
const [users, setUsers] = useState([])
|
||||
const [roles, setRoles] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// Create user form state
|
||||
const [newUsername, setNewUsername] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [createError, setCreateError] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// Create role form state
|
||||
const [newRoleSlug, setNewRoleSlug] = useState('')
|
||||
const [newRoleName, setNewRoleName] = useState('')
|
||||
const [roleCreateError, setRoleCreateError] = useState(null)
|
||||
const [creatingRole, setCreatingRole] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!isAdmin) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [u, r] = await Promise.all([listAdminUsers(), listRoles()])
|
||||
setUsers(u)
|
||||
setRoles(r)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
async function handleToggleStatus(user) {
|
||||
const next = user.status === 'active' ? 'disabled' : 'active'
|
||||
try {
|
||||
await setUserStatus(user.user_uid, next)
|
||||
setUsers(us => us.map(u => u.user_uid === user.user_uid ? { ...u, status: next } : u))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser(e) {
|
||||
e.preventDefault()
|
||||
if (!newUsername.trim() || !newPassword) { setCreateError('Username and password required'); return }
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
await createAdminUser(newUsername.trim(), newPassword, newEmail.trim() || undefined)
|
||||
setNewUsername('')
|
||||
setNewPassword('')
|
||||
setNewEmail('')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setCreateError(e.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRole(e) {
|
||||
e.preventDefault()
|
||||
if (!newRoleSlug.trim() || !newRoleName.trim()) { setRoleCreateError('Slug and name required'); return }
|
||||
setCreatingRole(true)
|
||||
setRoleCreateError(null)
|
||||
try {
|
||||
await createRole(newRoleSlug.trim(), newRoleName.trim())
|
||||
setNewRoleSlug('')
|
||||
setNewRoleName('')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setRoleCreateError(e.message)
|
||||
} finally {
|
||||
setCreatingRole(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<section id="admin-view" className="view admin-view is-active">
|
||||
<h1>Admin</h1>
|
||||
<p className="muted">You need admin privileges to access this panel.</p>
|
||||
<h2>Mounted Archives</h2>
|
||||
<div className="admin-list">
|
||||
{archives.map(a => (
|
||||
<div key={a.id} className="admin-archive">
|
||||
<strong>{a.label}</strong>
|
||||
<div className="muted">{a.archive_path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="admin-view" className="view admin-view is-active">
|
||||
<h1>Mounted Archives</h1>
|
||||
<div className="admin-list">
|
||||
{archives.map(archive => (
|
||||
<div key={archive.id} className="admin-archive">
|
||||
<strong>{archive.label}</strong>
|
||||
<div className="muted">{archive.archive_path}</div>
|
||||
</div>
|
||||
))}
|
||||
<h1>Admin</h1>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button className={`admin-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
|
||||
<button className={`admin-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
|
||||
<button className={`admin-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="capture-error">{error}</div>}
|
||||
|
||||
{tab === 'users' && (
|
||||
<div className="admin-section">
|
||||
<h2>Users</h2>
|
||||
{loading ? <p className="muted">Loading…</p> : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Email</th><th>Roles</th><th>Status</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.user_uid} className={u.status === 'disabled' ? 'admin-row-disabled' : ''}>
|
||||
<td>{u.username}</td>
|
||||
<td className="muted">{u.email || '—'}</td>
|
||||
<td>{u.role_slugs.join(', ') || '—'}</td>
|
||||
<td><span className={`status-badge status-${u.status}`}>{u.status}</span></td>
|
||||
<td>
|
||||
<button className="admin-action-btn" onClick={() => handleToggleStatus(u)}>
|
||||
{u.status === 'active' ? 'Ban' : 'Unban'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3>Create User</h3>
|
||||
<form className="admin-form" onSubmit={handleCreateUser}>
|
||||
<input className="admin-input" placeholder="Username" value={newUsername}
|
||||
onChange={e => setNewUsername(e.target.value)} required />
|
||||
<input className="admin-input" type="password" placeholder="Password (min 8 chars)"
|
||||
value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
|
||||
<input className="admin-input" type="email" placeholder="Email (optional)"
|
||||
value={newEmail} onChange={e => setNewEmail(e.target.value)} />
|
||||
{createError && <div className="capture-error">{createError}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create User'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'roles' && (
|
||||
<div className="admin-section">
|
||||
<h2>Roles</h2>
|
||||
{loading ? <p className="muted">Loading…</p> : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>Slug</th><th>Name</th><th>Level</th><th>Bit</th><th>Built-in</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map(r => (
|
||||
<tr key={r.role_uid}>
|
||||
<td><code>{r.slug}</code></td>
|
||||
<td>{r.name}</td>
|
||||
<td>{r.level}</td>
|
||||
<td>{r.bit_position}</td>
|
||||
<td>{r.is_builtin ? '✓' : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3>Create Custom Role</h3>
|
||||
<form className="admin-form" onSubmit={handleCreateRole}>
|
||||
<input className="admin-input" placeholder="Slug (e.g. moderator)" value={newRoleSlug}
|
||||
onChange={e => setNewRoleSlug(e.target.value)} required />
|
||||
<input className="admin-input" placeholder="Display Name (e.g. Moderator)"
|
||||
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
|
||||
{roleCreateError && <div className="capture-error">{roleCreateError}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={creatingRole}>
|
||||
{creatingRole ? 'Creating…' : 'Create Role'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'archives' && (
|
||||
<div className="admin-section">
|
||||
<h2>Mounted Archives</h2>
|
||||
<div className="admin-list">
|
||||
{archives.map(a => (
|
||||
<div key={a.id} className="admin-archive">
|
||||
<strong>{a.label}</strong>
|
||||
<div className="muted">{a.archive_path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue