mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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(())
|
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;
|
const RADIO_PAGE_SIZE: u32 = 25;
|
||||||
|
|
||||||
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
|
/// 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_radio_cover,
|
||||||
upload_artist_image,
|
upload_artist_image,
|
||||||
delete_radio_cover,
|
delete_radio_cover,
|
||||||
|
navidrome_login,
|
||||||
|
nd_list_users,
|
||||||
|
nd_create_user,
|
||||||
|
nd_update_user,
|
||||||
|
nd_delete_user,
|
||||||
search_radio_browser,
|
search_radio_browser,
|
||||||
get_top_radio_stations,
|
get_top_radio_stations,
|
||||||
fetch_url_bytes,
|
fetch_url_bytes,
|
||||||
|
|||||||
@@ -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<NdLoginResult> {
|
||||||
|
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
|
||||||
|
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map(u => {
|
||||||
|
const o = u as Record<string, unknown>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await invoke('nd_update_user', { serverUrl, token, id, ...data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
|
||||||
|
await invoke('nd_delete_user', { serverUrl, token, id });
|
||||||
|
}
|
||||||
@@ -467,6 +467,34 @@ export const deTranslation = {
|
|||||||
testBtn: 'Verbindung testen',
|
testBtn: 'Verbindung testen',
|
||||||
testingBtn: 'Teste…',
|
testingBtn: 'Teste…',
|
||||||
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
|
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)',
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
audiomuseDesc:
|
audiomuseDesc:
|
||||||
'Aktivieren, wenn dieser Server das <pluginLink>AudioMuse-AI-Navidrome-Plugin</pluginLink> nutzt. Schaltet Instant Mix pro Titel frei und nutzt ähnliche Künstler vom Server statt Last.fm auf Künstlerseiten.',
|
'Aktivieren, wenn dieser Server das <pluginLink>AudioMuse-AI-Navidrome-Plugin</pluginLink> 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.',
|
randomNavSplitDesc: '"Zufallsmix" und "Zufallsalben" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
|
||||||
tabInput: 'Eingabe',
|
tabInput: 'Eingabe',
|
||||||
tabServer: 'Server',
|
tabServer: 'Server',
|
||||||
|
tabUsers: 'Benutzer',
|
||||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||||
shortcutListening: 'Taste drücken…',
|
shortcutListening: 'Taste drücken…',
|
||||||
shortcutUnbound: '—',
|
shortcutUnbound: '—',
|
||||||
|
|||||||
@@ -469,6 +469,34 @@ export const enTranslation = {
|
|||||||
testBtn: 'Test Connection',
|
testBtn: 'Test Connection',
|
||||||
testingBtn: 'Testing…',
|
testingBtn: 'Testing…',
|
||||||
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
|
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)',
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
audiomuseDesc:
|
audiomuseDesc:
|
||||||
'Turn on if this server has the <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.',
|
'Turn on if this server has the <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> 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.',
|
randomNavSplitDesc: 'Show "Random Mix" and "Random Albums" as separate sidebar entries instead of the "Build a Mix" hub.',
|
||||||
tabInput: 'Input',
|
tabInput: 'Input',
|
||||||
tabServer: 'Server',
|
tabServer: 'Server',
|
||||||
|
tabUsers: 'Users',
|
||||||
tabSystem: 'System',
|
tabSystem: 'System',
|
||||||
tabGeneral: 'General',
|
tabGeneral: 'General',
|
||||||
ratingsSectionTitle: 'Ratings',
|
ratingsSectionTitle: 'Ratings',
|
||||||
|
|||||||
@@ -460,6 +460,34 @@ export const esTranslation = {
|
|||||||
testBtn: 'Probar Conexión',
|
testBtn: 'Probar Conexión',
|
||||||
testingBtn: 'Probando…',
|
testingBtn: 'Probando…',
|
||||||
serverCompatible: 'Compatible con: Navidrome · Gonic · Airsonic · Subsonic',
|
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)',
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
audiomuseDesc:
|
audiomuseDesc:
|
||||||
'Activa si este servidor tiene el plugin <pluginLink>AudioMuse-AI Navidrome</pluginLink> configurado. Habilita Mezcla Instantánea desde pistas y usa artistas similares del servidor en lugar de Last.fm en páginas de artistas.',
|
'Activa si este servidor tiene el plugin <pluginLink>AudioMuse-AI Navidrome</pluginLink> 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".',
|
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria" y "Álbumes Aleatorios" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
|
||||||
tabInput: 'Entrada',
|
tabInput: 'Entrada',
|
||||||
tabServer: 'Servidor',
|
tabServer: 'Servidor',
|
||||||
|
tabUsers: 'Usuarios',
|
||||||
tabSystem: 'Sistema',
|
tabSystem: 'Sistema',
|
||||||
tabGeneral: 'General',
|
tabGeneral: 'General',
|
||||||
ratingsSectionTitle: 'Calificaciones',
|
ratingsSectionTitle: 'Calificaciones',
|
||||||
|
|||||||
@@ -457,6 +457,34 @@ export const frTranslation = {
|
|||||||
testBtn: 'Tester la connexion',
|
testBtn: 'Tester la connexion',
|
||||||
testingBtn: 'Test en cours…',
|
testingBtn: 'Test en cours…',
|
||||||
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
|
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)',
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
audiomuseDesc:
|
audiomuseDesc:
|
||||||
'Activez si ce serveur utilise le <pluginLink>plugin Navidrome AudioMuse-AI</pluginLink>. Active le mix instantané depuis un morceau et affiche les artistes similaires côté serveur au lieu de Last.fm sur les pages artiste.',
|
'Activez si ce serveur utilise le <pluginLink>plugin Navidrome AudioMuse-AI</pluginLink>. 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".',
|
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',
|
tabInput: 'Entrée',
|
||||||
tabServer: 'Serveur',
|
tabServer: 'Serveur',
|
||||||
|
tabUsers: 'Utilisateurs',
|
||||||
shortcutsReset: 'Réinitialiser',
|
shortcutsReset: 'Réinitialiser',
|
||||||
shortcutListening: 'Appuyez sur une touche…',
|
shortcutListening: 'Appuyez sur une touche…',
|
||||||
shortcutUnbound: '—',
|
shortcutUnbound: '—',
|
||||||
|
|||||||
@@ -457,6 +457,34 @@ export const nbTranslation = {
|
|||||||
testBtn: 'Test tilkobling',
|
testBtn: 'Test tilkobling',
|
||||||
testingBtn: 'Tester…',
|
testingBtn: 'Tester…',
|
||||||
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
|
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)',
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
audiomuseDesc:
|
audiomuseDesc:
|
||||||
'Slå på hvis denne serveren bruker <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink>. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.',
|
'Slå på hvis denne serveren bruker <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink>. 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.',
|
randomNavSplitDesc: 'Vis "Tilfeldig miks" og "Tilfeldige album" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
|
||||||
tabShortcuts: 'Snarveier',
|
tabShortcuts: 'Snarveier',
|
||||||
tabServer: 'Tjener',
|
tabServer: 'Tjener',
|
||||||
|
tabUsers: 'Brukere',
|
||||||
tabSystem: 'System',
|
tabSystem: 'System',
|
||||||
tabGeneral: 'Generelt',
|
tabGeneral: 'Generelt',
|
||||||
ratingsSectionTitle: 'Vurderinger',
|
ratingsSectionTitle: 'Vurderinger',
|
||||||
|
|||||||
@@ -456,6 +456,34 @@ export const nlTranslation = {
|
|||||||
testBtn: 'Verbinding testen',
|
testBtn: 'Verbinding testen',
|
||||||
testingBtn: 'Testen…',
|
testingBtn: 'Testen…',
|
||||||
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
|
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)',
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
audiomuseDesc:
|
audiomuseDesc:
|
||||||
'Zet aan als deze server de <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink> gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpagina’s.',
|
'Zet aan als deze server de <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink> 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.',
|
randomNavSplitDesc: 'Toon "Willekeurige mix" en "Willekeurige albums" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
|
||||||
tabInput: 'Invoer',
|
tabInput: 'Invoer',
|
||||||
tabServer: 'Server',
|
tabServer: 'Server',
|
||||||
|
tabUsers: 'Gebruikers',
|
||||||
shortcutsReset: 'Standaard herstellen',
|
shortcutsReset: 'Standaard herstellen',
|
||||||
shortcutListening: 'Druk op een toets…',
|
shortcutListening: 'Druk op een toets…',
|
||||||
shortcutUnbound: '—',
|
shortcutUnbound: '—',
|
||||||
|
|||||||
@@ -474,6 +474,34 @@ export const ruTranslation = {
|
|||||||
testBtn: 'Проверить',
|
testBtn: 'Проверить',
|
||||||
testingBtn: 'Проверка…',
|
testingBtn: 'Проверка…',
|
||||||
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
|
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)',
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
audiomuseDesc:
|
audiomuseDesc:
|
||||||
'Включите, если на этом сервере настроен <pluginLink>плагин AudioMuse-AI для Navidrome</pluginLink>. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
|
'Включите, если на этом сервере настроен <pluginLink>плагин AudioMuse-AI для Navidrome</pluginLink>. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
|
||||||
@@ -642,6 +670,7 @@ export const ruTranslation = {
|
|||||||
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
|
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
|
||||||
tabInput: 'Ввод',
|
tabInput: 'Ввод',
|
||||||
tabServer: 'Сервер',
|
tabServer: 'Сервер',
|
||||||
|
tabUsers: 'Пользователи',
|
||||||
tabSystem: 'Система',
|
tabSystem: 'Система',
|
||||||
tabGeneral: 'Общие',
|
tabGeneral: 'Общие',
|
||||||
ratingsSectionTitle: 'Рейтинги',
|
ratingsSectionTitle: 'Рейтинги',
|
||||||
|
|||||||
@@ -452,6 +452,34 @@ export const zhTranslation = {
|
|||||||
testBtn: '测试连接',
|
testBtn: '测试连接',
|
||||||
testingBtn: '正在测试…',
|
testingBtn: '正在测试…',
|
||||||
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
|
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)',
|
audiomuseTitle: 'AudioMuse-AI(Navidrome)',
|
||||||
audiomuseDesc:
|
audiomuseDesc:
|
||||||
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
|
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
|
||||||
@@ -610,6 +638,7 @@ export const zhTranslation = {
|
|||||||
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。',
|
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。',
|
||||||
tabInput: '输入',
|
tabInput: '输入',
|
||||||
tabServer: '服务器',
|
tabServer: '服务器',
|
||||||
|
tabUsers: '用户',
|
||||||
tabSystem: '系统',
|
tabSystem: '系统',
|
||||||
tabGeneral: '通用',
|
tabGeneral: '通用',
|
||||||
ratingsSectionTitle: '评分',
|
ratingsSectionTitle: '评分',
|
||||||
|
|||||||
+369
-2
@@ -5,7 +5,8 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
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';
|
} from 'lucide-react';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { exportBackup, importBackup } from '../utils/backup';
|
import { exportBackup, importBackup } from '../utils/backup';
|
||||||
@@ -33,6 +34,10 @@ import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
|||||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
|
import {
|
||||||
|
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
|
||||||
|
type NdUser,
|
||||||
|
} from '../api/navidromeAdmin';
|
||||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
@@ -184,7 +189,7 @@ const SPECIAL_THANKS = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -244,6 +249,337 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserFormState {
|
||||||
|
userName: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialUserFormState(u?: NdUser): UserFormState {
|
||||||
|
return {
|
||||||
|
userName: u?.userName ?? '',
|
||||||
|
name: u?.name ?? '',
|
||||||
|
email: u?.email ?? '',
|
||||||
|
password: '',
|
||||||
|
isAdmin: !!u?.isAdmin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserForm({
|
||||||
|
initial,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
initial: NdUser | null;
|
||||||
|
onSave: (form: UserFormState) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState<UserFormState>(() => initialUserFormState(initial ?? undefined));
|
||||||
|
const [showPass, setShowPass] = useState(false);
|
||||||
|
const isEdit = !!initial;
|
||||||
|
|
||||||
|
const set = <K extends keyof UserFormState>(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 (
|
||||||
|
<div className="settings-card" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>
|
||||||
|
{isEdit ? t('settings.userMgmtEditUserTitle') : t('settings.userMgmtAddUserTitle')}
|
||||||
|
</h3>
|
||||||
|
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ fontSize: 13 }}>{t('settings.userMgmtUsername')}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={form.userName}
|
||||||
|
onChange={e => set('userName', e.target.value)}
|
||||||
|
disabled={isEdit}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ fontSize: 13 }}>{t('settings.userMgmtName')}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => set('name', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<label style={{ fontSize: 13 }}>{t('settings.userMgmtEmail')}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => set('email', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<label style={{ fontSize: 13 }}>{t('settings.userMgmtPassword')}</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type={showPass ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => set('password', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
style={{ paddingRight: '2.5rem' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
|
||||||
|
onClick={() => setShowPass(v => !v)}
|
||||||
|
>
|
||||||
|
{showPass ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isEdit && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||||
|
{t('settings.userMgmtPasswordEditHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', marginBottom: '1rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isAdmin}
|
||||||
|
onChange={e => set('isAdmin', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<Shield size={14} />
|
||||||
|
{t('settings.userMgmtRoleAdmin')}
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-ghost" onClick={onCancel} disabled={busy}>
|
||||||
|
{t('settings.userMgmtCancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => onSave(form)}
|
||||||
|
disabled={busy || !canSave}
|
||||||
|
>
|
||||||
|
{t('settings.userMgmtSave')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserManagementSection({
|
||||||
|
serverUrl,
|
||||||
|
token,
|
||||||
|
currentUsername,
|
||||||
|
}: {
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
currentUsername: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [users, setUsers] = useState<NdUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState<NdUser | 'new' | null>(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 (
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<Users size={18} />
|
||||||
|
<h2>{t('settings.userMgmtTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
{t('settings.userMgmtDesc')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="settings-card" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && loadError && (
|
||||||
|
<div className="settings-card" style={{ color: 'var(--danger)', fontSize: 13 }}>
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !loadError && (
|
||||||
|
<>
|
||||||
|
{editing ? (
|
||||||
|
<UserForm
|
||||||
|
initial={editing === 'new' ? null : editing}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-surface"
|
||||||
|
style={{ marginBottom: '0.75rem' }}
|
||||||
|
onClick={() => setEditing('new')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<UserPlus size={16} /> {t('settings.userMgmtAddUser')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
||||||
|
{t('settings.userMgmtEmpty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{users.map(u => {
|
||||||
|
const isSelf = u.userName === currentUsername;
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="settings-card">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<User size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{u.userName}</span>
|
||||||
|
{u.name && u.name !== u.userName && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>· {u.name}</span>
|
||||||
|
)}
|
||||||
|
{isSelf && (
|
||||||
|
<span style={{ fontSize: 10, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 10, fontWeight: 600 }}>
|
||||||
|
{t('settings.userMgmtYouBadge')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{u.isAdmin && (
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 10, fontWeight: 600, background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)', color: 'var(--text-primary)' }}
|
||||||
|
data-tooltip={t('settings.userMgmtRoleAdmin')}
|
||||||
|
>
|
||||||
|
<Shield size={10} />
|
||||||
|
{t('settings.userMgmtAdminBadge')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{u.email && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{u.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-surface"
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||||
|
onClick={() => setEditing(u)}
|
||||||
|
disabled={busy}
|
||||||
|
data-tooltip={t('settings.userMgmtEdit')}
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
{t('settings.userMgmtEdit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ color: 'var(--danger)', padding: '4px 8px' }}
|
||||||
|
onClick={() => handleDelete(u)}
|
||||||
|
disabled={busy || isSelf}
|
||||||
|
data-tooltip={t('settings.userMgmtDelete')}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
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 [contributorsOpen, setContributorsOpen] = useState(false);
|
||||||
const [fontPickerOpen, setFontPickerOpen] = useState(false);
|
const [fontPickerOpen, setFontPickerOpen] = useState(false);
|
||||||
const [discordOptionsOpen, setDiscordOptionsOpen] = 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(() => {
|
useEffect(() => {
|
||||||
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
|
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 }[] = [
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
|
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
|
||||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||||
|
...(ndAdminAuth ? [{ id: 'users' as Tab, label: t('settings.tabUsers'), icon: <Users size={15} /> }] : []),
|
||||||
{ id: 'audio', label: t('settings.tabAudio'), icon: <Music2 size={15} /> },
|
{ id: 'audio', label: t('settings.tabAudio'), icon: <Music2 size={15} /> },
|
||||||
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
|
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
|
||||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||||
@@ -2441,6 +2800,14 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── System ───────────────────────────────────────────────────────────── */}
|
{/* ── System ───────────────────────────────────────────────────────────── */}
|
||||||
|
{activeTab === 'users' && ndAdminAuth && (
|
||||||
|
<UserManagementSection
|
||||||
|
serverUrl={ndAdminAuth.serverUrl}
|
||||||
|
token={ndAdminAuth.token}
|
||||||
|
currentUsername={ndAdminAuth.username}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'system' && (
|
{activeTab === 'system' && (
|
||||||
<>
|
<>
|
||||||
<BackupSection />
|
<BackupSection />
|
||||||
|
|||||||
Reference in New Issue
Block a user