From dc819f3a2cf6c93000d2f7142abb398f37ea366d Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sun, 19 Apr 2026 23:35:14 +0200 Subject: [PATCH] feat(settings): admin-gated User Management tab via Navidrome REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/src/lib.rs | 148 +++++++++++++++ src/api/navidromeAdmin.ts | 65 +++++++ src/locales/de.ts | 29 +++ src/locales/en.ts | 29 +++ src/locales/es.ts | 29 +++ src/locales/fr.ts | 29 +++ src/locales/nb.ts | 29 +++ src/locales/nl.ts | 29 +++ src/locales/ru.ts | 29 +++ src/locales/zh.ts | 29 +++ src/pages/Settings.tsx | 371 +++++++++++++++++++++++++++++++++++++- 11 files changed, 814 insertions(+), 2 deletions(-) create mode 100644 src/api/navidromeAdmin.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fab73781..a2a8cf99 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + 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 { + 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::().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 { + 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 { + 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, diff --git a/src/api/navidromeAdmin.ts b/src/api/navidromeAdmin.ts new file mode 100644 index 00000000..313db437 --- /dev/null +++ b/src/api/navidromeAdmin.ts @@ -0,0 +1,65 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface NdUser { + id: string; + userName: string; + name: string; + email: string; + isAdmin: boolean; + lastLoginAt?: string | null; + createdAt?: string; + updatedAt?: string; +} + +export interface NdLoginResult { + token: string; + userId: string; + isAdmin: boolean; +} + +export async function ndLogin( + serverUrl: string, + username: string, + password: string, +): Promise { + return invoke('navidrome_login', { serverUrl, username, password }); +} + +export async function ndListUsers(serverUrl: string, token: string): Promise { + const raw = await invoke('nd_list_users', { serverUrl, token }); + if (!Array.isArray(raw)) return []; + return raw.map(u => { + const o = u as Record; + return { + id: String(o.id ?? ''), + userName: String(o.userName ?? ''), + name: String(o.name ?? ''), + email: String(o.email ?? ''), + isAdmin: !!o.isAdmin, + lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null, + createdAt: o.createdAt as string | undefined, + updatedAt: o.updatedAt as string | undefined, + }; + }); +} + +export async function ndCreateUser( + serverUrl: string, + token: string, + data: { userName: string; name: string; email: string; password: string; isAdmin: boolean }, +): Promise { + await invoke('nd_create_user', { serverUrl, token, ...data }); +} + +export async function ndUpdateUser( + serverUrl: string, + token: string, + id: string, + data: { userName: string; name: string; email: string; password: string; isAdmin: boolean }, +): Promise { + await invoke('nd_update_user', { serverUrl, token, id, ...data }); +} + +export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise { + await invoke('nd_delete_user', { serverUrl, token, id }); +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 5ae6c571..bd97eb69 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -467,6 +467,34 @@ export const deTranslation = { testBtn: 'Verbindung testen', testingBtn: 'Teste…', serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic', + userMgmtTitle: 'Benutzerverwaltung', + userMgmtDesc: 'Benutzer auf diesem Server verwalten. Erfordert Admin-Rechte.', + userMgmtNoAdmin: 'Du brauchst Admin-Rechte, um Benutzer auf diesem Server zu verwalten.', + userMgmtLoadError: 'Benutzer konnten nicht geladen werden.', + userMgmtEmpty: 'Keine Benutzer gefunden.', + userMgmtYouBadge: 'Du', + userMgmtAdminBadge: 'Admin', + userMgmtAddUser: 'Benutzer hinzufügen', + userMgmtAddUserTitle: 'Neuen Benutzer anlegen', + userMgmtEditUserTitle: 'Benutzer bearbeiten', + userMgmtUsername: 'Benutzername', + userMgmtName: 'Anzeigename', + userMgmtEmail: 'E-Mail', + userMgmtPassword: 'Passwort', + userMgmtPasswordEditHint: 'Neues Passwort eingeben, um es zu ändern.', + userMgmtRoleAdmin: 'Admin', + userMgmtSave: 'Speichern', + userMgmtCancel: 'Abbrechen', + userMgmtDelete: 'Löschen', + userMgmtEdit: 'Bearbeiten', + userMgmtConfirmDelete: 'Benutzer „{{username}}" löschen? Das kann nicht rückgängig gemacht werden.', + userMgmtCreateError: 'Benutzer konnte nicht angelegt werden.', + userMgmtUpdateError: 'Benutzer konnte nicht aktualisiert werden.', + userMgmtDeleteError: 'Benutzer konnte nicht gelöscht werden.', + userMgmtCreated: 'Benutzer angelegt.', + userMgmtUpdated: 'Benutzer aktualisiert.', + userMgmtDeleted: 'Benutzer gelöscht.', + userMgmtValidationMissing: 'Benutzername, Anzeigename und Passwort sind erforderlich.', audiomuseTitle: 'AudioMuse-AI (Navidrome)', audiomuseDesc: 'Aktivieren, wenn dieser Server das AudioMuse-AI-Navidrome-Plugin nutzt. Schaltet Instant Mix pro Titel frei und nutzt ähnliche Künstler vom Server statt Last.fm auf Künstlerseiten.', @@ -627,6 +655,7 @@ export const deTranslation = { randomNavSplitDesc: '"Zufallsmix" und "Zufallsalben" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.', tabInput: 'Eingabe', tabServer: 'Server', + tabUsers: 'Benutzer', shortcutsReset: 'Auf Standard zurücksetzen', shortcutListening: 'Taste drücken…', shortcutUnbound: '—', diff --git a/src/locales/en.ts b/src/locales/en.ts index 64fde340..7ea039af 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -469,6 +469,34 @@ export const enTranslation = { testBtn: 'Test Connection', testingBtn: 'Testing…', serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic', + userMgmtTitle: 'User Management', + userMgmtDesc: 'Manage users on this server. Requires admin privileges.', + userMgmtNoAdmin: 'You need admin privileges to manage users on this server.', + userMgmtLoadError: 'Failed to load users.', + userMgmtEmpty: 'No users found.', + userMgmtYouBadge: 'You', + userMgmtAdminBadge: 'Admin', + userMgmtAddUser: 'Add User', + userMgmtAddUserTitle: 'Add New User', + userMgmtEditUserTitle: 'Edit User', + userMgmtUsername: 'Username', + userMgmtName: 'Display Name', + userMgmtEmail: 'Email', + userMgmtPassword: 'Password', + userMgmtPasswordEditHint: 'Enter a new password to update it.', + userMgmtRoleAdmin: 'Admin', + userMgmtSave: 'Save', + userMgmtCancel: 'Cancel', + userMgmtDelete: 'Delete', + userMgmtEdit: 'Edit', + userMgmtConfirmDelete: 'Delete user "{{username}}"? This cannot be undone.', + userMgmtCreateError: 'Failed to create user.', + userMgmtUpdateError: 'Failed to update user.', + userMgmtDeleteError: 'Failed to delete user.', + userMgmtCreated: 'User created.', + userMgmtUpdated: 'User updated.', + userMgmtDeleted: 'User deleted.', + userMgmtValidationMissing: 'Username, display name and password are required.', audiomuseTitle: 'AudioMuse-AI (Navidrome)', audiomuseDesc: 'Turn on if this server has the AudioMuse-AI Navidrome plugin configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.', @@ -629,6 +657,7 @@ export const enTranslation = { randomNavSplitDesc: 'Show "Random Mix" and "Random Albums" as separate sidebar entries instead of the "Build a Mix" hub.', tabInput: 'Input', tabServer: 'Server', + tabUsers: 'Users', tabSystem: 'System', tabGeneral: 'General', ratingsSectionTitle: 'Ratings', diff --git a/src/locales/es.ts b/src/locales/es.ts index 7171ab4b..39bb90c8 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -460,6 +460,34 @@ export const esTranslation = { testBtn: 'Probar Conexión', testingBtn: 'Probando…', serverCompatible: 'Compatible con: Navidrome · Gonic · Airsonic · Subsonic', + userMgmtTitle: 'Gestión de usuarios', + userMgmtDesc: 'Administra los usuarios de este servidor. Requiere privilegios de administrador.', + userMgmtNoAdmin: 'Necesitas privilegios de administrador para gestionar usuarios en este servidor.', + userMgmtLoadError: 'No se pudieron cargar los usuarios.', + userMgmtEmpty: 'No se encontraron usuarios.', + userMgmtYouBadge: 'Tú', + userMgmtAdminBadge: 'Admin', + userMgmtAddUser: 'Añadir usuario', + userMgmtAddUserTitle: 'Nuevo usuario', + userMgmtEditUserTitle: 'Editar usuario', + userMgmtUsername: 'Nombre de usuario', + userMgmtName: 'Nombre visible', + userMgmtEmail: 'Correo electrónico', + userMgmtPassword: 'Contraseña', + userMgmtPasswordEditHint: 'Introduce una nueva contraseña para actualizarla.', + userMgmtRoleAdmin: 'Admin', + userMgmtSave: 'Guardar', + userMgmtCancel: 'Cancelar', + userMgmtDelete: 'Eliminar', + userMgmtEdit: 'Editar', + userMgmtConfirmDelete: '¿Eliminar el usuario «{{username}}»? Esta acción no se puede deshacer.', + userMgmtCreateError: 'No se pudo crear el usuario.', + userMgmtUpdateError: 'No se pudo actualizar el usuario.', + userMgmtDeleteError: 'No se pudo eliminar el usuario.', + userMgmtCreated: 'Usuario creado.', + userMgmtUpdated: 'Usuario actualizado.', + userMgmtDeleted: 'Usuario eliminado.', + userMgmtValidationMissing: 'Se requieren nombre de usuario, nombre visible y contraseña.', audiomuseTitle: 'AudioMuse-AI (Navidrome)', audiomuseDesc: 'Activa si este servidor tiene el plugin AudioMuse-AI Navidrome configurado. Habilita Mezcla Instantánea desde pistas y usa artistas similares del servidor en lugar de Last.fm en páginas de artistas.', @@ -620,6 +648,7 @@ export const esTranslation = { randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria" y "Álbumes Aleatorios" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".', tabInput: 'Entrada', tabServer: 'Servidor', + tabUsers: 'Usuarios', tabSystem: 'Sistema', tabGeneral: 'General', ratingsSectionTitle: 'Calificaciones', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 945ba3d5..47caf76f 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -457,6 +457,34 @@ export const frTranslation = { testBtn: 'Tester la connexion', testingBtn: 'Test en cours…', serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic', + userMgmtTitle: 'Gestion des utilisateurs', + userMgmtDesc: 'Gérer les utilisateurs sur ce serveur. Nécessite des privilèges administrateur.', + userMgmtNoAdmin: 'Vous devez être administrateur pour gérer les utilisateurs sur ce serveur.', + userMgmtLoadError: 'Impossible de charger les utilisateurs.', + userMgmtEmpty: 'Aucun utilisateur trouvé.', + userMgmtYouBadge: 'Vous', + userMgmtAdminBadge: 'Admin', + userMgmtAddUser: 'Ajouter un utilisateur', + userMgmtAddUserTitle: 'Nouvel utilisateur', + userMgmtEditUserTitle: 'Modifier l’utilisateur', + userMgmtUsername: 'Nom d’utilisateur', + userMgmtName: 'Nom affiché', + userMgmtEmail: 'E-mail', + userMgmtPassword: 'Mot de passe', + userMgmtPasswordEditHint: 'Saisir un nouveau mot de passe pour le modifier.', + userMgmtRoleAdmin: 'Admin', + userMgmtSave: 'Enregistrer', + userMgmtCancel: 'Annuler', + userMgmtDelete: 'Supprimer', + userMgmtEdit: 'Modifier', + userMgmtConfirmDelete: 'Supprimer l’utilisateur « {{username}} » ? Action irréversible.', + userMgmtCreateError: 'Impossible de créer l’utilisateur.', + userMgmtUpdateError: 'Impossible de mettre à jour l’utilisateur.', + userMgmtDeleteError: 'Impossible de supprimer l’utilisateur.', + userMgmtCreated: 'Utilisateur créé.', + userMgmtUpdated: 'Utilisateur mis à jour.', + userMgmtDeleted: 'Utilisateur supprimé.', + userMgmtValidationMissing: 'Nom d’utilisateur, nom affiché et mot de passe sont requis.', audiomuseTitle: 'AudioMuse-AI (Navidrome)', audiomuseDesc: 'Activez si ce serveur utilise le plugin Navidrome AudioMuse-AI. Active le mix instantané depuis un morceau et affiche les artistes similaires côté serveur au lieu de Last.fm sur les pages artiste.', @@ -615,6 +643,7 @@ export const frTranslation = { randomNavSplitDesc: 'Afficher "Mix Aléatoire" et "Albums Aléatoires" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".', tabInput: 'Entrée', tabServer: 'Serveur', + tabUsers: 'Utilisateurs', shortcutsReset: 'Réinitialiser', shortcutListening: 'Appuyez sur une touche…', shortcutUnbound: '—', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index d43f9772..2776af55 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -457,6 +457,34 @@ export const nbTranslation = { testBtn: 'Test tilkobling', testingBtn: 'Tester…', serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic', + userMgmtTitle: 'Brukeradministrasjon', + userMgmtDesc: 'Administrer brukere på denne serveren. Krever admin-rettigheter.', + userMgmtNoAdmin: 'Du trenger admin-rettigheter for å administrere brukere på denne serveren.', + userMgmtLoadError: 'Kunne ikke laste brukere.', + userMgmtEmpty: 'Ingen brukere funnet.', + userMgmtYouBadge: 'Deg', + userMgmtAdminBadge: 'Admin', + userMgmtAddUser: 'Legg til bruker', + userMgmtAddUserTitle: 'Ny bruker', + userMgmtEditUserTitle: 'Rediger bruker', + userMgmtUsername: 'Brukernavn', + userMgmtName: 'Visningsnavn', + userMgmtEmail: 'E-post', + userMgmtPassword: 'Passord', + userMgmtPasswordEditHint: 'Skriv inn et nytt passord for å oppdatere det.', + userMgmtRoleAdmin: 'Admin', + userMgmtSave: 'Lagre', + userMgmtCancel: 'Avbryt', + userMgmtDelete: 'Slett', + userMgmtEdit: 'Rediger', + userMgmtConfirmDelete: 'Slett brukeren «{{username}}»? Dette kan ikke angres.', + userMgmtCreateError: 'Kunne ikke opprette bruker.', + userMgmtUpdateError: 'Kunne ikke oppdatere bruker.', + userMgmtDeleteError: 'Kunne ikke slette bruker.', + userMgmtCreated: 'Bruker opprettet.', + userMgmtUpdated: 'Bruker oppdatert.', + userMgmtDeleted: 'Bruker slettet.', + userMgmtValidationMissing: 'Brukernavn, visningsnavn og passord er påkrevd.', audiomuseTitle: 'AudioMuse-AI (Navidrome)', audiomuseDesc: 'Slå på hvis denne serveren bruker AudioMuse-AI Navidrome-plugin. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.', @@ -614,6 +642,7 @@ export const nbTranslation = { randomNavSplitDesc: 'Vis "Tilfeldig miks" og "Tilfeldige album" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.', tabShortcuts: 'Snarveier', tabServer: 'Tjener', + tabUsers: 'Brukere', tabSystem: 'System', tabGeneral: 'Generelt', ratingsSectionTitle: 'Vurderinger', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 51bee638..a106258d 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -456,6 +456,34 @@ export const nlTranslation = { testBtn: 'Verbinding testen', testingBtn: 'Testen…', serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic', + userMgmtTitle: 'Gebruikersbeheer', + userMgmtDesc: 'Beheer gebruikers op deze server. Vereist admin-rechten.', + userMgmtNoAdmin: 'Je hebt admin-rechten nodig om gebruikers op deze server te beheren.', + userMgmtLoadError: 'Kon gebruikers niet laden.', + userMgmtEmpty: 'Geen gebruikers gevonden.', + userMgmtYouBadge: 'Jij', + userMgmtAdminBadge: 'Admin', + userMgmtAddUser: 'Gebruiker toevoegen', + userMgmtAddUserTitle: 'Nieuwe gebruiker', + userMgmtEditUserTitle: 'Gebruiker bewerken', + userMgmtUsername: 'Gebruikersnaam', + userMgmtName: 'Weergavenaam', + userMgmtEmail: 'E-mail', + userMgmtPassword: 'Wachtwoord', + userMgmtPasswordEditHint: 'Voer een nieuw wachtwoord in om het bij te werken.', + userMgmtRoleAdmin: 'Admin', + userMgmtSave: 'Opslaan', + userMgmtCancel: 'Annuleren', + userMgmtDelete: 'Verwijderen', + userMgmtEdit: 'Bewerken', + userMgmtConfirmDelete: 'Gebruiker "{{username}}" verwijderen? Dit kan niet ongedaan worden gemaakt.', + userMgmtCreateError: 'Aanmaken van gebruiker mislukt.', + userMgmtUpdateError: 'Bijwerken van gebruiker mislukt.', + userMgmtDeleteError: 'Verwijderen van gebruiker mislukt.', + userMgmtCreated: 'Gebruiker aangemaakt.', + userMgmtUpdated: 'Gebruiker bijgewerkt.', + userMgmtDeleted: 'Gebruiker verwijderd.', + userMgmtValidationMissing: 'Gebruikersnaam, weergavenaam en wachtwoord zijn vereist.', audiomuseTitle: 'AudioMuse-AI (Navidrome)', audiomuseDesc: 'Zet aan als deze server de AudioMuse-AI Navidrome-plugin gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpagina’s.', @@ -614,6 +642,7 @@ export const nlTranslation = { randomNavSplitDesc: 'Toon "Willekeurige mix" en "Willekeurige albums" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.', tabInput: 'Invoer', tabServer: 'Server', + tabUsers: 'Gebruikers', shortcutsReset: 'Standaard herstellen', shortcutListening: 'Druk op een toets…', shortcutUnbound: '—', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index f748538d..0bbc44ba 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -474,6 +474,34 @@ export const ruTranslation = { testBtn: 'Проверить', testingBtn: 'Проверка…', serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic', + userMgmtTitle: 'Управление пользователями', + userMgmtDesc: 'Управляйте пользователями этого сервера. Требуются права администратора.', + userMgmtNoAdmin: 'Для управления пользователями на этом сервере нужны права администратора.', + userMgmtLoadError: 'Не удалось загрузить пользователей.', + userMgmtEmpty: 'Пользователи не найдены.', + userMgmtYouBadge: 'Вы', + userMgmtAdminBadge: 'Админ', + userMgmtAddUser: 'Добавить пользователя', + userMgmtAddUserTitle: 'Новый пользователь', + userMgmtEditUserTitle: 'Изменить пользователя', + userMgmtUsername: 'Имя пользователя', + userMgmtName: 'Отображаемое имя', + userMgmtEmail: 'E-mail', + userMgmtPassword: 'Пароль', + userMgmtPasswordEditHint: 'Введите новый пароль, чтобы изменить его.', + userMgmtRoleAdmin: 'Админ', + userMgmtSave: 'Сохранить', + userMgmtCancel: 'Отмена', + userMgmtDelete: 'Удалить', + userMgmtEdit: 'Изменить', + userMgmtConfirmDelete: 'Удалить пользователя «{{username}}»? Действие нельзя отменить.', + userMgmtCreateError: 'Не удалось создать пользователя.', + userMgmtUpdateError: 'Не удалось обновить пользователя.', + userMgmtDeleteError: 'Не удалось удалить пользователя.', + userMgmtCreated: 'Пользователь создан.', + userMgmtUpdated: 'Пользователь обновлён.', + userMgmtDeleted: 'Пользователь удалён.', + userMgmtValidationMissing: 'Требуются имя пользователя, отображаемое имя и пароль.', audiomuseTitle: 'AudioMuse-AI (Navidrome)', audiomuseDesc: 'Включите, если на этом сервере настроен плагин AudioMuse-AI для Navidrome. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.', @@ -642,6 +670,7 @@ export const ruTranslation = { randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».', tabInput: 'Ввод', tabServer: 'Сервер', + tabUsers: 'Пользователи', tabSystem: 'Система', tabGeneral: 'Общие', ratingsSectionTitle: 'Рейтинги', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 2ba7faf7..7f0982af 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -452,6 +452,34 @@ export const zhTranslation = { testBtn: '测试连接', testingBtn: '正在测试…', serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic', + userMgmtTitle: '用户管理', + userMgmtDesc: '管理此服务器上的用户。需要管理员权限。', + userMgmtNoAdmin: '需要管理员权限才能管理此服务器上的用户。', + userMgmtLoadError: '加载用户失败。', + userMgmtEmpty: '未找到用户。', + userMgmtYouBadge: '您', + userMgmtAdminBadge: '管理员', + userMgmtAddUser: '添加用户', + userMgmtAddUserTitle: '新建用户', + userMgmtEditUserTitle: '编辑用户', + userMgmtUsername: '用户名', + userMgmtName: '显示名称', + userMgmtEmail: '电子邮箱', + userMgmtPassword: '密码', + userMgmtPasswordEditHint: '输入新密码以更新。', + userMgmtRoleAdmin: '管理员', + userMgmtSave: '保存', + userMgmtCancel: '取消', + userMgmtDelete: '删除', + userMgmtEdit: '编辑', + userMgmtConfirmDelete: '删除用户 "{{username}}"?此操作无法撤销。', + userMgmtCreateError: '创建用户失败。', + userMgmtUpdateError: '更新用户失败。', + userMgmtDeleteError: '删除用户失败。', + userMgmtCreated: '用户已创建。', + userMgmtUpdated: '用户已更新。', + userMgmtDeleted: '用户已删除。', + userMgmtValidationMissing: '用户名、显示名称和密码均为必填项。', audiomuseTitle: 'AudioMuse-AI(Navidrome)', audiomuseDesc: '若此服务器已配置 AudioMuse-AI Navidrome 插件请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。', @@ -610,6 +638,7 @@ export const zhTranslation = { randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。', tabInput: '输入', tabServer: '服务器', + tabUsers: '用户', tabSystem: '系统', tabGeneral: '通用', ratingsSectionTitle: '评分', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d52575ff..d8cf34fa 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,7 +5,8 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, - GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock + GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock, + Users, UserPlus, Pencil, Shield } from 'lucide-react'; import i18n from '../i18n'; import { exportBackup, importBackup } from '../utils/backup'; @@ -33,6 +34,10 @@ import { useHomeStore, HomeSectionId } from '../store/homeStore'; import { useDragDrop, useDragSource } from '../contexts/DragDropContext'; import { ALL_NAV_ITEMS } from '../config/navItems'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; +import { + ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser, + type NdUser, +} from '../api/navidromeAdmin'; import { switchActiveServer } from '../utils/switchActiveServer'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { Trans, useTranslation } from 'react-i18next'; @@ -184,7 +189,7 @@ const SPECIAL_THANKS = [ }, ] as const; -type Tab = 'general' | 'server' | 'audio' | 'storage' | 'appearance' | 'input' | 'system'; +type Tab = 'general' | 'server' | 'users' | 'audio' | 'storage' | 'appearance' | 'input' | 'system'; function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit) => void; onCancel: () => void }) { const { t } = useTranslation(); @@ -244,6 +249,337 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit void; + onCancel: () => void; + busy: boolean; +}) { + const { t } = useTranslation(); + const [form, setForm] = useState(() => initialUserFormState(initial ?? undefined)); + const [showPass, setShowPass] = useState(false); + const isEdit = !!initial; + + const set = (k: K, v: UserFormState[K]) => + setForm(f => ({ ...f, [k]: v })); + + const canSave = + form.userName.trim().length > 0 && + form.name.trim().length > 0 && + form.password.length > 0; + + return ( +
+

+ {isEdit ? t('settings.userMgmtEditUserTitle') : t('settings.userMgmtAddUserTitle')} +

+
+
+ + set('userName', e.target.value)} + disabled={isEdit} + autoComplete="off" + /> +
+
+ + set('name', e.target.value)} + autoComplete="off" + /> +
+
+
+ + set('email', e.target.value)} + autoComplete="off" + /> +
+
+ +
+ set('password', e.target.value)} + placeholder="••••••••" + autoComplete="new-password" + style={{ paddingRight: '2.5rem' }} + /> + +
+ {isEdit && ( +
+ {t('settings.userMgmtPasswordEditHint')} +
+ )} +
+ +
+ + +
+
+ ); +} + +function UserManagementSection({ + serverUrl, + token, + currentUsername, +}: { + serverUrl: string; + token: string; + currentUsername: string; +}) { + const { t } = useTranslation(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + const list = await ndListUsers(serverUrl, token); + setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName))); + } catch (e) { + const msg = (e instanceof Error && e.message) ? e.message : t('settings.userMgmtLoadError'); + setLoadError(msg); + } finally { + setLoading(false); + } + }, [serverUrl, token, t]); + + useEffect(() => { void load(); }, [load]); + + const handleSave = async (form: UserFormState) => { + const userName = form.userName.trim(); + const name = form.name.trim(); + const email = form.email.trim(); + if (!userName || !name || !form.password) { + showToast(t('settings.userMgmtValidationMissing'), 4000, 'error'); + return; + } + if (!token) return; + setBusy(true); + try { + if (editing === 'new') { + await ndCreateUser(serverUrl, token, { + userName, name, email, password: form.password, isAdmin: form.isAdmin, + }); + showToast(t('settings.userMgmtCreated'), 3000, 'info'); + } else if (editing) { + await ndUpdateUser(serverUrl, token, editing.id, { + userName, name, email, password: form.password, isAdmin: form.isAdmin, + }); + showToast(t('settings.userMgmtUpdated'), 3000, 'info'); + } + setEditing(null); + await load(); + } catch (e) { + const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null); + const fallback = editing === 'new' + ? t('settings.userMgmtCreateError') + : t('settings.userMgmtUpdateError'); + showToast(msg ?? fallback, 5000, 'error'); + } finally { + setBusy(false); + } + }; + + const handleDelete = async (u: NdUser) => { + if (!token) return; + if (!confirm(t('settings.userMgmtConfirmDelete', { username: u.userName }))) return; + setBusy(true); + try { + await ndDeleteUser(serverUrl, token, u.id); + showToast(t('settings.userMgmtDeleted'), 3000, 'info'); + await load(); + } catch (e) { + const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : t('settings.userMgmtDeleteError')); + showToast(msg, 5000, 'error'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ +

{t('settings.userMgmtTitle')}

+
+
+ {t('settings.userMgmtDesc')} +
+ + {loading && ( +
+
+ +
+ )} + + {!loading && loadError && ( +
+ {loadError} +
+ )} + + {!loading && !loadError && ( + <> + {editing ? ( + setEditing(null)} + busy={busy} + /> + ) : ( + + )} + + {users.length === 0 ? ( +
+ {t('settings.userMgmtEmpty')} +
+ ) : ( +
+ {users.map(u => { + const isSelf = u.userName === currentUsername; + return ( +
+
+
+ +
+
+ {u.userName} + {u.name && u.name !== u.userName && ( + · {u.name} + )} + {isSelf && ( + + {t('settings.userMgmtYouBadge')} + + )} + {u.isAdmin && ( + + + {t('settings.userMgmtAdminBadge')} + + )} +
+ {u.email && ( +
+ {u.email} +
+ )} +
+
+
+ + +
+
+
+ ); + })} +
+ )} + + )} +
+ ); +} + function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; @@ -423,6 +759,28 @@ export default function Settings() { const [contributorsOpen, setContributorsOpen] = useState(false); const [fontPickerOpen, setFontPickerOpen] = useState(false); const [discordOptionsOpen, setDiscordOptionsOpen] = useState(false); + const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null); + const [ndAuthChecked, setNdAuthChecked] = useState(false); + + useEffect(() => { + const server = auth.getActiveServer(); + setNdAuthChecked(false); + if (!server) { setNdAdminAuth(null); setNdAuthChecked(true); return; } + const serverUrl = (server.url.startsWith('http') ? server.url : `http://${server.url}`).replace(/\/$/, ''); + let cancelled = false; + ndLogin(serverUrl, server.username, server.password) + .then(res => { + if (cancelled) return; + setNdAdminAuth(res.isAdmin ? { token: res.token, serverUrl, username: server.username } : null); + }) + .catch(() => { if (!cancelled) setNdAdminAuth(null); }) + .finally(() => { if (!cancelled) setNdAuthChecked(true); }); + return () => { cancelled = true; }; + }, [auth.activeServerId]); + + useEffect(() => { + if (activeTab === 'users' && ndAuthChecked && ndAdminAuth === null) setActiveTab('general'); + }, [activeTab, ndAdminAuth, ndAuthChecked]); useEffect(() => { if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; } @@ -671,6 +1029,7 @@ export default function Settings() { const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: 'general', label: t('settings.tabGeneral'), icon: }, { id: 'server', label: t('settings.tabServer'), icon: }, + ...(ndAdminAuth ? [{ id: 'users' as Tab, label: t('settings.tabUsers'), icon: }] : []), { id: 'audio', label: t('settings.tabAudio'), icon: }, { id: 'storage', label: t('settings.tabStorage'), icon: }, { id: 'appearance', label: t('settings.tabAppearance'), icon: }, @@ -2441,6 +2800,14 @@ export default function Settings() { )} {/* ── System ───────────────────────────────────────────────────────────── */} + {activeTab === 'users' && ndAdminAuth && ( + + )} + {activeTab === 'system' && ( <>