mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user