@@ -976,6 +1222,7 @@ Status.propTypes = {
url: PropTypes.string.isRequired,
}),
),
+ radius_usage_enabled: PropTypes.bool,
saml_logout_url: PropTypes.string,
}).isRequired,
language: PropTypes.string.isRequired,
diff --git a/client/components/status/status.test.js b/client/components/status/status.test.js
index b43109ee..dfdd4f2f 100644
--- a/client/components/status/status.test.js
+++ b/client/components/status/status.test.js
@@ -93,6 +93,7 @@ const responseData = {
describe("
rendering with placeholder translation tags", () => {
const props = createTestProps();
+ props.statusPage.radius_usage_enabled = true;
it("should render translation placeholder correctly", () => {
const renderer = new ShallowRenderer();
const wrapper = renderer.render(
);
@@ -105,6 +106,7 @@ describe("
rendering", () => {
it("should render correctly", () => {
props = createTestProps();
+ props.statusPage.radius_usage_enabled = true;
const renderer = new ShallowRenderer();
loadTranslation("en", "default");
const component = renderer.render(
);
@@ -257,6 +259,14 @@ describe("
interactions", () => {
headers: {},
}),
)
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ status: 200,
+ statusText: "OK",
+ data: [],
+ headers: {},
+ }),
+ )
.mockImplementationOnce(() =>
Promise.resolve({
status: 200,
@@ -266,10 +276,12 @@ describe("
interactions", () => {
}),
);
jest.spyOn(Status.prototype, "getUserActiveRadiusSessions");
+ jest.spyOn(Status.prototype, "getUserRadiusUsage");
props = createTestProps({
userData: {...responseData, mustLogin: true},
});
+ props.statusPage.radius_usage_enabled = true;
validateToken.mockReturnValue(true);
const setLoading = jest.fn();
wrapper = shallow(
, {
@@ -282,6 +294,7 @@ describe("
interactions", () => {
expect(Status.prototype.getUserActiveRadiusSessions).toHaveBeenCalled();
expect(wrapper.instance().state.activeSessions.length).toBe(1);
expect(setLoading.mock.calls.length).toBe(1);
+ expect(Status.prototype.getUserRadiusUsage).toHaveBeenCalled();
wrapper.setProps({
location: {
search: "",
@@ -310,6 +323,7 @@ describe("
interactions", () => {
wrapper.instance().loginFormRef.current = mockRef;
wrapper.instance().componentDidMount();
await tick();
+ await tick();
expect(mockRef.submit.mock.calls.length).toBe(1);
Status.prototype.getUserActiveRadiusSessions.mockRestore();
});
@@ -790,7 +804,7 @@ describe("
interactions", () => {
wrapper.setState({rememberMe: true});
const handleLogout = jest.spyOn(wrapper.instance(), "handleLogout");
wrapper.find(".logout input.button").simulate("click", {});
- const modalWrapper = wrapper.find(Modal).shallow();
+ const modalWrapper = wrapper.find(Modal).first().shallow();
modalWrapper
.find(".modal-buttons button:first-child")
.simulate("click", {});
@@ -806,7 +820,7 @@ describe("
interactions", () => {
wrapper.setState({rememberMe: true});
const handleLogout = jest.spyOn(wrapper.instance(), "handleLogout");
wrapper.find(".logout input.button").simulate("click", {});
- const modalWrapper = wrapper.find(Modal).shallow();
+ const modalWrapper = wrapper.find(Modal).first().shallow();
modalWrapper.find(".modal-buttons button:last-child").simulate("click", {});
expect(handleLogout).toHaveBeenCalledWith(false);
});
@@ -1176,7 +1190,7 @@ describe("
interactions", () => {
status.componentDidMount();
wrapper.find(".logout input.button").simulate("click", {});
- const modalWrapper = wrapper.find(Modal).shallow();
+ const modalWrapper = wrapper.find(Modal).first().shallow();
modalWrapper.find(".modal-buttons button:last-child").simulate("click", {});
expect(handleLogout).toHaveBeenCalledWith(false);
expect(location.assign.mock.calls.length).toBe(0);
@@ -1625,4 +1639,280 @@ describe("
interactions", () => {
await tick();
expect(setLoading.mock.calls).toEqual([[true], [false]]);
});
+ it("test getUserRadiusUsage method", async () => {
+ jest.spyOn(toast, "error");
+ jest.spyOn(toast, "dismiss");
+ axios
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ status: 200,
+ statusText: "OK",
+ data: {
+ checks: [
+ {
+ attribute: "Max-Daily-Session",
+ op: ":=",
+ value: "10800",
+ result: 0,
+ type: "seconds",
+ },
+ ],
+ },
+ headers: {},
+ }),
+ )
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ status: 200,
+ statusText: "OK",
+ data: {
+ plan: {
+ id: "d5bc4d5a-0a8c-4e94-8d52-4c54836bd013",
+ name: "Free",
+ currency: "EUR",
+ is_free: true,
+ expire: null,
+ active: true,
+ },
+ },
+ headers: {},
+ }),
+ )
+ .mockImplementationOnce(() =>
+ Promise.reject({
+ response: {
+ status: 401,
+ headers: {},
+ },
+ }),
+ );
+ props = createTestProps();
+ wrapper = shallow(
, {
+ context: {setLoading: jest.fn()},
+ disableLifecycleMethods: true,
+ });
+ jest.spyOn(wrapper.instance(), "getUserRadiusUsage");
+ wrapper.instance().getUserRadiusUsage();
+ await tick();
+ expect(wrapper.instance().state.userChecks.length).toBe(1);
+ wrapper.instance().getUserRadiusUsage();
+ await tick();
+ expect(wrapper.instance().state.userPlan.is_free).toBe(true);
+ wrapper.instance().getUserRadiusUsage();
+ await tick();
+ expect(toast.error.mock.calls.length).toBe(1);
+ toast.error.mock.calls.pop()[1].onOpen();
+ expect(toast.dismiss).toHaveBeenCalledWith("main_toast_id");
+ expect(wrapper.instance().props.logout.mock.calls.length).toBe(1);
+ });
+ it("test upgradeUserPlan method handle error", async () => {
+ jest.spyOn(toast, "error");
+ jest.spyOn(toast, "dismiss");
+ axios.mockImplementation(() =>
+ Promise.reject({
+ response: {
+ status: 400,
+ statusText: "BAD_REQUEST",
+ data: {
+ plan_pricing: ["This plan requires billing info."],
+ },
+ },
+ }),
+ );
+ props = createTestProps();
+ wrapper = shallow(
, {
+ context: {setLoading: jest.fn()},
+ disableLifecycleMethods: true,
+ });
+ jest.spyOn(wrapper.instance(), "upgradeUserPlan");
+ wrapper.setState({upgradePlans: [{id: "1"}]});
+ wrapper.instance().upgradeUserPlan({target: {value: 0}});
+ await tick();
+ expect(toast.error.mock.calls.length).toBe(1);
+ });
+ it("should hide limit-info element if getUserRadiusUsage fails", async () => {
+ validateToken.mockReturnValue(true);
+ axios.mockImplementation(() =>
+ Promise.reject({
+ response: {
+ status: 404,
+ statusText: "404_NOT_FOUND",
+ data: "404",
+ },
+ }),
+ );
+ const prop = createTestProps();
+ prop.statusPage.links = links;
+ prop.isAuthenticated = true;
+ wrapper = shallow(
, {
+ context: {setLoading: jest.fn()},
+ });
+ await tick();
+ expect(wrapper.find(".limit-info").exists()).toBe(false);
+ });
+ it("should show user's radius usage", async () => {
+ validateToken.mockReturnValue(true);
+ axios
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ response: {
+ status: 200,
+ statusText: "OK",
+ },
+ data: [],
+ headers: {},
+ }),
+ )
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ status: 200,
+ statusText: "OK",
+ data: {
+ checks: [
+ {
+ attribute: "Max-Daily-Session",
+ op: ":=",
+ value: "10800",
+ result: 0,
+ type: "seconds",
+ },
+ {
+ attribute: "Max-Daily-Session-Traffic",
+ op: ":=",
+ value: "3000000000",
+ result: 0,
+ type: "bytes",
+ },
+ ],
+ },
+ headers: {},
+ }),
+ );
+ const prop = createTestProps();
+ prop.statusPage.links = links;
+ prop.statusPage.radius_usage_enabled = true;
+ prop.isAuthenticated = true;
+ wrapper = shallow(
, {
+ context: {setLoading: jest.fn()},
+ });
+ await tick();
+ expect(wrapper).toMatchSnapshot();
+ });
+ it("should show user's plan when subscription module is enabled", async () => {
+ validateToken.mockReturnValue(true);
+ jest.spyOn(toast, "success");
+ jest.spyOn(toast, "dismiss");
+ axios
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ response: {
+ status: 200,
+ statusText: "OK",
+ },
+ data: [],
+ headers: {},
+ }),
+ )
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ status: 200,
+ statusText: "OK",
+ data: {
+ checks: [
+ {
+ attribute: "Max-Daily-Session",
+ op: ":=",
+ value: "10800",
+ result: 10700,
+ type: "seconds",
+ },
+ {
+ attribute: "Max-Daily-Session-Traffic",
+ op: ":=",
+ value: "3000000000",
+ result: 3000000000,
+ type: "bytes",
+ },
+ ],
+ plan: {
+ name: "Free",
+ currency: "EUR",
+ is_free: true,
+ expire: null,
+ active: true,
+ },
+ },
+ headers: {},
+ }),
+ )
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ status: 200,
+ statusText: "OK",
+ data: [
+ {
+ plan: "Free",
+ pricing: "no expiration (free) (0 days)",
+ plan_description: "3 hours per day\r\n300 MB per day",
+ currency: "EUR",
+ price: "0.00",
+ },
+ {
+ plan: "Premium",
+ pricing: "per month (0 days)",
+ plan_description: "Unlimited time and traffic",
+ currency: "EUR",
+ price: "1.99",
+ },
+ {
+ plan: "Premium",
+ pricing: "per year (0 days)",
+ plan_description: "Unlimited time and traffic",
+ currency: "EUR",
+ price: "9.99",
+ },
+ ],
+ headers: {},
+ }),
+ )
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ response: {
+ status: 200,
+ statusText: "OK",
+ },
+ data: {
+ payment_url: "https://account.openwisp.io/payment/123",
+ },
+ headers: {},
+ }),
+ );
+ const prop = createTestProps();
+ prop.statusPage.links = links;
+ prop.statusPage.radius_usage_enabled = true;
+ prop.isAuthenticated = true;
+ prop.settings.subscriptions = true;
+ wrapper = shallow(
, {
+ context: {setLoading: jest.fn()},
+ });
+ wrapper.setState({showRadiusUsage: false});
+ await tick();
+ expect(wrapper).toMatchSnapshot();
+ wrapper.find("#plan-upgrade-btn").simulate("click");
+ await tick();
+ expect(wrapper).toMatchSnapshot();
+ const modalWrapper = wrapper.find(Modal).last().shallow();
+ window.console.log(modalWrapper.debug());
+ modalWrapper.find("#radio0").simulate("change", {target: {value: "0"}});
+ await tick();
+ toast.success.mock.calls.pop()[1].onOpen();
+ expect(toast.dismiss).toHaveBeenCalledWith("main_toast_id");
+ expect(prop.navigate).toHaveBeenCalledWith(
+ `/${prop.orgSlug}/payment/process`,
+ );
+ expect(wrapper.instance().props.setUserData).toHaveBeenCalledWith({
+ ...prop.userData,
+ payment_url: "https://account.openwisp.io/payment/123",
+ });
+ });
});
diff --git a/client/constants/index.js b/client/constants/index.js
index 0255ba2c..7040d046 100644
--- a/client/constants/index.js
+++ b/client/constants/index.js
@@ -10,6 +10,8 @@ export const paymentStatusUrl = (orgSlug, paymentId) =>
`${prefix}/${orgSlug}/payment/status/${paymentId}`;
export const getUserRadiusSessionsUrl = (orgSlug) =>
`${prefix}/${orgSlug}/account/session`;
+export const getUserRadiusUsageUrl = (orgSlug) =>
+ `${prefix}/${orgSlug}/account/usage`;
export const createMobilePhoneTokenUrl = (orgSlug) =>
`${prefix}/${orgSlug}/account/phone/token`;
export const mobilePhoneTokenStatusUrl = (orgSlug) =>
@@ -19,5 +21,6 @@ export const verifyMobilePhoneTokenUrl = (orgSlug) =>
export const mobilePhoneChangeUrl = (orgSlug) =>
`${prefix}/${orgSlug}/account/phone/change`;
export const plansApiUrl = `${prefix}/{orgSlug}/plan/`;
+export const upgradePlanApiUrl = `${prefix}/{orgSlug}/plan/upgrade`;
export const modalContentUrl = (orgSlug) => `${prefix}/${orgSlug}/modal`;
export const mainToastId = "main_toast_id";
diff --git a/client/utils/get-plan-selection.js b/client/utils/get-plan-selection.js
new file mode 100644
index 00000000..af6795ff
--- /dev/null
+++ b/client/utils/get-plan-selection.js
@@ -0,0 +1,62 @@
+import React from "react";
+import {t, gettext} from "ttag";
+
+import "./plan.css";
+
+const getPlan = (plan, index) => {
+ /* disable ttag */
+ const planTitle = gettext(plan.plan);
+ const planDesc = gettext(plan.plan_description);
+ /* enable ttag */
+ const pricingText = Number(plan.price)
+ ? `${plan.price} ${plan.currency} ${plan.pricing.replace("(0 days)", "")}`
+ : "";
+ return (
+
+ {planTitle}
+ {planDesc}
+ {pricingText && {pricingText} }
+
+ );
+};
+
+const getPlanSelection = (
+ plans,
+ selectedPlan,
+ onChange,
+ onFocus,
+ hideSelection,
+) => {
+ let index = 0;
+ return (
+
+
{t`PLAN_SETTING_TXT`}.
+ {plans.map((plan) => {
+ const currentIndex = String(index);
+ let planClass = "plan";
+ if (selectedPlan === currentIndex) {
+ planClass += " active";
+ } else if (selectedPlan !== null && selectedPlan !== currentIndex) {
+ planClass += " inactive";
+ }
+ index += 1;
+ return (
+
+
+ {getPlan(plan, currentIndex)}
+
+ );
+ })}
+
+ );
+};
+
+export default getPlanSelection;
diff --git a/client/utils/get-plans.js b/client/utils/get-plans.js
new file mode 100644
index 00000000..2a287ca9
--- /dev/null
+++ b/client/utils/get-plans.js
@@ -0,0 +1,26 @@
+import axios from "axios";
+import {toast} from "react-toastify";
+import {t} from "ttag";
+import getLanguageHeaders from "./get-language-headers";
+import {plansApiUrl} from "../constants";
+import logError from "./log-error";
+
+const getPlans = async (orgSlug, language, successCallback) => {
+ const plansUrl = plansApiUrl.replace("{orgSlug}", orgSlug);
+ axios({
+ method: "get",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "accept-language": getLanguageHeaders(language),
+ },
+ url: plansUrl,
+ data: {},
+ })
+ .then((response) => successCallback(response.data))
+ .catch((error) => {
+ toast.error(t`ERR_OCCUR`);
+ logError(error, "Error while fetching plans");
+ });
+};
+
+export default getPlans;
diff --git a/client/utils/modal.js b/client/utils/modal.js
index 28456c58..9b083a7a 100644
--- a/client/utils/modal.js
+++ b/client/utils/modal.js
@@ -5,7 +5,8 @@ import "./modal.css";
class InfoModal extends Component {
render() {
- const {active, toggleModal, handleResponse, content} = this.props;
+ const {active, toggleModal, handleResponse, content, isConfirmationDialog} =
+ this.props;
return (
@@ -17,22 +18,24 @@ class InfoModal extends Component {
✖
{content}
-
- handleResponse(true)}
- >
- {t`YES`}
-
- handleResponse(false)}
- >
- {t`NO`}
-
-
+ {isConfirmationDialog && (
+
+ handleResponse(true)}
+ >
+ {t`YES`}
+
+ handleResponse(false)}
+ >
+ {t`NO`}
+
+
+ )}
);
@@ -46,4 +49,8 @@ InfoModal.propTypes = {
toggleModal: propTypes.func.isRequired,
handleResponse: propTypes.func.isRequired,
content: propTypes.object.isRequired,
+ isConfirmationDialog: propTypes.bool,
+};
+InfoModal.defaultProps = {
+ isConfirmationDialog: true,
};
diff --git a/client/utils/plan.css b/client/utils/plan.css
new file mode 100644
index 00000000..367acb0e
--- /dev/null
+++ b/client/utils/plan.css
@@ -0,0 +1,51 @@
+.plans {
+ text-align: start;
+}
+
+.plans input {
+ float: right;
+ opacity: 0;
+}
+
+.plans .plan {
+ margin: 0 0 10px;
+ border-radius: 5px;
+ line-height: 30px;
+}
+
+.plans .plan label {
+ padding: 10px 15px;
+ cursor: pointer;
+}
+
+.plans .plan:last-child {
+ margin: 0;
+}
+
+.plans span.title {
+ font-size: 16px;
+ font-weight: bold;
+}
+
+.plans span,
+.plans label {
+ display: block;
+ font-weight: normal;
+}
+
+.plans span.price {
+ text-align: right;
+ font-weight: bold;
+}
+
+.plans .inactive label {
+ opacity: 0.4;
+}
+
+.plans .inactive label:hover {
+ opacity: 1;
+}
+
+.plans .active label {
+ cursor: default;
+}
diff --git a/i18n/de.po b/i18n/de.po
index 816daeec..28885db1 100644
--- a/i18n/de.po
+++ b/i18n/de.po
@@ -41,6 +41,22 @@ msgstr "Logout war erfolgreich"
msgid "PASSWORD_EXPIRED"
msgstr "Ihr Passwort ist abgelaufen. Bitte aktualisieren Sie es."
+#: client/components/status/status.js:384
+msgid "SUCCESS_UPGRADE_PLAN"
+msgstr "Plan-Upgrade läuft"
+
+#: client/components/status/status.js:934
+msgid "CURRENT_SUBSCRIPTION_TXT"
+msgstr "Aktuelles Abonnement:"
+
+#: client/components/status/status.js:1000
+msgid "USAGE_LIMIT_EXHAUSTED_TXT"
+msgstr "Sie haben den verfügbaren Datenverkehr ausgeschöpft. Führen Sie ein Upgrade durch, um den Dienst weiterhin nutzen zu können."
+
+#: client/components/status/status.js:964
+msgid "PLAN_UPGRADE_BTN_TXT"
+msgstr "Aktualisierung"
+
#: client/components/status/status.js:467
#: client/components/status/status.js:658
msgid "ACCT_ACTIVE"
diff --git a/i18n/en.po b/i18n/en.po
index 2842d56b..d7719337 100644
--- a/i18n/en.po
+++ b/i18n/en.po
@@ -525,6 +525,22 @@ msgstr "Logout successful"
msgid "PASSWORD_EXPIRED"
msgstr "Your password has expired, please update it."
+#: client/components/status/status.js:384
+msgid "SUCCESS_UPGRADE_PLAN"
+msgstr "Plan upgrade in progress"
+
+#: client/components/status/status.js:934
+msgid "CURRENT_SUBSCRIPTION_TXT"
+msgstr "Current subscription:"
+
+#: client/components/status/status.js:1000
+msgid "USAGE_LIMIT_EXHAUSTED_TXT"
+msgstr "You have exhausted the traffic available. Upgrade to continue using the service."
+
+#: client/components/status/status.js:964
+msgid "PLAN_UPGRADE_BTN_TXT"
+msgstr "Upgrade"
+
#: client/components/password-change/password-change.js:101
#: client/components/password-change/password-change.js:109
#: client/components/password-change/password-change.test.js:129
diff --git a/i18n/fur.po b/i18n/fur.po
index d7d89ff4..2b5c2e42 100644
--- a/i18n/fur.po
+++ b/i18n/fur.po
@@ -518,6 +518,22 @@ msgstr "Logout fat cun sucès"
msgid "PASSWORD_EXPIRED"
msgstr "La tô password al è scjadude, ti prei di aggiornâle."
+#: client/components/status/status.js:384
+msgid "SUCCESS_UPGRADE_PLAN"
+msgstr "Aggiornament dal paniere in corse"
+
+#: client/components/status/status.js:934
+msgid "CURRENT_SUBSCRIPTION_TXT"
+msgstr "Sotons abonament:"
+
+#: client/components/status/status.js:1000
+msgid "USAGE_LIMIT_EXHAUSTED_TXT"
+msgstr "Tu âs consumât il trafic disponibil. Fâ l'aggiornament par continuâ a doprâ il servizi."
+
+#: client/components/status/status.js:964
+msgid "PLAN_UPGRADE_BTN_TXT"
+msgstr "Potenziament"
+
#: client/components/password-change/password-change.js:61
#: client/components/password-change/password-change.test.js:115
#: client/components/password-change/password-change.test.js:120
diff --git a/i18n/it.po b/i18n/it.po
index 0cef5835..c80539ea 100644
--- a/i18n/it.po
+++ b/i18n/it.po
@@ -520,6 +520,22 @@ msgstr "Log out effettuato con successo"
msgid "PASSWORD_EXPIRED"
msgstr "La tua password è scaduta, aggiornala."
+#: client/components/status/status.js:384
+msgid "SUCCESS_UPGRADE_PLAN"
+msgstr "Piano di aggiornamento in corso"
+
+#: client/components/status/status.js:934
+msgid "CURRENT_SUBSCRIPTION_TXT"
+msgstr "Abbonamento attuale:"
+
+#: client/components/status/status.js:1000
+msgid "USAGE_LIMIT_EXHAUSTED_TXT"
+msgstr "Hai esaurito il traffico disponibile. Esegui l'upgrade per continuare a utilizzare il servizio."
+
+#: client/components/status/status.js:964
+msgid "PLAN_UPGRADE_BTN_TXT"
+msgstr "Aggiornamento"
+
#: client/components/password-change/password-change.js:61
#: client/components/password-change/password-change.test.js:115
#: client/components/password-change/password-change.test.js:120
diff --git a/i18n/ru.po b/i18n/ru.po
index 4d8adc22..5d4afa96 100644
--- a/i18n/ru.po
+++ b/i18n/ru.po
@@ -516,6 +516,22 @@ msgstr "Успешный выход"
msgid "PASSWORD_EXPIRED"
msgstr "Срок действия вашего пароля истек, обновите его."
+#: client/components/status/status.js:384
+msgid "SUCCESS_UPGRADE_PLAN"
+msgstr "Обновление плана выполняется"
+
+#: client/components/status/status.js:934
+msgid "CURRENT_SUBSCRIPTION_TXT"
+msgstr "Текущая подписка:"
+
+#: client/components/status/status.js:1000
+msgid "USAGE_LIMIT_EXHAUSTED_TXT"
+msgstr "Вы исчерпали доступный трафик. Обновите версию, чтобы продолжить пользоваться услугой."
+
+#: client/components/status/status.js:964
+msgid "PLAN_UPGRADE_BTN_TXT"
+msgstr "Обновление"
+
#: client/components/password-change/password-change.js:61
#: client/components/password-change/password-change.test.js:115
#: client/components/password-change/password-change.test.js:120
diff --git a/i18n/sl.po b/i18n/sl.po
index c0b6887d..26859657 100644
--- a/i18n/sl.po
+++ b/i18n/sl.po
@@ -462,6 +462,22 @@ msgstr "Odjava je bila uspešna"
msgid "PASSWORD_EXPIRED"
msgstr "Vaše geslo je poteklo, posodobite ga."
+#: client/components/status/status.js:384
+msgid "SUCCESS_UPGRADE_PLAN"
+msgstr "Prebieha aktualizácia plánu"
+
+#: client/components/status/status.js:934
+msgid "CURRENT_SUBSCRIPTION_TXT"
+msgstr "Aktuálne predplatné:"
+
+#: client/components/status/status.js:1000
+msgid "USAGE_LIMIT_EXHAUSTED_TXT"
+msgstr "Vyčerpali ste dostupnú premávku. Ak chcete službu naďalej používať, inovujte ju."
+
+#: client/components/status/status.js:964
+msgid "PLAN_UPGRADE_BTN_TXT"
+msgstr "Inovovať"
+
#: client/components/password-change/password-change.js:87
#: client/components/password-change/password-change.js:93
#: client/components/password-change/password-change.test.js:129
diff --git a/internals/config/default.yml b/internals/config/default.yml
index dfdb402a..8010a393 100644
--- a/internals/config/default.yml
+++ b/internals/config/default.yml
@@ -49,6 +49,7 @@ client:
secondary_text: false
registration_form:
+ auto_select_first_plan: false
input_fields:
phone_number: {}
username:
@@ -100,6 +101,7 @@ client:
social_links: []
status_page:
+ radius_usage_enabled: false
links: []
login_form:
diff --git a/organizations/default/default.yml b/organizations/default/default.yml
index e7f9c46a..e18a3daf 100644
--- a/organizations/default/default.yml
+++ b/organizations/default/default.yml
@@ -145,6 +145,7 @@ client:
- text:
en: "Change your password"
url: "/{orgSlug}/change-password/"
+ radius_usage_enabled: true
login_form:
social_login:
diff --git a/package.json b/package.json
index 6635f56e..1917c128 100644
--- a/package.json
+++ b/package.json
@@ -6,8 +6,10 @@
"dependencies": {
"@babel/register": "^7.10.1",
"@types/morgan": "^1.9.2",
+ "add": "^2.0.6",
"autoprefixer": "^9.8.0",
"axios": "^0.24.0",
+ "bytes": "^3.1.2",
"compression": "^1.7.4",
"concurrently": "^8.2.1",
"cookie-parser": "^1.4.5",
@@ -16,6 +18,7 @@
"deep-object-diff": "^1.1.0",
"deepmerge": "^4.2.2",
"dompurify": "^3.0.6",
+ "duration-formatter": "^1.0.7",
"express": "^4.17.1",
"fs-extra": "^11.1.0",
"history": "^5.2.0",
@@ -27,6 +30,7 @@
"morgan": "^1.10.0",
"node-plop": "^0.31.0",
"nodemon": "^2.0.4",
+ "pretty-bytes": "^6.1.1",
"prop-types": "^15.7.2",
"qs": "^6.9.4",
"raf": "^3.4.1",
diff --git a/server/controllers/user-radius-usage-controller.js b/server/controllers/user-radius-usage-controller.js
new file mode 100644
index 00000000..3830e0c2
--- /dev/null
+++ b/server/controllers/user-radius-usage-controller.js
@@ -0,0 +1,70 @@
+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";
+
+const getUserRadiusUsage = (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} = conf;
+ let radiusUsagePathName;
+ if (conf.settings.subscriptions) {
+ radiusUsagePathName = "user_plan_radius_usage";
+ } else {
+ radiusUsagePathName = "user_radius_usage";
+ }
+ const userRadiusUsageUrl = reverse(radiusUsagePathName, getSlug(conf));
+ const timeout = conf.timeout * 1000;
+ // make AJAX request
+ axios({
+ method: "get",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ Authorization: req.headers.authorization,
+ "accept-language": req.headers["accept-language"],
+ },
+ url: `${host}${userRadiusUsageUrl}/`,
+ timeout,
+ params: req.query,
+ })
+ .then((response) => {
+ if ("link" in response.headers) {
+ res.setHeader("link", response.headers.link);
+ }
+ res
+ .status(response.status)
+ .type("application/json")
+ .send(response.data);
+ })
+ .catch((error) => {
+ logResponseError(error);
+ // forward 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: "INTERNAL_SERVER_ERROR",
+ });
+ }
+};
+
+export default getUserRadiusUsage;
diff --git a/server/controllers/user-upgrade-plan-controller.js b/server/controllers/user-upgrade-plan-controller.js
new file mode 100644
index 00000000..32f6acb7
--- /dev/null
+++ b/server/controllers/user-upgrade-plan-controller.js
@@ -0,0 +1,64 @@
+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";
+
+const userUpgradePlan = (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} = conf;
+ const userUpgradePlanUrl = reverse(
+ "user_plan_radius_usage",
+ getSlug(conf),
+ );
+ const timeout = conf.timeout * 1000;
+ // make AJAX request
+ axios({
+ method: "put",
+ headers: {
+ "content-type": "application/json",
+ Authorization: req.headers.authorization,
+ "accept-language": req.headers["accept-language"],
+ },
+ url: `${host}${userUpgradePlanUrl}/`,
+ data: req.body,
+ timeout,
+ })
+ .then((response) => {
+ res
+ .status(response.status)
+ .type("application/json")
+ .send(response.data);
+ })
+ .catch((error) => {
+ logResponseError(error);
+ // forward 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: "INTERNAL_SERVER_ERROR",
+ });
+ }
+};
+
+export default userUpgradePlan;
diff --git a/server/routes/account.js b/server/routes/account.js
index 42260545..76482c71 100644
--- a/server/routes/account.js
+++ b/server/routes/account.js
@@ -5,6 +5,7 @@ import passwordResetConfirm from "../controllers/password-reset-confirm-controll
import passwordReset from "../controllers/password-reset-controller";
import registration from "../controllers/registration-controller";
import getUserRadiusSessions from "../controllers/user-radius-sessions-controller";
+import getUserRadiusUsage from "../controllers/user-radius-usage-controller";
import validateToken from "../controllers/validate-token-controller";
import {
createMobilePhoneToken,
@@ -23,6 +24,7 @@ router.post("/password/reset/confirm/", errorHandler(passwordResetConfirm));
router.post("/password/reset", errorHandler(passwordReset));
router.post("/", errorHandler(registration));
router.get("/session/", errorHandler(getUserRadiusSessions));
+router.get("/usage/", errorHandler(getUserRadiusUsage));
router.post("/phone/token", errorHandler(createMobilePhoneToken));
router.get("/phone/token/status", errorHandler(mobilePhoneTokenStatus));
router.post("/phone/verify", errorHandler(verifyMobilePhoneToken));
diff --git a/server/routes/plans.js b/server/routes/plans.js
index d5278afb..e2907c34 100644
--- a/server/routes/plans.js
+++ b/server/routes/plans.js
@@ -1,9 +1,11 @@
import {Router} from "express";
import plans from "../controllers/plans-controller";
+import userUpgradePlan from "../controllers/user-upgrade-plan-controller";
import errorHandler from "../utils/error-handler";
const router = Router({mergeParams: true});
router.get("/", errorHandler(plans));
+router.post("/upgrade", errorHandler(userUpgradePlan));
export default router;
diff --git a/server/utils/openwisp-urls.js b/server/utils/openwisp-urls.js
index 42b78249..d3b10685 100644
--- a/server/utils/openwisp-urls.js
+++ b/server/utils/openwisp-urls.js
@@ -7,6 +7,8 @@ const paths = {
user_auth_token: "/account/token",
validate_auth_token: "/account/token/validate",
user_radius_sessions: "/account/session",
+ user_radius_usage: "/account/usage",
+ user_plan_radius_usage: "/account/plan",
create_mobile_phone_token: "/account/phone/token",
mobile_phone_token_status: "/account/phone/token/active",
verify_mobile_phone_token: "/account/phone/verify",
@@ -21,7 +23,11 @@ const reverse = (name, orgSlug) => {
if (!path) {
throw new Error(`Reverse for path "${name}" not found.`);
}
- if (name === "plans" || name === "payment_status") {
+ if (
+ name === "plans" ||
+ name === "payment_status" ||
+ name === "user_plan_radius_usage"
+ ) {
prefix = prefix.replace("/radius/", "/subscriptions/");
}
return `${prefix.replace("{orgSlug}", orgSlug)}${path}`;
diff --git a/yarn.lock b/yarn.lock
index 2bb21d18..f33cb2ac 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2573,6 +2573,11 @@ acorn@^8.0.4, acorn@^8.2.4:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==
+add@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/add/-/add-2.0.6.tgz#248f0a9f6e5a528ef2295dbeec30532130ae2235"
+ integrity sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==
+
agent-base@6:
version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
@@ -3504,7 +3509,7 @@ bytes@3.1.0:
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
-bytes@3.1.2:
+bytes@3.1.2, bytes@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
@@ -4996,6 +5001,11 @@ duplexify@^3.4.2, duplexify@^3.6.0:
readable-stream "^2.0.0"
stream-shift "^1.0.0"
+duration-formatter@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/duration-formatter/-/duration-formatter-1.0.7.tgz#caf764f2f7911ed629b0e19a2f0a9bf30422555d"
+ integrity sha512-uM0qX7L1HF1m3zX4yPYU264chtwZUmneHUmzEYG70pTO+PTjhSeMm7v6L+q+ZFOFerT39Gce6WXX+FMssBWplQ==
+
ecc-jsbn@~0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@@ -10110,6 +10120,11 @@ prettier@^2.2.0:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==
+pretty-bytes@^6.1.1:
+ version "6.1.1"
+ resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
+ integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==
+
pretty-error@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6"