diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 038b4500..ee52fd46 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -385,6 +385,49 @@ async fn nd_delete_user( 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 { + 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::().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, +) -> 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; /// 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_update_user, nd_delete_user, + nd_list_libraries, + nd_set_user_libraries, search_radio_browser, get_top_radio_stations, fetch_url_bytes, diff --git a/src/api/navidromeAdmin.ts b/src/api/navidromeAdmin.ts index 313db437..3383b061 100644 --- a/src/api/navidromeAdmin.ts +++ b/src/api/navidromeAdmin.ts @@ -1,11 +1,17 @@ import { invoke } from '@tauri-apps/api/core'; +export interface NdLibrary { + id: number; + name: string; +} + export interface NdUser { id: string; userName: string; name: string; email: string; isAdmin: boolean; + libraryIds: number[]; lastLoginAt?: string | null; createdAt?: string; updatedAt?: string; @@ -25,6 +31,18 @@ export async function ndLogin( return invoke('navidrome_login', { serverUrl, username, password }); } +function extractLibraryIds(o: Record): number[] { + const libs = o.libraries; + if (!Array.isArray(libs)) return []; + const ids: number[] = []; + for (const l of libs) { + const id = (l as Record)?.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 { const raw = await invoke('nd_list_users', { serverUrl, token }); if (!Array.isArray(raw)) return []; @@ -36,6 +54,7 @@ export async function ndListUsers(serverUrl: string, token: string): Promise { + const raw = await invoke('nd_list_libraries', { serverUrl, token }); + if (!Array.isArray(raw)) return []; + return raw.map(l => { + const o = l as Record; + 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 { + await invoke('nd_set_user_libraries', { serverUrl, token, id, libraryIds }); +} + 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 }); +): Promise<{ id: string }> { + const raw = await invoke('nd_create_user', { serverUrl, token, ...data }); + const o = (raw as Record | null) ?? {}; + return { id: String(o.id ?? '') }; } export async function ndUpdateUser( diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx new file mode 100644 index 00000000..a6fb2722 --- /dev/null +++ b/src/components/ConfirmModal.tsx @@ -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( +
+
e.stopPropagation()} + style={{ maxWidth: '380px' }} + > + +

{title}

+

+ {message} +

+
+ + +
+
+
, + document.body, + ); +} diff --git a/src/locales/de.ts b/src/locales/de.ts index a104c5a9..57437b6e 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -483,6 +483,12 @@ export const deTranslation = { userMgmtPassword: 'Passwort', userMgmtPasswordEditHint: 'Neues Passwort eingeben, um es zu ändern.', 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', userMgmtCancel: 'Abbrechen', userMgmtDelete: 'Löschen', diff --git a/src/locales/en.ts b/src/locales/en.ts index df559894..85a9d0ad 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -485,6 +485,12 @@ export const enTranslation = { userMgmtPassword: 'Password', userMgmtPasswordEditHint: 'Enter a new password to update it.', 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', userMgmtCancel: 'Cancel', userMgmtDelete: 'Delete', diff --git a/src/locales/es.ts b/src/locales/es.ts index b14e60da..82ca2064 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -476,6 +476,12 @@ export const esTranslation = { userMgmtPassword: 'Contraseña', userMgmtPasswordEditHint: 'Introduce una nueva contraseña para actualizarla.', 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', userMgmtCancel: 'Cancelar', userMgmtDelete: 'Eliminar', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index e5df2dbe..681676fd 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -473,6 +473,12 @@ export const frTranslation = { userMgmtPassword: 'Mot de passe', userMgmtPasswordEditHint: 'Saisir un nouveau mot de passe pour le modifier.', 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', userMgmtCancel: 'Annuler', userMgmtDelete: 'Supprimer', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 9865f36c..5dd1d3e1 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -473,6 +473,12 @@ export const nbTranslation = { userMgmtPassword: 'Passord', userMgmtPasswordEditHint: 'Skriv inn et nytt passord for å oppdatere det.', 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', userMgmtCancel: 'Avbryt', userMgmtDelete: 'Slett', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 4510c5ec..11300ec7 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -472,6 +472,12 @@ export const nlTranslation = { userMgmtPassword: 'Wachtwoord', userMgmtPasswordEditHint: 'Voer een nieuw wachtwoord in om het bij te werken.', 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', userMgmtCancel: 'Annuleren', userMgmtDelete: 'Verwijderen', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 2420adac..626d18ab 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -490,6 +490,12 @@ export const ruTranslation = { userMgmtPassword: 'Пароль', userMgmtPasswordEditHint: 'Введите новый пароль, чтобы изменить его.', userMgmtRoleAdmin: 'Админ', + userMgmtLibraries: 'Библиотеки', + userMgmtLibrariesAdminHint: 'Администраторы автоматически имеют доступ ко всем библиотекам.', + userMgmtLibrariesEmpty: 'На этом сервере нет доступных библиотек.', + userMgmtLibrariesValidation: 'Выберите хотя бы одну библиотеку.', + userMgmtLibrariesUpdateError: 'Пользователь сохранён, но не удалось назначить библиотеки', + userMgmtNoLibraries: 'Библиотеки не назначены', userMgmtSave: 'Сохранить', userMgmtCancel: 'Отмена', userMgmtDelete: 'Удалить', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index bef7b513..77dd75c4 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -468,6 +468,12 @@ export const zhTranslation = { userMgmtPassword: '密码', userMgmtPasswordEditHint: '输入新密码以更新。', userMgmtRoleAdmin: '管理员', + userMgmtLibraries: '音乐库', + userMgmtLibrariesAdminHint: '管理员用户自动拥有所有音乐库的访问权限。', + userMgmtLibrariesEmpty: '此服务器上没有可用的音乐库。', + userMgmtLibrariesValidation: '请至少选择一个音乐库。', + userMgmtLibrariesUpdateError: '用户已保存,但音乐库分配失败', + userMgmtNoLibraries: '未分配音乐库', userMgmtSave: '保存', userMgmtCancel: '取消', userMgmtDelete: '删除', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 706be3db..f5a40999 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -6,7 +6,7 @@ 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, - Users, UserPlus, Pencil, Shield + Users, UserPlus, Shield } from 'lucide-react'; import i18n from '../i18n'; import { exportBackup, importBackup } from '../utils/backup'; @@ -36,10 +36,12 @@ import { ALL_NAV_ITEMS } from '../config/navItems'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser, - type NdUser, + ndListLibraries, ndSetUserLibraries, + type NdUser, type NdLibrary, } from '../api/navidromeAdmin'; import { switchActiveServer } from '../utils/switchActiveServer'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import ConfirmModal from '../components/ConfirmModal'; import { Trans, useTranslation } from 'react-i18next'; import Equalizer from '../components/Equalizer'; import StarRating from '../components/StarRating'; @@ -255,41 +257,55 @@ interface UserFormState { email: string; password: string; 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 { userName: u?.userName ?? '', name: u?.name ?? '', email: u?.email ?? '', password: '', isAdmin: !!u?.isAdmin, + libraryIds: u ? [...u.libraryIds] : defaultIds, }; } function UserForm({ initial, + libraries, onSave, onCancel, busy, }: { initial: NdUser | null; + libraries: NdLibrary[]; onSave: (form: UserFormState) => void; onCancel: () => void; busy: boolean; }) { const { t } = useTranslation(); - const [form, setForm] = useState(() => initialUserFormState(initial ?? undefined)); + const [form, setForm] = useState(() => initialUserFormState(initial ?? undefined, libraries)); const [showPass, setShowPass] = useState(false); const isEdit = !!initial; const set = (k: K, v: UserFormState[K]) => 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 = form.userName.trim().length > 0 && form.name.trim().length > 0 && - form.password.length > 0; + form.password.length > 0 && + (form.isAdmin || form.libraryIds.length > 0); return (
@@ -364,6 +380,54 @@ function UserForm({ {t('settings.userMgmtRoleAdmin')} +
+ + {form.isAdmin ? ( +
+ {t('settings.userMgmtLibrariesAdminHint')} +
+ ) : libraries.length === 0 ? ( +
+ {t('settings.userMgmtLibrariesEmpty')} +
+ ) : ( + <> +
+ {libraries.map(lib => ( + + ))} +
+ {form.libraryIds.length === 0 && ( +
+ {t('settings.userMgmtLibrariesValidation')} +
+ )} + + )} +
) : ( -
+
{users.map(u => { 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 ( -
-
-
- -
-
- {u.userName} - {u.name && u.name !== u.userName && ( - · {u.name} - )} - {isSelf && ( - - {t('settings.userMgmtYouBadge')} - - )} - {u.isAdmin && ( - - - {t('settings.userMgmtAdminBadge')} - - )} -
- {u.email && ( -
- {u.email} -
- )} -
-
-
- - -
-
+
{ if (!busy) setEditing(u); }} + onKeyDown={(e) => { + if ((e.key === 'Enter' || e.key === ' ') && !busy) { + e.preventDefault(); + setEditing(u); + } + }} + style={{ + padding: '6px 10px', + display: 'flex', + alignItems: 'center', + gap: 10, + cursor: busy ? 'default' : 'pointer', + }} + > + + {u.userName} + {u.name && u.name !== u.userName && ( + · {u.name} + )} + {isSelf && ( + + {t('settings.userMgmtYouBadge')} + + )} + {u.isAdmin && ( + + + {t('settings.userMgmtAdminBadge')} + + )} + {libNames && ( + + {libNames} + + )} +
); })} @@ -576,6 +668,18 @@ function UserManagementSection({ )} )} + { if (confirmingDelete) void performDelete(confirmingDelete); }} + onCancel={() => setConfirmingDelete(null)} + /> ); } diff --git a/src/styles/components.css b/src/styles/components.css index 50cd922e..5d24bf4f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -2454,6 +2454,19 @@ 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 { font-size: 0.8rem; padding: 0.5rem 0.75rem;