mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -6,6 +6,7 @@ import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useAuthStore, type ServerProfile } from '../store/authStore';
|
||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
@@ -123,7 +124,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
{servers.map(srv => {
|
||||
const active = srv.id === activeServerId;
|
||||
const busy = switchingId !== null;
|
||||
const labelText = (srv.name || srv.url).trim() || srv.url;
|
||||
const labelText = serverListDisplayLabel(srv, servers);
|
||||
return (
|
||||
<button
|
||||
key={srv.id}
|
||||
|
||||
@@ -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 { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
|
||||
@@ -77,12 +78,17 @@ export function useConnectionStatus() {
|
||||
}, [check]);
|
||||
|
||||
const server = useAuthStore(s => s.getActiveServer());
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const serverName = useMemo(
|
||||
() => (server ? serverListDisplayLabel(server, servers) : ''),
|
||||
[server, servers],
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
isRetrying,
|
||||
retry,
|
||||
isLan: server ? isLanUrl(server.url) : false,
|
||||
serverName: server?.name ?? '',
|
||||
serverName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,6 +351,9 @@ export const deTranslation = {
|
||||
urlRequired: 'Bitte Server-URL eingeben.',
|
||||
savedServers: 'Gespeicherte Server',
|
||||
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: {
|
||||
connected: 'Verbunden',
|
||||
@@ -509,6 +512,19 @@ export const deTranslation = {
|
||||
userMgmtUpdated: 'Benutzer aktualisiert.',
|
||||
userMgmtDeleted: 'Benutzer gelöscht.',
|
||||
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)',
|
||||
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.',
|
||||
|
||||
@@ -352,6 +352,9 @@ export const enTranslation = {
|
||||
urlRequired: 'Please enter a server URL.',
|
||||
savedServers: 'Saved Servers',
|
||||
addNew: 'Or add a new server',
|
||||
orMagicString: 'Or magic string',
|
||||
magicStringPlaceholder: 'Paste a share string (psysonic1-…)',
|
||||
magicStringInvalid: 'Invalid or unreadable magic string.',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Connected',
|
||||
@@ -511,6 +514,19 @@ export const enTranslation = {
|
||||
userMgmtUpdated: 'User updated.',
|
||||
userMgmtDeleted: 'User deleted.',
|
||||
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)',
|
||||
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.',
|
||||
|
||||
@@ -353,6 +353,9 @@ export const esTranslation = {
|
||||
urlRequired: 'Por favor ingresa una URL de servidor.',
|
||||
savedServers: 'Servidores Guardados',
|
||||
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: {
|
||||
connected: 'Conectado',
|
||||
@@ -502,6 +505,19 @@ export const esTranslation = {
|
||||
userMgmtUpdated: 'Usuario actualizado.',
|
||||
userMgmtDeleted: 'Usuario eliminado.',
|
||||
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)',
|
||||
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.',
|
||||
|
||||
@@ -351,6 +351,9 @@ export const frTranslation = {
|
||||
urlRequired: 'Veuillez saisir une URL de serveur.',
|
||||
savedServers: 'Serveurs enregistrés',
|
||||
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: {
|
||||
connected: 'Connecté',
|
||||
@@ -499,6 +502,19 @@ export const frTranslation = {
|
||||
userMgmtUpdated: 'Utilisateur mis à jour.',
|
||||
userMgmtDeleted: 'Utilisateur supprimé.',
|
||||
userMgmtValidationMissing: 'Nom d’utilisateur, nom affiché et mot de passe sont requis.',
|
||||
userMgmtValidationMissingIdentity: 'Le nom d’utilisateur 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 l’utilisateur. S’il diffère de l’actuel, 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é (l’encodage n’est 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)',
|
||||
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.',
|
||||
|
||||
@@ -351,6 +351,9 @@ export const nbTranslation = {
|
||||
urlRequired: 'Vennligst skriv inn en tjeneer-URL.',
|
||||
savedServers: 'Lagrede servere',
|
||||
addNew: 'Eller legg til en ny tjener',
|
||||
orMagicString: 'Eller magic string',
|
||||
magicStringPlaceholder: 'Lim inn en delingsstreng (psysonic1-…)',
|
||||
magicStringInvalid: 'Ugyldig eller uleselig magic string.',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Tilkoblet',
|
||||
@@ -499,6 +502,19 @@ export const nbTranslation = {
|
||||
userMgmtUpdated: 'Bruker oppdatert.',
|
||||
userMgmtDeleted: 'Bruker slettet.',
|
||||
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)',
|
||||
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.',
|
||||
|
||||
@@ -350,6 +350,9 @@ export const nlTranslation = {
|
||||
urlRequired: 'Voer een server-URL in.',
|
||||
savedServers: 'Opgeslagen servers',
|
||||
addNew: 'Of een nieuwe server toevoegen',
|
||||
orMagicString: 'Of magic string',
|
||||
magicStringPlaceholder: 'Plak een deelstring (psysonic1-…)',
|
||||
magicStringInvalid: 'Ongeldige of onleesbare magic string.',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Verbonden',
|
||||
@@ -498,6 +501,19 @@ export const nlTranslation = {
|
||||
userMgmtUpdated: 'Gebruiker bijgewerkt.',
|
||||
userMgmtDeleted: 'Gebruiker verwijderd.',
|
||||
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)',
|
||||
audiomuseDesc:
|
||||
'Zet aan als deze server de <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink> gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpagina’s.',
|
||||
|
||||
@@ -370,6 +370,9 @@ export const ruTranslation = {
|
||||
urlRequired: 'Укажите адрес сервера.',
|
||||
savedServers: 'Сохранённые серверы',
|
||||
addNew: 'Или добавить новый сервер',
|
||||
orMagicString: 'Или magic string',
|
||||
magicStringPlaceholder: 'Вставьте строку приглашения (psysonic1-…)',
|
||||
magicStringInvalid: 'Неверная или нечитаемая magic string.',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Подключено',
|
||||
@@ -522,6 +525,19 @@ export const ruTranslation = {
|
||||
userMgmtUpdated: 'Пользователь обновлён.',
|
||||
userMgmtDeleted: 'Пользователь удалён.',
|
||||
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)',
|
||||
audiomuseDesc:
|
||||
'Включите, если на этом сервере настроен <pluginLink>плагин AudioMuse-AI для Navidrome</pluginLink>. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
|
||||
|
||||
@@ -350,6 +350,9 @@ export const zhTranslation = {
|
||||
urlRequired: '请输入服务器地址。',
|
||||
savedServers: '已保存的服务器',
|
||||
addNew: '或添加新服务器',
|
||||
orMagicString: '或使用魔法字符串',
|
||||
magicStringPlaceholder: '粘贴分享字符串(psysonic1-…)',
|
||||
magicStringInvalid: '魔法字符串无效或无法解析。',
|
||||
},
|
||||
connection: {
|
||||
connected: '已连接',
|
||||
@@ -494,6 +497,19 @@ export const zhTranslation = {
|
||||
userMgmtUpdated: '用户已更新。',
|
||||
userMgmtDeleted: '用户已删除。',
|
||||
userMgmtValidationMissing: '用户名、显示名称和密码均为必填项。',
|
||||
userMgmtValidationMissingIdentity: '用户名和显示名称为必填项。',
|
||||
userMgmtMagicStringGenerate: '生成魔法字符串',
|
||||
userMgmtSaveAndMagicString: '保存并获取魔法字符串',
|
||||
userMgmtMagicStringPasswordNavHint:
|
||||
'Navidrome 会保存此密码。若与当前密码不同,服务器上的登录密码将被更新。',
|
||||
userMgmtMagicStringPlaintextWarning:
|
||||
'请谨慎分享魔法字符串:其中包含未加密的密码(编码不等于加密)。掌握完整字符串的人可以以该用户身份登录。',
|
||||
userMgmtMagicStringCopied: '魔法字符串已复制到剪贴板。',
|
||||
userMgmtMagicStringCopyFailed: '无法复制到剪贴板。',
|
||||
userMgmtMagicStringLoginFailed: '密码验证失败,无法确认凭据。',
|
||||
userMgmtMagicStringModalTitle: '生成魔法字符串',
|
||||
userMgmtMagicStringModalDesc: '请输入用户「{{username}}」的 Subsonic 密码,它会包含在复制的魔法字符串中。',
|
||||
userMgmtMagicStringModalConfirm: '复制字符串',
|
||||
audiomuseTitle: 'AudioMuse-AI(Navidrome)',
|
||||
audiomuseDesc:
|
||||
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
|
||||
|
||||
+92
-19
@@ -4,6 +4,8 @@ import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { decodeServerMagicString, DECODED_PASSWORD_VISUAL_MASK } from '../utils/serverMagicString';
|
||||
import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<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 [form, setForm] = useState({ serverName: '', url: '', username: '', password: '' });
|
||||
const [magicString, setMagicString] = useState('');
|
||||
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 [testMessage, setTestMessage] = useState('');
|
||||
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
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 }) => {
|
||||
if (!profile.url.trim()) {
|
||||
setTestMessage(t('login.urlRequired'));
|
||||
@@ -90,10 +116,29 @@ export default function Login() {
|
||||
|
||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||
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 });
|
||||
};
|
||||
|
||||
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 });
|
||||
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 }} />
|
||||
<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>
|
||||
</button>
|
||||
@@ -167,34 +212,62 @@ export default function Login() {
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
value={form.username}
|
||||
onChange={update('username')}
|
||||
readOnly={blockPasswordReveal}
|
||||
autoComplete="username"
|
||||
style={blockPasswordReveal ? { cursor: 'default' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">{t('login.password')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<label htmlFor={blockPasswordReveal ? 'login-password-mask' : 'login-password'}>{t('login.password')}</label>
|
||||
{blockPasswordReveal ? (
|
||||
<input
|
||||
id="login-password"
|
||||
id="login-password-mask"
|
||||
className="input"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={update('password')}
|
||||
autoComplete="current-password"
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
type="text"
|
||||
readOnly
|
||||
value={DECODED_PASSWORD_VISUAL_MASK}
|
||||
autoComplete="off"
|
||||
aria-label={t('login.password')}
|
||||
style={{ letterSpacing: '0.12em', cursor: 'default' }}
|
||||
/>
|
||||
<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 style={{ position: 'relative' }}>
|
||||
<input
|
||||
id="login-password"
|
||||
className="input"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
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 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 && (
|
||||
<div className={`login-status login-status--${status}`} role="alert">
|
||||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />}
|
||||
|
||||
+475
-29
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
@@ -6,7 +7,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, Shield
|
||||
Users, UserPlus, Shield, Wand2
|
||||
} from 'lucide-react';
|
||||
import i18n from '../i18n';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
@@ -47,6 +48,13 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import Equalizer from '../components/Equalizer';
|
||||
import StarRating from '../components/StarRating';
|
||||
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'];
|
||||
|
||||
@@ -197,11 +205,55 @@ type Tab = 'general' | 'server' | 'users' | 'audio' | 'storage' | 'appearance' |
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
|
||||
const [magicString, setMagicString] = useState('');
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
|
||||
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
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 (
|
||||
<div className="settings-card" style={{ marginTop: '1rem' }}>
|
||||
<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-group">
|
||||
<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 className="form-group">
|
||||
<label style={{ fontSize: 13 }}>{t('settings.serverPassword')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{blockPasswordReveal ? (
|
||||
<input
|
||||
className="input"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={update('password')}
|
||||
placeholder="••••••••"
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
type="text"
|
||||
readOnly
|
||||
value={DECODED_PASSWORD_VISUAL_MASK}
|
||||
autoComplete="off"
|
||||
aria-label={t('settings.serverPassword')}
|
||||
style={{ letterSpacing: '0.12em', cursor: 'default' }}
|
||||
/>
|
||||
<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 style={{ position: 'relative' }}>
|
||||
<input
|
||||
className="input"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={update('password')}
|
||||
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 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' }}>
|
||||
<button className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
|
||||
<button
|
||||
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')}
|
||||
</button>
|
||||
@@ -276,21 +360,42 @@ function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]):
|
||||
function UserForm({
|
||||
initial,
|
||||
libraries,
|
||||
shareServerUrl,
|
||||
ndToken,
|
||||
onUsersDirty,
|
||||
onSave,
|
||||
onSaveAndGetMagic,
|
||||
onCancel,
|
||||
busy,
|
||||
}: {
|
||||
initial: NdUser | null;
|
||||
libraries: NdLibrary[];
|
||||
shareServerUrl: string;
|
||||
ndToken: string;
|
||||
onUsersDirty?: () => void | Promise<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;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<UserFormState>(() => initialUserFormState(initial ?? undefined, libraries));
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [magicGenBusy, setMagicGenBusy] = useState(false);
|
||||
const [showNewUserRequiredErrors, setShowNewUserRequiredErrors] = useState(false);
|
||||
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]) =>
|
||||
setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
@@ -302,12 +407,79 @@ function UserForm({
|
||||
: [...f.libraryIds, id],
|
||||
}));
|
||||
|
||||
const newUserPasswordOk = form.password.trim().length > 0;
|
||||
const canSave =
|
||||
form.userName.trim().length > 0 &&
|
||||
form.name.trim().length > 0 &&
|
||||
form.password.length > 0 &&
|
||||
(isEdit || newUserPasswordOk) &&
|
||||
(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 (
|
||||
<div className="settings-card" style={{ marginBottom: '1.25rem' }}>
|
||||
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>
|
||||
@@ -315,7 +487,10 @@ function UserForm({
|
||||
</h3>
|
||||
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
||||
<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
|
||||
className="input"
|
||||
type="text"
|
||||
@@ -323,16 +498,23 @@ function UserForm({
|
||||
onChange={e => set('userName', e.target.value)}
|
||||
disabled={isEdit}
|
||||
autoComplete="off"
|
||||
aria-invalid={markInvalid && !form.userName.trim()}
|
||||
style={markInvalid && !form.userName.trim() ? { borderColor: 'var(--danger)' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
className="input"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-invalid={markInvalid && !form.name.trim()}
|
||||
style={markInvalid && !form.name.trim() ? { borderColor: 'var(--danger)' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,7 +529,10 @@ function UserForm({
|
||||
/>
|
||||
</div>
|
||||
<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' }}>
|
||||
<input
|
||||
className="input"
|
||||
@@ -356,7 +541,11 @@ function UserForm({
|
||||
onChange={e => set('password', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
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
|
||||
type="button"
|
||||
@@ -429,14 +618,75 @@ function UserForm({
|
||||
</>
|
||||
)}
|
||||
</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' }}>
|
||||
<button className="btn btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
{t('settings.userMgmtCancel')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => onSave(form)}
|
||||
disabled={busy || !canSave}
|
||||
onClick={() => trySave()}
|
||||
disabled={busy || (isEdit && !canSave)}
|
||||
>
|
||||
{t('settings.userMgmtSave')}
|
||||
</button>
|
||||
@@ -479,6 +729,9 @@ function UserManagementSection({
|
||||
const [editing, setEditing] = useState<NdUser | 'new' | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState<NdUser | null>(null);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -504,9 +757,16 @@ function UserManagementSection({
|
||||
const userName = form.userName.trim();
|
||||
const name = form.name.trim();
|
||||
const email = form.email.trim();
|
||||
if (!userName || !name || !form.password) {
|
||||
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
|
||||
return;
|
||||
if (editing === 'new') {
|
||||
if (!userName || !name || !form.password.trim()) {
|
||||
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) {
|
||||
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) => {
|
||||
if (!token) return;
|
||||
setConfirmingDelete(null);
|
||||
@@ -597,7 +908,11 @@ function UserManagementSection({
|
||||
<UserForm
|
||||
initial={editing === 'new' ? null : editing}
|
||||
libraries={libraries}
|
||||
shareServerUrl={serverUrl}
|
||||
ndToken={token}
|
||||
onUsersDirty={load}
|
||||
onSave={handleSave}
|
||||
onSaveAndGetMagic={editing === 'new' ? handleSaveAndGetMagic : undefined}
|
||||
onCancel={() => setEditing(null)}
|
||||
busy={busy}
|
||||
/>
|
||||
@@ -674,6 +989,22 @@ function UserManagementSection({
|
||||
{libNames}
|
||||
</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
|
||||
style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: libNames ? 0 : 'auto' }}
|
||||
data-tooltip={lastSeenAbsolute || undefined}
|
||||
@@ -708,6 +1039,121 @@ function UserManagementSection({
|
||||
onConfirm={() => { if (confirmingDelete) void performDelete(confirmingDelete); }}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1099,7 +1545,7 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -2779,7 +3225,7 @@ export default function Settings() {
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<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 && (
|
||||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}>
|
||||
{t('settings.serverActive')}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user