feat: add Slot for login page

This change adds a Slot for the login page allowing it to be customised.

Since this touched the Login Page, LoginPage and Logistration have also
been refactored to move away from redux connect.

Adapted for frontend-base: uses Slot from @openedx/frontend-base instead
of PluginSlot from @openedx/frontend-plugin-framework, slot files live
under src/slots/, and the slot ID follows the frontend-base naming
convention (org.openedx.frontend.slot.authn.loginComponent.v1).

Co-Authored-By: Adolfo R. Brandes <adolfo@axim.org>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kshitij.sobti
2026-03-06 09:32:07 -03:00
committed by Adolfo R. Brandes
parent 4fc41b0fe7
commit c31c397c61
8 changed files with 180 additions and 163 deletions

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { connect } from 'react-redux';
import {
useCallback, useEffect, useMemo, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
getSiteConfig, sendPageEvent, sendTrackEvent, useIntl
} from '@openedx/frontend-base';
import {
Form, StatefulButton,
} from '@openedx/paragon';
import { Form, StatefulButton } from '@openedx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import Skeleton from 'react-loading-skeleton';
@@ -23,9 +23,7 @@ import { getThirdPartyAuthContext } from '../common-components/data/actions';
import { thirdPartyAuthContextSelector } from '../common-components/data/selectors';
import EnterpriseSSO from '../common-components/EnterpriseSSO';
import ThirdPartyAuth from '../common-components/ThirdPartyAuth';
import {
DEFAULT_STATE, PENDING_STATE, RESET_PAGE,
} from '../data/constants';
import { PENDING_STATE, RESET_PAGE } from '../data/constants';
import {
getActivationStatus,
getAllPossibleQueryParams,
@@ -35,45 +33,57 @@ import {
} from '../data/utils';
import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess';
import AccountActivationMessage from './AccountActivationMessage';
import {
backupLoginFormBegin,
dismissPasswordResetBanner,
loginRequest,
} from './data/actions';
import { backupLoginFormBegin, dismissPasswordResetBanner, loginRequest } from './data/actions';
import { INVALID_FORM, TPA_AUTHENTICATION_FAILURE } from './data/constants';
import LoginFailureMessage from './LoginFailure';
import messages from './messages';
const LoginPage = (props) => {
const LoginPage = ({
institutionLogin,
handleInstitutionLogin,
}) => {
const dispatch = useDispatch();
const backupFormState = useCallback((data) => dispatch(backupLoginFormBegin(data)), [dispatch]);
const getTPADataFromBackend = useCallback(() => dispatch(getThirdPartyAuthContext()), [dispatch]);
const {
backedUpFormData,
loginErrorCode,
loginErrorContext,
loginResult,
shouldBackupState,
thirdPartyAuthContext: {
providers,
currentProvider,
secondaryProviders,
finishAuthUrl,
platformName,
errorMessage: thirdPartyErrorMessage,
},
thirdPartyAuthApiStatus,
institutionLogin,
showResetPasswordSuccessBanner,
submitState,
// Actions
backupFormState,
handleInstitutionLogin,
getTPADataFromBackend,
} = props;
thirdPartyAuthContext,
thirdPartyAuthApiStatus,
} = useSelector((state) => ({
backedUpFormData: state.login.loginFormData,
loginErrorCode: state.login.loginErrorCode,
loginErrorContext: state.login.loginErrorContext,
loginResult: state.login.loginResult,
shouldBackupState: state.login.shouldBackupState,
showResetPasswordSuccessBanner: state.login.showResetPasswordSuccessBanner,
submitState: state.login.submitState,
thirdPartyAuthContext: thirdPartyAuthContextSelector(state),
thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
}));
const {
providers,
currentProvider,
secondaryProviders,
finishAuthUrl,
platformName,
errorMessage: thirdPartyErrorMessage,
} = thirdPartyAuthContext;
const { formatMessage } = useIntl();
const activationMsgType = getActivationStatus();
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields });
const [errorCode, setErrorCode] = useState({ type: '', count: 0, context: {} });
const [errorCode, setErrorCode] = useState({
type: '',
count: 0,
context: {},
});
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
const tpaHint = getTpaHint();
@@ -87,7 +97,7 @@ const LoginPage = (props) => {
payload.tpa_hint = tpaHint;
}
getTPADataFromBackend(payload);
}, [getTPADataFromBackend, queryParams, tpaHint]);
}, [queryParams, tpaHint, getTPADataFromBackend]);
/**
* Backup the login form in redux when login page is toggled.
*/
@@ -98,7 +108,7 @@ const LoginPage = (props) => {
errors: { ...errors },
});
}
}, [shouldBackupState, formFields, errors, backupFormState]);
}, [backupFormState, shouldBackupState, formFields, errors]);
useEffect(() => {
if (loginErrorCode) {
@@ -123,7 +133,10 @@ const LoginPage = (props) => {
}, [thirdPartyErrorMessage]);
const validateFormFields = (payload) => {
const { emailOrUsername, password } = payload;
const {
emailOrUsername,
password,
} = payload;
const fieldErrors = { ...errors };
if (emailOrUsername === '') {
@@ -141,14 +154,18 @@ const LoginPage = (props) => {
const handleSubmit = (event) => {
event.preventDefault();
if (showResetPasswordSuccessBanner) {
props.dismissPasswordResetBanner();
dispatch(dismissPasswordResetBanner());
}
const formData = { ...formFields };
const validationErrors = validateFormFields(formData);
if (validationErrors.emailOrUsername || validationErrors.password) {
setErrors({ ...validationErrors });
setErrorCode(prevState => ({ type: INVALID_FORM, count: prevState.count + 1, context: {} }));
setErrorCode(prevState => ({
type: INVALID_FORM,
count: prevState.count + 1,
context: {},
}));
return;
}
@@ -158,23 +175,35 @@ const LoginPage = (props) => {
password: formData.password,
...queryParams,
};
props.loginRequest(payload);
dispatch(loginRequest(payload));
};
const handleOnChange = (event) => {
const { name, value } = event.target;
setFormFields(prevState => ({ ...prevState, [name]: value }));
const {
name,
value,
} = event.target;
setFormFields(prevState => ({
...prevState,
[name]: value,
}));
};
const handleOnFocus = (event) => {
const { name } = event.target;
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
setErrors(prevErrors => ({
...prevErrors,
[name]: '',
}));
};
const trackForgotPasswordLinkClick = () => {
sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
};
const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders);
const {
provider,
skipHintedLogin,
} = getTpaProvider(tpaHint, providers, secondaryProviders);
if (tpaHint) {
if (thirdPartyAuthApiStatus === PENDING_STATE) {
@@ -281,88 +310,9 @@ const LoginPage = (props) => {
);
};
const mapStateToProps = state => {
const loginPageState = state.login;
return {
backedUpFormData: loginPageState.loginFormData,
loginErrorCode: loginPageState.loginErrorCode,
loginErrorContext: loginPageState.loginErrorContext,
loginResult: loginPageState.loginResult,
shouldBackupState: loginPageState.shouldBackupState,
showResetPasswordSuccessBanner: loginPageState.showResetPasswordSuccessBanner,
submitState: loginPageState.submitState,
thirdPartyAuthContext: thirdPartyAuthContextSelector(state),
thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
};
};
LoginPage.propTypes = {
backedUpFormData: PropTypes.shape({
formFields: PropTypes.shape({}),
errors: PropTypes.shape({}),
}),
loginErrorCode: PropTypes.string,
loginErrorContext: PropTypes.shape({
email: PropTypes.string,
redirectUrl: PropTypes.string,
context: PropTypes.shape({}),
}),
loginResult: PropTypes.shape({
redirectUrl: PropTypes.string,
success: PropTypes.bool,
}),
shouldBackupState: PropTypes.bool,
showResetPasswordSuccessBanner: PropTypes.bool,
submitState: PropTypes.string,
thirdPartyAuthApiStatus: PropTypes.string,
institutionLogin: PropTypes.bool.isRequired,
thirdPartyAuthContext: PropTypes.shape({
currentProvider: PropTypes.string,
errorMessage: PropTypes.string,
platformName: PropTypes.string,
providers: PropTypes.arrayOf(PropTypes.shape({})),
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({})),
finishAuthUrl: PropTypes.string,
}),
// Actions
backupFormState: PropTypes.func.isRequired,
dismissPasswordResetBanner: PropTypes.func.isRequired,
loginRequest: PropTypes.func.isRequired,
getTPADataFromBackend: PropTypes.func.isRequired,
handleInstitutionLogin: PropTypes.func.isRequired,
};
LoginPage.defaultProps = {
backedUpFormData: {
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
loginErrorCode: null,
loginErrorContext: {},
loginResult: {},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
submitState: DEFAULT_STATE,
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthContext: {
currentProvider: null,
errorMessage: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [],
},
};
export default connect(
mapStateToProps,
{
backupFormState: backupLoginFormBegin,
dismissPasswordResetBanner,
loginRequest,
getTPADataFromBackend: getThirdPartyAuthContext,
},
)(LoginPage);
export default LoginPage;

View File

@@ -40,6 +40,14 @@ describe('LoginPage', () => {
const initialState = {
login: {
loginResult: { success: false, redirectUrl: '' },
loginFormData: {
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
},
commonComponents: {
thirdPartyAuthApiStatus: null,

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import {
useAppConfig, getAuthService, getSiteConfig, sendPageEvent, sendTrackEvent, useIntl
@@ -23,16 +23,20 @@ import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
import {
getTpaHint, getTpaProvider, updatePathWithQueryParams,
} from '../data/utils';
import { LoginPage } from '../login';
import { backupLoginForm } from '../login/data/actions';
import { RegistrationPage } from '../register';
import { backupRegistrationForm } from '../register/data/actions';
import LoginComponentSlot from '../slots/LoginComponentSlot';
const Logistration = (props) => {
const { selectedPage, tpaProviders } = props;
const Logistration = ({
selectedPage,
}) => {
const tpaHint = getTpaHint();
const tpaProviders = useSelector(tpaProvidersSelector);
const dispatch = useDispatch();
const {
providers, secondaryProviders,
providers,
secondaryProviders,
} = tpaProviders;
const { formatMessage } = useIntl();
const [institutionLogin, setInstitutionLogin] = useState(false);
@@ -44,7 +48,8 @@ const Logistration = (props) => {
useEffect(() => {
const authService = getAuthService();
if (authService) {
authService.getCsrfTokenService().getCsrfToken(getSiteConfig().lmsBaseUrl);
authService.getCsrfTokenService()
.getCsrfToken(getSiteConfig().lmsBaseUrl);
}
});
@@ -70,11 +75,11 @@ const Logistration = (props) => {
return;
}
sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' });
props.clearThirdPartyAuthContextErrorMessage();
dispatch(clearThirdPartyAuthContextErrorMessage());
if (tabKey === LOGIN_PAGE) {
props.backupRegistrationForm();
dispatch(backupRegistrationForm());
} else if (tabKey === REGISTER_PAGE) {
props.backupLoginForm();
dispatch(backupLoginForm());
}
setKey(tabKey);
};
@@ -110,7 +115,10 @@ const Logistration = (props) => {
{!institutionLogin && (
<h3 className="mb-4.5">{formatMessage(messages['logistration.sign.in'])}</h3>
)}
<LoginPage institutionLogin={institutionLogin} handleInstitutionLogin={handleInstitutionLogin} />
<LoginComponentSlot
institutionLogin={institutionLogin}
handleInstitutionLogin={handleInstitutionLogin}
/>
</div>
</>
)
@@ -123,7 +131,11 @@ const Logistration = (props) => {
</Tabs>
)
: (!isValidTpaHint() && !hideRegistrationLink && (
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={(tabKey) => handleOnSelect(tabKey, selectedPage)}>
<Tabs
defaultActiveKey={selectedPage}
id="controlled-tab"
onSelect={(tabKey) => handleOnSelect(tabKey, selectedPage)}
>
<Tab title={formatMessage(messages['logistration.register'])} eventKey={REGISTER_PAGE} />
<Tab title={formatMessage(messages['logistration.sign.in'])} eventKey={LOGIN_PAGE} />
</Tabs>
@@ -138,7 +150,12 @@ const Logistration = (props) => {
</h3>
)}
{selectedPage === LOGIN_PAGE
? <LoginPage institutionLogin={institutionLogin} handleInstitutionLogin={handleInstitutionLogin} />
? (
<LoginComponentSlot
institutionLogin={institutionLogin}
handleInstitutionLogin={handleInstitutionLogin}
/>
)
: (
<RegistrationPage
institutionLogin={institutionLogin}
@@ -155,35 +172,10 @@ const Logistration = (props) => {
Logistration.propTypes = {
selectedPage: PropTypes.string,
backupLoginForm: PropTypes.func.isRequired,
backupRegistrationForm: PropTypes.func.isRequired,
clearThirdPartyAuthContextErrorMessage: PropTypes.func.isRequired,
tpaProviders: PropTypes.shape({
providers: PropTypes.arrayOf(PropTypes.shape({})),
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({})),
}),
};
Logistration.defaultProps = {
tpaProviders: {
providers: [],
secondaryProviders: [],
},
};
Logistration.defaultProps = {
selectedPage: REGISTER_PAGE,
};
const mapStateToProps = state => ({
tpaProviders: tpaProvidersSelector(state),
});
export default connect(
mapStateToProps,
{
backupLoginForm,
backupRegistrationForm,
clearThirdPartyAuthContextErrorMessage,
},
)(Logistration);
export default Logistration;

View File

@@ -56,16 +56,26 @@ describe('Logistration', () => {
marketingEmailsOptIn: true,
},
formFields: {
name: '', email: '', username: '', password: '',
name: '',
email: '',
username: '',
password: '',
},
emailSuggestion: {
suggestion: '', type: '',
suggestion: '',
type: '',
},
errors: {
name: '', email: '', username: '', password: '',
name: '',
email: '',
username: '',
password: '',
},
},
registrationResult: { success: false, redirectUrl: '' },
registrationResult: {
success: false,
redirectUrl: '',
},
registrationError: {},
usernameSuggestions: [],
validationApiRateLimited: false,
@@ -77,7 +87,18 @@ describe('Logistration', () => {
},
},
login: {
loginResult: { success: false, redirectUrl: '' },
loginResult: {
success: false,
redirectUrl: '',
},
loginFormData: {
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
},
};

View File

@@ -0,0 +1,19 @@
# Login Component Slot
### Slot ID: `org.openedx.frontend.slot.authn.loginComponent.v1`
## Description
This slot is used to replace/modify/hide the login component.
## Example
### Default content
![Default Login Page](./default_component.png)
### With a prepended message
![Login Page with prepended message](./component_with_prefix.png)
The site configuration can be used to add a widget before the login component
using the slot's `PREPEND` operation. Refer to the `@openedx/frontend-base`
Slot documentation for configuration details.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -0,0 +1,27 @@
import { Slot } from '@openedx/frontend-base';
import PropTypes from 'prop-types';
import LoginPage from '../../login/LoginPage';
const LoginComponentSlot = ({
institutionLogin,
handleInstitutionLogin,
}) => (
<Slot
id="org.openedx.frontend.slot.authn.loginComponent.v1"
institutionLogin={institutionLogin}
handleInstitutionLogin={handleInstitutionLogin}
>
<LoginPage
institutionLogin={institutionLogin}
handleInstitutionLogin={handleInstitutionLogin}
/>
</Slot>
);
LoginComponentSlot.propTypes = {
institutionLogin: PropTypes.bool,
handleInstitutionLogin: PropTypes.func,
};
export default LoginComponentSlot;