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:
@@ -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