diff --git a/.eslintrc.json b/.eslintrc.json index 08b181685..fc3fcfb10 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -47,5 +47,6 @@ ] } ] - } + }, + "ignorePatterns": ["openwisp-radius/"] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b907e2a4..0eaf3692b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,8 @@ jobs: - name: Get openwisp-radius run: | - git clone --depth=1 https://github.com/openwisp/openwisp-radius/ openwisp-radius + # TODO: Remove branch before merging + git clone --depth=1 --branch issues/692-different-identity-verification https://github.com/openwisp/openwisp-radius/ openwisp-radius cd openwisp-radius echo "OpenWISP RADIUS commit: $(git rev-parse HEAD)" echo "OW_RADIUS_VERSION=$(git rev-parse HEAD)" >> $GITHUB_ENV @@ -131,6 +132,7 @@ jobs: pip install -U pip setuptools wheel pip install -U -e ".[saml]" pip install -U -r requirements-test.txt + pip install --no-deps --no-cache-dir --force-reinstall "https://github.com/openwisp/openwisp-users/tarball/issues/497-export-users" pip install -U "Django~=5.2.0" ./tests/manage.py migrate diff --git a/browser-test/browser_test_utils.py b/browser-test/browser_test_utils.py index 5c4a3378d..3702f19db 100644 --- a/browser-test/browser_test_utils.py +++ b/browser-test/browser_test_utils.py @@ -8,9 +8,12 @@ def cleanup_test_data(test_data): mobile_data = test_data["mobileVerificationTestUser"] + cross_org_data = test_data["crossOrgPhoneVerificationUser"] User.objects.filter(username=test_data["testuser"]["email"]).delete() User.objects.filter(username=test_data["expiredPasswordUser"]["email"]).delete() + User.objects.filter(username=cross_org_data["phoneNumber"]).delete() User.objects.filter(username=mobile_data["phoneNumber"]).delete() User.objects.filter(username=mobile_data["changePhoneNumber"]).delete() Organization.objects.filter(name=mobile_data["organization"]).delete() RadiusAccounting.objects.filter(username=test_data["testuser"]["email"]).delete() + RadiusAccounting.objects.filter(username=cross_org_data["phoneNumber"]).delete() diff --git a/browser-test/cross-org-phone-verification.test.js b/browser-test/cross-org-phone-verification.test.js new file mode 100644 index 000000000..01bf128f6 --- /dev/null +++ b/browser-test/cross-org-phone-verification.test.js @@ -0,0 +1,57 @@ +import {until} from "selenium-webdriver"; +import { + getDriver, + getElementByCss, + urls, + initialData, + initializeData, + tearDown, + getPhoneToken, + successToastSelector, +} from "./utils"; + +describe("Selenium tests for cross-organization phone verification", () => { + let driver; + + beforeAll(async () => { + await initializeData("crossOrgPhoneVerification"); + driver = await getDriver(); + }, 30000); + + afterAll(async () => { + await tearDown(driver); + }); + + it("should login to a new organization and complete phone verification", async () => { + const data = initialData().crossOrgPhoneVerificationUser; + await driver.get(urls.verificationLogin(data.targetOrganization)); + const username = await getElementByCss(driver, "input#username"); + await username.sendKeys(data.phoneNumber); + const password = await getElementByCss(driver, "input#password"); + await password.sendKeys(data.password); + const submitBtn = await getElementByCss(driver, "input[type=submit]"); + await submitBtn.click(); + const successToastDiv = await getElementByCss(driver, successToastSelector); + await driver.wait(until.elementIsVisible(successToastDiv)); + await driver.wait( + until.urlContains( + `/${data.targetOrganization}/mobile-phone-verification`, + ), + 5000, + ); + const codeInput = await getElementByCss(driver, "input#code"); + const token = getPhoneToken(data.phoneNumber); + await codeInput.sendKeys(token); + const verifyBtn = await getElementByCss(driver, "button[type='submit']"); + await verifyBtn.click(); + await driver.wait( + until.urlContains(`/${data.targetOrganization}/status`), + 5000, + ); + const emailElement = await getElementByCss( + driver, + "div > p:nth-child(5) > span", + ); + expect(await emailElement.getText()).toEqual(data.email); + }); +}); diff --git a/browser-test/get_phone_token.py b/browser-test/get_phone_token.py index 01e2ad2c8..a1251ca52 100755 --- a/browser-test/get_phone_token.py +++ b/browser-test/get_phone_token.py @@ -39,11 +39,13 @@ def load_test_data(): PhoneToken = load_model("openwisp_radius", "PhoneToken") test_data = load_test_data() +phone_number = ( + sys.argv[2] + if len(sys.argv) > 1 + else test_data["mobileVerificationTestUser"]["phoneNumber"] +) try: - user = User.objects.filter( - username=test_data["mobileVerificationTestUser"]["phoneNumber"] - ).first() - phone_token = PhoneToken.objects.filter(user=user).first() + phone_token = PhoneToken.objects.filter(phone_number=phone_number).first() sys.stdout.write(phone_token.token) sys.exit(0) except Exception as e: diff --git a/browser-test/initialize_data.py b/browser-test/initialize_data.py index dab15578d..6bd318177 100755 --- a/browser-test/initialize_data.py +++ b/browser-test/initialize_data.py @@ -23,6 +23,7 @@ def load_test_data(): # do not initialize data for registration tests registration_tests = "register" in sys.argv create_mobile_verification_org = "mobileVerification" in sys.argv +cross_org_phone_verification_tests = "crossOrgPhoneVerification" in sys.argv expired_password_tests = "expiredPassword" in sys.argv sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -79,6 +80,47 @@ def load_test_data(): RegisteredUser.objects.create(user=user, method=data["method"]) OrganizationUser.objects.create(organization=org, user=user) +if cross_org_phone_verification_tests: + data = test_data["crossOrgPhoneVerificationUser"] + target_org, _ = Organization.objects.get_or_create( + slug=data["targetOrganization"], name=data["targetOrganization"] + ) + target_settings, created = OrganizationRadiusSettings.objects.get_or_create( + organization=target_org, + defaults={ + "needs_identity_verification": True, + "sms_verification": True, + "sms_sender": data["email"], + }, + ) + if not created: + target_settings.needs_identity_verification = True + target_settings.sms_verification = True + target_settings.sms_sender = data["email"] + target_settings.save() + cross_org_user = User.objects.create_user( + username=data["phoneNumber"], + password=data["password"], + email=data["email"], + phone_number=data["phoneNumber"], + ) + try: + source_org = Organization.objects.get(slug=data["sourceOrganization"]) + except Organization.DoesNotExist: + print( + ( + f"The source organization {data['sourceOrganization']} does not exist " + f"in the OpenWISP Radius environment specified ({OPENWISP_RADIUS_PATH}), " + f"please create it and repeat the tests." + ), + file=sys.stderr, + ) + else: + OrganizationUser.objects.create(organization=source_org, user=cross_org_user) + RegisteredUser.objects.create( + user=cross_org_user, method=data["method"], is_verified=True + ) + try: org = Organization.objects.get(slug=test_user_organization) diff --git a/browser-test/mobile-phone-change.test.js b/browser-test/mobile-phone-change.test.js index 0873edeb5..b2637981f 100644 --- a/browser-test/mobile-phone-change.test.js +++ b/browser-test/mobile-phone-change.test.js @@ -59,7 +59,7 @@ describe("Selenium tests for ", () => { expect(await successToastDiv.getText()).toEqual("Login successful"); let codeInput = await getElementByCss(driver, "input#code"); await driver.wait(until.elementIsVisible(codeInput)); - const token = getPhoneToken(); + const token = getPhoneToken(data.phoneNumber); await codeInput.sendKeys(token); submitBtn = await getElementByCss(driver, "button[type='submit']"); await driver.wait(until.elementIsVisible(submitBtn)); @@ -94,7 +94,7 @@ describe("Selenium tests for ", () => { expect(await successToastDiv.getText()).toEqual( "SMS verification code sent successfully.", ); - const newToken = getPhoneToken(); + const newToken = getPhoneToken(data.changePhoneNumber); codeInput = await getElementByCss(driver, "input#code"); await driver.wait(until.elementIsVisible(codeInput)); await codeInput.sendKeys(newToken); diff --git a/browser-test/mobile-verfication.test.js b/browser-test/mobile-verfication.test.js index 772abaf4b..6433a857b 100644 --- a/browser-test/mobile-verfication.test.js +++ b/browser-test/mobile-verfication.test.js @@ -66,7 +66,7 @@ describe("Selenium tests for ", () => { await driver.wait(until.elementIsVisible(failureToastDiv)); expect(await failureToastDiv.getText()).toEqual("Invalid code."); await driver.navigate().refresh(); - const token = getPhoneToken(); + const token = getPhoneToken(data.phoneNumber); codeInput = await getElementByCss(driver, "input#code"); await codeInput.clear(); await codeInput.sendKeys(token); diff --git a/browser-test/testData.json b/browser-test/testData.json index b4bf89fd6..dd93cacee 100644 --- a/browser-test/testData.json +++ b/browser-test/testData.json @@ -19,5 +19,13 @@ "method": "mobile_phone", "changePhoneNumber": "+393660011333" }, + "crossOrgPhoneVerificationUser": { + "email": "crossorg-phone@openwisp.org", + "password": "testuser", + "sourceOrganization": "default", + "targetOrganization": "mobile", + "phoneNumber": "+911234567899", + "method": "mobile_phone" + }, "allOrgScript": "analytics.js" } diff --git a/browser-test/utils.js b/browser-test/utils.js index 7f5505f95..90fab1744 100644 --- a/browser-test/utils.js +++ b/browser-test/utils.js @@ -72,8 +72,8 @@ export const tearDown = async (driver) => { driver.close(); }; -export const getPhoneToken = () => { - const result = spawnSync("./browser-test/get_phone_token.py"); +export const getPhoneToken = (phoneNumber) => { + const result = spawnSync("./browser-test/get_phone_token.py", [phoneNumber]); return result.stdout.toString(); }; diff --git a/client/components/complete-signup/complete-signup.js b/client/components/complete-signup/complete-signup.js new file mode 100644 index 000000000..3d52e6aa1 --- /dev/null +++ b/client/components/complete-signup/complete-signup.js @@ -0,0 +1,362 @@ +/* eslint-disable camelcase */ +import "./index.css"; + +import PropTypes from "prop-types"; +import React from "react"; +import {toast} from "react-toastify"; +import {t} from "ttag"; + +import LoadingContext from "../../utils/loading-context"; +import getErrorText from "../../utils/get-error-text"; +import getError from "../../utils/get-error"; +import getPlanSelection from "../../utils/get-plan-selection"; +import getPlans from "../../utils/get-plans"; +import logError from "../../utils/log-error"; +import {getVerificationRoute} from "../../utils/pending-verification"; +import upgradePlan from "../../utils/upgrade-plan"; +import updateRegistrationMethod from "../../utils/update-registration-method"; +import Contact from "../contact-box"; + +const handleFlowError = (error, setErrorsState) => { + const errorText = error.response + ? getErrorText(error, t`ERR_OCCUR`) + : t`ERR_OCCUR`; + logError(error, errorText); + toast.error(errorText); + if (setErrorsState) { + setErrorsState({nonfield: [errorText]}); + } +}; + +export default class CompleteSignup extends React.Component { + constructor(props) { + super(props); + this.isComponentMounted = true; + this.state = { + errors: {}, + plans: [], + plansFetched: false, + plansError: null, + selectedPlan: null, + message: null, + submitting: false, + }; + } + + setStateSafe(state, callback) { + if (this.isComponentMounted) { + this.setState(state, callback); + } + } + + componentWillUnmount() { + this.isComponentMounted = false; + } + + componentDidMount() { + const {setLoading} = this.context; + const {setTitle, orgName, settings, orgSlug, language, userData} = + this.props; + setTitle(t`REGISTRATION_TITL`, orgName); + setLoading(true); + this.handleAutoTransition({ + orgSlug, + userData, + settings, + language, + }); + } + + /** + * Handles success callback when plans are fetched successfully. + * Differentiates between empty plans (org disabled registration) + * vs HTTP errors. + */ + handlePlansSuccess = (plans) => { + const {setLoading} = this.context; + const plansError = null; + let message = null; + + if (!plans || plans.length === 0) { + // Empty plans array (200 OK) means org has disabled registration + message = t`ORG_REGISTRATION_DISABLED`; + } + this.setStateSafe({plans, plansFetched: true, plansError, message}); + setLoading(false); + }; + + /** + * Handles failure callback when plans fail to fetch (network error, 500, etc). + * Shows error toast and allows retry. + */ + handlePlansFailure = () => { + const {setLoading} = this.context; + this.setStateSafe({ + plans: [], + plansFetched: true, + plansError: t`PLANS_FETCH_ERR`, + }); + setLoading(false); + }; + + finalOperations = (nextUserData, route) => { + const {setLoading} = this.context; + const {setUserData, navigate} = this.props; + if (this.isComponentMounted) { + setUserData(nextUserData); + navigate(route); + setLoading(false); + } + }; + + /** + * Handles auto-transition based on organization settings. + * - If subscriptions disabled: proceed with registration method update + * - If subscriptions enabled: fetch plans and show plan selection UI + */ + handleAutoTransition = async ({orgSlug, userData, settings, language}) => { + const {setLoading} = this.context; + // If subscriptions are disabled, proceed with auto-transition + if (!settings.subscriptions) { + try { + if (settings.mobile_phone_verification) { + // Update registration method to mobile_phone for verification + await updateRegistrationMethod( + orgSlug, + "mobile_phone", + userData.auth_token, + language, + ); + const nextUserData = {...userData, method: "mobile_phone"}; + this.finalOperations( + nextUserData, + getVerificationRoute(orgSlug, "mobile_phone"), + ); + return; + } + // No mobile verification - go directly to status + await updateRegistrationMethod( + orgSlug, + "", + userData.auth_token, + language, + ); + const nextUserData = {...userData, method: ""}; + this.finalOperations(nextUserData, `/${orgSlug}/status`); + } catch (error) { + if (this.isComponentMounted) { + handleFlowError(error, (errors) => this.setStateSafe({errors})); + setLoading(false); + } + } + return; + } + // Subscriptions enabled - fetch plans for selection + getPlans( + orgSlug, + language, + this.handlePlansSuccess, + this.handlePlansFailure, + ); + }; + + /** + * Unified handler for submitting a plan selection (free or paid). + * Handles: + * 1. Registering with the selected plan + * 2. Updating registration method based on plan type and settings + * 3. Navigating to appropriate verification flow + * + * @param {string} planIndex - Index of the selected plan in the plans array + */ + handleSubmitPlan = async (planIndex) => { + const {setLoading} = this.context; + const {orgSlug, userData, setUserData, navigate, settings, language} = + this.props; + const {plans, submitting} = this.state; + const selectedPlan = plans[planIndex]; + if (!selectedPlan || submitting) { + return; + } + + setLoading(true); + this.setStateSafe({errors: {}, submitting: true}); + const requiresPayment = selectedPlan.requires_payment === true; + + try { + // Upgrade to selected plan + let paymentUrl = null; + if (selectedPlan.id) { + const response = await upgradePlan( + orgSlug, + selectedPlan.id, + userData.auth_token, + language, + ); + paymentUrl = response.payment_url; + } + + // Update registration method based on plan and settings + // For paid plans, always use bank_card method + // For free plans, check mobile_phone_verification setting + let nextUserData; + if (requiresPayment) { + await updateRegistrationMethod( + orgSlug, + "bank_card", + userData.auth_token, + language, + ); + nextUserData = { + ...userData, + method: "bank_card", + payment_url: paymentUrl, + }; + if (this.isComponentMounted) { + setUserData(nextUserData); + navigate(getVerificationRoute(orgSlug, "bank_card")); + setLoading(false); + } + return; + } + + // Free plan - check if mobile verification is enabled + if (settings.mobile_phone_verification) { + await updateRegistrationMethod( + orgSlug, + "mobile_phone", + userData.auth_token, + language, + ); + nextUserData = {...userData, method: "mobile_phone"}; + this.finalOperations( + nextUserData, + getVerificationRoute(orgSlug, "mobile_phone"), + ); + return; + } + + // No mobile verification - go to status page + await updateRegistrationMethod( + orgSlug, + "", + userData.auth_token, + language, + ); + nextUserData = {...userData, method: ""}; + this.finalOperations(nextUserData, `/${orgSlug}/status`); + } catch (error) { + if (this.isComponentMounted) { + handleFlowError(error, (errors) => + this.setStateSafe({errors, submitting: false}), + ); + setLoading(false); + } + } finally { + if (this.isComponentMounted) { + this.setStateSafe({submitting: false}); + } + } + }; + + handlePlanChange = (event) => { + const planIndex = event.target.value; + const {plans} = this.state; + const selectedPlan = plans[planIndex]; + if (!selectedPlan) { + return; + } + this.setStateSafe({selectedPlan: planIndex}); + this.handleSubmitPlan(planIndex); + }; + + handlePlansRetry = () => { + const {orgSlug, language} = this.props; + const {setLoading} = this.context; + setLoading(true); + this.setStateSafe({errors: {}, plansError: null, message: null}, () => { + getPlans( + orgSlug, + language, + this.handlePlansSuccess, + this.handlePlansFailure, + ); + }); + }; + + render() { + const {defaultLanguage, orgName, settings} = this.props; + const {plans, plansFetched, plansError, message, selectedPlan, errors} = + this.state; + return ( +
+
+
+
+

{t`REGISTRATION_TITL`}

+
+ {t`REGISTRATION_COMPLETE_PROMPT`} {orgName}. +
+ {getError(errors)} + {/* HTTP error state - show error with retry button */} + {settings.subscriptions && plansFetched && plansError && ( +
+
{plansError}
+ +
+ )} + {/* Empty plans (200 OK) - organization disabled registration */} + {settings.subscriptions && + plansFetched && + !plansError && + message && ( +
+
{message}
+
+ )} + {/* Success state - show plan selection */} + {settings.subscriptions && + plansFetched && + !plansError && + !message && ( +
+ {getPlanSelection( + defaultLanguage, + plans, + selectedPlan, + this.handlePlanChange, + )} +
+ )} +
+
+ +
+
+ ); + } +} + +CompleteSignup.contextType = LoadingContext; +CompleteSignup.propTypes = { + orgSlug: PropTypes.string.isRequired, + orgName: PropTypes.string.isRequired, + settings: PropTypes.shape({ + mobile_phone_verification: PropTypes.bool, + subscriptions: PropTypes.bool, + }).isRequired, + defaultLanguage: PropTypes.string.isRequired, + userData: PropTypes.shape({ + auth_token: PropTypes.string, + }).isRequired, + setTitle: PropTypes.func.isRequired, + setUserData: PropTypes.func.isRequired, + navigate: PropTypes.func.isRequired, + language: PropTypes.string.isRequired, +}; diff --git a/client/components/complete-signup/complete-signup.test.js b/client/components/complete-signup/complete-signup.test.js new file mode 100644 index 000000000..07994440e --- /dev/null +++ b/client/components/complete-signup/complete-signup.test.js @@ -0,0 +1,394 @@ +/* eslint-disable prefer-promise-reject-errors */ +import React from "react"; +import {shallow} from "enzyme"; +import {toast} from "react-toastify"; +import PropTypes from "prop-types"; + +import {t} from "ttag"; +import {loadingContextValue} from "../../utils/loading-context"; +import CompleteSignup from "./complete-signup"; +import getPlans from "../../utils/get-plans"; +import upgradePlan from "../../utils/upgrade-plan"; +import updateRegistrationMethod from "../../utils/update-registration-method"; + +jest.mock("../../utils/update-registration-method"); +jest.mock("../../utils/get-plans"); +jest.mock("../../utils/upgrade-plan"); + +const plans = [ + { + id: "free-plan", + plan: "Free", + pricing: "no expiration (free) (0 days)", + plan_description: "free plan", + currency: "EUR", + requires_payment: false, + requires_invoice: false, + price: "0.00", + }, + { + id: "paid-plan", + plan: "Premium", + pricing: "per year (365 days)", + plan_description: "paid plan", + currency: "EUR", + requires_payment: true, + requires_invoice: false, + price: "9.99", + }, +]; + +const createTestProps = (props) => ({ + orgSlug: "default", + orgName: "Default", + settings: { + mobile_phone_verification: true, + subscriptions: true, + }, + defaultLanguage: "en", + userData: { + auth_token: "test-token", + method: "pending_verification", + }, + setTitle: jest.fn(), + setUserData: jest.fn(), + navigate: jest.fn(), + language: "en", + ...props, +}); + +describe("", () => { + let props; + let wrapper; + + beforeEach(() => { + props = createTestProps(); + updateRegistrationMethod.mockReset(); + getPlans.mockReset(); + upgradePlan.mockReset(); + CompleteSignup.contextTypes = { + setLoading: PropTypes.func, + }; + wrapper = shallow(, { + context: loadingContextValue, + }); + }); + + it("fetches plans when subscriptions are enabled", () => { + expect(getPlans).toHaveBeenCalledWith( + "default", + "en", + wrapper.instance().handlePlansSuccess, + wrapper.instance().handlePlansFailure, + ); + }); + + it("auto-transitions to phone verification when subscriptions are disabled", async () => { + getPlans.mockClear(); + props = createTestProps({ + settings: { + mobile_phone_verification: true, + subscriptions: false, + }, + }); + updateRegistrationMethod.mockResolvedValue({method: "mobile_phone"}); + + wrapper = shallow(, { + context: loadingContextValue, + }); + + await Promise.resolve(); + + expect(updateRegistrationMethod).toHaveBeenCalledWith( + "default", + "mobile_phone", + "test-token", + "en", + ); + expect(getPlans).not.toHaveBeenCalled(); + expect(props.setUserData).toHaveBeenCalledWith( + expect.objectContaining({method: "mobile_phone"}), + ); + expect(props.navigate).toHaveBeenCalledWith( + "/default/mobile-phone-verification", + ); + }); + + it("auto-transitions to status when subscriptions and phone verification are disabled", async () => { + getPlans.mockClear(); + props = createTestProps({ + settings: { + mobile_phone_verification: false, + subscriptions: false, + }, + }); + updateRegistrationMethod.mockResolvedValue({method: ""}); + wrapper = shallow(, { + context: loadingContextValue, + }); + await Promise.resolve(); + expect(updateRegistrationMethod).toHaveBeenCalledWith( + "default", + "", + "test-token", + "en", + ); + expect(getPlans).not.toHaveBeenCalled(); + expect(props.setUserData).toHaveBeenCalledWith( + expect.objectContaining({method: ""}), + ); + expect(props.navigate).toHaveBeenCalledWith("/default/status"); + }); + + it("shows error toast when auto-transition fails", async () => { + getPlans.mockClear(); + props = createTestProps({ + settings: { + mobile_phone_verification: true, + subscriptions: false, + }, + }); + const errorToast = jest.spyOn(toast, "error").mockImplementation(() => {}); + updateRegistrationMethod.mockRejectedValue({ + response: {data: {detail: "token expired"}}, + }); + + wrapper = shallow(, { + context: loadingContextValue, + }); + + await Promise.resolve(); + + expect(errorToast).toHaveBeenCalled(); + expect(props.navigate).not.toHaveBeenCalled(); + }); + + it("shows generic error toast when auto-transition fails without response", async () => { + getPlans.mockClear(); + props = createTestProps({ + settings: { + mobile_phone_verification: false, + subscriptions: false, + }, + }); + const errorToast = jest.spyOn(toast, "error").mockImplementation(() => {}); + updateRegistrationMethod.mockRejectedValue(new Error("network error")); + + wrapper = shallow(, { + context: loadingContextValue, + }); + + await Promise.resolve(); + + expect(errorToast).toHaveBeenCalledWith(t`ERR_OCCUR`); + expect(props.navigate).not.toHaveBeenCalled(); + }); + + it("renders generic copy with organization name", () => { + expect(wrapper.text()).toContain(t`REGISTRATION_COMPLETE_PROMPT`); + expect(wrapper.text()).toContain("Default"); + }); + + it("shows plans after successful fetch", () => { + wrapper.instance().handlePlansSuccess(plans); + + expect(wrapper.find(".plans")).toHaveLength(1); + }); + + it("shows error UI when plans fetch fails", () => { + const errorToast = jest.spyOn(toast, "error").mockImplementation(() => {}); + jest.clearAllMocks(); + wrapper.instance().handlePlansFailure(); + expect(wrapper.find(".complete-signup-error")).toHaveLength(1); + expect(wrapper.find(".plans")).toHaveLength(0); + expect(props.navigate).not.toHaveBeenCalled(); + expect(errorToast).not.toHaveBeenCalled(); + errorToast.mockRestore(); + }); + + it("shows message when plans fetch returns empty array", () => { + wrapper.instance().handlePlansSuccess([]); + + expect(wrapper.find(".complete-signup-error")).toHaveLength(1); + expect(wrapper.find(".plans")).toHaveLength(0); + expect(props.navigate).not.toHaveBeenCalled(); + }); + + it("retries plan fetch on retry button click", () => { + wrapper.instance().handlePlansFailure(); + wrapper.instance().handlePlansRetry(); + + expect(getPlans).toHaveBeenCalledWith( + "default", + "en", + wrapper.instance().handlePlansSuccess, + wrapper.instance().handlePlansFailure, + ); + }); + + it("clears retry state before fetching plans again", () => { + wrapper.instance().handlePlansFailure(); + wrapper.setState({message: "Registration is disabled"}); + + wrapper.instance().handlePlansRetry(); + + expect(wrapper.instance().state.plansError).toBe(null); + expect(wrapper.instance().state.message).toBe(null); + }); + + it("shows disabled registration message when plans are null", () => { + wrapper.instance().handlePlansSuccess(null); + + expect(wrapper.find(".complete-signup-error")).toHaveLength(1); + expect(wrapper.find(".plans")).toHaveLength(0); + }); + + it("handles free plan selection with phone verification enabled", async () => { + upgradePlan.mockResolvedValue({}); + updateRegistrationMethod.mockResolvedValue({method: "mobile_phone"}); + wrapper.instance().handlePlansSuccess(plans); + + await wrapper.instance().handleSubmitPlan(0); + + expect(upgradePlan).toHaveBeenCalledWith( + "default", + "free-plan", + "test-token", + "en", + ); + expect(updateRegistrationMethod).toHaveBeenCalledWith( + "default", + "mobile_phone", + "test-token", + "en", + ); + expect(props.navigate).toHaveBeenCalledWith( + "/default/mobile-phone-verification", + ); + }); + + it("handles free plan selection without phone verification", async () => { + props = createTestProps({ + settings: { + mobile_phone_verification: false, + subscriptions: true, + }, + }); + wrapper = shallow(, { + context: loadingContextValue, + }); + upgradePlan.mockResolvedValue({}); + wrapper.instance().handlePlansSuccess(plans); + + await wrapper.instance().handleSubmitPlan(0); + + expect(updateRegistrationMethod).toHaveBeenCalledWith( + "default", + "", + "test-token", + "en", + ); + expect(props.setUserData).toHaveBeenCalledWith( + expect.objectContaining({method: ""}), + ); + expect(props.navigate).toHaveBeenCalledWith("/default/status"); + }); + + it("submits paid plan and redirects to payment draft", async () => { + updateRegistrationMethod.mockResolvedValue({method: "bank_card"}); + upgradePlan.mockResolvedValue({ + payment_url: "https://payment.example/1", + }); + wrapper.instance().handlePlansSuccess(plans); + + await wrapper.instance().handleSubmitPlan(1); + + expect(updateRegistrationMethod).toHaveBeenCalledWith( + "default", + "bank_card", + "test-token", + "en", + ); + expect(upgradePlan).toHaveBeenCalledWith( + "default", + "paid-plan", + "test-token", + "en", + ); + expect(props.setUserData).toHaveBeenCalledWith( + expect.objectContaining({ + method: "bank_card", + payment_url: "https://payment.example/1", + }), + ); + expect(props.navigate).toHaveBeenCalledWith("/default/payment/draft"); + }); + + it("ignores invalid plan submission indexes", async () => { + wrapper.instance().handlePlansSuccess(plans); + + await wrapper.instance().handleSubmitPlan(9); + + expect(upgradePlan).not.toHaveBeenCalled(); + expect(updateRegistrationMethod).not.toHaveBeenCalled(); + expect(props.navigate).not.toHaveBeenCalled(); + }); + + it("submits plans from handlePlanChange when the selection is valid", () => { + const handleSubmitPlan = jest + .spyOn(wrapper.instance(), "handleSubmitPlan") + .mockImplementation(() => Promise.resolve()); + wrapper.instance().handlePlansSuccess(plans); + + wrapper.instance().handlePlanChange({target: {value: "0"}}); + + expect(wrapper.instance().state.selectedPlan).toBe("0"); + expect(handleSubmitPlan).toHaveBeenCalledWith("0"); + }); + + it("ignores invalid selections in handlePlanChange", () => { + const handleSubmitPlan = jest + .spyOn(wrapper.instance(), "handleSubmitPlan") + .mockImplementation(() => Promise.resolve()); + wrapper.instance().handlePlansSuccess(plans); + + wrapper.instance().handlePlanChange({target: {value: "10"}}); + + expect(wrapper.instance().state.selectedPlan).toBe(null); + expect(handleSubmitPlan).not.toHaveBeenCalled(); + }); + + it("shows an error toast when plan submission fails", async () => { + const errorToast = jest.spyOn(toast, "error").mockImplementation(() => {}); + updateRegistrationMethod.mockRejectedValue({ + response: {data: {detail: "bad request"}}, + }); + upgradePlan.mockResolvedValue({}); + wrapper.instance().handlePlansSuccess(plans); + + await wrapper.instance().handleSubmitPlan(0); + + expect(props.navigate).not.toHaveBeenCalled(); + expect(errorToast).toHaveBeenCalled(); + }); + + it("shows generic error toast when plan submission fails without response", async () => { + const errorToast = jest.spyOn(toast, "error").mockImplementation(() => {}); + upgradePlan.mockRejectedValue(new Error("network error")); + wrapper.instance().handlePlansSuccess(plans); + + await wrapper.instance().handleSubmitPlan(0); + + expect(props.navigate).not.toHaveBeenCalled(); + expect(errorToast).toHaveBeenCalledWith(t`ERR_OCCUR`); + }); + + it("does not set local state after unmount", () => { + const setStateSpy = jest.spyOn(wrapper.instance(), "setState"); + + wrapper.instance().componentWillUnmount(); + wrapper.instance().setStateSafe({submittingMethod: "mobile_phone"}); + + expect(setStateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/client/components/complete-signup/index.css b/client/components/complete-signup/index.css new file mode 100644 index 000000000..d3a3269a3 --- /dev/null +++ b/client/components/complete-signup/index.css @@ -0,0 +1,9 @@ +#complete-signup .complete-signup-buttons { + display: flex; + flex-direction: column; + gap: 1rem; +} + +#complete-signup .complete-signup-plans { + margin-top: 1rem; +} diff --git a/client/components/complete-signup/index.js b/client/components/complete-signup/index.js new file mode 100644 index 000000000..a6a6e29e3 --- /dev/null +++ b/client/components/complete-signup/index.js @@ -0,0 +1,23 @@ +import {connect} from "react-redux"; + +import Component from "./complete-signup"; +import {setTitle, setUserData} from "../../actions/dispatchers"; + +const mapStateToProps = (state) => { + const conf = state.organization.configuration; + return { + orgSlug: conf.slug, + orgName: conf.name, + settings: conf.settings, + defaultLanguage: conf.default_language, + userData: conf.userData, + language: state.language, + }; +}; + +const mapDispatchToProps = (dispatch) => ({ + setTitle: setTitle(dispatch), + setUserData: setUserData(dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/client/components/login/login.js b/client/components/login/login.js index 56d32960f..17191701e 100644 --- a/client/components/login/login.js +++ b/client/components/login/login.js @@ -27,6 +27,7 @@ import Modal from "../modal"; import {Status} from "../organization-wrapper/lazy-import"; import getError from "../../utils/get-error"; import getLanguageHeaders from "../../utils/get-language-headers"; +import {userPendingVerification} from "../../utils/pending-verification"; import redirectToPayment from "../../utils/redirect-to-payment"; import {localStorage, sessionStorage} from "../../utils/storage"; @@ -299,16 +300,29 @@ export default class Login extends React.Component { if (!remember_me || useSessionStorage) { sessionStorage.setItem(`${orgSlug}_auth_token`, data.key); } + const {key: auth_token, ...authenticatedUser} = data; + const nextUserData = { + ...authenticatedUser, + auth_token, + mustLogin: true, + }; this.dismissWait(); toast.success(t`LOGIN_SUCCESS`, { toastId: mainToastId, }); - const {key: auth_token} = data; - delete data.key; // eslint-disable-line no-param-reassign - setUserData({...data, auth_token, mustLogin: true}); - // if requires payment redirect to payment status component - if (data.method === "bank_card" && data.is_verified === false) { + setUserData(nextUserData); + if (userPendingVerification(data)) { + navigate(`/${orgSlug}/complete-signup`); + authenticate(true); + return; + } + if ( + nextUserData.method === "bank_card" && + nextUserData.is_verified === false + ) { redirectToPayment(orgSlug, navigate); + authenticate(true); + return; } authenticate(true); }; diff --git a/client/components/login/login.test.js b/client/components/login/login.test.js index afe886425..3e0353942 100644 --- a/client/components/login/login.test.js +++ b/client/components/login/login.test.js @@ -37,6 +37,7 @@ const createTestProps = (props) => ({ termsAndConditions: defaultConfig.terms_and_conditions, settings: { mobile_phone_verification: false, + subscriptions: false, radius_realms: false, passwordless_auth_token_name: "sesame", }, @@ -504,6 +505,86 @@ describe(" interactions", () => { expect(redirectToPayment).toHaveBeenCalledWith("default", props.navigate); expect(authenticateMock.calls.length).toBe(1); }); + it("should redirect when pending verification has only phone enabled", async () => { + props.settings = { + mobile_phone_verification: true, + subscriptions: false, + }; + wrapper = mountComponent(props); + const login = wrapper.find(Login); + await login.instance().handleAuthentication({ + ...responseData, + is_verified: false, + method: "pending_verification", + }); + expect(login.instance().props.setUserData).toHaveBeenCalledWith({ + ...userData, + is_verified: false, + method: "pending_verification", + auth_token: responseData.key, + mustLogin: true, + }); + expect(props.navigate).toHaveBeenCalledWith("/default/complete-signup"); + expect(login.instance().props.authenticate).toHaveBeenCalledWith(true); + }); + it("should redirect to complete signup when pending verification has only subscriptions enabled", async () => { + props.settings = { + mobile_phone_verification: false, + subscriptions: true, + }; + wrapper = mountComponent(props); + const login = wrapper.find(Login); + await login.instance().handleAuthentication({ + ...responseData, + is_verified: false, + method: "pending_verification", + }); + expect(login.instance().props.setUserData).toHaveBeenCalledWith({ + ...userData, + is_verified: false, + method: "pending_verification", + auth_token: responseData.key, + mustLogin: true, + }); + expect(props.navigate).toHaveBeenCalledWith("/default/complete-signup"); + expect(login.instance().props.authenticate).toHaveBeenCalledWith(true); + }); + it("should redirect to complete signup when multiple verification methods are enabled", async () => { + props.settings = { + mobile_phone_verification: true, + subscriptions: true, + }; + wrapper = mountComponent(props); + const login = wrapper.find(Login); + await login.instance().handleAuthentication({ + ...responseData, + is_verified: false, + method: "pending_verification", + }); + expect(login.instance().props.setUserData).toHaveBeenCalledWith({ + ...userData, + is_verified: false, + method: "pending_verification", + auth_token: responseData.key, + mustLogin: true, + }); + expect(props.navigate).toHaveBeenCalledWith("/default/complete-signup"); + expect(login.instance().props.authenticate).toHaveBeenCalledWith(true); + }); + it("should NOT redirect to complete signup when method is not pending_verification", async () => { + props.settings = { + mobile_phone_verification: true, + subscriptions: true, + }; + wrapper = mountComponent(props); + const login = wrapper.find(Login); + await login.instance().handleAuthentication({ + ...responseData, + is_verified: false, + method: "mobile_phone", + }); + expect(props.navigate).not.toHaveBeenCalledWith("/default/complete-signup"); + }); it("phone_number field should be present if mobile phone verification is on", async () => { props.settings = {mobile_phone_verification: true}; wrapper = mountComponent(props); diff --git a/client/components/mobile-phone-change/mobile-phone-change.js b/client/components/mobile-phone-change/mobile-phone-change.js index fb6313abb..4c676e66f 100644 --- a/client/components/mobile-phone-change/mobile-phone-change.js +++ b/client/components/mobile-phone-change/mobile-phone-change.js @@ -33,10 +33,21 @@ class MobilePhoneChange extends React.Component { phone_number: "", errors: {}, }; + this.isComponentMounted = true; this.handleSubmit = this.handleSubmit.bind(this); this.handleChange = this.handleChange.bind(this); } + setStateSafe(state, callback) { + if (this.isComponentMounted) { + this.setState(state, callback); + } + } + + componentWillUnmount() { + this.isComponentMounted = false; + } + async componentDidMount() { const {setLoading} = this.context; const {cookies, orgSlug, setUserData, logout, setTitle, orgName, language} = @@ -55,9 +66,11 @@ class MobilePhoneChange extends React.Component { if (isValid) { ({userData} = this.props); const {phone_number} = userData; - this.setState({phone_number}); + this.setStateSafe({phone_number}); + } + if (this.isComponentMounted) { + setLoading(false); } - setLoading(false); } handleSubmit(event) { @@ -81,7 +94,7 @@ class MobilePhoneChange extends React.Component { }), }) .then(() => { - this.setState({ + this.setStateSafe({ errors: {}, }); setUserData({...userData, is_verified: false, phone_number}); @@ -97,7 +110,7 @@ class MobilePhoneChange extends React.Component { toast.error(errorText); } setLoading(false); - this.setState({ + this.setStateSafe({ errors: { ...errors, ...(data.phone_number ? {phone_number: data.phone_number} : null), diff --git a/client/components/mobile-phone-change/mobile-phone-change.test.js b/client/components/mobile-phone-change/mobile-phone-change.test.js index 51bc52527..fb26d6644 100644 --- a/client/components/mobile-phone-change/mobile-phone-change.test.js +++ b/client/components/mobile-phone-change/mobile-phone-change.test.js @@ -388,8 +388,8 @@ describe("Change Phone Number: corner cases", () => { it("should recognize if user is active", async () => { validateToken.mockReturnValue(true); - userData.is_active = true; - props.userData = userData; + const localUser = {...userData, is_active: true}; + props.userData = localUser; wrapper = await mountComponent(props); const component = wrapper.find(MobilePhoneChange); expect(component.instance().state.phone_number).toBe("+393660011222"); @@ -406,8 +406,8 @@ describe("Change Phone Number: corner cases", () => { it("shouldn't redirect if user is active and mobile verificaton is true", async () => { validateToken.mockReturnValue(true); - props.userData = userData; - props.userData.is_active = true; + const localUser = {...userData, is_active: true}; + props.userData = localUser; props.settings.mobile_phone_verification = true; wrapper = await mountComponent(props); expect(wrapper.find(StatusMock)).toHaveLength(0); @@ -415,9 +415,8 @@ describe("Change Phone Number: corner cases", () => { it("should not redirect if user registration method is mobile_phone", async () => { validateToken.mockReturnValue(true); - props.userData = userData; - props.userData.is_active = true; - props.userData.method = "mobile_phone"; + const localUser = {...userData, is_active: true, method: "mobile_phone"}; + props.userData = localUser; props.settings.mobile_phone_verification = true; wrapper = await mountComponent(props); expect(wrapper.find(StatusMock)).toHaveLength(0); @@ -434,6 +433,20 @@ describe("Change Phone Number: corner cases", () => { props.language, ); }); + + it("should redirect if user registration method is pending_verification", async () => { + validateToken.mockReturnValue(true); + const localUser = { + ...userData, + is_active: true, + method: "pending_verification", + }; + props.userData = localUser; + props.settings.mobile_phone_verification = true; + wrapper = await mountComponent(props); + expect(wrapper.find(StatusMock)).toHaveLength(1); + }); + it("should redirect if mobile_phone_verification disabled", async () => { props.settings.mobile_phone_verification = false; wrapper = await mountComponent(props); @@ -442,9 +455,8 @@ describe("Change Phone Number: corner cases", () => { it("should redirect if user registration method is not mobile_phone", async () => { validateToken.mockReturnValue(true); - props.userData = userData; - props.userData.is_active = true; - props.userData.method = "saml"; + const localUser = {...userData, is_active: true, method: "saml"}; + props.userData = localUser; props.settings.mobile_phone_verification = true; wrapper = await mountComponent(props); expect(wrapper.find(StatusMock)).toHaveLength(1); diff --git a/client/components/organization-wrapper/__snapshots__/organization-wrapper.test.js.snap b/client/components/organization-wrapper/__snapshots__/organization-wrapper.test.js.snap index e5eed6f6b..e06692120 100644 --- a/client/components/organization-wrapper/__snapshots__/organization-wrapper.test.js.snap +++ b/client/components/organization-wrapper/__snapshots__/organization-wrapper.test.js.snap @@ -210,6 +210,14 @@ exports[` interactions should render main title if pageTi } path="payment/:status" /> + + } + path="complete-signup" + /> interactions should render pageTitle if it is n } path="payment/:status" /> + + } + path="complete-signup" + /> interactions should show route for authenticate } path="payment/:status" /> + + } + path="complete-signup" + /> + + } + path="complete-signup" + /> + + } + path="complete-signup" + /> import(/* webpackChunkName: 'PaymentProcess' */ "../payment-process"), ); +export const CompleteSignup = React.lazy( + () => import(/* webpackChunkName: 'CompleteSignup' */ "../complete-signup"), +); export const ConnectedDoesNotExist = React.lazy( () => import(/* webpackChunkName: 'ConnectedDoesNotExist' */ "../404"), ); diff --git a/client/components/organization-wrapper/organization-wrapper.js b/client/components/organization-wrapper/organization-wrapper.js index 2fa3ea7ed..27183fb70 100644 --- a/client/components/organization-wrapper/organization-wrapper.js +++ b/client/components/organization-wrapper/organization-wrapper.js @@ -17,6 +17,7 @@ import LoadingContext from "../../utils/loading-context"; import Loader from "../../utils/loader"; import needsVerify from "../../utils/needs-verify"; import loadTranslation from "../../utils/load-translation"; +import {userPendingVerification} from "../../utils/pending-verification"; import Login from "../login"; import { Registration, @@ -28,6 +29,7 @@ import { MobilePhoneVerification, PaymentStatus, PaymentProcess, + CompleteSignup, ConnectedDoesNotExist, DoesNotExist, } from "./lazy-import"; @@ -117,6 +119,8 @@ export default class OrganizationWrapper extends React.Component { if (cssPath) css.push(cssPath); const userAutoLogin = localStorage.getItem("userAutoLogin") === "true"; const needsVerifyPhone = needsVerify("mobile_phone", userData, settings); + const needsVerifyBankCard = needsVerify("bank_card", userData, settings); + const needsMethodSelection = userPendingVerification(userData); if (organization.exists === true) { const {setLoading} = this; let extraClasses = ""; @@ -153,6 +157,9 @@ export default class OrganizationWrapper extends React.Component { { + if (isAuthenticated && needsMethodSelection) { + return ; + } if (isAuthenticated && !needsVerifyPhone) { return ; } @@ -173,6 +180,9 @@ export default class OrganizationWrapper extends React.Component { { + if (isAuthenticated && needsMethodSelection) { + return ; + } if ( isAuthenticated && needsVerifyPhone === false && @@ -217,26 +227,44 @@ export default class OrganizationWrapper extends React.Component { /> - ) : ( - - ) - } + element={(() => { + if (isAuthenticated) { + if (needsMethodSelection) { + return ( + + ); + } + if (is_active) { + return ( + + ); + } + } + return ; + })()} /> { - if (isAuthenticated && needsVerifyPhone) - return ( - - ); if (isAuthenticated) { + if (needsMethodSelection) { + return ( + + ); + } + if (needsVerifyPhone) { + return ( + + ); + } return ( }> } /> + { + if (!isAuthenticated) { + return ; + } + if (!needsMethodSelection) { + if (needsVerifyPhone) { + return ( + + ); + } + if (needsVerifyBankCard) { + return ; + } + return ; + } + return ( + }> + + + ); + })()} + /> } /> @@ -395,6 +449,7 @@ OrganizationWrapper.propTypes = { userData: PropTypes.object, settings: PropTypes.shape({ mobile_phone_verification: PropTypes.bool, + subscriptions: PropTypes.bool, }), js: PropTypes.array, }), diff --git a/client/components/organization-wrapper/organization-wrapper.test.js b/client/components/organization-wrapper/organization-wrapper.test.js index a9c0fecb9..8f26abc88 100644 --- a/client/components/organization-wrapper/organization-wrapper.test.js +++ b/client/components/organization-wrapper/organization-wrapper.test.js @@ -12,6 +12,7 @@ import Footer from "../footer"; import Header from "../header"; import Loader from "../../utils/loader"; import needsVerify from "../../utils/needs-verify"; +import {userPendingVerification} from "../../utils/pending-verification"; import Login from "../login"; import { Registration, @@ -23,6 +24,7 @@ import { MobilePhoneVerification, PaymentStatus, PaymentProcess, + CompleteSignup, ConnectedDoesNotExist, } from "./lazy-import"; import Logout from "./lazy-logout"; @@ -30,6 +32,7 @@ import Logout from "./lazy-logout"; jest.mock("../../utils/get-config"); jest.mock("../../utils/load-translation"); jest.mock("../../utils/needs-verify"); +jest.mock("../../utils/pending-verification"); const userData = { is_active: true, @@ -165,6 +168,7 @@ describe(" interactions", () => { beforeEach(() => { needsVerify.mockReturnValue(false); + userPendingVerification.mockReturnValue(false); originalError = console.error; lastConsoleOutuput = null; console.error = (data) => { @@ -327,6 +331,8 @@ describe(" interactions", () => { , ), ); + element = pathMap["complete-signup"]; + expect(element).toEqual(); const elements = pathMap["*"]; expect(JSON.stringify(elements[0])).toEqual( JSON.stringify( @@ -354,6 +360,7 @@ describe("Test Organization Wrapper for unauthenticated users", () => { console.error = () => {}; props = createTestProps(); props.organization.configuration.isAuthenticated = false; + userPendingVerification.mockReturnValue(false); localStorage.setItem("userAutoLogin", true); wrapper = shallow(); }); @@ -436,6 +443,8 @@ describe("Test Organization Wrapper for unauthenticated users", () => { , ), ); + element = pathMap["complete-signup"]; + expect(element).toEqual(); const elements = pathMap["*"]; expect(JSON.stringify(elements[0])).toEqual( JSON.stringify( @@ -464,6 +473,7 @@ describe("Test Organization Wrapper for authenticated and unverified users", () console.error = () => {}; props = createTestProps(); needsVerify.mockReturnValue(true); + userPendingVerification.mockReturnValue(false); wrapper = shallow(); }); @@ -541,6 +551,10 @@ describe("Test Organization Wrapper for authenticated and unverified users", () , ), ); + element = pathMap["complete-signup"]; + expect(element).toEqual( + , + ); const elements = pathMap["*"]; expect(JSON.stringify(elements[0])).toEqual( JSON.stringify( @@ -556,6 +570,121 @@ describe("Test Organization Wrapper for authenticated and unverified users", () ); expect(JSON.stringify(elements[2])).toEqual(JSON.stringify(