From 793cb7e6a41f2528b447b9f494bd9c4d0332d414 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 19 Dec 2023 18:27:48 +0530 Subject: [PATCH] [feature] Added option to show user's data usage on status page #735 When subscriptions module is used, show user's current plan. If user's current plan is free, then show option to upgrade the plan. Closes #735 --- client/components/status/index.css | 46 ++++++++ client/components/status/status.js | 101 +++++++++++++++++- client/constants/index.js | 2 + package.json | 3 + .../user-radius-usage-controller.js | 70 ++++++++++++ server/routes/account.js | 2 + server/utils/openwisp-urls.js | 8 +- yarn.lock | 15 +++ 8 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 server/controllers/user-radius-usage-controller.js diff --git a/client/components/status/index.css b/client/components/status/index.css index 79e3429d..ec1c32ff 100644 --- a/client/components/status/index.css +++ b/client/components/status/index.css @@ -68,6 +68,52 @@ overflow-wrap: break-word; word-wrap: break-word; } +.flex-wrapper { + flex-wrap: wrap; + max-width: 1000px; + margin: 0 auto; +} + +.flex-row { + flex-grow: 1; + flex-direction: column; + flex-wrap: wrap; + width: 100%; +} + +.flex-row > .row { + padding: 3%; + margin: 0 0 10px; + box-sizing: border-box; +} + +.limit-info { + text-align: center; + margin-bottom: 15px; +} + +.limit-info h3 { + margin: 0 0 10px; +} +.limit-info progress { + width: 80%; + height: 40px; +} +.limit-info p.progress { + font-size: 20px; +} +.limit-info p:last-child { + margin-bottom: 0; +} +.limit-info .button { + margin-top: 5px; +} + +.limit-info .exahusted { + color: #ba2121f7; + margin-top: 15px; + line-height: 1.5em; +} @media screen and (min-width: 0px) and (max-width: 500px) { .logout-modal-container p.message { diff --git a/client/components/status/status.js b/client/components/status/status.js index 9c648296..f4946243 100644 --- a/client/components/status/status.js +++ b/client/components/status/status.js @@ -12,7 +12,13 @@ import {Link} from "react-router-dom"; import {toast} from "react-toastify"; import InfinteScroll from "react-infinite-scroll-component"; import {t, gettext} from "ttag"; -import {getUserRadiusSessionsUrl, mainToastId} from "../../constants"; +import prettyBytes from "pretty-bytes"; +import {timeFromSeconds} from "duration-formatter"; +import { + getUserRadiusSessionsUrl, + getUserRadiusUsageUrl, + mainToastId, +} from "../../constants"; import LoadingContext from "../../utils/loading-context"; import getText from "../../utils/get-text"; import logError from "../../utils/log-error"; @@ -48,6 +54,8 @@ export default class Status extends React.Component { loadSpinner: true, modalActive: false, rememberMe: false, + userChecks: [], + userPlan: {}, }; this.repeatLogin = false; this.getUserRadiusSessions = this.getUserRadiusSessions.bind(this); @@ -196,6 +204,7 @@ export default class Status extends React.Component { componentWillUnmount = () => { clearInterval(this.intervalId); + clearInterval(this.getUserRadiusUsageIntervalId); window.removeEventListener("resize", this.updateScreenWidth); }; @@ -238,6 +247,10 @@ export default class Status extends React.Component { this.intervalId = setInterval(() => { this.getUserActiveRadiusSessions(); }, 60000); + await this.getUserRadiusUsage(); + this.getUserRadiusUsageIntervalId = setInterval(() => { + this.getUserRadiusUsage(); + }, 60000); window.addEventListener("resize", this.updateScreenWidth); this.updateSpinner(); } @@ -288,6 +301,41 @@ export default class Status extends React.Component { } } + async getUserRadiusUsage() { + const {cookies, orgSlug, logout, userData} = this.props; + const url = getUserRadiusUsageUrl(orgSlug); + const auth_token = cookies.get(`${orgSlug}_auth_token`); + handleSession(orgSlug, auth_token, cookies); + const options = {}; + try { + const response = await axios({ + method: "get", + headers: { + "content-type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${userData.auth_token}`, + }, + url, + }); + options.userChecks = response.data.checks; + if (response.data.plan) { + options.userPlan = response.data.plan; + } + this.setState(options); + } catch (error) { + // logout only if unauthorized or forbidden + if ( + error.response && + (error.response.status === 401 || error.response.status === 403) + ) { + logout(cookies, orgSlug); + toast.error(t`ERR_OCCUR`, { + onOpen: () => toast.dismiss(mainToastId), + }); + } + logError(error, t`ERR_OCCUR`); + } + } + async getUserActiveRadiusSessions(params = {}) { const para = { is_open: true, @@ -752,6 +800,17 @@ export default class Status extends React.Component { }, }); + getUserCheckFormattedValue = (value, type) => { + switch (type) { + case "bytes": + return prettyBytes(parseInt(value, 10)); + case "seconds": + return timeFromSeconds(parseInt(value, 10)); + default: + return value; + } + }; + render() { const { statusPage, @@ -762,6 +821,7 @@ export default class Status extends React.Component { isAuthenticated, userData, internetMode, + settings, } = this.props; const {links} = statusPage; const { @@ -769,6 +829,8 @@ export default class Status extends React.Component { password, userInfo, activeSessions, + userChecks, + userPlan, pastSessions, sessionsToLogout, hasMoreSessions, @@ -787,7 +849,42 @@ export default class Status extends React.Component { handleResponse={this.handleLogout} content={

{t`LOGOUT_MODAL_CONTENT`}

} /> -
+
+
+
+ {settings.subscriptions && ( +

Current subscriptions: {userPlan.name}

+ )} + {userChecks && + userChecks.map((check) => ( +
+ +

+ + {this.getUserCheckFormattedValue( + check.result, + check.type, + )} + {" "} + of{" "} + {this.getUserCheckFormattedValue(check.value, check.type)}{" "} + used +

+
+ ))} + {settings.subscriptions && userPlan.is_free && ( +

+ +

+ )} +
+
diff --git a/client/constants/index.js b/client/constants/index.js index 0255ba2c..6c75d39e 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) => diff --git a/package.json b/package.json index 6635f56e..d808c5b1 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dependencies": { "@babel/register": "^7.10.1", "@types/morgan": "^1.9.2", + "add": "^2.0.6", "autoprefixer": "^9.8.0", "axios": "^0.24.0", "compression": "^1.7.4", @@ -16,6 +17,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 +29,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/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/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..a58ac976 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" @@ -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"