mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
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 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';
|
||
import {
|
||
decodeServerMagicString,
|
||
DECODED_PASSWORD_VISUAL_MASK,
|
||
encodeServerMagicString,
|
||
type ServerMagicPayload,
|
||
} from '../utils/server/serverMagicString';
|
||
import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/server/serverDisplayName';
|
||
|
||
const PsysonicLogo = () => (
|
||
<img src="/logo-psysonic.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
|
||
);
|
||
|
||
export default function Login() {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { t } = useTranslation();
|
||
const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore();
|
||
|
||
// alternateUrl / shareUsesLocalUrl are not user-editable on this page (Login
|
||
// stays single-address by design); they're populated only when a v2 magic
|
||
// string is decoded so the dual-address shape persists straight from the
|
||
// invite onto the saved profile.
|
||
const [form, setForm] = useState({
|
||
serverName: '',
|
||
url: '',
|
||
username: '',
|
||
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);
|
||
/** After a valid magic string decode, do not allow revealing the password in the UI. */
|
||
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
|
||
const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
|
||
const [testMessage, setTestMessage] = useState('');
|
||
|
||
useEffect(() => {
|
||
const inv = (location.state as { openAddServerInvite?: ServerMagicPayload } | null)?.openAddServerInvite;
|
||
if (!inv) return;
|
||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setShowPass(false);
|
||
setBlockPasswordReveal(true);
|
||
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]);
|
||
|
||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||
|
||
const handleMagicStringChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const v = e.target.value;
|
||
setMagicString(v);
|
||
const trimmed = v.trim();
|
||
const decoded = decodeServerMagicString(trimmed);
|
||
if (decoded) {
|
||
setShowPass(false);
|
||
setBlockPasswordReveal(true);
|
||
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('');
|
||
}
|
||
}
|
||
};
|
||
|
||
const attemptConnect = async (profile: {
|
||
name: string;
|
||
url: string;
|
||
username: string;
|
||
password: string;
|
||
alternateUrl?: string;
|
||
shareUsesLocalUrl?: boolean;
|
||
customHeaders?: CustomHeaderEntry[];
|
||
customHeadersApplyTo?: CustomHeadersApplyTo;
|
||
}) => {
|
||
if (!profile.url.trim()) {
|
||
setTestMessage(t('login.urlRequired'));
|
||
setStatus('error');
|
||
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);
|
||
|
||
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 pingWithCredentialsForProfile(pingProfile, urlTrimmed);
|
||
} catch {
|
||
ping = { ok: false };
|
||
}
|
||
|
||
setConnecting(false);
|
||
|
||
if (ping.ok) {
|
||
// Connection succeeded — now persist to store
|
||
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
|
||
// Dual-address fields persist straight off a v2 magic invite even
|
||
// 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,
|
||
shareUsesLocalUrl: profile.shareUsesLocalUrl ?? false,
|
||
}
|
||
: {}),
|
||
});
|
||
serverId = existing.id;
|
||
} else {
|
||
serverId = addServer({
|
||
name: profile.name.trim() || profile.url.trim(),
|
||
url: urlTrimmed,
|
||
username: usernameTrimmed,
|
||
password: profile.password,
|
||
...savedHeaders,
|
||
...(altTrimmed
|
||
? {
|
||
alternateUrl: altTrimmed,
|
||
shareUsesLocalUrl: profile.shareUsesLocalUrl ?? false,
|
||
}
|
||
: {}),
|
||
});
|
||
}
|
||
const identity = {
|
||
type: ping.type,
|
||
serverVersion: ping.serverVersion,
|
||
openSubsonic: ping.openSubsonic,
|
||
};
|
||
useAuthStore.getState().setSubsonicServerIdentity(serverId, identity);
|
||
scheduleInstantMixProbeForServer(
|
||
serverId,
|
||
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');
|
||
setTestMessage(t('login.connected'));
|
||
setTimeout(() => navigate('/'), 600);
|
||
} else {
|
||
setStatus('error');
|
||
setConnectionError(t('login.error'));
|
||
setTestMessage(t('login.error'));
|
||
}
|
||
};
|
||
|
||
const handleFormSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const ms = magicString.trim();
|
||
if (ms) {
|
||
const decoded = decodeServerMagicString(ms);
|
||
if (!decoded) {
|
||
setStatus('error');
|
||
setTestMessage(t('login.magicStringInvalid'));
|
||
return;
|
||
}
|
||
await attemptConnect({
|
||
name: form.serverName.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
|
||
url: decoded.url,
|
||
username: decoded.username,
|
||
password: decoded.password,
|
||
alternateUrl: decoded.alternateUrl,
|
||
shareUsesLocalUrl: decoded.shareUsesLocalUrl,
|
||
customHeaders: form.customHeaders,
|
||
customHeadersApplyTo: form.customHeadersApplyTo,
|
||
});
|
||
return;
|
||
}
|
||
await attemptConnect({
|
||
name: form.serverName,
|
||
url: form.url,
|
||
username: form.username,
|
||
password: form.password,
|
||
alternateUrl: form.alternateUrl,
|
||
shareUsesLocalUrl: form.shareUsesLocalUrl,
|
||
customHeaders: form.customHeaders,
|
||
customHeadersApplyTo: form.customHeadersApplyTo,
|
||
});
|
||
};
|
||
|
||
const handleQuickConnect = async (srv: typeof servers[0]) => {
|
||
setMagicString('');
|
||
setBlockPasswordReveal(false);
|
||
setShowPass(false);
|
||
setForm({
|
||
serverName: srv.name,
|
||
url: srv.url,
|
||
username: srv.username,
|
||
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,
|
||
url: srv.url,
|
||
username: srv.username,
|
||
password: srv.password,
|
||
alternateUrl: srv.alternateUrl,
|
||
shareUsesLocalUrl: srv.shareUsesLocalUrl,
|
||
customHeaders: srv.customHeaders,
|
||
customHeadersApplyTo: srv.customHeadersApplyTo,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="login-page">
|
||
<div className="login-bg" aria-hidden="true" />
|
||
<div className="login-card animate-fade-in">
|
||
<div className="login-lang-picker" aria-label={t('settings.language')}>
|
||
<Globe size={14} aria-hidden="true" />
|
||
<CustomSelect
|
||
value={i18n.language}
|
||
onChange={v => i18n.changeLanguage(v)}
|
||
options={[
|
||
{ value: 'en', label: t('settings.languageEn') },
|
||
{ value: 'de', label: t('settings.languageDe') },
|
||
{ value: 'es', label: t('settings.languageEs') },
|
||
{ value: 'fr', label: t('settings.languageFr') },
|
||
{ value: 'nl', label: t('settings.languageNl') },
|
||
{ value: 'nb', label: t('settings.languageNb') },
|
||
{ value: 'ru', label: t('settings.languageRu') },
|
||
{ value: 'zh', label: t('settings.languageZh') },
|
||
{ value: 'ro', label: t('settings.languageRo') },
|
||
{ value: 'ja', label: t('settings.languageJa') },
|
||
{ value: 'hu', label: t('settings.languageHu') },
|
||
]}
|
||
/>
|
||
</div>
|
||
<div className="login-logo">
|
||
<PsysonicLogo />
|
||
</div>
|
||
<h1 className="login-title">Psysonic</h1>
|
||
<p className="login-subtitle">{t('login.subtitle')}</p>
|
||
|
||
{/* Saved servers quick-connect */}
|
||
{servers.length > 0 && (
|
||
<div className="login-saved-servers">
|
||
<div className="login-saved-label">{t('login.savedServers')}</div>
|
||
{servers.map(srv => (
|
||
<button
|
||
key={srv.id}
|
||
className="btn btn-surface login-server-btn"
|
||
onClick={() => handleQuickConnect(srv)}
|
||
disabled={status === 'testing'}
|
||
>
|
||
<Server size={14} style={{ flexShrink: 0 }} />
|
||
<div style={{ textAlign: 'left', minWidth: 0 }}>
|
||
<div style={{ fontWeight: 600 }} className="truncate">{serverListDisplayLabel(srv, servers)}</div>
|
||
<div style={{ fontSize: 11, opacity: 0.7 }} className="truncate">{srv.username}@{srv.url}</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
<div className="login-divider"><span>{t('login.addNew')}</span></div>
|
||
</div>
|
||
)}
|
||
|
||
<form className="login-form" onSubmit={handleFormSubmit} noValidate>
|
||
<div className="form-group">
|
||
<label htmlFor="login-server-name">{t('login.serverName')}</label>
|
||
<input
|
||
id="login-server-name"
|
||
className="input"
|
||
type="text"
|
||
placeholder={t('login.serverNamePlaceholder')}
|
||
value={form.serverName}
|
||
onChange={update('serverName')}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="login-url">{t('login.serverUrl')}</label>
|
||
<input
|
||
id="login-url"
|
||
className="input"
|
||
type="text"
|
||
placeholder={t('login.serverUrlPlaceholder')}
|
||
value={form.url}
|
||
onChange={update('url')}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label htmlFor="login-username">{t('login.username')}</label>
|
||
<input
|
||
id="login-username"
|
||
className="input"
|
||
type="text"
|
||
placeholder={t('login.usernamePlaceholder')}
|
||
value={form.username}
|
||
onChange={update('username')}
|
||
readOnly={blockPasswordReveal}
|
||
autoComplete="username"
|
||
style={blockPasswordReveal ? { cursor: 'default' } : undefined}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label htmlFor={blockPasswordReveal ? 'login-password-mask' : 'login-password'}>{t('login.password')}</label>
|
||
{blockPasswordReveal ? (
|
||
<input
|
||
id="login-password-mask"
|
||
className="input"
|
||
type="text"
|
||
readOnly
|
||
value={DECODED_PASSWORD_VISUAL_MASK}
|
||
autoComplete="off"
|
||
aria-label={t('login.password')}
|
||
style={{ letterSpacing: '0.12em', cursor: 'default' }}
|
||
/>
|
||
) : (
|
||
<div style={{ position: 'relative' }}>
|
||
<input
|
||
id="login-password"
|
||
className="input"
|
||
type={showPass ? 'text' : 'password'}
|
||
placeholder="••••••••"
|
||
value={form.password}
|
||
onChange={update('password')}
|
||
autoComplete="current-password"
|
||
style={{ paddingRight: '2.5rem' }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
|
||
onClick={() => setShowPass(v => !v)}
|
||
aria-label={showPass ? t('login.hidePassword') : t('login.showPassword')}
|
||
>
|
||
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</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
|
||
id="login-magic-string"
|
||
className="input"
|
||
type="text"
|
||
placeholder={t('login.magicStringPlaceholder')}
|
||
value={magicString}
|
||
onChange={handleMagicStringChange}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
|
||
{testMessage && (
|
||
<div className={`login-status login-status--${status}`} role="alert">
|
||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />}
|
||
{status === 'ok' && <Wifi size={16} />}
|
||
{status === 'error' && <WifiOff size={16} />}
|
||
<span>{testMessage}</span>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
className="btn btn-primary"
|
||
style={{ width: '100%', justifyContent: 'center', padding: '0.75rem', fontSize: '15px' }}
|
||
id="login-connect-btn"
|
||
disabled={status === 'testing'}
|
||
>
|
||
{status === 'testing' ? t('login.connecting') : t('login.connect')}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|