feat(login): custom HTTP headers on initial Add Server dialog (#1187) (#1188)

This commit is contained in:
cucadmuh
2026-06-25 13:44:50 +03:00
committed by GitHub
parent a88d5f3181
commit 4aa6427727
6 changed files with 278 additions and 131 deletions
+13 -115
View File
@@ -4,8 +4,10 @@ import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '../
import { showToast } from '../../utils/ui/toast';
import {
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
serverCustomHeadersFromForm,
validateCustomHeaders,
} from '../../utils/server/serverHttpHeaders';
import { CustomHttpHeadersEditor } from './CustomHttpHeadersEditor';
import {
decodeServerMagicString,
encodeServerMagicString,
@@ -193,19 +195,8 @@ export function AddServerForm({
return true;
};
const customHeadersPayload = (): Pick<
ServerProfile,
'customHeaders' | 'customHeadersApplyTo'
> => {
const rows = form.customHeaders
.map(h => ({ name: h.name.trim(), value: h.value }))
.filter(h => h.name || h.value);
if (!rows.length) return {};
return {
customHeaders: rows,
customHeadersApplyTo: form.customHeadersApplyTo,
};
};
const customHeadersPayload = () =>
serverCustomHeadersFromForm(form.customHeaders, form.customHeadersApplyTo);
const submit = async () => {
const ms = magicString.trim();
@@ -381,108 +372,15 @@ export function AddServerForm({
)}
</div>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13, padding: '4px 0' }}
onClick={() => setForm(f => ({ ...f, customHeadersOpen: !f.customHeadersOpen }))}
>
{form.customHeadersOpen ? '▾' : '▸'} {t('settings.customHeadersTitle')}
</button>
{form.customHeadersOpen && (
<div style={{ marginTop: 8 }}>
<p style={{ fontSize: 11, opacity: 0.75, margin: '0 0 8px' }}>
{t('settings.customHeadersHelp')}
</p>
{form.customHeaders.map((row, index) => (
<div key={index} className="form-row" style={{ marginBottom: 6, gap: 8 }}>
<input
className="input"
type="text"
value={row.name}
onChange={e => {
const name = e.target.value;
setForm(f => {
const customHeaders = f.customHeaders.map((h, i) =>
i === index ? { ...h, name } : h,
);
return { ...f, customHeaders };
});
}}
placeholder={t('settings.customHeadersNamePlaceholder')}
autoComplete="off"
/>
<input
className="input"
type="password"
value={row.value}
onChange={e => {
const value = e.target.value;
setForm(f => ({
...f,
customHeaders: f.customHeaders.map((h, i) =>
i === index ? { ...h, value } : h,
),
}));
}}
placeholder={t('settings.customHeadersValuePlaceholder')}
autoComplete="off"
/>
<button
type="button"
className="btn btn-ghost"
aria-label={t('settings.customHeadersRemoveRow')}
onClick={() =>
setForm(f => ({
...f,
customHeaders:
f.customHeaders.length <= 1
? [{ name: '', value: '' }]
: f.customHeaders.filter((_, i) => i !== index),
}))
}
>
×
</button>
</div>
))}
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, marginBottom: 8 }}
onClick={() =>
setForm(f => ({
...f,
customHeaders: [...f.customHeaders, { name: '', value: '' }],
}))
}
>
{t('settings.customHeadersAddRow')}
</button>
<fieldset
disabled={!form.customHeaders.some(h => h.name.trim() || h.value)}
style={{ border: 'none', padding: 0, margin: 0 }}
>
<legend style={{ fontSize: 12, marginBottom: 4 }}>{t('settings.customHeadersApplyTo')}</legend>
{(['public', 'local', 'both'] as const).map(kind => (
<label key={kind} style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
<input
type="radio"
name="customHeadersApplyTo"
checked={form.customHeadersApplyTo === kind}
onChange={() => setForm(f => ({ ...f, customHeadersApplyTo: kind }))}
/>{' '}
{t(`settings.customHeadersApplyTo_${kind}`)}
</label>
))}
</fieldset>
<p style={{ fontSize: 11, opacity: 0.65, marginTop: 6 }}>
{t('settings.customHeadersNotInShare')}
</p>
</div>
)}
</div>
<CustomHttpHeadersEditor
headers={form.customHeaders}
applyTo={form.customHeadersApplyTo}
open={form.customHeadersOpen}
onOpenChange={open => setForm(f => ({ ...f, customHeadersOpen: open }))}
onHeadersChange={customHeaders => setForm(f => ({ ...f, customHeaders }))}
onApplyToChange={customHeadersApplyTo => setForm(f => ({ ...f, customHeadersApplyTo }))}
radioGroupName="addServerCustomHeadersApplyTo"
/>
{!isEdit && (
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
@@ -0,0 +1,114 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { CustomHeaderEntry, CustomHeadersApplyTo } from '../../store/authStoreTypes';
export type CustomHttpHeadersEditorProps = {
headers: CustomHeaderEntry[];
applyTo: CustomHeadersApplyTo;
open: boolean;
onOpenChange: (open: boolean) => void;
onHeadersChange: (headers: CustomHeaderEntry[]) => void;
onApplyToChange: (applyTo: CustomHeadersApplyTo) => void;
/** Optional id prefix for radio group name (avoid collisions when multiple forms mount). */
radioGroupName?: string;
};
export function CustomHttpHeadersEditor({
headers,
applyTo,
open,
onOpenChange,
onHeadersChange,
onApplyToChange,
radioGroupName = 'customHeadersApplyTo',
}: CustomHttpHeadersEditorProps) {
const { t } = useTranslation();
return (
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13, padding: '4px 0' }}
onClick={() => onOpenChange(!open)}
>
{open ? '▾' : '▸'} {t('settings.customHeadersTitle')}
</button>
{open && (
<div style={{ marginTop: 8 }}>
<p style={{ fontSize: 11, opacity: 0.75, margin: '0 0 8px' }}>
{t('settings.customHeadersHelp')}
</p>
{headers.map((row, index) => (
<div key={index} className="form-row" style={{ marginBottom: 6, gap: 8 }}>
<input
className="input"
type="text"
value={row.name}
onChange={e => {
const name = e.target.value;
onHeadersChange(headers.map((h, i) => (i === index ? { ...h, name } : h)));
}}
placeholder={t('settings.customHeadersNamePlaceholder')}
autoComplete="off"
/>
<input
className="input"
type="password"
value={row.value}
onChange={e => {
const value = e.target.value;
onHeadersChange(headers.map((h, i) => (i === index ? { ...h, value } : h)));
}}
placeholder={t('settings.customHeadersValuePlaceholder')}
autoComplete="off"
/>
<button
type="button"
className="btn btn-ghost"
aria-label={t('settings.customHeadersRemoveRow')}
onClick={() =>
onHeadersChange(
headers.length <= 1
? [{ name: '', value: '' }]
: headers.filter((_, i) => i !== index),
)
}
>
×
</button>
</div>
))}
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, marginBottom: 8 }}
onClick={() => onHeadersChange([...headers, { name: '', value: '' }])}
>
{t('settings.customHeadersAddRow')}
</button>
<fieldset
disabled={!headers.some(h => h.name.trim() || h.value)}
style={{ border: 'none', padding: 0, margin: 0 }}
>
<legend style={{ fontSize: 12, marginBottom: 4 }}>{t('settings.customHeadersApplyTo')}</legend>
{(['public', 'local', 'both'] as const).map(kind => (
<label key={kind} style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
<input
type="radio"
name={radioGroupName}
checked={applyTo === kind}
onChange={() => onApplyToChange(kind)}
/>{' '}
{t(`settings.customHeadersApplyTo_${kind}`)}
</label>
))}
</fieldset>
<p style={{ fontSize: 11, opacity: 0.65, marginTop: 6 }}>
{t('settings.customHeadersNotInShare')}
</p>
</div>
)}
</div>
);
}
+40 -3
View File
@@ -7,7 +7,7 @@ import { useAuthStore } from '@/store/authStore';
import { encodeServerMagicString } from '@/utils/server/serverMagicString';
vi.mock('@/api/subsonic', () => ({
pingWithCredentials: vi.fn(async () => ({
pingWithCredentialsForProfile: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
@@ -16,11 +16,15 @@ vi.mock('@/api/subsonic', () => ({
scheduleInstantMixProbeForServer: vi.fn(),
}));
import { pingWithCredentials } from '@/api/subsonic';
vi.mock('@/utils/server/syncServerHttpContext', () => ({
syncServerHttpContextForProfile: vi.fn(async () => undefined),
}));
import { pingWithCredentialsForProfile } from '@/api/subsonic';
beforeEach(() => {
resetAuthStore();
vi.mocked(pingWithCredentials).mockClear();
vi.mocked(pingWithCredentialsForProfile).mockClear();
});
afterEach(() => {
@@ -94,4 +98,37 @@ describe('Login — v2 magic string paste persistence', () => {
expect(saved.alternateUrl).toBeUndefined();
expect(saved.shareUsesLocalUrl).toBeUndefined();
});
it('persists custom HTTP headers and probes with gate profile on first connect', async () => {
const Login = (await import('./Login')).default;
renderWithProviders(<Login />);
const user = userEvent.setup();
await user.type(screen.getByLabelText(/server url/i), 'https://music.example.com');
await user.type(screen.getByLabelText(/username/i), 'tester');
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
await user.click(screen.getByRole('button', { name: /custom http headers/i }));
const nameInputs = screen.getAllByPlaceholderText(/header name/i);
const valueInputs = screen.getAllByPlaceholderText(/header value/i);
await user.type(nameInputs[0]!, 'CF-Access-Client-Secret');
await user.type(valueInputs[0]!, 'gate-secret');
await user.click(screen.getByRole('button', { name: /connect/i }));
await waitFor(() => {
expect(useAuthStore.getState().servers.length).toBe(1);
});
const saved = useAuthStore.getState().servers[0]!;
expect(saved.customHeaders).toEqual([{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }]);
expect(saved.customHeadersApplyTo).toBe('public');
expect(vi.mocked(pingWithCredentialsForProfile)).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://music.example.com',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
}),
'https://music.example.com',
);
});
});
+83 -13
View File
@@ -2,7 +2,15 @@ import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Wifi, WifiOff, Eye, EyeOff, Server, Globe } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '../store/authStoreTypes';
import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { CustomHttpHeadersEditor } from '../components/settings/CustomHttpHeadersEditor';
import {
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
serverCustomHeadersFromForm,
validateCustomHeaders,
} from '../utils/server/serverHttpHeaders';
import { syncServerHttpContextForProfile } from '../utils/server/syncServerHttpContext';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import CustomSelect from '../components/CustomSelect';
@@ -35,6 +43,9 @@ export default function Login() {
password: '',
alternateUrl: '' as string,
shareUsesLocalUrl: false,
customHeaders: [{ name: '', value: '' }] as CustomHeaderEntry[],
customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO as CustomHeadersApplyTo,
customHeadersOpen: false,
});
const [magicString, setMagicString] = useState('');
const [showPass, setShowPass] = useState(false);
@@ -50,14 +61,15 @@ export default function Login() {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowPass(false);
setBlockPasswordReveal(true);
setForm({
setForm(f => ({
...f,
serverName: (inv.name && inv.name.trim()) || shortHostFromServerUrl(inv.url),
url: inv.url,
username: inv.username,
password: inv.password,
alternateUrl: inv.alternateUrl ?? '',
shareUsesLocalUrl: inv.shareUsesLocalUrl ?? false,
});
}));
setMagicString(encodeServerMagicString(inv));
navigate('/login', { replace: true, state: {} });
}, [location.state, navigate]);
@@ -73,14 +85,15 @@ export default function Login() {
if (decoded) {
setShowPass(false);
setBlockPasswordReveal(true);
setForm({
setForm(f => ({
...f,
serverName: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
alternateUrl: decoded.alternateUrl ?? '',
shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false,
});
}));
if (status === 'error') {
setStatus('idle');
setTestMessage('');
@@ -95,6 +108,8 @@ export default function Login() {
password: string;
alternateUrl?: string;
shareUsesLocalUrl?: boolean;
customHeaders?: CustomHeaderEntry[];
customHeadersApplyTo?: CustomHeadersApplyTo;
}) => {
if (!profile.url.trim()) {
setTestMessage(t('login.urlRequired'));
@@ -102,16 +117,42 @@ export default function Login() {
return;
}
const headerRows = (profile.customHeaders ?? []).filter(h => h.name.trim() || h.value);
if (headerRows.length) {
const headerValidation = validateCustomHeaders(headerRows);
if (!headerValidation.ok) {
const first = headerValidation.fieldErrors[0]!;
setTestMessage(t(first.messageKey, { defaultValue: first.messageKey }));
setStatus('error');
return;
}
}
setStatus('testing');
setTestMessage(t('login.connecting'));
setConnecting(true);
setConnectionError(null);
// Test connection directly with entered credentials — don't touch the store yet.
// This avoids any race condition with Zustand's async store rehydration.
let ping: Awaited<ReturnType<typeof pingWithCredentials>>;
const urlTrimmed = profile.url.trim();
const usernameTrimmed = profile.username.trim();
const headersPayload = serverCustomHeadersFromForm(
profile.customHeaders ?? [],
profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
);
const pingProfile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo'
> = {
url: urlTrimmed,
username: usernameTrimmed,
password: profile.password,
alternateUrl: profile.alternateUrl?.trim() || undefined,
...headersPayload,
};
let ping: Awaited<ReturnType<typeof pingWithCredentialsForProfile>>;
try {
ping = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
ping = await pingWithCredentialsForProfile(pingProfile, urlTrimmed);
} catch {
ping = { ok: false };
}
@@ -125,11 +166,16 @@ export default function Login() {
// though Login itself never shows the second-address field. The
// user can edit/remove them later via Settings → Servers.
const altTrimmed = profile.alternateUrl?.trim() ?? '';
const savedHeaders = serverCustomHeadersFromForm(
profile.customHeaders ?? [],
profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
);
let serverId: string;
if (existing) {
updateServer(existing.id, {
name: profile.name.trim() || profile.url.trim(),
password: profile.password,
...savedHeaders,
...(altTrimmed
? {
alternateUrl: altTrimmed,
@@ -141,9 +187,10 @@ export default function Login() {
} else {
serverId = addServer({
name: profile.name.trim() || profile.url.trim(),
url: profile.url.trim(),
username: profile.username.trim(),
url: urlTrimmed,
username: usernameTrimmed,
password: profile.password,
...savedHeaders,
...(altTrimmed
? {
alternateUrl: altTrimmed,
@@ -160,11 +207,13 @@ export default function Login() {
useAuthStore.getState().setSubsonicServerIdentity(serverId, identity);
scheduleInstantMixProbeForServer(
serverId,
profile.url.trim(),
profile.username.trim(),
urlTrimmed,
usernameTrimmed,
profile.password,
identity,
);
const saved = useAuthStore.getState().servers.find(s => s.id === serverId);
if (saved) void syncServerHttpContextForProfile(saved);
setActiveServer(serverId);
setLoggedIn(true);
setStatus('ok');
@@ -194,6 +243,8 @@ export default function Login() {
password: decoded.password,
alternateUrl: decoded.alternateUrl,
shareUsesLocalUrl: decoded.shareUsesLocalUrl,
customHeaders: form.customHeaders,
customHeadersApplyTo: form.customHeadersApplyTo,
});
return;
}
@@ -204,6 +255,8 @@ export default function Login() {
password: form.password,
alternateUrl: form.alternateUrl,
shareUsesLocalUrl: form.shareUsesLocalUrl,
customHeaders: form.customHeaders,
customHeadersApplyTo: form.customHeadersApplyTo,
});
};
@@ -218,6 +271,11 @@ export default function Login() {
password: srv.password,
alternateUrl: srv.alternateUrl ?? '',
shareUsesLocalUrl: srv.shareUsesLocalUrl ?? false,
customHeaders: srv.customHeaders?.length
? srv.customHeaders.map(h => ({ ...h }))
: [{ name: '', value: '' }],
customHeadersApplyTo: srv.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
customHeadersOpen: Boolean(srv.customHeaders?.length),
});
await attemptConnect({
name: srv.name,
@@ -226,6 +284,8 @@ export default function Login() {
password: srv.password,
alternateUrl: srv.alternateUrl,
shareUsesLocalUrl: srv.shareUsesLocalUrl,
customHeaders: srv.customHeaders,
customHeadersApplyTo: srv.customHeadersApplyTo,
});
};
@@ -361,6 +421,16 @@ export default function Login() {
</div>
</div>
<CustomHttpHeadersEditor
headers={form.customHeaders}
applyTo={form.customHeadersApplyTo}
open={form.customHeadersOpen}
onOpenChange={customHeadersOpen => setForm(f => ({ ...f, customHeadersOpen }))}
onHeadersChange={customHeaders => setForm(f => ({ ...f, customHeaders }))}
onApplyToChange={customHeadersApplyTo => setForm(f => ({ ...f, customHeadersApplyTo }))}
radioGroupName="loginCustomHeadersApplyTo"
/>
<div className="form-group">
<label htmlFor="login-magic-string">{t('login.orMagicString')}</label>
<input
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
headersForServerRequest,
requestBaseUrlFromHttpUrl,
serverCustomHeadersFromForm,
validateCustomHeaders,
} from './serverHttpHeaders';
@@ -51,3 +52,18 @@ describe('validateCustomHeaders', () => {
).toEqual({ ok: true });
});
});
describe('serverCustomHeadersFromForm', () => {
it('returns empty object when all rows are blank', () => {
expect(serverCustomHeadersFromForm([{ name: '', value: '' }], 'public')).toEqual({});
});
it('trims and returns profile fields for non-empty rows', () => {
expect(
serverCustomHeadersFromForm([{ name: ' X-Gate ', value: 'secret' }], 'public'),
).toEqual({
customHeaders: [{ name: 'X-Gate', value: 'secret' }],
customHeadersApplyTo: 'public',
});
});
});
+12
View File
@@ -96,6 +96,18 @@ export function validateCustomHeaders(
return { ok: true };
}
/** Non-empty custom header rows from a form editor → profile fields (or omit when empty). */
export function serverCustomHeadersFromForm(
headers: CustomHeaderEntry[],
applyTo: CustomHeadersApplyTo,
): Pick<ServerProfile, 'customHeaders' | 'customHeadersApplyTo'> | Record<string, never> {
const rows = headers
.map(h => ({ name: h.name.trim(), value: h.value }))
.filter(h => h.name || h.value);
if (!rows.length) return {};
return { customHeaders: rows, customHeadersApplyTo: applyTo };
}
function headersRecord(entries: CustomHeaderEntry[]): Record<string, string> {
const out: Record<string, string> = {};
for (const row of entries) {