diff --git a/NEXT.md b/NEXT.md index 221a9ab..197b71a 100644 --- a/NEXT.md +++ b/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. ``` diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index b23d782..87a1fd0 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -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, + pub status: String, + pub created_at: String, + pub role_slugs: Vec, + 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 Result { + 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> { + 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> { + 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)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)))? + .collect::>()?; + + 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 = + rs.query_map([id], |r| r.get(0))?.collect::>()?; + 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> { + 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 = + rs.query_map([id], |r| r.get(0))?.collect::>()?; + 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 { + 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 { + if status == "disabled" { + let id: Option = 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> { + 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::>() + .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 { + 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 { 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); + } } diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 2fe9819..ba4f571 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -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 } + +#[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, + auth_user: AuthUser, +) -> Result>, 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, + auth_user: AuthUser, + Json(body): Json, +) -> Result<(StatusCode, Json), 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, + auth_user: AuthUser, + Path(uid): Path, + Json(body): Json, +) -> Result, 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, + auth_user: AuthUser, + Path(uid): Path, + Json(body): Json, +) -> Result { + 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, + auth_user: AuthUser, + Path((uid, role_slug)): Path<(String, String)>, +) -> Result { + 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, + auth_user: AuthUser, +) -> Result>, 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, + auth_user: AuthUser, + Json(body): Json, +) -> Result<(StatusCode, Json), 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); + } } diff --git a/frontend/src/api.js b/frontend/src/api.js index d4cbd8e..8c5d9bb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) => { diff --git a/frontend/src/components/AdminView.jsx b/frontend/src/components/AdminView.jsx index f9520c2..7abd8b2 100644 --- a/frontend/src/components/AdminView.jsx +++ b/frontend/src/components/AdminView.jsx @@ -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 ( +
+

Admin

+

You need admin privileges to access this panel.

+

Mounted Archives

+
+ {archives.map(a => ( +
+ {a.label} +
{a.archive_path}
+
+ ))} +
+
+ ) + } + return (
-

Mounted Archives

-
- {archives.map(archive => ( -
- {archive.label} -
{archive.archive_path}
-
- ))} +

Admin

+ +
+ + +
+ + {error &&
{error}
} + + {tab === 'users' && ( +
+

Users

+ {loading ?

Loading…

: ( + + + + + + {users.map(u => ( + + + + + + + + ))} + +
UsernameEmailRolesStatusActions
{u.username}{u.email || '—'}{u.role_slugs.join(', ') || '—'}{u.status} + +
+ )} + +

Create User

+
+ setNewUsername(e.target.value)} required /> + setNewPassword(e.target.value)} required /> + setNewEmail(e.target.value)} /> + {createError &&
{createError}
} + +
+
+ )} + + {tab === 'roles' && ( +
+

Roles

+ {loading ?

Loading…

: ( + + + + + + {roles.map(r => ( + + + + + + + + ))} + +
SlugNameLevelBitBuilt-in
{r.slug}{r.name}{r.level}{r.bit_position}{r.is_builtin ? '✓' : ''}
+ )} + +

Create Custom Role

+
+ setNewRoleSlug(e.target.value)} required /> + setNewRoleName(e.target.value)} required /> + {roleCreateError &&
{roleCreateError}
} + +
+
+ )} + + {tab === 'archives' && ( +
+

Mounted Archives

+
+ {archives.map(a => ( +
+ {a.label} +
{a.archive_path}
+
+ ))} +
+
+ )}
) }