Files
Psychotoxical-psysonic/src/pages/Login.tsx
T

470 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 effects 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>
);
}