refactor login flow and hooks, address autologin issues

This commit is contained in:
seavor 2026-04-18 10:14:31 -05:00
parent dcd6dc00f4
commit bd2382c94e
43 changed files with 2179 additions and 484 deletions

View file

@ -1,29 +1,192 @@
import React, { useEffect, useState } from 'react';
import { Form, Field } from 'react-final-form';
import { Form, Field, useFormState, FormApi } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import { CheckboxField, InputField, KnownHosts } from '@app/components';
import { useAutoConnect } from '@app/hooks';
import { HostDTO, SettingDTO } from '@app/services';
import { App } from '@app/types';
import { useAppSelector, ServerSelectors } from '@app/store';
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
import './LoginForm.css';
const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginFormProps) => {
interface LoginFormProps {
onSubmit: (values: any) => void;
disableSubmitButton: boolean;
onResetPassword: () => void;
}
interface LoginFormBodyProps extends LoginFormProps {
form: FormApi;
handleSubmit: (event?: React.SyntheticEvent) => void;
}
const LoginFormBody = ({
form,
handleSubmit,
disableSubmitButton,
onResetPassword,
}: LoginFormBodyProps) => {
const { t } = useTranslation();
const PASSWORD_LABEL = t('Common.label.password');
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
const [host, setHost] = useState(null);
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
const [autoConnect, setAutoConnect] = useAutoConnect();
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
const settings = useSettings();
const hosts = useKnownHosts();
const { values } = useFormState();
const validate = values => {
const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined;
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
const [storedHashInvalidated, setStoredHashInvalidated] = useState(false);
const canUseStoredPassword = (remember: boolean, password: string | undefined) =>
Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated);
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
// Host-sync: when the selected host changes, mirror its username + stored-
// password hint into the form. Deliberately does NOT touch autoConnect — the
// persisted setting is decoupled from which host is currently picked.
useEffect(() => {
if (!selectedHost) {
return;
}
form.change('userName', selectedHost.userName);
form.change('password', '');
form.change('remember', Boolean(selectedHost.remember));
setStoredHashInvalidated(false);
togglePasswordLabel(
Boolean(selectedHost.remember && selectedHost.hashedPassword)
);
}, [selectedHost, form]);
// Mirror the persisted autoConnect setting into the form checkbox so the
// field reflects truth as soon as settings load.
useEffect(() => {
if (settings.status !== LoadingState.READY) {
return;
}
form.change('autoConnect', settings.value?.autoConnect);
}, [settings, form]);
const onUserNameChange = (userName: string | undefined) => {
const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase();
if (canUseStoredPassword(values.remember, values.password) && fieldChanged) {
setStoredHashInvalidated(true);
}
};
const onRememberChange = (checked: boolean) => {
// When the user unchecks "remember password", the auto-connect checkbox
// can't meaningfully stay on (there are no saved credentials to use), so
// reflect that in the form UI. Note: this writes only to the form field,
// NOT to the persisted setting — toggling host-level remember is not a
// user intent to change the app-level auto-connect preference.
if (!checked && values.autoConnect) {
form.change('autoConnect', false);
}
togglePasswordLabel(canUseStoredPassword(checked, values.password));
};
// User-initiated toggle of the auto-connect checkbox. This is the ONLY path
// that writes to the persisted setting — wired directly to the Checkbox's
// native onChange (see JSX below), not to a <OnChange> listener, because
// OnChange fires on programmatic form.change calls too (host-sync effects
// etc.) and would leak those into Dexie.
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
fieldOnChange(checked);
if (settings.status === LoadingState.READY) {
void settings.update({ autoConnect: checked });
}
if (checked && !values.remember) {
form.change('remember', true);
}
};
return (
<form className="loginForm" onSubmit={handleSubmit}>
<div className="loginForm-items">
<div className="loginForm-item">
<Field
label={t('Common.label.username')}
name="userName"
component={InputField}
autoComplete="username"
/>
<OnChange name="userName">{onUserNameChange}</OnChange>
</div>
<div className="loginForm-item">
<Field
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
onFocus={() => setUseStoredPasswordLabel(false)}
onBlur={() =>
togglePasswordLabel(canUseStoredPassword(values.remember, values.password))
}
name="password"
type="password"
component={InputField}
autoComplete="new-password"
/>
</div>
<div className="loginForm-actions">
<Field
label={t('LoginForm.label.savePassword')}
name="remember"
component={CheckboxField}
/>
<OnChange name="remember">{onRememberChange}</OnChange>
<Button color="primary" onClick={onResetPassword}>
{t('LoginForm.label.forgot')}
</Button>
</div>
<div className="loginForm-item">
<Field name="selectedHost" component={KnownHosts} />
</div>
<div className="loginForm-actions">
<Field name="autoConnect" type="checkbox">
{({ input }) => (
<FormControlLabel
className="checkbox-field"
label={t('LoginForm.label.autoConnect')}
control={
<Checkbox
className="checkbox-field__box"
checked={!!input.value}
onChange={(_e, checked) => onUserToggleAutoConnect(checked, input.onChange)}
color="primary"
/>
}
/>
)}
</Field>
</div>
</div>
<Button
className="loginForm-submit rounded tall"
color="primary"
variant="contained"
type="submit"
disabled={disableSubmitButton}
>
{t('LoginForm.label.login')}
</Button>
</form>
);
};
const LoginForm = (props: LoginFormProps) => {
const { t } = useTranslation();
const validate = (values: any) => {
const errors: any = {};
if (!values.userName) {
@ -34,137 +197,20 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm
}
return errors;
}
const useStoredPassword = (remember, password) => remember && host?.hashedPassword && !password;
const togglePasswordLabel = (useStoredLabel) => {
setUseStoredPasswordLabel(useStoredLabel);
};
const handleOnSubmit = ({ userName, ...values }) => {
const handleOnSubmit = ({ userName, ...values }: any) => {
userName = userName?.trim();
onSubmit({ userName, ...values });
}
props.onSubmit({ userName, ...values });
};
return (
<Form onSubmit={handleOnSubmit} validate={validate}>
{({ handleSubmit, form }) => {
const { values } = form.getState();
useEffect(() => {
SettingDTO.get(App.APP_USER).then((userSetting: SettingDTO) => {
if (userSetting?.autoConnect && !connectionAttemptMade) {
HostDTO.getAll().then(hosts => {
let lastSelectedHost = hosts.find(({ lastSelected }) => lastSelected);
if (lastSelectedHost?.remember && lastSelectedHost?.hashedPassword) {
togglePasswordLabel(true);
form.change('selectedHost', lastSelectedHost);
form.change('userName', lastSelectedHost.userName);
form.change('remember', true);
form.submit();
}
});
}
});
}, []);
useEffect(() => {
if (!host) {
return;
}
form.change('userName', host.userName);
form.change('password', '');
onRememberChange(host.remember);
onAutoConnectChange(host.remember && autoConnect);
togglePasswordLabel(useStoredPassword(host.remember, values.password));
}, [host]);
const onUserNameChange = (userName) => {
const fieldChanged = host?.userName?.toLowerCase() !== values.userName?.toLowerCase();
if (useStoredPassword(values.remember, values.password) && fieldChanged) {
setHost(({ hashedPassword: _hashedPassword, ...s }) => ({ ...s, userName }));
}
}
const onRememberChange = (checked) => {
form.change('remember', checked);
if (!checked && values.autoConnect) {
onAutoConnectChange(false);
}
togglePasswordLabel(useStoredPassword(checked, values.password));
}
const onAutoConnectChange = (checked) => {
setAutoConnect(checked);
form.change('autoConnect', checked);
if (checked && !values.remember) {
form.change('remember', true);
}
}
return (
<form className='loginForm' onSubmit={handleSubmit}>
<div className='loginForm-items'>
<div className='loginForm-item'>
<Field label={t('Common.label.username')} name='userName' component={InputField} autoComplete='username' />
<OnChange name="userName">{onUserNameChange}</OnChange>
</div>
<div className='loginForm-item'>
<Field
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
onFocus={() => setUseStoredPasswordLabel(false)}
onBlur={() => togglePasswordLabel(useStoredPassword(values.remember, values.password))}
name='password'
type='password'
component={InputField}
autoComplete='new-password'
/>
</div>
<div className='loginForm-actions'>
<Field label={t('LoginForm.label.savePassword')} name='remember' component={CheckboxField} />
<OnChange name="remember">{onRememberChange}</OnChange>
<Button color='primary' onClick={onResetPassword}>
{ t('LoginForm.label.forgot') }
</Button>
</div>
<div className='loginForm-item'>
<Field name='selectedHost' component={KnownHosts} />
<OnChange name="selectedHost">{setHost}</OnChange>
</div>
<div className='loginForm-actions'>
<Field label={t('LoginForm.label.autoConnect')} name='autoConnect' component={CheckboxField} />
<OnChange name="autoConnect">{onAutoConnectChange}</OnChange>
</div>
</div>
<Button
className='loginForm-submit rounded tall'
color='primary'
variant='contained'
type='submit'
disabled={disableSubmitButton}
>
{ t('LoginForm.label.login') }
</Button>
</form>
)
}}
{({ handleSubmit, form }) => (
<LoginFormBody {...props} form={form} handleSubmit={handleSubmit} />
)}
</Form>
);
};
interface LoginFormProps {
onSubmit: any;
disableSubmitButton: boolean,
onResetPassword: any;
}
export default LoginForm;