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 && (
+
+ )}
+ {/* 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());
});
+
+ it("should route complete-signup based on remaining phone verification need", () => {
+ let pathMap = {};
+ pathMap = wrapper.find(Route).reduce((mapRoute, route) => {
+ const map = mapRoute;
+ const routeProps = route.props();
+ if (routeProps.path === "*")
+ map["*"] = [...(map["*"] || []), routeProps.element];
+ else map[routeProps.path] = routeProps.element;
+ return map;
+ }, {});
+ expect(pathMap["complete-signup"]).toEqual(
+ ,
+ );
+ needsVerify.mockReturnValue(false);
+ wrapper.setProps({organization: {...props.organization}});
+ pathMap = wrapper.find(Route).reduce((mapRoute, route) => {
+ const map = mapRoute;
+ const routeProps = route.props();
+ if (routeProps.path === "*")
+ map["*"] = [...(map["*"] || []), routeProps.element];
+ else map[routeProps.path] = routeProps.element;
+ return map;
+ }, {});
+ expect(pathMap["complete-signup"]).toEqual(
+ ,
+ );
+ expect(pathMap["mobile-phone-verification"]).toEqual(
+ ,
+ );
+ });
+
+ it("should route complete-signup to payment draft when bank card verification is pending", () => {
+ needsVerify.mockImplementation((method) => method === "bank_card");
+ userPendingVerification.mockReturnValue(false);
+ const component = shallow(, {
+ disableLifecycleMethods: true,
+ });
+ component
+ .instance()
+ .setState({translationLoaded: true, configLoaded: true});
+
+ const pathMap = component.find(Route).reduce((mapRoute, route) => {
+ const map = mapRoute;
+ const routeProps = route.props();
+ if (routeProps.path === "*")
+ map["*"] = [...(map["*"] || []), routeProps.element];
+ else map[routeProps.path] = routeProps.element;
+ return map;
+ }, {});
+
+ expect(pathMap["complete-signup"]).toEqual(
+ ,
+ );
+ });
+});
+
+describe("Test Organization Wrapper for pending verification user", () => {
+ let props;
+ let wrapper;
+ let originalError;
+
+ const getPathMap = (component = wrapper) =>
+ component.find(Route).reduce((mapRoute, route) => {
+ const map = mapRoute;
+ const routeProps = route.props();
+ if (routeProps.path === "*")
+ map["*"] = [...(map["*"] || []), routeProps.element];
+ else map[routeProps.path] = routeProps.element;
+ return map;
+ }, {});
+
+ beforeEach(() => {
+ originalError = console.error;
+ console.error = jest.fn();
+ props = createTestProps();
+ needsVerify.mockReturnValue(false);
+ userPendingVerification.mockReturnValue(true);
+ wrapper = shallow();
+ });
+
+ afterEach(() => {
+ console.error = originalError;
+ });
+
+ it("should route authenticated pending verification users to complete-signup", () => {
+ const pathMap = getPathMap();
+ expect(pathMap["registration/*"]).toEqual(
+ ,
+ );
+ expect(pathMap["login/*"]).toEqual(
+ ,
+ );
+ expect(pathMap.status).toEqual();
+ expect(pathMap["mobile-phone-verification"]).toEqual(
+ ,
+ );
+ expect(JSON.stringify(pathMap["complete-signup"])).toEqual(
+ JSON.stringify(
+ }>
+
+ ,
+ ),
+ );
+ });
+
+ it("should preserve query params when redirecting pending verification users from login", () => {
+ wrapper.setProps({
+ location: {pathname: "", search: "?next=/default/status"},
+ });
+ const pathMap = getPathMap();
+ expect(pathMap["login/*"]).toEqual(
+ ,
+ );
+ });
});
describe("Test routes", () => {
diff --git a/client/components/status/status.js b/client/components/status/status.js
index 8b0054ab2..0ee7b2bc6 100644
--- a/client/components/status/status.js
+++ b/client/components/status/status.js
@@ -14,12 +14,10 @@ import InfinteScroll from "react-infinite-scroll-component";
import {t, gettext} from "ttag";
import {filesize} from "filesize";
import {timeFromSeconds} from "duration-formatter";
-import getLanguageHeaders from "../../utils/get-language-headers";
import {
getUserRadiusSessionsUrl,
getUserRadiusUsageUrl,
- upgradePlanApiUrl,
mainToastId,
} from "../../constants";
import LoadingContext from "../../utils/loading-context";
@@ -37,6 +35,7 @@ import {localStorage} from "../../utils/storage";
import handleSession from "../../utils/session";
import getPlanSelection from "../../utils/get-plan-selection";
import getPlans from "../../utils/get-plans";
+import upgradePlan from "../../utils/upgrade-plan";
export default class Status extends React.Component {
constructor(props) {
@@ -467,29 +466,22 @@ export default class Status extends React.Component {
setUserData,
captivePortalSyncAuth,
} = this.props;
- const upgradePlanUrl = upgradePlanApiUrl.replace("{orgSlug}", orgSlug);
const auth_token = cookies.get(`${orgSlug}_auth_token`);
const {upgradePlans} = this.state;
handleSession(orgSlug, auth_token, cookies);
- axios({
- method: "post",
- headers: {
- "content-type": "application/json",
- "accept-language": getLanguageHeaders(language),
- Authorization: `Bearer ${userData.auth_token}`,
- },
- url: upgradePlanUrl,
- data: {
- plan_pricing: upgradePlans[event.target.value].id,
- },
- })
+ upgradePlan(
+ orgSlug,
+ upgradePlans[event.target.value].id,
+ userData.auth_token,
+ language,
+ )
.then((response) => {
toast.success(t`SUCCESS_UPGRADE_PLAN`, {
onOpen: () => toast.dismiss(mainToastId),
});
setUserData({
...userData,
- payment_url: response.data.payment_url,
+ payment_url: response.payment_url,
});
// After a successful payment, the user is redirected back to the status page.
// If the user plan was previously exhausted, they need to be logged into the captive portal
diff --git a/client/constants/index.js b/client/constants/index.js
index 7040d0461..ed082b826 100644
--- a/client/constants/index.js
+++ b/client/constants/index.js
@@ -20,6 +20,8 @@ export const verifyMobilePhoneTokenUrl = (orgSlug) =>
`${prefix}/${orgSlug}/account/phone/verify`;
export const mobilePhoneChangeUrl = (orgSlug) =>
`${prefix}/${orgSlug}/account/phone/change`;
+export const updateMethodApiUrl = (orgSlug) =>
+ `${prefix}/${orgSlug}/account/registration-method/`;
export const plansApiUrl = `${prefix}/{orgSlug}/plan/`;
export const upgradePlanApiUrl = `${prefix}/{orgSlug}/plan/upgrade`;
export const modalContentUrl = (orgSlug) => `${prefix}/${orgSlug}/modal`;
diff --git a/client/utils/get-plans.js b/client/utils/get-plans.js
index 2a287ca90..4c2b66d14 100644
--- a/client/utils/get-plans.js
+++ b/client/utils/get-plans.js
@@ -5,7 +5,12 @@ import getLanguageHeaders from "./get-language-headers";
import {plansApiUrl} from "../constants";
import logError from "./log-error";
-const getPlans = async (orgSlug, language, successCallback) => {
+const getPlans = async (
+ orgSlug,
+ language,
+ successCallback,
+ failureCallback,
+) => {
const plansUrl = plansApiUrl.replace("{orgSlug}", orgSlug);
axios({
method: "get",
@@ -20,6 +25,9 @@ const getPlans = async (orgSlug, language, successCallback) => {
.catch((error) => {
toast.error(t`ERR_OCCUR`);
logError(error, "Error while fetching plans");
+ if (typeof failureCallback === "function") {
+ failureCallback();
+ }
});
};
diff --git a/client/utils/pending-verification.js b/client/utils/pending-verification.js
new file mode 100644
index 000000000..9ea7b1418
--- /dev/null
+++ b/client/utils/pending-verification.js
@@ -0,0 +1,12 @@
+export const userPendingVerification = (user = {}) =>
+ user.method === "pending_verification" && user.is_verified === false;
+
+export const getVerificationRoute = (orgSlug, method) => {
+ if (method === "bank_card") {
+ return `/${orgSlug}/payment/draft`;
+ }
+ if (method === "mobile_phone") {
+ return `/${orgSlug}/mobile-phone-verification`;
+ }
+ throw new Error(`Unknown verification method: ${method}`);
+};
diff --git a/client/utils/update-registration-method.js b/client/utils/update-registration-method.js
new file mode 100644
index 000000000..ccfd64316
--- /dev/null
+++ b/client/utils/update-registration-method.js
@@ -0,0 +1,18 @@
+import axios from "axios";
+
+import {updateMethodApiUrl} from "../constants";
+import getLanguageHeaders from "./get-language-headers";
+
+const updateRegistrationMethod = (orgSlug, method, authToken, language) =>
+ axios({
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ "accept-language": getLanguageHeaders(language),
+ Authorization: `Bearer ${authToken}`,
+ },
+ url: updateMethodApiUrl(orgSlug),
+ data: {method},
+ }).then((response) => response.data);
+
+export default updateRegistrationMethod;
diff --git a/client/utils/upgrade-plan.js b/client/utils/upgrade-plan.js
new file mode 100644
index 000000000..f4751593f
--- /dev/null
+++ b/client/utils/upgrade-plan.js
@@ -0,0 +1,20 @@
+import axios from "axios";
+
+import {upgradePlanApiUrl} from "../constants";
+import getLanguageHeaders from "./get-language-headers";
+
+const upgradePlan = (orgSlug, planPricing, authToken, language) =>
+ axios({
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ "accept-language": getLanguageHeaders(language),
+ Authorization: `Bearer ${authToken}`,
+ },
+ url: upgradePlanApiUrl.replace("{orgSlug}", orgSlug),
+ data: {
+ plan_pricing: planPricing,
+ },
+ }).then((response) => response.data);
+
+export default upgradePlan;
diff --git a/client/utils/utils.test.js b/client/utils/utils.test.js
index 24b84f504..0cef54cc3 100644
--- a/client/utils/utils.test.js
+++ b/client/utils/utils.test.js
@@ -5,6 +5,7 @@ import {Cookies} from "react-cookie";
import {shallow, mount} from "enzyme";
import * as dependency from "react-toastify";
import {createMemoryHistory} from "history";
+import {t} from "ttag";
import authenticate from "./authenticate";
import isInternalLink from "./check-internal-links";
import customMerge from "./custom-merge";
@@ -27,6 +28,10 @@ import {initialState} from "../reducers/organization";
import {localStorage, sessionStorage, storageFallback} from "./storage";
import getPaymentStatusRedirectUrl from "./get-payment-status";
import withRouteProps from "./with-route-props";
+import updateRegistrationMethod from "./update-registration-method";
+import getPlans from "./get-plans";
+import upgradePlan from "./upgrade-plan";
+import {upgradePlanApiUrl} from "../constants";
jest.mock("axios");
jest.mock("./load-translation");
@@ -300,7 +305,7 @@ describe("Validate Token tests", () => {
response_code: "INTERNAL_SERVER_ERROR",
},
};
- jest.spyOn(global.console, "log").mockImplementation();
+ const consoleLog = jest.spyOn(global.console, "log").mockImplementation();
axios.mockImplementationOnce(() => Promise.resolve(response));
const errorMethod = jest.spyOn(dependency.toast, "error");
const {orgSlug, cookies, setUserData, userData, logout, language} =
@@ -320,7 +325,7 @@ describe("Validate Token tests", () => {
expect(logout).toHaveBeenCalledWith(expect.any(Cookies), "default");
const cookiesArg = logout.mock.calls[0][0];
expect(cookiesArg.cookies).toEqual({default_auth_token: "token"});
- expect(console.log).toHaveBeenCalledWith(response);
+ expect(consoleLog).toHaveBeenCalledWith(response);
});
it("should show error toast on invalid token", async () => {
const response = {
@@ -331,7 +336,7 @@ describe("Validate Token tests", () => {
},
};
axios.mockImplementationOnce(() => Promise.reject(response));
- jest.spyOn(global.console, "log").mockImplementation();
+ const consoleLog = jest.spyOn(global.console, "log").mockImplementation();
const errorMethod = jest.spyOn(dependency.toast, "error");
const {orgSlug, cookies, setUserData, userData, logout, language} =
getArgs();
@@ -349,7 +354,7 @@ describe("Validate Token tests", () => {
const cookiesArg = logout.mock.calls[0][0];
expect(cookiesArg.cookies).toEqual({default_auth_token: "token"});
expect(setUserData.mock.calls.length).toBe(1);
- expect(console.log).toHaveBeenCalledWith(response);
+ expect(consoleLog).toHaveBeenCalledWith(response);
expect(setUserData.mock.calls.pop()).toEqual([initialState.userData]);
});
it("should show error if user is locked out", async () => {
@@ -871,6 +876,125 @@ describe("getPaymentStatusRedirectUrl tests", () => {
expect(consoleLog).toHaveBeenCalledWith(response);
});
});
+describe("update-registration-method", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ it("posts the selected registration method and returns response data", async () => {
+ axios.mockResolvedValueOnce({
+ data: {method: "mobile_phone"},
+ });
+
+ const response = await updateRegistrationMethod(
+ "default",
+ "mobile_phone",
+ "test-token",
+ "en",
+ );
+
+ expect(axios).toHaveBeenCalledWith({
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ "accept-language": "en,en-US,en",
+ Authorization: "Bearer test-token",
+ },
+ url: "/api/v1/default/account/registration-method/",
+ data: {method: "mobile_phone"},
+ });
+ expect(response).toEqual({method: "mobile_phone"});
+ });
+
+ it("supports empty registration methods", async () => {
+ axios.mockResolvedValueOnce({
+ data: {method: ""},
+ });
+
+ await updateRegistrationMethod("default", "", "test-token", "en");
+
+ expect(axios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: {method: ""},
+ }),
+ );
+ });
+
+ it("propagates request failures", async () => {
+ const error = new Error("request failed");
+ axios.mockRejectedValueOnce(error);
+
+ await expect(
+ updateRegistrationMethod("default", "bank_card", "test-token", "en"),
+ ).rejects.toBe(error);
+ });
+});
+describe("get-plans", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ it("fetches plans and passes response data to the success callback", async () => {
+ const successCallback = jest.fn();
+ const failureCallback = jest.fn();
+ axios.mockResolvedValueOnce({
+ data: [{id: "free-plan"}],
+ });
+
+ await getPlans("default", "en", successCallback, failureCallback);
+ await Promise.resolve();
+
+ expect(axios).toHaveBeenCalledWith({
+ method: "get",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "accept-language": "en,en-US,en",
+ },
+ url: "/api/v1/default/plan/",
+ data: {},
+ });
+ expect(successCallback).toHaveBeenCalledWith([{id: "free-plan"}]);
+ expect(failureCallback).not.toHaveBeenCalled();
+ });
+
+ it("shows an error toast, logs the error and calls failure callback", async () => {
+ const successCallback = jest.fn();
+ const failureCallback = jest.fn();
+ const consoleLog = jest.spyOn(global.console, "log").mockImplementation();
+ const errorToast = jest
+ .spyOn(dependency.toast, "error")
+ .mockImplementation(() => {});
+ const error = new Error("network error");
+ axios.mockRejectedValueOnce(error);
+
+ await getPlans("default", "en", successCallback, failureCallback);
+ await Promise.resolve();
+
+ expect(errorToast).toHaveBeenCalledWith(t`ERR_OCCUR`);
+ expect(consoleLog).toHaveBeenCalledWith(error);
+ expect(failureCallback).toHaveBeenCalledTimes(1);
+ expect(successCallback).not.toHaveBeenCalled();
+ });
+
+ it("handles failures without a failure callback", async () => {
+ const successCallback = jest.fn();
+ const consoleLog = jest.spyOn(global.console, "log").mockImplementation();
+ const errorToast = jest
+ .spyOn(dependency.toast, "error")
+ .mockImplementation(() => {});
+ const error = new Error("network error");
+ axios.mockRejectedValueOnce(error);
+
+ await getPlans("default", "en", successCallback);
+ await Promise.resolve();
+
+ expect(errorToast).toHaveBeenCalledWith(t`ERR_OCCUR`);
+ expect(consoleLog).toHaveBeenCalledWith(error);
+ expect(successCallback).not.toHaveBeenCalled();
+ });
+});
describe("withRouteProps test", () => {
it("should add route props to component", () => {
const Component = () => React.Component;
@@ -886,3 +1010,30 @@ describe("withRouteProps test", () => {
});
});
});
+describe("upgrade-plan", () => {
+ it("makes POST request with correct URL, headers and body", async () => {
+ const mockResponse = {payment_url: "/default/payment/success"};
+ axios.mockResolvedValueOnce({data: mockResponse});
+ const result = await upgradePlan("default", "premium", "test-token", "en");
+ expect(axios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: "post",
+ headers: expect.objectContaining({
+ "content-type": "application/json",
+ Authorization: "Bearer test-token",
+ }),
+ url: upgradePlanApiUrl.replace("{orgSlug}", "default"),
+ data: {plan_pricing: "premium"},
+ }),
+ );
+ expect(result).toBe(mockResponse);
+ });
+
+ it("rejects on request failure", async () => {
+ const error = new Error("request failed");
+ axios.mockRejectedValueOnce(error);
+ await expect(
+ upgradePlan("default", "premium", "test-token", "en"),
+ ).rejects.toBe(error);
+ });
+});
diff --git a/i18n/de.po b/i18n/de.po
index 2145eb849..03bfdbed6 100644
--- a/i18n/de.po
+++ b/i18n/de.po
@@ -678,3 +678,20 @@ msgstr "Die gesuchte Seite konnte leider nicht gefunden werden."
#: client/components/404/404.js:37
msgid "HOME_PG_LINK_TXT"
msgstr "Zurück zur ersten Seite"
+
+#: client/components/complete-signup/complete-signup.js:77
+msgid "ORG_REGISTRATION_DISABLED"
+msgstr "Die Registrierung ist für diese Organisation derzeit deaktiviert"
+
+#: client/components/complete-signup/complete-signup.js:92
+#: client/components/complete-signup/complete-signup.js:95
+msgid "PLANS_FETCH_ERR"
+msgstr "Pläne konnten nicht geladen werden"
+
+#: client/components/complete-signup/complete-signup.js:288
+msgid "REGISTRATION_COMPLETE_PROMPT"
+msgstr "Bitte schließen Sie Ihre Registrierung ab bei"
+
+#: client/components/complete-signup/complete-signup.js:300
+msgid "RETRY"
+msgstr "Erneut versuchen"
diff --git a/i18n/en.po b/i18n/en.po
index d65789415..4205fe9d9 100644
--- a/i18n/en.po
+++ b/i18n/en.po
@@ -673,3 +673,20 @@ msgstr "Sorry, we couldn't find the page you were looking for."
#: client/components/404/404.js:37
msgid "HOME_PG_LINK_TXT"
msgstr "Go back to homepage"
+
+#: client/components/complete-signup/complete-signup.js:77
+msgid "ORG_REGISTRATION_DISABLED"
+msgstr "Registration is currently disabled for this organization"
+
+#: client/components/complete-signup/complete-signup.js:92
+#: client/components/complete-signup/complete-signup.js:95
+msgid "PLANS_FETCH_ERR"
+msgstr "Failed to load plans"
+
+#: client/components/complete-signup/complete-signup.js:288
+msgid "REGISTRATION_COMPLETE_PROMPT"
+msgstr "Please complete your registration to"
+
+#: client/components/complete-signup/complete-signup.js:300
+msgid "RETRY"
+msgstr "Retry"
diff --git a/i18n/es.po b/i18n/es.po
index b55db7e19..2a7dd7c7b 100644
--- a/i18n/es.po
+++ b/i18n/es.po
@@ -685,3 +685,20 @@ msgstr "Lo sentimos, no pudimos encontrar la página que estabas buscando."
#: client/components/404/404.js:37
msgid "HOME_PG_LINK_TXT"
msgstr "Volver a la página de inicio"
+
+#: client/components/complete-signup/complete-signup.js:77
+msgid "ORG_REGISTRATION_DISABLED"
+msgstr "El registro está actualmente deshabilitado para esta organización"
+
+#: client/components/complete-signup/complete-signup.js:92
+#: client/components/complete-signup/complete-signup.js:95
+msgid "PLANS_FETCH_ERR"
+msgstr "No se pudieron cargar los planes"
+
+#: client/components/complete-signup/complete-signup.js:288
+msgid "REGISTRATION_COMPLETE_PROMPT"
+msgstr "Por favor, complete su registro a"
+
+#: client/components/complete-signup/complete-signup.js:300
+msgid "RETRY"
+msgstr "Reintentar"
diff --git a/i18n/fur.po b/i18n/fur.po
index f17aa1085..7bea799aa 100644
--- a/i18n/fur.po
+++ b/i18n/fur.po
@@ -676,3 +676,20 @@ msgstr "O domandìn scuse, la pagjine cirude no je stade cjatade."
#: client/components/404/404.js:37
msgid "HOME_PG_LINK_TXT"
msgstr "Torne ae prime pagjine"
+
+#: client/components/complete-signup/complete-signup.js:77
+msgid "ORG_REGISTRATION_DISABLED"
+msgstr "La registrazion e je disabilitade par cheste organizazion"
+
+#: client/components/complete-signup/complete-signup.js:92
+#: client/components/complete-signup/complete-signup.js:95
+msgid "PLANS_FETCH_ERR"
+msgstr "Nol è pussibil cjamâ i plans"
+
+#: client/components/complete-signup/complete-signup.js:288
+msgid "REGISTRATION_COMPLETE_PROMPT"
+msgstr "Par favôr, complete la vuestre registrazion a"
+
+#: client/components/complete-signup/complete-signup.js:300
+msgid "RETRY"
+msgstr "Torne"
diff --git a/i18n/it.po b/i18n/it.po
index f8ebc27b7..8a7702688 100644
--- a/i18n/it.po
+++ b/i18n/it.po
@@ -680,3 +680,20 @@ msgstr "Ci scusiamo, la pagina cercata non è stata trovata."
#: client/components/404/404.js:37
msgid "HOME_PG_LINK_TXT"
msgstr "Torna alla prima pagina"
+
+#: client/components/complete-signup/complete-signup.js:77
+msgid "ORG_REGISTRATION_DISABLED"
+msgstr "La registrazione è attualmente disabilitata per questa organizzazione"
+
+#: client/components/complete-signup/complete-signup.js:92
+#: client/components/complete-signup/complete-signup.js:95
+msgid "PLANS_FETCH_ERR"
+msgstr "Impossibile caricare i piani"
+
+#: client/components/complete-signup/complete-signup.js:288
+msgid "REGISTRATION_COMPLETE_PROMPT"
+msgstr "Si prega di completare la registrazione a"
+
+#: client/components/complete-signup/complete-signup.js:300
+msgid "RETRY"
+msgstr "Riprova"
diff --git a/i18n/ru.po b/i18n/ru.po
index 29a6cd44f..ebef6e700 100644
--- a/i18n/ru.po
+++ b/i18n/ru.po
@@ -672,3 +672,20 @@ msgstr "404 Страница не найдена.."
#: client/components/404/404.js:37
msgid "HOME_PG_LINK_TXT"
msgstr "Вернись на первую страницу"
+
+#: client/components/complete-signup/complete-signup.js:77
+msgid "ORG_REGISTRATION_DISABLED"
+msgstr "Регистрация в настоящее время отключена для этой организации"
+
+#: client/components/complete-signup/complete-signup.js:92
+#: client/components/complete-signup/complete-signup.js:95
+msgid "PLANS_FETCH_ERR"
+msgstr "Не удалось загрузить планы"
+
+#: client/components/complete-signup/complete-signup.js:288
+msgid "REGISTRATION_COMPLETE_PROMPT"
+msgstr "Пожалуйста, завершите регистрацию в"
+
+#: client/components/complete-signup/complete-signup.js:300
+msgid "RETRY"
+msgstr "Повторить попытку"
diff --git a/i18n/sl.po b/i18n/sl.po
index 1f7a7f168..a0f96d7ff 100644
--- a/i18n/sl.po
+++ b/i18n/sl.po
@@ -664,3 +664,20 @@ msgstr "Oprostite, iskane strani ni bilo mogoče najti."
#: client/components/404/404.js:37
msgid "HOME_PG_LINK_TXT"
msgstr "Nazaj na domačo stran"
+
+#: client/components/complete-signup/complete-signup.js:77
+msgid "ORG_REGISTRATION_DISABLED"
+msgstr "Registracija je trenutno onemogočena za to organizacijo"
+
+#: client/components/complete-signup/complete-signup.js:92
+#: client/components/complete-signup/complete-signup.js:95
+msgid "PLANS_FETCH_ERR"
+msgstr "Ni bilo mogoče naložiti načrte"
+
+#: client/components/complete-signup/complete-signup.js:288
+msgid "REGISTRATION_COMPLETE_PROMPT"
+msgstr "Prosimo, dokončajte svojo registracijo za"
+
+#: client/components/complete-signup/complete-signup.js:300
+msgid "RETRY"
+msgstr "Poskusi znova"
diff --git a/server/controllers/registration-method-controller.js b/server/controllers/registration-method-controller.js
new file mode 100644
index 000000000..6cf199117
--- /dev/null
+++ b/server/controllers/registration-method-controller.js
@@ -0,0 +1,83 @@
+import axios from "axios";
+import merge from "deepmerge";
+
+import config from "../config.json";
+import defaultConfig from "../utils/default-config";
+import {logResponseError} from "../utils/logger";
+import reverse from "../utils/openwisp-urls";
+import getSlug from "../utils/get-slug";
+
+// eslint-disable-next-line consistent-return
+const updateRegistrationMethod = (req, res) => {
+ const reqOrg = req.params.organization;
+ const validSlug = config.some((org) => {
+ if (org.slug === reqOrg) {
+ // merge default config and custom config
+ const conf = merge(defaultConfig, org);
+ const {host, settings} = conf;
+ const url = reverse("update_registration_method", getSlug(conf));
+ const timeout = conf.timeout * 1000;
+ const requestHeaders = req.headers || {};
+
+ // compute allowed methods from config
+ const allowedMethods = [""];
+ if (settings && settings.subscriptions) {
+ allowedMethods.push("bank_card");
+ }
+ if (settings && settings.mobile_phone_verification) {
+ allowedMethods.push("mobile_phone");
+ }
+ // validate method against allowed methods
+ if (
+ !req.body ||
+ typeof req.body.method !== "string" ||
+ !allowedMethods.includes(req.body.method)
+ ) {
+ return res.status(400).type("application/json").send({
+ response_code: "INVALID_METHOD",
+ });
+ }
+
+ // make AJAX request
+ axios({
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ Authorization: requestHeaders.authorization,
+ "accept-language": requestHeaders["accept-language"],
+ },
+ url: `${host}${url}/`,
+ timeout,
+ data: {method: req.body.method},
+ })
+ .then((response) => {
+ res
+ .status(response.status)
+ .type("application/json")
+ .send(response.data);
+ })
+ .catch((error) => {
+ logResponseError(error);
+ try {
+ res
+ .status(error.response.status)
+ .type("application/json")
+ .send(error.response.data);
+ } catch (err) {
+ res.status(500).type("application/json").send({
+ response_code: "INTERNAL_SERVER_ERROR",
+ });
+ }
+ });
+ }
+ return org.slug === reqOrg;
+ });
+ // return 404 for invalid organization slug or org not listed in config
+ if (!validSlug) {
+ res.status(404).type("application/json").send({
+ response_code: "NOT_FOUND",
+ });
+ }
+};
+
+export default updateRegistrationMethod;
diff --git a/server/controllers/registration-method-controller.test.js b/server/controllers/registration-method-controller.test.js
new file mode 100644
index 000000000..d1b0719cf
--- /dev/null
+++ b/server/controllers/registration-method-controller.test.js
@@ -0,0 +1,243 @@
+import axios from "axios";
+import updateRegistrationMethod from "./registration-method-controller";
+
+jest.mock("axios");
+jest.mock("../utils/logger", () => ({
+ logResponseError: jest.fn(),
+}));
+jest.mock("../config.json", () => [
+ {
+ slug: "default",
+ host: "https://radius.test",
+ timeout: 10,
+ settings: {
+ subscriptions: true,
+ mobile_phone_verification: true,
+ },
+ },
+]);
+
+const createResponse = () => {
+ const res = {};
+ res.status = jest.fn(() => res);
+ res.type = jest.fn(() => res);
+ res.send = jest.fn(() => res);
+ return res;
+};
+
+describe("registration-method-controller", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.resetAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ it("proxies the registration method update", async () => {
+ axios.mockResolvedValueOnce({
+ status: 200,
+ data: {method: "mobile_phone"},
+ });
+ const res = createResponse();
+ await updateRegistrationMethod(
+ {
+ params: {organization: "default"},
+ body: {method: "mobile_phone"},
+ headers: {
+ authorization: "Bearer test-token",
+ "accept-language": "en",
+ },
+ },
+ res,
+ );
+ expect(axios).toHaveBeenCalledWith({
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ Authorization: "Bearer test-token",
+ "accept-language": "en",
+ },
+ url: "https://radius.test/api/v1/radius/organization/default/account/registration-method/",
+ timeout: 10000,
+ data: {method: "mobile_phone"},
+ });
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.type).toHaveBeenCalledWith("application/json");
+ expect(res.send).toHaveBeenCalledWith({method: "mobile_phone"});
+ });
+
+ it("proxies empty registration method updates", async () => {
+ axios.mockResolvedValueOnce({
+ status: 200,
+ data: {method: ""},
+ });
+ const res = createResponse();
+ await updateRegistrationMethod(
+ {
+ params: {organization: "default"},
+ body: {method: ""},
+ headers: {
+ authorization: "Bearer test-token",
+ "accept-language": "en",
+ },
+ },
+ res,
+ );
+ expect(axios).toHaveBeenCalledWith({
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ Authorization: "Bearer test-token",
+ "accept-language": "en",
+ },
+ url: "https://radius.test/api/v1/radius/organization/default/account/registration-method/",
+ timeout: 10000,
+ data: {method: ""},
+ });
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.type).toHaveBeenCalledWith("application/json");
+ expect(res.send).toHaveBeenCalledWith({method: ""});
+ });
+
+ it("returns 404 for an invalid organization slug", () => {
+ const res = createResponse();
+ updateRegistrationMethod(
+ {
+ params: {organization: "missing-org"},
+ body: {method: "mobile_phone"},
+ headers: {},
+ },
+ res,
+ );
+ expect(axios).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.type).toHaveBeenCalledWith("application/json");
+ expect(res.send).toHaveBeenCalledWith({
+ response_code: "NOT_FOUND",
+ });
+ });
+
+ it("returns 400 for unsupported registration methods", () => {
+ const res = createResponse();
+ updateRegistrationMethod(
+ {
+ params: {organization: "default"},
+ body: {method: "sms"},
+ headers: {},
+ },
+ res,
+ );
+ expect(axios).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.type).toHaveBeenCalledWith("application/json");
+ expect(res.send).toHaveBeenCalledWith({
+ response_code: "INVALID_METHOD",
+ });
+ });
+
+ it("handles error response with error.response.status", async () => {
+ const error = new Error("Bad request");
+ error.response = {
+ status: 400,
+ data: {response_code: "BAD_REQUEST"},
+ };
+ axios.mockImplementationOnce(() => Promise.reject(error));
+ const res = createResponse();
+ await updateRegistrationMethod(
+ {
+ params: {organization: "default"},
+ body: {method: "mobile_phone"},
+ headers: {
+ authorization: "Bearer test-token",
+ "accept-language": "en",
+ },
+ },
+ res,
+ );
+ await Promise.resolve();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.type).toHaveBeenCalledWith("application/json");
+ expect(res.send).toHaveBeenCalledWith({response_code: "BAD_REQUEST"});
+ });
+
+ it("handles error without error.response.status (internal error)", async () => {
+ const error = new Error("Internal server error");
+ axios.mockImplementationOnce(() => Promise.reject(error));
+ const res = createResponse();
+ await updateRegistrationMethod(
+ {
+ params: {organization: "default"},
+ body: {method: "mobile_phone"},
+ headers: {
+ authorization: "Bearer test-token",
+ "accept-language": "en",
+ },
+ },
+ res,
+ );
+ await Promise.resolve();
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.type).toHaveBeenCalledWith("application/json");
+ expect(res.send).toHaveBeenCalledWith({
+ response_code: "INTERNAL_SERVER_ERROR",
+ });
+ });
+
+ it("handles request with missing headers", async () => {
+ axios.mockResolvedValueOnce({
+ status: 200,
+ data: {method: "mobile_phone"},
+ });
+ const res = createResponse();
+ await updateRegistrationMethod(
+ {
+ params: {organization: "default"},
+ body: {method: "mobile_phone"},
+ headers: {},
+ },
+ res,
+ );
+ expect(axios).toHaveBeenCalledWith({
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ Authorization: undefined,
+ "accept-language": undefined,
+ },
+ url: "https://radius.test/api/v1/radius/organization/default/account/registration-method/",
+ timeout: 10000,
+ data: {method: "mobile_phone"},
+ });
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.type).toHaveBeenCalledWith("application/json");
+ expect(res.send).toHaveBeenCalledWith({method: "mobile_phone"});
+ });
+
+ it("handles request with undefined headers (uses default empty object)", async () => {
+ axios.mockResolvedValueOnce({
+ status: 200,
+ data: {method: "mobile_phone"},
+ });
+ const res = createResponse();
+ await updateRegistrationMethod(
+ {
+ params: {organization: "default"},
+ body: {method: "mobile_phone"},
+ },
+ res,
+ );
+ expect(axios).toHaveBeenCalledWith({
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ Authorization: undefined,
+ "accept-language": undefined,
+ },
+ url: "https://radius.test/api/v1/radius/organization/default/account/registration-method/",
+ timeout: 10000,
+ data: {method: "mobile_phone"},
+ });
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.type).toHaveBeenCalledWith("application/json");
+ expect(res.send).toHaveBeenCalledWith({method: "mobile_phone"});
+ });
+});
diff --git a/server/routes/account.js b/server/routes/account.js
index 58b9e15da..5ad33ae71 100644
--- a/server/routes/account.js
+++ b/server/routes/account.js
@@ -13,6 +13,7 @@ import {
mobilePhoneTokenStatus,
} from "../controllers/mobile-phone-token-controller";
import mobilePhoneNumberChange from "../controllers/mobile-phone-number-change-controller";
+import updateRegistrationMethod from "../controllers/registration-method-controller";
import errorHandler from "../utils/error-handler";
const router = Router({mergeParams: true});
@@ -29,5 +30,6 @@ router.post("/phone/token", errorHandler(createMobilePhoneToken));
router.get("/phone/token/status", errorHandler(mobilePhoneTokenStatus));
router.post("/phone/verify", errorHandler(verifyMobilePhoneToken));
router.post("/phone/change", errorHandler(mobilePhoneNumberChange));
+router.post("/registration-method", errorHandler(updateRegistrationMethod));
export default router;
diff --git a/server/utils/logger.js b/server/utils/logger.js
index 0ae51e9a1..dd7e9f172 100644
--- a/server/utils/logger.js
+++ b/server/utils/logger.js
@@ -119,18 +119,53 @@ try {
// no op
}
+const redactHeaders = (headers = {}) => {
+ const h = {...headers};
+ Object.keys(h).forEach((key) => {
+ if (key.toLowerCase() === "authorization") {
+ h[key] = "[REDACTED]";
+ }
+ });
+ return h;
+};
+
+const sanitizeResponse = (response) => {
+ if (!response) return response;
+ return {
+ status: response.status,
+ data: response.data,
+ config: {
+ url: response.config?.url,
+ method: response.config?.method,
+ headers: redactHeaders(response.config?.headers),
+ },
+ };
+};
+
+const sanitizeRequest = (request) => {
+ if (!request) return request;
+ return {
+ path: request.path ?? request.URL?.pathname,
+ method: request.method,
+ };
+};
+
export const logResponseError = (error) => {
try {
if (error.response) {
const {status, data, config} = error.response;
- if (status >= 500) Logger.error(error.response);
- else
+ const safeResponse = sanitizeResponse(error.response);
+ if (status >= 500) {
+ Logger.error(safeResponse);
+ } else
Logger.info(
`Request to ${config.url} failed with ${status}:\n${JSON.stringify(data)}`,
);
} else if (error.request) {
- // The request was made but no response was received
- Logger.error({error, request: error.request});
+ Logger.error({
+ message: error.message,
+ request: sanitizeRequest(error.request),
+ });
} else Logger.error(error.message);
} catch (err) {
Logger.error(err);
diff --git a/server/utils/logger.test.js b/server/utils/logger.test.js
new file mode 100644
index 000000000..ddc4056ca
--- /dev/null
+++ b/server/utils/logger.test.js
@@ -0,0 +1,93 @@
+import Logger, {logResponseError} from "./logger";
+
+describe("logResponseError sanitization", () => {
+ let errorSpy;
+ let infoSpy;
+
+ beforeEach(() => {
+ errorSpy = jest.spyOn(Logger, "error").mockImplementation(() => {});
+ infoSpy = jest.spyOn(Logger, "info").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("redacts authorization headers for 5xx responses", () => {
+ const error = {
+ response: {
+ status: 500,
+ data: {},
+ config: {
+ url: "https://radius.test/api/v1/test",
+ method: "post",
+ headers: {
+ "content-type": "application/json",
+ Authorization: "Bearer secret-token",
+ authorization: "Bearer lowercase-token",
+ AUTHORIZATION: "Bearer uppercase-token",
+ },
+ },
+ },
+ };
+ logResponseError(error);
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ const loggedArg = errorSpy.mock.calls[0][0];
+ expect(loggedArg.config.headers).toEqual({
+ Authorization: "[REDACTED]",
+ authorization: "[REDACTED]",
+ AUTHORIZATION: "[REDACTED]",
+ "content-type": "application/json",
+ });
+ });
+
+ it("does not log headers for non-5xx but logs message safely", () => {
+ const error = {
+ response: {
+ status: 400,
+ data: {response_code: "BAD_REQUEST"},
+ config: {
+ url: "https://radius.test/api/v1/test",
+ method: "post",
+ headers: {
+ Authorization: "Bearer secret-token",
+ },
+ },
+ },
+ };
+ logResponseError(error);
+ expect(infoSpy).toHaveBeenCalledTimes(1);
+ const message = infoSpy.mock.calls[0][0];
+ expect(message).not.toContain("secret-token");
+ expect(message).toContain("400");
+ });
+
+ it("sanitizes request object (no headers leakage)", () => {
+ const error = {
+ message: "Network Error",
+ request: {
+ path: "/api/v1/test",
+ method: "post",
+ headers: {
+ Authorization: "Bearer secret-token",
+ },
+ },
+ };
+ logResponseError(error);
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ const loggedArg = errorSpy.mock.calls[0][0];
+ expect(loggedArg.request).toEqual({
+ path: "/api/v1/test",
+ method: "post",
+ });
+ expect(JSON.stringify(loggedArg)).not.toContain("secret-token");
+ });
+
+ it("logs generic error message safely", () => {
+ const error = {
+ message: "Something went wrong",
+ };
+ logResponseError(error);
+ expect(errorSpy).toHaveBeenCalledWith("Something went wrong");
+ });
+});
diff --git a/server/utils/openwisp-urls.js b/server/utils/openwisp-urls.js
index d3b10685e..0464ae9ab 100644
--- a/server/utils/openwisp-urls.js
+++ b/server/utils/openwisp-urls.js
@@ -13,6 +13,7 @@ const paths = {
mobile_phone_token_status: "/account/phone/token/active",
verify_mobile_phone_token: "/account/phone/verify",
mobile_phone_number_change: "/account/phone/change",
+ update_registration_method: "/account/registration-method",
plans: "/plan",
payment_status: "/payment/{paymentId}/status",
};