mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(users): per-user library assignment + themed confirm modal (#222)
Adds Navidrome library assignment to the User Management settings
panel: GET /api/library + PUT /api/user/{id}/library wired through
new Rust commands. UserForm gets a checkbox picker (hidden for
admins, who auto-receive all libraries server-side) with inline
validation. User rows compacted to a single clickable line with
hover highlight; native confirm() replaced by a portal-based
ConfirmModal (theme-aware, ESC/Enter, vertically centered).
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
6d3c50264a
commit
459c9f688d
@@ -385,6 +385,49 @@ async fn nd_delete_user(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn nd_list_libraries(
|
||||||
|
server_url: String,
|
||||||
|
token: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.get(format!("{}/api/library", 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user.
|
||||||
|
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn nd_set_user_libraries(
|
||||||
|
server_url: String,
|
||||||
|
token: String,
|
||||||
|
id: String,
|
||||||
|
library_ids: Vec<i64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let body = serde_json::json!({ "libraryIds": library_ids });
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.put(format!("{}/api/user/{}/library", server_url, id))
|
||||||
|
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||||
|
.json(&body)
|
||||||
|
.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).
|
||||||
@@ -3342,6 +3385,8 @@ pub fn run() {
|
|||||||
nd_create_user,
|
nd_create_user,
|
||||||
nd_update_user,
|
nd_update_user,
|
||||||
nd_delete_user,
|
nd_delete_user,
|
||||||
|
nd_list_libraries,
|
||||||
|
nd_set_user_libraries,
|
||||||
search_radio_browser,
|
search_radio_browser,
|
||||||
get_top_radio_stations,
|
get_top_radio_stations,
|
||||||
fetch_url_bytes,
|
fetch_url_bytes,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface NdLibrary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NdUser {
|
export interface NdUser {
|
||||||
id: string;
|
id: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
libraryIds: number[];
|
||||||
lastLoginAt?: string | null;
|
lastLoginAt?: string | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -25,6 +31,18 @@ export async function ndLogin(
|
|||||||
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
|
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractLibraryIds(o: Record<string, unknown>): number[] {
|
||||||
|
const libs = o.libraries;
|
||||||
|
if (!Array.isArray(libs)) return [];
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const l of libs) {
|
||||||
|
const id = (l as Record<string, unknown>)?.id;
|
||||||
|
if (typeof id === 'number') ids.push(id);
|
||||||
|
else if (typeof id === 'string' && /^\d+$/.test(id)) ids.push(Number(id));
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
|
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
|
||||||
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
|
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
@@ -36,6 +54,7 @@ export async function ndListUsers(serverUrl: string, token: string): Promise<NdU
|
|||||||
name: String(o.name ?? ''),
|
name: String(o.name ?? ''),
|
||||||
email: String(o.email ?? ''),
|
email: String(o.email ?? ''),
|
||||||
isAdmin: !!o.isAdmin,
|
isAdmin: !!o.isAdmin,
|
||||||
|
libraryIds: extractLibraryIds(o),
|
||||||
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
|
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
|
||||||
createdAt: o.createdAt as string | undefined,
|
createdAt: o.createdAt as string | undefined,
|
||||||
updatedAt: o.updatedAt as string | undefined,
|
updatedAt: o.updatedAt as string | undefined,
|
||||||
@@ -43,12 +62,35 @@ export async function ndListUsers(serverUrl: string, token: string): Promise<NdU
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ndListLibraries(serverUrl: string, token: string): Promise<NdLibrary[]> {
|
||||||
|
const raw = await invoke<unknown>('nd_list_libraries', { serverUrl, token });
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map(l => {
|
||||||
|
const o = l as Record<string, unknown>;
|
||||||
|
const id = typeof o.id === 'number'
|
||||||
|
? o.id
|
||||||
|
: typeof o.id === 'string' && /^\d+$/.test(o.id) ? Number(o.id) : 0;
|
||||||
|
return { id, name: String(o.name ?? '') };
|
||||||
|
}).filter(l => l.id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ndSetUserLibraries(
|
||||||
|
serverUrl: string,
|
||||||
|
token: string,
|
||||||
|
id: string,
|
||||||
|
libraryIds: number[],
|
||||||
|
): Promise<void> {
|
||||||
|
await invoke('nd_set_user_libraries', { serverUrl, token, id, libraryIds });
|
||||||
|
}
|
||||||
|
|
||||||
export async function ndCreateUser(
|
export async function ndCreateUser(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
token: string,
|
token: string,
|
||||||
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
|
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
|
||||||
): Promise<void> {
|
): Promise<{ id: string }> {
|
||||||
await invoke('nd_create_user', { serverUrl, token, ...data });
|
const raw = await invoke<unknown>('nd_create_user', { serverUrl, token, ...data });
|
||||||
|
const o = (raw as Record<string, unknown> | null) ?? {};
|
||||||
|
return { id: String(o.id ?? '') };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ndUpdateUser(
|
export async function ndUpdateUser(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
danger?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
danger,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
else if (e.key === 'Enter') onConfirm();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, onCancel, onConfirm]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const confirmStyle = danger
|
||||||
|
? { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={onCancel}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: '380px' }}
|
||||||
|
>
|
||||||
|
<button className="modal-close" onClick={onCancel} aria-label={cancelLabel}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{title}</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||||
|
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -483,6 +483,12 @@ export const deTranslation = {
|
|||||||
userMgmtPassword: 'Passwort',
|
userMgmtPassword: 'Passwort',
|
||||||
userMgmtPasswordEditHint: 'Neues Passwort eingeben, um es zu ändern.',
|
userMgmtPasswordEditHint: 'Neues Passwort eingeben, um es zu ändern.',
|
||||||
userMgmtRoleAdmin: 'Admin',
|
userMgmtRoleAdmin: 'Admin',
|
||||||
|
userMgmtLibraries: 'Bibliotheken',
|
||||||
|
userMgmtLibrariesAdminHint: 'Admin-Benutzer haben automatisch Zugriff auf alle Bibliotheken.',
|
||||||
|
userMgmtLibrariesEmpty: 'Keine Bibliotheken auf diesem Server vorhanden.',
|
||||||
|
userMgmtLibrariesValidation: 'Mindestens eine Bibliothek auswählen.',
|
||||||
|
userMgmtLibrariesUpdateError: 'Benutzer gespeichert, aber Bibliothekszuweisung fehlgeschlagen',
|
||||||
|
userMgmtNoLibraries: 'Keine Bibliotheken zugewiesen',
|
||||||
userMgmtSave: 'Speichern',
|
userMgmtSave: 'Speichern',
|
||||||
userMgmtCancel: 'Abbrechen',
|
userMgmtCancel: 'Abbrechen',
|
||||||
userMgmtDelete: 'Löschen',
|
userMgmtDelete: 'Löschen',
|
||||||
|
|||||||
@@ -485,6 +485,12 @@ export const enTranslation = {
|
|||||||
userMgmtPassword: 'Password',
|
userMgmtPassword: 'Password',
|
||||||
userMgmtPasswordEditHint: 'Enter a new password to update it.',
|
userMgmtPasswordEditHint: 'Enter a new password to update it.',
|
||||||
userMgmtRoleAdmin: 'Admin',
|
userMgmtRoleAdmin: 'Admin',
|
||||||
|
userMgmtLibraries: 'Libraries',
|
||||||
|
userMgmtLibrariesAdminHint: 'Admin users automatically have access to all libraries.',
|
||||||
|
userMgmtLibrariesEmpty: 'No libraries available on this server.',
|
||||||
|
userMgmtLibrariesValidation: 'Select at least one library.',
|
||||||
|
userMgmtLibrariesUpdateError: 'User saved, but library assignment failed',
|
||||||
|
userMgmtNoLibraries: 'No libraries assigned',
|
||||||
userMgmtSave: 'Save',
|
userMgmtSave: 'Save',
|
||||||
userMgmtCancel: 'Cancel',
|
userMgmtCancel: 'Cancel',
|
||||||
userMgmtDelete: 'Delete',
|
userMgmtDelete: 'Delete',
|
||||||
|
|||||||
@@ -476,6 +476,12 @@ export const esTranslation = {
|
|||||||
userMgmtPassword: 'Contraseña',
|
userMgmtPassword: 'Contraseña',
|
||||||
userMgmtPasswordEditHint: 'Introduce una nueva contraseña para actualizarla.',
|
userMgmtPasswordEditHint: 'Introduce una nueva contraseña para actualizarla.',
|
||||||
userMgmtRoleAdmin: 'Admin',
|
userMgmtRoleAdmin: 'Admin',
|
||||||
|
userMgmtLibraries: 'Bibliotecas',
|
||||||
|
userMgmtLibrariesAdminHint: 'Los usuarios admin tienen acceso automáticamente a todas las bibliotecas.',
|
||||||
|
userMgmtLibrariesEmpty: 'No hay bibliotecas disponibles en este servidor.',
|
||||||
|
userMgmtLibrariesValidation: 'Selecciona al menos una biblioteca.',
|
||||||
|
userMgmtLibrariesUpdateError: 'Usuario guardado, pero la asignación de bibliotecas falló',
|
||||||
|
userMgmtNoLibraries: 'Sin bibliotecas asignadas',
|
||||||
userMgmtSave: 'Guardar',
|
userMgmtSave: 'Guardar',
|
||||||
userMgmtCancel: 'Cancelar',
|
userMgmtCancel: 'Cancelar',
|
||||||
userMgmtDelete: 'Eliminar',
|
userMgmtDelete: 'Eliminar',
|
||||||
|
|||||||
@@ -473,6 +473,12 @@ export const frTranslation = {
|
|||||||
userMgmtPassword: 'Mot de passe',
|
userMgmtPassword: 'Mot de passe',
|
||||||
userMgmtPasswordEditHint: 'Saisir un nouveau mot de passe pour le modifier.',
|
userMgmtPasswordEditHint: 'Saisir un nouveau mot de passe pour le modifier.',
|
||||||
userMgmtRoleAdmin: 'Admin',
|
userMgmtRoleAdmin: 'Admin',
|
||||||
|
userMgmtLibraries: 'Bibliothèques',
|
||||||
|
userMgmtLibrariesAdminHint: 'Les utilisateurs admin ont automatiquement accès à toutes les bibliothèques.',
|
||||||
|
userMgmtLibrariesEmpty: 'Aucune bibliothèque disponible sur ce serveur.',
|
||||||
|
userMgmtLibrariesValidation: 'Sélectionnez au moins une bibliothèque.',
|
||||||
|
userMgmtLibrariesUpdateError: 'Utilisateur enregistré, mais l\u2019attribution des bibliothèques a échoué',
|
||||||
|
userMgmtNoLibraries: 'Aucune bibliothèque attribuée',
|
||||||
userMgmtSave: 'Enregistrer',
|
userMgmtSave: 'Enregistrer',
|
||||||
userMgmtCancel: 'Annuler',
|
userMgmtCancel: 'Annuler',
|
||||||
userMgmtDelete: 'Supprimer',
|
userMgmtDelete: 'Supprimer',
|
||||||
|
|||||||
@@ -473,6 +473,12 @@ export const nbTranslation = {
|
|||||||
userMgmtPassword: 'Passord',
|
userMgmtPassword: 'Passord',
|
||||||
userMgmtPasswordEditHint: 'Skriv inn et nytt passord for å oppdatere det.',
|
userMgmtPasswordEditHint: 'Skriv inn et nytt passord for å oppdatere det.',
|
||||||
userMgmtRoleAdmin: 'Admin',
|
userMgmtRoleAdmin: 'Admin',
|
||||||
|
userMgmtLibraries: 'Biblioteker',
|
||||||
|
userMgmtLibrariesAdminHint: 'Administratorbrukere har automatisk tilgang til alle biblioteker.',
|
||||||
|
userMgmtLibrariesEmpty: 'Ingen biblioteker tilgjengelig på denne serveren.',
|
||||||
|
userMgmtLibrariesValidation: 'Velg minst ett bibliotek.',
|
||||||
|
userMgmtLibrariesUpdateError: 'Brukeren ble lagret, men bibliotekstilordning mislyktes',
|
||||||
|
userMgmtNoLibraries: 'Ingen biblioteker tilordnet',
|
||||||
userMgmtSave: 'Lagre',
|
userMgmtSave: 'Lagre',
|
||||||
userMgmtCancel: 'Avbryt',
|
userMgmtCancel: 'Avbryt',
|
||||||
userMgmtDelete: 'Slett',
|
userMgmtDelete: 'Slett',
|
||||||
|
|||||||
@@ -472,6 +472,12 @@ export const nlTranslation = {
|
|||||||
userMgmtPassword: 'Wachtwoord',
|
userMgmtPassword: 'Wachtwoord',
|
||||||
userMgmtPasswordEditHint: 'Voer een nieuw wachtwoord in om het bij te werken.',
|
userMgmtPasswordEditHint: 'Voer een nieuw wachtwoord in om het bij te werken.',
|
||||||
userMgmtRoleAdmin: 'Admin',
|
userMgmtRoleAdmin: 'Admin',
|
||||||
|
userMgmtLibraries: 'Bibliotheken',
|
||||||
|
userMgmtLibrariesAdminHint: 'Beheerders hebben automatisch toegang tot alle bibliotheken.',
|
||||||
|
userMgmtLibrariesEmpty: 'Geen bibliotheken beschikbaar op deze server.',
|
||||||
|
userMgmtLibrariesValidation: 'Selecteer ten minste één bibliotheek.',
|
||||||
|
userMgmtLibrariesUpdateError: 'Gebruiker opgeslagen, maar bibliotheektoewijzing mislukt',
|
||||||
|
userMgmtNoLibraries: 'Geen bibliotheken toegewezen',
|
||||||
userMgmtSave: 'Opslaan',
|
userMgmtSave: 'Opslaan',
|
||||||
userMgmtCancel: 'Annuleren',
|
userMgmtCancel: 'Annuleren',
|
||||||
userMgmtDelete: 'Verwijderen',
|
userMgmtDelete: 'Verwijderen',
|
||||||
|
|||||||
@@ -490,6 +490,12 @@ export const ruTranslation = {
|
|||||||
userMgmtPassword: 'Пароль',
|
userMgmtPassword: 'Пароль',
|
||||||
userMgmtPasswordEditHint: 'Введите новый пароль, чтобы изменить его.',
|
userMgmtPasswordEditHint: 'Введите новый пароль, чтобы изменить его.',
|
||||||
userMgmtRoleAdmin: 'Админ',
|
userMgmtRoleAdmin: 'Админ',
|
||||||
|
userMgmtLibraries: 'Библиотеки',
|
||||||
|
userMgmtLibrariesAdminHint: 'Администраторы автоматически имеют доступ ко всем библиотекам.',
|
||||||
|
userMgmtLibrariesEmpty: 'На этом сервере нет доступных библиотек.',
|
||||||
|
userMgmtLibrariesValidation: 'Выберите хотя бы одну библиотеку.',
|
||||||
|
userMgmtLibrariesUpdateError: 'Пользователь сохранён, но не удалось назначить библиотеки',
|
||||||
|
userMgmtNoLibraries: 'Библиотеки не назначены',
|
||||||
userMgmtSave: 'Сохранить',
|
userMgmtSave: 'Сохранить',
|
||||||
userMgmtCancel: 'Отмена',
|
userMgmtCancel: 'Отмена',
|
||||||
userMgmtDelete: 'Удалить',
|
userMgmtDelete: 'Удалить',
|
||||||
|
|||||||
@@ -468,6 +468,12 @@ export const zhTranslation = {
|
|||||||
userMgmtPassword: '密码',
|
userMgmtPassword: '密码',
|
||||||
userMgmtPasswordEditHint: '输入新密码以更新。',
|
userMgmtPasswordEditHint: '输入新密码以更新。',
|
||||||
userMgmtRoleAdmin: '管理员',
|
userMgmtRoleAdmin: '管理员',
|
||||||
|
userMgmtLibraries: '音乐库',
|
||||||
|
userMgmtLibrariesAdminHint: '管理员用户自动拥有所有音乐库的访问权限。',
|
||||||
|
userMgmtLibrariesEmpty: '此服务器上没有可用的音乐库。',
|
||||||
|
userMgmtLibrariesValidation: '请至少选择一个音乐库。',
|
||||||
|
userMgmtLibrariesUpdateError: '用户已保存,但音乐库分配失败',
|
||||||
|
userMgmtNoLibraries: '未分配音乐库',
|
||||||
userMgmtSave: '保存',
|
userMgmtSave: '保存',
|
||||||
userMgmtCancel: '取消',
|
userMgmtCancel: '取消',
|
||||||
userMgmtDelete: '删除',
|
userMgmtDelete: '删除',
|
||||||
|
|||||||
+168
-64
@@ -6,7 +6,7 @@ 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
|
Users, UserPlus, 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';
|
||||||
@@ -36,10 +36,12 @@ import { ALL_NAV_ITEMS } from '../config/navItems';
|
|||||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
import {
|
import {
|
||||||
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
|
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
|
||||||
type NdUser,
|
ndListLibraries, ndSetUserLibraries,
|
||||||
|
type NdUser, type NdLibrary,
|
||||||
} from '../api/navidromeAdmin';
|
} 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 ConfirmModal from '../components/ConfirmModal';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import Equalizer from '../components/Equalizer';
|
import Equalizer from '../components/Equalizer';
|
||||||
import StarRating from '../components/StarRating';
|
import StarRating from '../components/StarRating';
|
||||||
@@ -255,41 +257,55 @@ interface UserFormState {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
libraryIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialUserFormState(u?: NdUser): UserFormState {
|
function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]): UserFormState {
|
||||||
|
const defaultIds = allLibraries.map(l => l.id);
|
||||||
return {
|
return {
|
||||||
userName: u?.userName ?? '',
|
userName: u?.userName ?? '',
|
||||||
name: u?.name ?? '',
|
name: u?.name ?? '',
|
||||||
email: u?.email ?? '',
|
email: u?.email ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
isAdmin: !!u?.isAdmin,
|
isAdmin: !!u?.isAdmin,
|
||||||
|
libraryIds: u ? [...u.libraryIds] : defaultIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserForm({
|
function UserForm({
|
||||||
initial,
|
initial,
|
||||||
|
libraries,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy,
|
busy,
|
||||||
}: {
|
}: {
|
||||||
initial: NdUser | null;
|
initial: NdUser | null;
|
||||||
|
libraries: NdLibrary[];
|
||||||
onSave: (form: UserFormState) => void;
|
onSave: (form: UserFormState) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form, setForm] = useState<UserFormState>(() => initialUserFormState(initial ?? undefined));
|
const [form, setForm] = useState<UserFormState>(() => initialUserFormState(initial ?? undefined, libraries));
|
||||||
const [showPass, setShowPass] = useState(false);
|
const [showPass, setShowPass] = useState(false);
|
||||||
const isEdit = !!initial;
|
const isEdit = !!initial;
|
||||||
|
|
||||||
const set = <K extends keyof UserFormState>(k: K, v: UserFormState[K]) =>
|
const set = <K extends keyof UserFormState>(k: K, v: UserFormState[K]) =>
|
||||||
setForm(f => ({ ...f, [k]: v }));
|
setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const toggleLib = (id: number) =>
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
libraryIds: f.libraryIds.includes(id)
|
||||||
|
? f.libraryIds.filter(x => x !== id)
|
||||||
|
: [...f.libraryIds, id],
|
||||||
|
}));
|
||||||
|
|
||||||
const canSave =
|
const canSave =
|
||||||
form.userName.trim().length > 0 &&
|
form.userName.trim().length > 0 &&
|
||||||
form.name.trim().length > 0 &&
|
form.name.trim().length > 0 &&
|
||||||
form.password.length > 0;
|
form.password.length > 0 &&
|
||||||
|
(form.isAdmin || form.libraryIds.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-card" style={{ marginBottom: '1.25rem' }}>
|
<div className="settings-card" style={{ marginBottom: '1.25rem' }}>
|
||||||
@@ -364,6 +380,54 @@ function UserForm({
|
|||||||
<Shield size={14} />
|
<Shield size={14} />
|
||||||
{t('settings.userMgmtRoleAdmin')}
|
{t('settings.userMgmtRoleAdmin')}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ fontSize: 13, marginBottom: 6, display: 'block' }}>
|
||||||
|
{t('settings.userMgmtLibraries')}
|
||||||
|
</label>
|
||||||
|
{form.isAdmin ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('settings.userMgmtLibrariesAdminHint')}
|
||||||
|
</div>
|
||||||
|
) : libraries.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('settings.userMgmtLibrariesEmpty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
maxHeight: 180,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: `1px solid ${form.libraryIds.length === 0 ? 'var(--danger)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{libraries.map(lib => (
|
||||||
|
<label
|
||||||
|
key={lib.id}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', padding: '2px 0' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.libraryIds.includes(lib.id)}
|
||||||
|
onChange={() => toggleLib(lib.id)}
|
||||||
|
/>
|
||||||
|
{lib.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{form.libraryIds.length === 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 4 }}>
|
||||||
|
{t('settings.userMgmtLibrariesValidation')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
<button className="btn btn-ghost" onClick={onCancel} disabled={busy}>
|
<button className="btn btn-ghost" onClick={onCancel} disabled={busy}>
|
||||||
{t('settings.userMgmtCancel')}
|
{t('settings.userMgmtCancel')}
|
||||||
@@ -391,17 +455,23 @@ function UserManagementSection({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [users, setUsers] = useState<NdUser[]>([]);
|
const [users, setUsers] = useState<NdUser[]>([]);
|
||||||
|
const [libraries, setLibraries] = useState<NdLibrary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [editing, setEditing] = useState<NdUser | 'new' | null>(null);
|
const [editing, setEditing] = useState<NdUser | 'new' | null>(null);
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState<NdUser | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const list = await ndListUsers(serverUrl, token);
|
const [list, libs] = await Promise.all([
|
||||||
|
ndListUsers(serverUrl, token),
|
||||||
|
ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]),
|
||||||
|
]);
|
||||||
setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName)));
|
setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName)));
|
||||||
|
setLibraries([...libs].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = (e instanceof Error && e.message) ? e.message : t('settings.userMgmtLoadError');
|
const msg = (e instanceof Error && e.message) ? e.message : t('settings.userMgmtLoadError');
|
||||||
setLoadError(msg);
|
setLoadError(msg);
|
||||||
@@ -420,19 +490,36 @@ function UserManagementSection({
|
|||||||
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
|
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
|
||||||
|
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
let targetId: string;
|
||||||
if (editing === 'new') {
|
if (editing === 'new') {
|
||||||
await ndCreateUser(serverUrl, token, {
|
const created = await ndCreateUser(serverUrl, token, {
|
||||||
userName, name, email, password: form.password, isAdmin: form.isAdmin,
|
userName, name, email, password: form.password, isAdmin: form.isAdmin,
|
||||||
});
|
});
|
||||||
|
targetId = created.id;
|
||||||
showToast(t('settings.userMgmtCreated'), 3000, 'info');
|
showToast(t('settings.userMgmtCreated'), 3000, 'info');
|
||||||
} else if (editing) {
|
} else if (editing) {
|
||||||
await ndUpdateUser(serverUrl, token, editing.id, {
|
await ndUpdateUser(serverUrl, token, editing.id, {
|
||||||
userName, name, email, password: form.password, isAdmin: form.isAdmin,
|
userName, name, email, password: form.password, isAdmin: form.isAdmin,
|
||||||
});
|
});
|
||||||
|
targetId = editing.id;
|
||||||
showToast(t('settings.userMgmtUpdated'), 3000, 'info');
|
showToast(t('settings.userMgmtUpdated'), 3000, 'info');
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.isAdmin && form.libraryIds.length > 0) {
|
||||||
|
try {
|
||||||
|
await ndSetUserLibraries(serverUrl, token, targetId, form.libraryIds);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e instanceof Error && e.message) ? e.message : String(e);
|
||||||
|
showToast(`${t('settings.userMgmtLibrariesUpdateError')}: ${msg}`, 5000, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
@@ -447,9 +534,9 @@ function UserManagementSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (u: NdUser) => {
|
const performDelete = async (u: NdUser) => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
if (!confirm(t('settings.userMgmtConfirmDelete', { username: u.userName }))) return;
|
setConfirmingDelete(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await ndDeleteUser(serverUrl, token, u.id);
|
await ndDeleteUser(serverUrl, token, u.id);
|
||||||
@@ -491,6 +578,7 @@ function UserManagementSection({
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<UserForm
|
<UserForm
|
||||||
initial={editing === 'new' ? null : editing}
|
initial={editing === 'new' ? null : editing}
|
||||||
|
libraries={libraries}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onCancel={() => setEditing(null)}
|
onCancel={() => setEditing(null)}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -511,64 +599,68 @@ function UserManagementSection({
|
|||||||
{t('settings.userMgmtEmpty')}
|
{t('settings.userMgmtEmpty')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{users.map(u => {
|
{users.map(u => {
|
||||||
const isSelf = u.userName === currentUsername;
|
const isSelf = u.userName === currentUsername;
|
||||||
|
const libNames = u.isAdmin
|
||||||
|
? null
|
||||||
|
: u.libraryIds.length === 0
|
||||||
|
? t('settings.userMgmtNoLibraries')
|
||||||
|
: libraries.filter(l => u.libraryIds.includes(l.id)).map(l => l.name).join(', ');
|
||||||
return (
|
return (
|
||||||
<div key={u.id} className="settings-card">
|
<div
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
|
key={u.id}
|
||||||
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 10 }}>
|
className="settings-card user-row"
|
||||||
<User size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
role="button"
|
||||||
<div style={{ minWidth: 0 }}>
|
tabIndex={0}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
onClick={() => { if (!busy) setEditing(u); }}
|
||||||
<span style={{ fontWeight: 600 }}>{u.userName}</span>
|
onKeyDown={(e) => {
|
||||||
{u.name && u.name !== u.userName && (
|
if ((e.key === 'Enter' || e.key === ' ') && !busy) {
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>· {u.name}</span>
|
e.preventDefault();
|
||||||
)}
|
setEditing(u);
|
||||||
{isSelf && (
|
}
|
||||||
<span style={{ fontSize: 10, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 10, fontWeight: 600 }}>
|
}}
|
||||||
{t('settings.userMgmtYouBadge')}
|
style={{
|
||||||
</span>
|
padding: '6px 10px',
|
||||||
)}
|
display: 'flex',
|
||||||
{u.isAdmin && (
|
alignItems: 'center',
|
||||||
<span
|
gap: 10,
|
||||||
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)' }}
|
cursor: busy ? 'default' : 'pointer',
|
||||||
data-tooltip={t('settings.userMgmtRoleAdmin')}
|
}}
|
||||||
>
|
>
|
||||||
<Shield size={10} />
|
<User size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
{t('settings.userMgmtAdminBadge')}
|
<span style={{ fontWeight: 600, fontSize: 13, flexShrink: 0 }}>{u.userName}</span>
|
||||||
</span>
|
{u.name && u.name !== u.userName && (
|
||||||
)}
|
<span style={{ fontSize: 12, color: 'var(--text-muted)', flexShrink: 0 }}>· {u.name}</span>
|
||||||
</div>
|
)}
|
||||||
{u.email && (
|
{isSelf && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 10, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 10, fontWeight: 600, flexShrink: 0 }}>
|
||||||
{u.email}
|
{t('settings.userMgmtYouBadge')}
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
{u.isAdmin && (
|
||||||
</div>
|
<span
|
||||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
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)', flexShrink: 0 }}
|
||||||
<button
|
data-tooltip={t('settings.userMgmtRoleAdmin')}
|
||||||
className="btn btn-surface"
|
>
|
||||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
<Shield size={10} />
|
||||||
onClick={() => setEditing(u)}
|
{t('settings.userMgmtAdminBadge')}
|
||||||
disabled={busy}
|
</span>
|
||||||
data-tooltip={t('settings.userMgmtEdit')}
|
)}
|
||||||
>
|
{libNames && (
|
||||||
<Pencil size={13} />
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
|
||||||
{t('settings.userMgmtEdit')}
|
{libNames}
|
||||||
</button>
|
</span>
|
||||||
<button
|
)}
|
||||||
className="btn btn-ghost"
|
<button
|
||||||
style={{ color: 'var(--danger)', padding: '4px 8px' }}
|
className="btn btn-ghost"
|
||||||
onClick={() => handleDelete(u)}
|
style={{ color: 'var(--danger)', padding: '2px 6px', marginLeft: 'auto', flexShrink: 0 }}
|
||||||
disabled={busy || isSelf}
|
onClick={(e) => { e.stopPropagation(); setConfirmingDelete(u); }}
|
||||||
data-tooltip={t('settings.userMgmtDelete')}
|
disabled={busy || isSelf}
|
||||||
>
|
data-tooltip={t('settings.userMgmtDelete')}
|
||||||
<Trash2 size={14} />
|
>
|
||||||
</button>
|
<Trash2 size={14} />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -576,6 +668,18 @@ function UserManagementSection({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!confirmingDelete}
|
||||||
|
title={t('settings.userMgmtDelete')}
|
||||||
|
message={confirmingDelete
|
||||||
|
? t('settings.userMgmtConfirmDelete', { username: confirmingDelete.userName })
|
||||||
|
: ''}
|
||||||
|
confirmLabel={t('settings.userMgmtDelete')}
|
||||||
|
cancelLabel={t('settings.userMgmtCancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => { if (confirmingDelete) void performDelete(confirmingDelete); }}
|
||||||
|
onCancel={() => setConfirmingDelete(null)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2454,6 +2454,19 @@
|
|||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-card.user-row {
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
.settings-card.user-row:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border-subtle));
|
||||||
|
}
|
||||||
|
.settings-card.user-row:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-hint {
|
.settings-hint {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user