1
Fork 0
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:
TheGeneralist 2026-06-26 16:24:15 +02:00
commit 2c3d3eb86e
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
5 changed files with 713 additions and 22 deletions

31
NEXT.md
View file

@ -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.
```

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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) => {

View file

@ -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>
)
}