feat(servers/settings): magic string invites, Navidrome admin share, and add-user validation (#258)

* feat(servers): magic string invites and duplicate-aware labels

Add psysonic1- share payloads (URL, Subsonic credentials, optional server
name). Login and add-server forms decode on input and keep magic field
below standard fields. Navidrome User Management generates strings after
PUT password updates where required. Resolve duplicate display names as
username@host in chrome, settings, and login.

* feat(servers): tighten magic-string import and admin share flow

After a decoded magic string, username is read-only and the password field
shows a fixed-length mask so length is not inferred. Password reveal stays
disabled until saved-server quick connect (login) or reopening the add-
server form. Navidrome admin share UI persists the password via API before
copy and adds a plaintext-handling disclaimer. Locales updated.

* feat(settings): save new Navidrome user and copy magic string

Add a non-admin "Save and get magic string" flow: create the user, assign
libraries when needed, then encode credentials to the clipboard. Reuse
the plaintext-handling disclaimer without the password-update hint meant
for edits. Strings added for all locales.

* fix(settings): tighten Navidrome add-user validation and submit feedback

Require username, display name, and a non-empty password (trimmed) for new
users; gate Save and “save and get magic string” on explicit checks with
toast feedback. Show red borders only after a failed submit, and clear
them when the user fills the fields. When editing, keep an empty password
as “unchanged” and validate identity fields with a dedicated message.
Strings: userMgmtValidationMissingIdentity across locales.
This commit is contained in:
cucadmuh
2026-04-22 01:45:28 +03:00
committed by GitHub
parent b61c168430
commit f6f76723d8
16 changed files with 910 additions and 51 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ import { ConnectionStatus } from '../hooks/useConnectionStatus';
import { useAuthStore, type ServerProfile } from '../store/authStore'; import { useAuthStore, type ServerProfile } from '../store/authStore';
import { switchActiveServer } from '../utils/switchActiveServer'; import { switchActiveServer } from '../utils/switchActiveServer';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import { serverListDisplayLabel } from '../utils/serverDisplayName';
interface Props { interface Props {
status: ConnectionStatus; status: ConnectionStatus;
@@ -123,7 +124,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
{servers.map(srv => { {servers.map(srv => {
const active = srv.id === activeServerId; const active = srv.id === activeServerId;
const busy = switchingId !== null; const busy = switchingId !== null;
const labelText = (srv.name || srv.url).trim() || srv.url; const labelText = serverListDisplayLabel(srv, servers);
return ( return (
<button <button
key={srv.id} key={srv.id}
+8 -2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { serverListDisplayLabel } from '../utils/serverDisplayName';
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
@@ -77,12 +78,17 @@ export function useConnectionStatus() {
}, [check]); }, [check]);
const server = useAuthStore(s => s.getActiveServer()); const server = useAuthStore(s => s.getActiveServer());
const servers = useAuthStore(s => s.servers);
const serverName = useMemo(
() => (server ? serverListDisplayLabel(server, servers) : ''),
[server, servers],
);
return { return {
status, status,
isRetrying, isRetrying,
retry, retry,
isLan: server ? isLanUrl(server.url) : false, isLan: server ? isLanUrl(server.url) : false,
serverName: server?.name ?? '', serverName,
}; };
} }
+16
View File
@@ -351,6 +351,9 @@ export const deTranslation = {
urlRequired: 'Bitte Server-URL eingeben.', urlRequired: 'Bitte Server-URL eingeben.',
savedServers: 'Gespeicherte Server', savedServers: 'Gespeicherte Server',
addNew: 'Oder neuen Server hinzufügen', addNew: 'Oder neuen Server hinzufügen',
orMagicString: 'Oder Magic-String',
magicStringPlaceholder: 'Freigabe-String einfügen (psysonic1-…)',
magicStringInvalid: 'Ungültiger oder nicht lesbarer Magic-String.',
}, },
connection: { connection: {
connected: 'Verbunden', connected: 'Verbunden',
@@ -509,6 +512,19 @@ export const deTranslation = {
userMgmtUpdated: 'Benutzer aktualisiert.', userMgmtUpdated: 'Benutzer aktualisiert.',
userMgmtDeleted: 'Benutzer gelöscht.', userMgmtDeleted: 'Benutzer gelöscht.',
userMgmtValidationMissing: 'Benutzername, Anzeigename und Passwort sind erforderlich.', userMgmtValidationMissing: 'Benutzername, Anzeigename und Passwort sind erforderlich.',
userMgmtValidationMissingIdentity: 'Benutzername und Anzeigename sind erforderlich.',
userMgmtMagicStringGenerate: 'Magic-String erzeugen',
userMgmtSaveAndMagicString: 'Speichern und Magic-String kopieren',
userMgmtMagicStringPasswordNavHint:
'Navidrome speichert dieses Passwort für den Benutzer. Weicht es vom aktuellen ab, wird das Anmeldepasswort auf dem Server aktualisiert.',
userMgmtMagicStringPlaintextWarning:
'Teilen Sie den Magic-String vorsichtig: Er enthält das Passwort im Klartext (Kodierung ist keine Verschlüsselung). Wer die vollständige Zeichenkette hat, kann sich als dieser Benutzer anmelden.',
userMgmtMagicStringCopied: 'Magic-String in die Zwischenablage kopiert.',
userMgmtMagicStringCopyFailed: 'Kopieren in die Zwischenablage fehlgeschlagen.',
userMgmtMagicStringLoginFailed: 'Passwortprüfung fehlgeschlagen — Anmeldedaten konnten nicht verifiziert werden.',
userMgmtMagicStringModalTitle: 'Magic-String erzeugen',
userMgmtMagicStringModalDesc: 'Subsonic-Passwort für „{{username}}" eingeben — es wird in den kopierten Magic-String übernommen.',
userMgmtMagicStringModalConfirm: 'String kopieren',
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.',
+16
View File
@@ -352,6 +352,9 @@ export const enTranslation = {
urlRequired: 'Please enter a server URL.', urlRequired: 'Please enter a server URL.',
savedServers: 'Saved Servers', savedServers: 'Saved Servers',
addNew: 'Or add a new server', addNew: 'Or add a new server',
orMagicString: 'Or magic string',
magicStringPlaceholder: 'Paste a share string (psysonic1-…)',
magicStringInvalid: 'Invalid or unreadable magic string.',
}, },
connection: { connection: {
connected: 'Connected', connected: 'Connected',
@@ -511,6 +514,19 @@ export const enTranslation = {
userMgmtUpdated: 'User updated.', userMgmtUpdated: 'User updated.',
userMgmtDeleted: 'User deleted.', userMgmtDeleted: 'User deleted.',
userMgmtValidationMissing: 'Username, display name and password are required.', userMgmtValidationMissing: 'Username, display name and password are required.',
userMgmtValidationMissingIdentity: 'Username and display name are required.',
userMgmtMagicStringGenerate: 'Generate magic string',
userMgmtSaveAndMagicString: 'Save and get magic string',
userMgmtMagicStringPasswordNavHint:
'Navidrome will save this password for the user. If it differs from the current one, the server will update the login password.',
userMgmtMagicStringPlaintextWarning:
'Share the magic string carefully: it contains an unencrypted password (encoding is not encryption). Anyone with the full string can sign in as this user.',
userMgmtMagicStringCopied: 'Magic string copied to clipboard.',
userMgmtMagicStringCopyFailed: 'Could not copy to clipboard.',
userMgmtMagicStringLoginFailed: 'Password check failed — credentials could not be verified.',
userMgmtMagicStringModalTitle: 'Generate magic string',
userMgmtMagicStringModalDesc: 'Enter the Subsonic password for "{{username}}". It is included in the copied magic string.',
userMgmtMagicStringModalConfirm: 'Copy string',
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.',
+16
View File
@@ -353,6 +353,9 @@ export const esTranslation = {
urlRequired: 'Por favor ingresa una URL de servidor.', urlRequired: 'Por favor ingresa una URL de servidor.',
savedServers: 'Servidores Guardados', savedServers: 'Servidores Guardados',
addNew: 'O agregar un nuevo servidor', addNew: 'O agregar un nuevo servidor',
orMagicString: 'O cadena mágica',
magicStringPlaceholder: 'Pega una cadena de invitación (psysonic1-…)',
magicStringInvalid: 'Cadena mágica no válida o ilegible.',
}, },
connection: { connection: {
connected: 'Conectado', connected: 'Conectado',
@@ -502,6 +505,19 @@ export const esTranslation = {
userMgmtUpdated: 'Usuario actualizado.', userMgmtUpdated: 'Usuario actualizado.',
userMgmtDeleted: 'Usuario eliminado.', userMgmtDeleted: 'Usuario eliminado.',
userMgmtValidationMissing: 'Se requieren nombre de usuario, nombre visible y contraseña.', userMgmtValidationMissing: 'Se requieren nombre de usuario, nombre visible y contraseña.',
userMgmtValidationMissingIdentity: 'Se requieren nombre de usuario y nombre visible.',
userMgmtMagicStringGenerate: 'Generar cadena mágica',
userMgmtSaveAndMagicString: 'Guardar y obtener cadena mágica',
userMgmtMagicStringPasswordNavHint:
'Navidrome guardará esta contraseña para el usuario. Si difiere de la actual, se actualizará la contraseña de acceso en el servidor.',
userMgmtMagicStringPlaintextWarning:
'Comparte la cadena mágica con cuidado: contiene la contraseña sin cifrar (la codificación no es cifrado). Quien tenga la cadena completa puede iniciar sesión como este usuario.',
userMgmtMagicStringCopied: 'Cadena mágica copiada al portapapeles.',
userMgmtMagicStringCopyFailed: 'No se pudo copiar al portapapeles.',
userMgmtMagicStringLoginFailed: 'Falló la comprobación de la contraseña; no se pudieron verificar las credenciales.',
userMgmtMagicStringModalTitle: 'Generar cadena mágica',
userMgmtMagicStringModalDesc: 'Introduce la contraseña Subsonic de «{{username}}». Se incluirá en la cadena mágica copiada.',
userMgmtMagicStringModalConfirm: 'Copiar cadena',
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.',
+16
View File
@@ -351,6 +351,9 @@ export const frTranslation = {
urlRequired: 'Veuillez saisir une URL de serveur.', urlRequired: 'Veuillez saisir une URL de serveur.',
savedServers: 'Serveurs enregistrés', savedServers: 'Serveurs enregistrés',
addNew: 'Ou ajouter un nouveau serveur', addNew: 'Ou ajouter un nouveau serveur',
orMagicString: 'Ou chaîne magique',
magicStringPlaceholder: 'Collez une chaîne de partage (psysonic1-…)',
magicStringInvalid: 'Chaîne magique invalide ou illisible.',
}, },
connection: { connection: {
connected: 'Connecté', connected: 'Connecté',
@@ -499,6 +502,19 @@ export const frTranslation = {
userMgmtUpdated: 'Utilisateur mis à jour.', userMgmtUpdated: 'Utilisateur mis à jour.',
userMgmtDeleted: 'Utilisateur supprimé.', userMgmtDeleted: 'Utilisateur supprimé.',
userMgmtValidationMissing: 'Nom dutilisateur, nom affiché et mot de passe sont requis.', userMgmtValidationMissing: 'Nom dutilisateur, nom affiché et mot de passe sont requis.',
userMgmtValidationMissingIdentity: 'Le nom dutilisateur et le nom affiché sont requis.',
userMgmtMagicStringGenerate: 'Générer la chaîne magique',
userMgmtSaveAndMagicString: 'Enregistrer et obtenir la chaîne magique',
userMgmtMagicStringPasswordNavHint:
'Navidrome enregistrera ce mot de passe pour lutilisateur. Sil diffère de lactuel, le mot de passe de connexion sur le serveur sera mis à jour.',
userMgmtMagicStringPlaintextWarning:
'Partagez la chaîne magique avec prudence : elle contient un mot de passe non chiffré (lencodage nest pas du chiffrement). Quiconque possède la chaîne complète peut se connecter en tant que cet utilisateur.',
userMgmtMagicStringCopied: 'Chaîne magique copiée dans le presse-papiers.',
userMgmtMagicStringCopyFailed: 'Impossible de copier dans le presse-papiers.',
userMgmtMagicStringLoginFailed: 'Échec de la vérification du mot de passe — identifiants non confirmés.',
userMgmtMagicStringModalTitle: 'Générer la chaîne magique',
userMgmtMagicStringModalDesc: 'Saisissez le mot de passe Subsonic de « {{username}} ». Il est inclus dans la chaîne magique copiée.',
userMgmtMagicStringModalConfirm: 'Copier la chaîne',
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.',
+16
View File
@@ -351,6 +351,9 @@ export const nbTranslation = {
urlRequired: 'Vennligst skriv inn en tjeneer-URL.', urlRequired: 'Vennligst skriv inn en tjeneer-URL.',
savedServers: 'Lagrede servere', savedServers: 'Lagrede servere',
addNew: 'Eller legg til en ny tjener', addNew: 'Eller legg til en ny tjener',
orMagicString: 'Eller magic string',
magicStringPlaceholder: 'Lim inn en delingsstreng (psysonic1-…)',
magicStringInvalid: 'Ugyldig eller uleselig magic string.',
}, },
connection: { connection: {
connected: 'Tilkoblet', connected: 'Tilkoblet',
@@ -499,6 +502,19 @@ export const nbTranslation = {
userMgmtUpdated: 'Bruker oppdatert.', userMgmtUpdated: 'Bruker oppdatert.',
userMgmtDeleted: 'Bruker slettet.', userMgmtDeleted: 'Bruker slettet.',
userMgmtValidationMissing: 'Brukernavn, visningsnavn og passord er påkrevd.', userMgmtValidationMissing: 'Brukernavn, visningsnavn og passord er påkrevd.',
userMgmtValidationMissingIdentity: 'Brukernavn og visningsnavn er påkrevd.',
userMgmtMagicStringGenerate: 'Generer magic string',
userMgmtSaveAndMagicString: 'Lagre og hent magic string',
userMgmtMagicStringPasswordNavHint:
'Navidrome lagrer dette passordet for brukeren. Hvis det avviker fra det nåværende, oppdateres innloggingspassordet på serveren.',
userMgmtMagicStringPlaintextWarning:
'Del magic string med varsomhet: den inneholder passordet i klartekst (koding er ikke kryptering). Den som har hele strengen, kan logge inn som denne brukeren.',
userMgmtMagicStringCopied: 'Magic string er kopiert til utklippstavlen.',
userMgmtMagicStringCopyFailed: 'Klarte ikke å kopiere til utklippstavlen.',
userMgmtMagicStringLoginFailed: 'Passordsjekk mislyktes — påloggingsdetaljene kunne ikke verifiseres.',
userMgmtMagicStringModalTitle: 'Generer magic string',
userMgmtMagicStringModalDesc: 'Skriv inn Subsonic-passordet for «{{username}}». Det tas med i den kopierte magic string-en.',
userMgmtMagicStringModalConfirm: 'Kopier streng',
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.',
+16
View File
@@ -350,6 +350,9 @@ export const nlTranslation = {
urlRequired: 'Voer een server-URL in.', urlRequired: 'Voer een server-URL in.',
savedServers: 'Opgeslagen servers', savedServers: 'Opgeslagen servers',
addNew: 'Of een nieuwe server toevoegen', addNew: 'Of een nieuwe server toevoegen',
orMagicString: 'Of magic string',
magicStringPlaceholder: 'Plak een deelstring (psysonic1-…)',
magicStringInvalid: 'Ongeldige of onleesbare magic string.',
}, },
connection: { connection: {
connected: 'Verbonden', connected: 'Verbonden',
@@ -498,6 +501,19 @@ export const nlTranslation = {
userMgmtUpdated: 'Gebruiker bijgewerkt.', userMgmtUpdated: 'Gebruiker bijgewerkt.',
userMgmtDeleted: 'Gebruiker verwijderd.', userMgmtDeleted: 'Gebruiker verwijderd.',
userMgmtValidationMissing: 'Gebruikersnaam, weergavenaam en wachtwoord zijn vereist.', userMgmtValidationMissing: 'Gebruikersnaam, weergavenaam en wachtwoord zijn vereist.',
userMgmtValidationMissingIdentity: 'Gebruikersnaam en weergavenaam zijn vereist.',
userMgmtMagicStringGenerate: 'Magic string genereren',
userMgmtSaveAndMagicString: 'Opslaan en magic string kopiëren',
userMgmtMagicStringPasswordNavHint:
'Navidrome slaat dit wachtwoord voor de gebruiker op. Als het afwijkt van het huidige wachtwoord, wordt het inlogwachtwoord op de server bijgewerkt.',
userMgmtMagicStringPlaintextWarning:
'Wees voorzichtig met delen: de magic string bevat een onversleuteld wachtwoord (codering is geen encryptie). Wie de volledige string heeft, kan als deze gebruiker inloggen.',
userMgmtMagicStringCopied: 'Magic string naar het klembord gekopieerd.',
userMgmtMagicStringCopyFailed: 'Kopiëren naar het klembord is mislukt.',
userMgmtMagicStringLoginFailed: 'Wachtwoordcontrole mislukt — aanmeldgegevens konden niet worden geverifieerd.',
userMgmtMagicStringModalTitle: 'Magic string genereren',
userMgmtMagicStringModalDesc: 'Voer het Subsonic-wachtwoord voor „{{username}}" in. Het wordt opgenomen in de gekopieerde magic string.',
userMgmtMagicStringModalConfirm: 'String kopiëren',
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 artiestpaginas.', '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 artiestpaginas.',
+16
View File
@@ -370,6 +370,9 @@ export const ruTranslation = {
urlRequired: 'Укажите адрес сервера.', urlRequired: 'Укажите адрес сервера.',
savedServers: 'Сохранённые серверы', savedServers: 'Сохранённые серверы',
addNew: 'Или добавить новый сервер', addNew: 'Или добавить новый сервер',
orMagicString: 'Или magic string',
magicStringPlaceholder: 'Вставьте строку приглашения (psysonic1-…)',
magicStringInvalid: 'Неверная или нечитаемая magic string.',
}, },
connection: { connection: {
connected: 'Подключено', connected: 'Подключено',
@@ -522,6 +525,19 @@ export const ruTranslation = {
userMgmtUpdated: 'Пользователь обновлён.', userMgmtUpdated: 'Пользователь обновлён.',
userMgmtDeleted: 'Пользователь удалён.', userMgmtDeleted: 'Пользователь удалён.',
userMgmtValidationMissing: 'Требуются имя пользователя, отображаемое имя и пароль.', userMgmtValidationMissing: 'Требуются имя пользователя, отображаемое имя и пароль.',
userMgmtValidationMissingIdentity: 'Укажите имя пользователя и отображаемое имя.',
userMgmtMagicStringGenerate: 'Сгенерировать magic string',
userMgmtSaveAndMagicString: 'Сохранить и получить magic string',
userMgmtMagicStringPasswordNavHint:
'Navidrome сохранит этот пароль для пользователя. Если он не совпадает с текущим, на сервере будет установлен новый пароль входа.',
userMgmtMagicStringPlaintextWarning:
'Передавайте magic string осторожно: в ней пароль в открытом виде (кодирование — не шифрование). У кого есть полная строка, тот может войти под этим пользователем.',
userMgmtMagicStringCopied: 'Magic string скопирован в буфер обмена.',
userMgmtMagicStringCopyFailed: 'Не удалось скопировать в буфер обмена.',
userMgmtMagicStringLoginFailed: 'Пароль не подошёл — не удалось проверить учётные данные.',
userMgmtMagicStringModalTitle: 'Сгенерировать magic string',
userMgmtMagicStringModalDesc: 'Введите пароль Subsonic для «{{username}}» — он попадёт в копируемую magic string.',
userMgmtMagicStringModalConfirm: 'Скопировать строку',
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.',
+16
View File
@@ -350,6 +350,9 @@ export const zhTranslation = {
urlRequired: '请输入服务器地址。', urlRequired: '请输入服务器地址。',
savedServers: '已保存的服务器', savedServers: '已保存的服务器',
addNew: '或添加新服务器', addNew: '或添加新服务器',
orMagicString: '或使用魔法字符串',
magicStringPlaceholder: '粘贴分享字符串(psysonic1-…)',
magicStringInvalid: '魔法字符串无效或无法解析。',
}, },
connection: { connection: {
connected: '已连接', connected: '已连接',
@@ -494,6 +497,19 @@ export const zhTranslation = {
userMgmtUpdated: '用户已更新。', userMgmtUpdated: '用户已更新。',
userMgmtDeleted: '用户已删除。', userMgmtDeleted: '用户已删除。',
userMgmtValidationMissing: '用户名、显示名称和密码均为必填项。', userMgmtValidationMissing: '用户名、显示名称和密码均为必填项。',
userMgmtValidationMissingIdentity: '用户名和显示名称为必填项。',
userMgmtMagicStringGenerate: '生成魔法字符串',
userMgmtSaveAndMagicString: '保存并获取魔法字符串',
userMgmtMagicStringPasswordNavHint:
'Navidrome 会保存此密码。若与当前密码不同,服务器上的登录密码将被更新。',
userMgmtMagicStringPlaintextWarning:
'请谨慎分享魔法字符串:其中包含未加密的密码(编码不等于加密)。掌握完整字符串的人可以以该用户身份登录。',
userMgmtMagicStringCopied: '魔法字符串已复制到剪贴板。',
userMgmtMagicStringCopyFailed: '无法复制到剪贴板。',
userMgmtMagicStringLoginFailed: '密码验证失败,无法确认凭据。',
userMgmtMagicStringModalTitle: '生成魔法字符串',
userMgmtMagicStringModalDesc: '请输入用户「{{username}}」的 Subsonic 密码,它会包含在复制的魔法字符串中。',
userMgmtMagicStringModalConfirm: '复制字符串',
audiomuseTitle: 'AudioMuse-AINavidrome', audiomuseTitle: 'AudioMuse-AINavidrome',
audiomuseDesc: audiomuseDesc:
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。', '若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
+92 -19
View File
@@ -4,6 +4,8 @@ import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { decodeServerMagicString, DECODED_PASSWORD_VISUAL_MASK } from '../utils/serverMagicString';
import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName';
const PsysonicLogo = () => ( const PsysonicLogo = () => (
<img src="/logo-psysonic.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} /> <img src="/logo-psysonic.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
@@ -15,13 +17,37 @@ export default function Login() {
const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore(); const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore();
const [form, setForm] = useState({ serverName: '', url: '', username: '', password: '' }); const [form, setForm] = useState({ serverName: '', url: '', username: '', password: '' });
const [magicString, setMagicString] = useState('');
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
/** After a valid magic string decode, do not allow revealing the password in the UI. */
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [testMessage, setTestMessage] = useState(''); const [testMessage, setTestMessage] = useState('');
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [k]: e.target.value })); setForm(f => ({ ...f, [k]: e.target.value }));
const handleMagicStringChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setMagicString(v);
const trimmed = v.trim();
const decoded = decodeServerMagicString(trimmed);
if (decoded) {
setShowPass(false);
setBlockPasswordReveal(true);
setForm({
serverName: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
});
if (status === 'error') {
setStatus('idle');
setTestMessage('');
}
}
};
const attemptConnect = async (profile: { name: string; url: string; username: string; password: string }) => { const attemptConnect = async (profile: { name: string; url: string; username: string; password: string }) => {
if (!profile.url.trim()) { if (!profile.url.trim()) {
setTestMessage(t('login.urlRequired')); setTestMessage(t('login.urlRequired'));
@@ -90,10 +116,29 @@ export default function Login() {
const handleFormSubmit = async (e: React.FormEvent) => { const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const ms = magicString.trim();
if (ms) {
const decoded = decodeServerMagicString(ms);
if (!decoded) {
setStatus('error');
setTestMessage(t('login.magicStringInvalid'));
return;
}
await attemptConnect({
name: form.serverName.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
});
return;
}
await attemptConnect({ name: form.serverName, url: form.url, username: form.username, password: form.password }); await attemptConnect({ name: form.serverName, url: form.url, username: form.username, password: form.password });
}; };
const handleQuickConnect = async (srv: typeof servers[0]) => { const handleQuickConnect = async (srv: typeof servers[0]) => {
setMagicString('');
setBlockPasswordReveal(false);
setShowPass(false);
setForm({ serverName: srv.name, url: srv.url, username: srv.username, password: srv.password }); setForm({ serverName: srv.name, url: srv.url, username: srv.username, password: srv.password });
await attemptConnect({ name: srv.name, url: srv.url, username: srv.username, password: srv.password }); await attemptConnect({ name: srv.name, url: srv.url, username: srv.username, password: srv.password });
}; };
@@ -121,7 +166,7 @@ export default function Login() {
> >
<Server size={14} style={{ flexShrink: 0 }} /> <Server size={14} style={{ flexShrink: 0 }} />
<div style={{ textAlign: 'left', minWidth: 0 }}> <div style={{ textAlign: 'left', minWidth: 0 }}>
<div style={{ fontWeight: 600 }} className="truncate">{srv.name || srv.url}</div> <div style={{ fontWeight: 600 }} className="truncate">{serverListDisplayLabel(srv, servers)}</div>
<div style={{ fontSize: 11, opacity: 0.7 }} className="truncate">{srv.username}@{srv.url}</div> <div style={{ fontSize: 11, opacity: 0.7 }} className="truncate">{srv.username}@{srv.url}</div>
</div> </div>
</button> </button>
@@ -167,34 +212,62 @@ export default function Login() {
placeholder={t('login.usernamePlaceholder')} placeholder={t('login.usernamePlaceholder')}
value={form.username} value={form.username}
onChange={update('username')} onChange={update('username')}
readOnly={blockPasswordReveal}
autoComplete="username" autoComplete="username"
style={blockPasswordReveal ? { cursor: 'default' } : undefined}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="login-password">{t('login.password')}</label> <label htmlFor={blockPasswordReveal ? 'login-password-mask' : 'login-password'}>{t('login.password')}</label>
<div style={{ position: 'relative' }}> {blockPasswordReveal ? (
<input <input
id="login-password" id="login-password-mask"
className="input" className="input"
type={showPass ? 'text' : 'password'} type="text"
placeholder="••••••••" readOnly
value={form.password} value={DECODED_PASSWORD_VISUAL_MASK}
onChange={update('password')} autoComplete="off"
autoComplete="current-password" aria-label={t('login.password')}
style={{ paddingRight: '2.5rem' }} style={{ letterSpacing: '0.12em', cursor: 'default' }}
/> />
<button ) : (
type="button" <div style={{ position: 'relative' }}>
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} <input
onClick={() => setShowPass(v => !v)} id="login-password"
aria-label={showPass ? t('login.hidePassword') : t('login.showPassword')} className="input"
> type={showPass ? 'text' : 'password'}
{showPass ? <EyeOff size={16} /> : <Eye size={16} />} placeholder="••••••••"
</button> value={form.password}
</div> onChange={update('password')}
autoComplete="current-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)}
aria-label={showPass ? t('login.hidePassword') : t('login.showPassword')}
>
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
</div> </div>
</div> </div>
<div className="form-group">
<label htmlFor="login-magic-string">{t('login.orMagicString')}</label>
<input
id="login-magic-string"
className="input"
type="text"
placeholder={t('login.magicStringPlaceholder')}
value={magicString}
onChange={handleMagicStringChange}
autoComplete="off"
/>
</div>
{testMessage && ( {testMessage && (
<div className={`login-status login-status--${status}`} role="alert"> <div className={`login-status login-status--${status}`} role="alert">
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />} {status === 'testing' && <div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />}
+475 -29
View File
@@ -1,4 +1,5 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { version as appVersion } from '../../package.json'; import { version as appVersion } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw'; import changelogRaw from '../../CHANGELOG.md?raw';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
@@ -6,7 +7,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, Shield Users, UserPlus, Shield, Wand2
} 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';
@@ -47,6 +48,13 @@ 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';
import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity'; import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity';
import {
decodeServerMagicString,
encodeServerMagicString,
copyTextToClipboard,
DECODED_PASSWORD_VISUAL_MASK,
} from '../utils/serverMagicString';
import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
@@ -197,11 +205,55 @@ type Tab = 'general' | 'server' | 'users' | 'audio' | 'storage' | 'appearance' |
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();
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
const [magicString, setMagicString] = useState('');
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [k]: e.target.value })); setForm(f => ({ ...f, [k]: e.target.value }));
const handleMagicStringChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setMagicString(v);
const trimmed = v.trim();
const decoded = decodeServerMagicString(trimmed);
if (decoded) {
setShowPass(false);
setBlockPasswordReveal(true);
setForm({
name: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
});
}
};
const submit = () => {
const ms = magicString.trim();
if (ms) {
const decoded = decodeServerMagicString(ms);
if (!decoded) {
showToast(t('login.magicStringInvalid'), 4000, 'error');
return;
}
onSave({
name: form.name.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
});
return;
}
if (!form.url.trim()) return;
onSave({
name: form.name.trim() || form.url.trim(),
url: form.url.trim(),
username: form.username.trim(),
password: form.password,
});
};
return ( return (
<div className="settings-card" style={{ marginTop: '1rem' }}> <div className="settings-card" style={{ marginTop: '1rem' }}>
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>{t('settings.addServerTitle')}</h3> <h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>{t('settings.addServerTitle')}</h3>
@@ -216,34 +268,66 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
<div className="form-row" style={{ marginBottom: '0.75rem' }}> <div className="form-row" style={{ marginBottom: '0.75rem' }}>
<div className="form-group"> <div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.serverUsername')}</label> <label style={{ fontSize: 13 }}>{t('settings.serverUsername')}</label>
<input className="input" type="text" value={form.username} onChange={update('username')} placeholder="admin" autoComplete="off" /> <input
className="input"
type="text"
value={form.username}
onChange={update('username')}
placeholder="admin"
autoComplete="off"
readOnly={blockPasswordReveal}
style={blockPasswordReveal ? { cursor: 'default' } : undefined}
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.serverPassword')}</label> <label style={{ fontSize: 13 }}>{t('settings.serverPassword')}</label>
<div style={{ position: 'relative' }}> {blockPasswordReveal ? (
<input <input
className="input" className="input"
type={showPass ? 'text' : 'password'} type="text"
value={form.password} readOnly
onChange={update('password')} value={DECODED_PASSWORD_VISUAL_MASK}
placeholder="••••••••" autoComplete="off"
style={{ paddingRight: '2.5rem' }} aria-label={t('settings.serverPassword')}
style={{ letterSpacing: '0.12em', cursor: 'default' }}
/> />
<button ) : (
type="button" <div style={{ position: 'relative' }}>
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} <input
onClick={() => setShowPass(v => !v)} className="input"
> type={showPass ? 'text' : 'password'}
{showPass ? <EyeOff size={14} /> : <Eye size={14} />} value={form.password}
</button> onChange={update('password')}
</div> placeholder="••••••••"
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>
)}
</div> </div>
</div> </div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
<input
className="input"
type="text"
value={magicString}
onChange={handleMagicStringChange}
placeholder={t('login.magicStringPlaceholder')}
autoComplete="off"
/>
</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}>{t('common.cancel')}</button> <button className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => form.url.trim() && onSave({ name: form.name.trim() || form.url.trim(), url: form.url.trim(), username: form.username.trim(), password: form.password })} onClick={submit}
> >
{t('common.add')} {t('common.add')}
</button> </button>
@@ -276,21 +360,42 @@ function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]):
function UserForm({ function UserForm({
initial, initial,
libraries, libraries,
shareServerUrl,
ndToken,
onUsersDirty,
onSave, onSave,
onSaveAndGetMagic,
onCancel, onCancel,
busy, busy,
}: { }: {
initial: NdUser | null; initial: NdUser | null;
libraries: NdLibrary[]; libraries: NdLibrary[];
shareServerUrl: string;
ndToken: string;
onUsersDirty?: () => void | Promise<void>;
onSave: (form: UserFormState) => void; onSave: (form: UserFormState) => void;
/** New user only: create on Navidrome then copy magic string to clipboard. */
onSaveAndGetMagic?: (form: UserFormState) => void | Promise<void>;
onCancel: () => void; onCancel: () => void;
busy: boolean; busy: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form, setForm] = useState<UserFormState>(() => initialUserFormState(initial ?? undefined, libraries)); const [form, setForm] = useState<UserFormState>(() => initialUserFormState(initial ?? undefined, libraries));
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
const [magicGenBusy, setMagicGenBusy] = useState(false);
const [showNewUserRequiredErrors, setShowNewUserRequiredErrors] = useState(false);
const isEdit = !!initial; const isEdit = !!initial;
useEffect(() => {
setShowNewUserRequiredErrors(false);
}, [initial?.id]);
useEffect(() => {
if (!isEdit && form.userName.trim() && form.name.trim() && form.password.trim()) {
setShowNewUserRequiredErrors(false);
}
}, [isEdit, form.userName, form.name, form.password]);
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 }));
@@ -302,12 +407,79 @@ function UserForm({
: [...f.libraryIds, id], : [...f.libraryIds, id],
})); }));
const newUserPasswordOk = form.password.trim().length > 0;
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 && (isEdit || newUserPasswordOk) &&
(form.isAdmin || form.libraryIds.length > 0); (form.isAdmin || form.libraryIds.length > 0);
const generateMagicString = async () => {
if (!shareServerUrl.trim() || !form.password.trim() || !initial || !ndToken.trim()) return;
setMagicGenBusy(true);
try {
await ndUpdateUser(shareServerUrl.trim(), ndToken, initial.id, {
userName: form.userName.trim(),
name: form.name.trim(),
email: form.email.trim(),
password: form.password,
isAdmin: form.isAdmin,
});
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
showToast(msg ?? t('settings.userMgmtUpdateError'), 5000, 'error');
return;
} finally {
setMagicGenBusy(false);
}
const str = encodeServerMagicString({
url: shareServerUrl.trim(),
username: form.userName.trim(),
password: form.password,
name: shortHostFromServerUrl(shareServerUrl),
});
const ok = await copyTextToClipboard(str);
showToast(
ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'),
ok ? 3000 : 5000,
ok ? 'info' : 'error',
);
if (ok) void onUsersDirty?.();
};
const runSaveAndGetMagic = async () => {
if (!onSaveAndGetMagic) return;
if (!form.userName.trim() || !form.name.trim() || !form.password.trim()) {
setShowNewUserRequiredErrors(true);
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
return;
}
setMagicGenBusy(true);
try {
await onSaveAndGetMagic(form);
} finally {
setMagicGenBusy(false);
}
};
const invalidNewUserCore =
!isEdit && (!form.userName.trim() || !form.name.trim() || !form.password.trim());
const trySave = () => {
if (invalidNewUserCore) {
setShowNewUserRequiredErrors(true);
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
onSave(form);
};
const markInvalid = showNewUserRequiredErrors && !isEdit;
return ( return (
<div className="settings-card" style={{ marginBottom: '1.25rem' }}> <div className="settings-card" style={{ marginBottom: '1.25rem' }}>
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}> <h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>
@@ -315,7 +487,10 @@ function UserForm({
</h3> </h3>
<div className="form-row" style={{ marginBottom: '0.75rem' }}> <div className="form-row" style={{ marginBottom: '0.75rem' }}>
<div className="form-group"> <div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.userMgmtUsername')}</label> <label style={{ fontSize: 13 }}>
{t('settings.userMgmtUsername')}
{!isEdit && <span style={{ color: 'var(--text-muted)' }}> *</span>}
</label>
<input <input
className="input" className="input"
type="text" type="text"
@@ -323,16 +498,23 @@ function UserForm({
onChange={e => set('userName', e.target.value)} onChange={e => set('userName', e.target.value)}
disabled={isEdit} disabled={isEdit}
autoComplete="off" autoComplete="off"
aria-invalid={markInvalid && !form.userName.trim()}
style={markInvalid && !form.userName.trim() ? { borderColor: 'var(--danger)' } : undefined}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.userMgmtName')}</label> <label style={{ fontSize: 13 }}>
{t('settings.userMgmtName')}
{!isEdit && <span style={{ color: 'var(--text-muted)' }}> *</span>}
</label>
<input <input
className="input" className="input"
type="text" type="text"
value={form.name} value={form.name}
onChange={e => set('name', e.target.value)} onChange={e => set('name', e.target.value)}
autoComplete="off" autoComplete="off"
aria-invalid={markInvalid && !form.name.trim()}
style={markInvalid && !form.name.trim() ? { borderColor: 'var(--danger)' } : undefined}
/> />
</div> </div>
</div> </div>
@@ -347,7 +529,10 @@ function UserForm({
/> />
</div> </div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}> <div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.userMgmtPassword')}</label> <label style={{ fontSize: 13 }}>
{t('settings.userMgmtPassword')}
{!isEdit && <span style={{ color: 'var(--text-muted)' }}> *</span>}
</label>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<input <input
className="input" className="input"
@@ -356,7 +541,11 @@ function UserForm({
onChange={e => set('password', e.target.value)} onChange={e => set('password', e.target.value)}
placeholder="••••••••" placeholder="••••••••"
autoComplete="new-password" autoComplete="new-password"
style={{ paddingRight: '2.5rem' }} aria-invalid={markInvalid && !form.password.trim()}
style={{
paddingRight: '2.5rem',
...(markInvalid && !form.password.trim() ? { borderColor: 'var(--danger)' } : {}),
}}
/> />
<button <button
type="button" type="button"
@@ -429,14 +618,75 @@ function UserForm({
</> </>
)} )}
</div> </div>
{!form.isAdmin && !isEdit && onSaveAndGetMagic && shareServerUrl.trim() && ndToken.trim() && (
<div style={{ marginBottom: '1rem' }}>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
<button
type="button"
className="btn btn-surface"
onClick={() => void runSaveAndGetMagic()}
disabled={busy || magicGenBusy}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<Wand2 size={16} />
{t('settings.userMgmtSaveAndMagicString')}
</button>
</div>
)}
{!form.isAdmin && isEdit && shareServerUrl.trim() && form.password.trim().length > 0 && ndToken.trim() && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8, lineHeight: 1.45 }}>
{t('settings.userMgmtMagicStringPasswordNavHint')}
</div>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
<button
type="button"
className="btn btn-surface"
onClick={() => void generateMagicString()}
disabled={busy || magicGenBusy}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<Wand2 size={16} />
{t('settings.userMgmtMagicStringGenerate')}
</button>
</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')}
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => onSave(form)} onClick={() => trySave()}
disabled={busy || !canSave} disabled={busy || (isEdit && !canSave)}
> >
{t('settings.userMgmtSave')} {t('settings.userMgmtSave')}
</button> </button>
@@ -479,6 +729,9 @@ function UserManagementSection({
const [editing, setEditing] = useState<NdUser | 'new' | null>(null); const [editing, setEditing] = useState<NdUser | 'new' | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState<NdUser | null>(null); const [confirmingDelete, setConfirmingDelete] = useState<NdUser | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [magicRowUser, setMagicRowUser] = useState<NdUser | null>(null);
const [magicRowPassword, setMagicRowPassword] = useState('');
const [magicRowSubmitting, setMagicRowSubmitting] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -504,9 +757,16 @@ function UserManagementSection({
const userName = form.userName.trim(); const userName = form.userName.trim();
const name = form.name.trim(); const name = form.name.trim();
const email = form.email.trim(); const email = form.email.trim();
if (!userName || !name || !form.password) { if (editing === 'new') {
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error'); if (!userName || !name || !form.password.trim()) {
return; showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
} else if (editing) {
if (!userName || !name) {
showToast(t('settings.userMgmtValidationMissingIdentity'), 4000, 'error');
return;
}
} }
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) { if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error'); showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
@@ -552,6 +812,57 @@ function UserManagementSection({
} }
}; };
const handleSaveAndGetMagic = async (form: UserFormState) => {
if (editing !== 'new' || form.isAdmin) return;
const userName = form.userName.trim();
const name = form.name.trim();
const email = form.email.trim();
if (!userName || !name || !form.password.trim()) {
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
return;
}
if (!token) return;
setBusy(true);
try {
const created = await ndCreateUser(serverUrl, token, {
userName, name, email, password: form.password, isAdmin: form.isAdmin,
});
const targetId = created.id;
showToast(t('settings.userMgmtCreated'), 3000, 'info');
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');
}
}
const str = encodeServerMagicString({
url: serverUrl.trim(),
username: userName,
password: form.password,
name: shortHostFromServerUrl(serverUrl),
});
const ok = await copyTextToClipboard(str);
showToast(
ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'),
ok ? 3000 : 5000,
ok ? 'info' : 'error',
);
setEditing(null);
await load();
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
showToast(msg ?? t('settings.userMgmtCreateError'), 5000, 'error');
} finally {
setBusy(false);
}
};
const performDelete = async (u: NdUser) => { const performDelete = async (u: NdUser) => {
if (!token) return; if (!token) return;
setConfirmingDelete(null); setConfirmingDelete(null);
@@ -597,7 +908,11 @@ function UserManagementSection({
<UserForm <UserForm
initial={editing === 'new' ? null : editing} initial={editing === 'new' ? null : editing}
libraries={libraries} libraries={libraries}
shareServerUrl={serverUrl}
ndToken={token}
onUsersDirty={load}
onSave={handleSave} onSave={handleSave}
onSaveAndGetMagic={editing === 'new' ? handleSaveAndGetMagic : undefined}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
busy={busy} busy={busy}
/> />
@@ -674,6 +989,22 @@ function UserManagementSection({
{libNames} {libNames}
</span> </span>
)} )}
{!u.isAdmin && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: '2px 6px', flexShrink: 0, marginLeft: libNames ? 0 : 'auto' }}
onClick={(e) => {
e.stopPropagation();
setMagicRowUser(u);
setMagicRowPassword('');
}}
disabled={busy}
data-tooltip={t('settings.userMgmtMagicStringGenerate')}
>
<Wand2 size={14} />
</button>
)}
<span <span
style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: libNames ? 0 : 'auto' }} style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: libNames ? 0 : 'auto' }}
data-tooltip={lastSeenAbsolute || undefined} data-tooltip={lastSeenAbsolute || undefined}
@@ -708,6 +1039,121 @@ function UserManagementSection({
onConfirm={() => { if (confirmingDelete) void performDelete(confirmingDelete); }} onConfirm={() => { if (confirmingDelete) void performDelete(confirmingDelete); }}
onCancel={() => setConfirmingDelete(null)} onCancel={() => setConfirmingDelete(null)}
/> />
{magicRowUser && createPortal(
<div
className="modal-overlay"
onClick={() => !magicRowSubmitting && setMagicRowUser(null)}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '400px' }}
>
<button
type="button"
className="modal-close"
onClick={() => !magicRowSubmitting && setMagicRowUser(null)}
aria-label={t('settings.userMgmtCancel')}
>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.userMgmtMagicStringModalTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5, fontSize: 13 }}>
{t('settings.userMgmtMagicStringModalDesc', { username: magicRowUser.userName })}
</p>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.45, fontSize: 12 }}>
{t('settings.userMgmtMagicStringPasswordNavHint')}
</p>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: '1rem',
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
<div className="form-group" style={{ marginBottom: '1.25rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.userMgmtPassword')}</label>
<input
className="input"
type="password"
value={magicRowPassword}
onChange={e => setMagicRowPassword(e.target.value)}
autoComplete="off"
disabled={magicRowSubmitting}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => !magicRowSubmitting && setMagicRowUser(null)}
disabled={magicRowSubmitting}
>
{t('settings.userMgmtCancel')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={!magicRowPassword.trim() || magicRowSubmitting}
onClick={() => {
if (!magicRowUser || !magicRowPassword.trim() || !token) return;
void (async () => {
setMagicRowSubmitting(true);
try {
await ndUpdateUser(serverUrl, token, magicRowUser.id, {
userName: magicRowUser.userName,
name: magicRowUser.name,
email: magicRowUser.email,
password: magicRowPassword.trim(),
isAdmin: magicRowUser.isAdmin,
});
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
showToast(msg ?? t('settings.userMgmtUpdateError'), 5000, 'error');
return;
} finally {
setMagicRowSubmitting(false);
}
const str = encodeServerMagicString({
url: serverUrl,
username: magicRowUser.userName,
password: magicRowPassword.trim(),
name: shortHostFromServerUrl(serverUrl),
});
const ok = await copyTextToClipboard(str);
showToast(
ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'),
ok ? 3000 : 5000,
ok ? 'info' : 'error',
);
if (ok) {
setMagicRowUser(null);
setMagicRowPassword('');
await load();
}
})();
}}
>
{t('settings.userMgmtMagicStringModalConfirm')}
</button>
</div>
</div>
</div>,
document.body,
)}
</section> </section>
); );
} }
@@ -1099,7 +1545,7 @@ export default function Settings() {
}; };
const deleteServer = (server: ServerProfile) => { const deleteServer = (server: ServerProfile) => {
if (confirm(t('settings.confirmDeleteServer', { name: server.name || server.url }))) { if (confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) {
auth.removeServer(server.id); auth.removeServer(server.id);
} }
}; };
@@ -2779,7 +3225,7 @@ export default function Settings() {
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
<span style={{ fontWeight: 600 }}>{srv.name || srv.url}</span> <span style={{ fontWeight: 600 }}>{serverListDisplayLabel(srv, auth.servers)}</span>
{isActive && ( {isActive && (
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}> <span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}>
{t('settings.serverActive')} {t('settings.serverActive')}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { ServerProfile } from '../store/authStore';
import { serverListDisplayLabel, shortHostFromServerUrl } from './serverDisplayName';
function srv(p: Partial<ServerProfile> & Pick<ServerProfile, 'id'>): ServerProfile {
return {
name: '',
url: 'https://example.com',
username: 'u',
password: 'p',
...p,
};
}
describe('shortHostFromServerUrl', () => {
it('strips https and path', () => {
expect(shortHostFromServerUrl('https://music.one.com/v1')).toBe('music.one.com');
});
it('keeps port', () => {
expect(shortHostFromServerUrl('http://127.0.0.1:4533')).toBe('127.0.0.1:4533');
});
});
describe('serverListDisplayLabel', () => {
it('uses short host when name empty', () => {
const a = srv({ id: '1', url: 'https://a.com', username: 'u', password: 'p', name: '' });
expect(serverListDisplayLabel(a, [a])).toBe('a.com');
});
it('disambiguates duplicate names', () => {
const a = srv({ id: '1', url: 'https://music.one.com', username: 'alice', password: 'p', name: 'Home' });
const b = srv({ id: '2', url: 'https://other.net', username: 'bob', password: 'p', name: 'Home' });
const all = [a, b];
expect(serverListDisplayLabel(a, all)).toBe('alice@music.one.com');
expect(serverListDisplayLabel(b, all)).toBe('bob@other.net');
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { ServerProfile } from '../store/authStore';
/** Host (+ port) from a server base URL, e.g. `https://music.one.com/foo` → `music.one.com`. */
export function shortHostFromServerUrl(urlRaw: string): string {
const t = urlRaw.trim();
if (!t) return '';
try {
const u = new URL(t.includes('://') ? t : `https://${t}`);
return u.host;
} catch {
return t
.replace(/^https?:\/\//i, '')
.split('/')[0]
?.split('?')[0]
?.trim() ?? t;
}
}
/**
* Label for server lists and chrome: if several servers share the same effective name,
* show `username@host` so entries stay distinguishable.
*/
export function serverListDisplayLabel(server: ServerProfile, all: ServerProfile[]): string {
const nameTrim = (server.name || '').trim();
const shortHost = shortHostFromServerUrl(server.url);
const key = nameTrim || shortHost;
const collisions = all.filter(s => {
const nt = (s.name || '').trim();
const sh = shortHostFromServerUrl(s.url);
return (nt || sh) === key;
});
if (collisions.length < 2) {
return nameTrim || shortHost || server.url.trim();
}
return `${server.username}@${shortHost}`;
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import {
SERVER_MAGIC_STRING_PREFIX,
DECODED_PASSWORD_VISUAL_MASK,
decodeServerMagicString,
encodeServerMagicString,
} from './serverMagicString';
describe('DECODED_PASSWORD_VISUAL_MASK', () => {
it('has fixed length independent of real passwords', () => {
expect(DECODED_PASSWORD_VISUAL_MASK.length).toBe(10);
});
});
describe('serverMagicString', () => {
it('round-trips url, username, password', () => {
const original = {
url: 'https://music.example.com',
username: 'alice',
password: 's3cret!',
};
const encoded = encodeServerMagicString(original);
expect(encoded.startsWith(SERVER_MAGIC_STRING_PREFIX)).toBe(true);
expect(decodeServerMagicString(encoded)).toEqual(original);
});
it('round-trips optional name', () => {
const original = {
url: 'http://127.0.0.1:4533',
username: 'bob',
password: 'x',
name: 'Home',
};
const encoded = encodeServerMagicString(original);
expect(decodeServerMagicString(encoded)).toEqual(original);
});
it('rejects invalid input', () => {
expect(decodeServerMagicString('')).toBeNull();
expect(decodeServerMagicString('nope')).toBeNull();
expect(decodeServerMagicString(`${SERVER_MAGIC_STRING_PREFIX}%%%`)).toBeNull();
});
});
+90
View File
@@ -0,0 +1,90 @@
/** Prefix for server share strings generated by Psysonic (Subsonic credentials bundle). */
export const SERVER_MAGIC_STRING_PREFIX = 'psysonic1-';
/** Fixed-length placeholder so a password field does not reveal the real password length after decode. */
export const DECODED_PASSWORD_VISUAL_MASK = '••••••••••';
export interface ServerMagicPayload {
url: string;
username: string;
password: string;
/** Optional display name for the saved server entry */
name?: string;
}
function utf8ToBase64Url(json: string): string {
const bytes = new TextEncoder().encode(json);
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
const b64 = btoa(bin);
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64UrlToUtf8(b64url: string): string {
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4));
const bin = atob(b64 + pad);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
/** Encode server URL + Subsonic credentials into a single pasteable string. */
export function encodeServerMagicString(p: ServerMagicPayload): string {
const payload = {
v: 1 as const,
url: p.url.trim(),
u: p.username,
w: p.password,
...(p.name?.trim() ? { n: p.name.trim() } : {}),
};
return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(payload));
}
/**
* Decode a magic string from {@link encodeServerMagicString}.
* Accepts optional surrounding whitespace.
*/
export function decodeServerMagicString(raw: string): ServerMagicPayload | null {
const s = raw.trim();
if (!s.startsWith(SERVER_MAGIC_STRING_PREFIX)) return null;
const b64 = s.slice(SERVER_MAGIC_STRING_PREFIX.length).trim();
if (!b64) return null;
let obj: unknown;
try {
obj = JSON.parse(base64UrlToUtf8(b64));
} catch {
return null;
}
if (!obj || typeof obj !== 'object') return null;
const o = obj as Record<string, unknown>;
if (o.v !== 1) return null;
const url = typeof o.url === 'string' ? o.url.trim() : '';
const username = typeof o.u === 'string' ? o.u : '';
const password = typeof o.w === 'string' ? o.w : '';
const name = typeof o.n === 'string' && o.n.trim() ? o.n.trim() : undefined;
if (!url || !username) return null;
return { url, username, password, name };
}
export async function copyTextToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
}