feat(settings): admin-gated User Management tab via Navidrome REST API

Adds a dedicated Settings tab for managing Navidrome users (list, create,
edit, delete). Only visible to admins — gated by `/auth/login` probe on
mount. Uses Navidrome's native /api/user endpoints instead of Subsonic
(getUsers.view etc. return only the caller on Navidrome). All HTTP calls
go through new Rust commands to bypass WebView CORS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-19 23:35:14 +02:00
parent 69dd374e80
commit dc819f3a2c
11 changed files with 814 additions and 2 deletions
+148
View File
@@ -242,6 +242,149 @@ async fn delete_radio_cover(
Ok(())
}
/// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)]
struct NdLoginResult {
token: String,
#[serde(rename = "userId")]
user_id: String,
#[serde(rename = "isAdmin")]
is_admin: bool,
}
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command]
async fn navidrome_login(
server_url: String,
username: String,
password: String,
) -> Result<NdLoginResult, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
}
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
let user_id = data["id"].as_str().unwrap_or("").to_string();
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
Ok(NdLoginResult { token, user_id, is_admin })
}
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
#[tauri::command]
async fn nd_list_users(
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let resp = reqwest::Client::new()
.get(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
resp.json::<serde_json::Value>().await.map_err(|e| e.to_string())
}
/// POST `/api/user` — create a user.
#[tauri::command]
async fn nd_create_user(
server_url: String,
token: String,
user_name: String,
name: String,
email: String,
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let body = serde_json::json!({
"userName": user_name,
"name": name,
"email": email,
"password": password,
"isAdmin": is_admin,
});
let resp = reqwest::Client::new()
.post(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("HTTP {}: {}", status, text));
}
serde_json::from_str(&text).map_err(|e| e.to_string())
}
/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged.
#[tauri::command]
async fn nd_update_user(
server_url: String,
token: String,
id: String,
user_name: String,
name: String,
email: String,
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let mut body = serde_json::json!({
"id": id,
"userName": user_name,
"name": name,
"email": email,
"isAdmin": is_admin,
});
if !password.is_empty() {
body["password"] = serde_json::Value::String(password);
}
let resp = reqwest::Client::new()
.put(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("HTTP {}: {}", status, text));
}
Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null))
}
/// DELETE `/api/user/{id}`.
#[tauri::command]
async fn nd_delete_user(
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let resp = reqwest::Client::new()
.delete(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("HTTP {}: {}", status, text));
}
Ok(())
}
const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
@@ -3180,6 +3323,11 @@ pub fn run() {
upload_radio_cover,
upload_artist_image,
delete_radio_cover,
navidrome_login,
nd_list_users,
nd_create_user,
nd_update_user,
nd_delete_user,
search_radio_browser,
get_top_radio_stations,
fetch_url_bytes,