mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user