From f6f76723d894b0cf42d590f92e3d4fbb51d7f720 Mon Sep 17 00:00:00 2001
From: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
Date: Wed, 22 Apr 2026 01:45:28 +0300
Subject: [PATCH] feat(servers/settings): magic string invites, Navidrome admin
share, and add-user validation (#258)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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.
---
src/components/ConnectionIndicator.tsx | 3 +-
src/hooks/useConnectionStatus.ts | 10 +-
src/locales/de.ts | 16 +
src/locales/en.ts | 16 +
src/locales/es.ts | 16 +
src/locales/fr.ts | 16 +
src/locales/nb.ts | 16 +
src/locales/nl.ts | 16 +
src/locales/ru.ts | 16 +
src/locales/zh.ts | 16 +
src/pages/Login.tsx | 111 +++++-
src/pages/Settings.tsx | 504 +++++++++++++++++++++++--
src/utils/serverDisplayName.test.ts | 36 ++
src/utils/serverDisplayName.ts | 36 ++
src/utils/serverMagicString.test.ts | 43 +++
src/utils/serverMagicString.ts | 90 +++++
16 files changed, 910 insertions(+), 51 deletions(-)
create mode 100644 src/utils/serverDisplayName.test.ts
create mode 100644 src/utils/serverDisplayName.ts
create mode 100644 src/utils/serverMagicString.test.ts
create mode 100644 src/utils/serverMagicString.ts
diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx
index 96db0f58..8c33923b 100644
--- a/src/components/ConnectionIndicator.tsx
+++ b/src/components/ConnectionIndicator.tsx
@@ -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 (
@@ -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}
/>
-
-
+
+ {blockPasswordReveal ? (
-
-
+ ) : (
+
+
+
+
+ )}
+
+
+
+
+
{testMessage && (
{status === 'testing' &&
}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 65b74e54..7b43deec 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -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
) => 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) =>
setForm(f => ({ ...f, [k]: e.target.value }));
+ const handleMagicStringChange = (e: React.ChangeEvent) => {
+ 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 (
+
+
+
+
@@ -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
;
onSave: (form: UserFormState) => void;
+ /** New user only: create on Navidrome then copy magic string to clipboard. */
+ onSaveAndGetMagic?: (form: UserFormState) => void | Promise;
onCancel: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
const [form, setForm] = useState(() => 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: 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 (
@@ -315,7 +487,10 @@ function UserForm({
@@ -347,7 +529,10 @@ function UserForm({
/>
-
+
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)' } : {}),
+ }}
/>
)}
+ {!form.isAdmin && !isEdit && onSaveAndGetMagic && shareServerUrl.trim() && ndToken.trim() && (
+
+
+ {t('settings.userMgmtMagicStringPlaintextWarning')}
+
+
+
+ )}
+ {!form.isAdmin && isEdit && shareServerUrl.trim() && form.password.trim().length > 0 && ndToken.trim() && (
+
+
+ {t('settings.userMgmtMagicStringPasswordNavHint')}
+
+
+ {t('settings.userMgmtMagicStringPlaintextWarning')}
+
+
+
+ )}
@@ -479,6 +729,9 @@ function UserManagementSection({
const [editing, setEditing] = useState
(null);
const [confirmingDelete, setConfirmingDelete] = useState(null);
const [busy, setBusy] = useState(false);
+ const [magicRowUser, setMagicRowUser] = useState(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({
setEditing(null)}
busy={busy}
/>
@@ -674,6 +989,22 @@ function UserManagementSection({
{libNames}
)}
+ {!u.isAdmin && (
+
+ )}
{ if (confirmingDelete) void performDelete(confirmingDelete); }}
onCancel={() => setConfirmingDelete(null)}
/>
+ {magicRowUser && createPortal(
+ !magicRowSubmitting && setMagicRowUser(null)}
+ role="dialog"
+ aria-modal="true"
+ style={{ alignItems: 'center', paddingTop: 0 }}
+ >
+
e.stopPropagation()}
+ style={{ maxWidth: '400px' }}
+ >
+
+
+ {t('settings.userMgmtMagicStringModalTitle')}
+
+
+ {t('settings.userMgmtMagicStringModalDesc', { username: magicRowUser.userName })}
+
+
+ {t('settings.userMgmtMagicStringPasswordNavHint')}
+
+
+ {t('settings.userMgmtMagicStringPlaintextWarning')}
+
+
+
+ setMagicRowPassword(e.target.value)}
+ autoComplete="off"
+ disabled={magicRowSubmitting}
+ />
+
+
+
+
+
+
+
,
+ document.body,
+ )}
);
}
@@ -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() {
- {srv.name || srv.url}
+ {serverListDisplayLabel(srv, auth.servers)}
{isActive && (
{t('settings.serverActive')}
diff --git a/src/utils/serverDisplayName.test.ts b/src/utils/serverDisplayName.test.ts
new file mode 100644
index 00000000..d6bf81c6
--- /dev/null
+++ b/src/utils/serverDisplayName.test.ts
@@ -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 & Pick): 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');
+ });
+});
diff --git a/src/utils/serverDisplayName.ts b/src/utils/serverDisplayName.ts
new file mode 100644
index 00000000..fb466800
--- /dev/null
+++ b/src/utils/serverDisplayName.ts
@@ -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}`;
+}
diff --git a/src/utils/serverMagicString.test.ts b/src/utils/serverMagicString.test.ts
new file mode 100644
index 00000000..c5ba806d
--- /dev/null
+++ b/src/utils/serverMagicString.test.ts
@@ -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();
+ });
+});
diff --git a/src/utils/serverMagicString.ts b/src/utils/serverMagicString.ts
new file mode 100644
index 00000000..6247d892
--- /dev/null
+++ b/src/utils/serverMagicString.ts
@@ -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;
+ 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 {
+ 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;
+ }
+ }
+}