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 (

{t('settings.addServerTitle')}

@@ -216,34 +268,66 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit
- +
-
+ {blockPasswordReveal ? ( - -
+ ) : ( +
+ + +
+ )}
+
+ + +
@@ -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({

- + set('userName', e.target.value)} disabled={isEdit} autoComplete="off" + aria-invalid={markInvalid && !form.userName.trim()} + style={markInvalid && !form.userName.trim() ? { borderColor: 'var(--danger)' } : undefined} />
- + set('name', e.target.value)} autoComplete="off" + aria-invalid={markInvalid && !form.name.trim()} + style={markInvalid && !form.name.trim() ? { borderColor: 'var(--danger)' } : undefined} />
@@ -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; + } + } +}