From 23826ec1a1a8f749ed55b0751ce2e1ff843e2b91 Mon Sep 17 00:00:00 2001 From: Vivek Chand Date: Sat, 10 Aug 2019 00:12:43 +0530 Subject: [PATCH] [component] Implement auth and write status page component #25 closes #25 --- client/actions/actions.test.js | 17 ++- client/actions/logout.js | 12 +++ client/actions/set-organization.js | 18 +++- client/assets/default/facebook.svg | 1 + client/assets/default/favicon.png | Bin 0 -> 44140 bytes client/assets/default/linkedin.svg | 1 + client/assets/default/openwisp.svg | 3 + client/assets/default/twitter.svg | 22 ++++ .../__snapshots__/contact.test.js.snap | 89 +++++++++++++++ client/components/contact-box/contact.js | 81 ++++++++++++++ client/components/contact-box/contact.test.js | 25 +++++ client/components/contact-box/index.css | 47 ++++++++ client/components/contact-box/index.js | 15 +++ .../header/__snapshots__/header.test.js.snap | 26 ++++- client/components/header/header.js | 2 +- client/components/header/header.test.js | 27 +++-- client/components/login/index.js | 10 +- client/components/login/login.js | 9 +- client/components/login/login.test.js | 4 + .../components/organization-wrapper/index.js | 4 +- .../organization-wrapper.js | 60 +++++++++-- .../organization-wrapper.test.js | 2 + client/components/registration/index.js | 10 +- .../components/registration/registration.js | 4 +- .../registration/registration.test.js | 4 + .../status/__snapshots__/status.test.js.snap | 47 ++++++++ client/components/status/index.css | 81 ++++++++++++++ client/components/status/index.js | 25 +++++ client/components/status/status.js | 101 ++++++++++++++++++ client/components/status/status.test.js | 47 ++++++++ client/constants/action-types.js | 3 +- client/constants/index.js | 5 +- client/index.css | 2 + client/reducers/organization.js | 9 ++ client/utils/authenticate.js | 6 ++ client/utils/utils.test.js | 14 +++ org-configurations/default-configuration.yml | 46 +++++++- package-lock.json | 8 ++ package.json | 3 +- readme.md | 16 +++ server/controllers/obtain-token-controller.js | 11 +- server/controllers/registration-controller.js | 14 ++- .../controllers/validate-token-controller.js | 97 +++++++++++++++++ server/index.js | 2 + server/routes/account.js | 2 + 45 files changed, 982 insertions(+), 50 deletions(-) create mode 100644 client/actions/logout.js create mode 100644 client/assets/default/facebook.svg create mode 100644 client/assets/default/favicon.png create mode 100644 client/assets/default/linkedin.svg create mode 100644 client/assets/default/openwisp.svg create mode 100755 client/assets/default/twitter.svg create mode 100644 client/components/contact-box/__snapshots__/contact.test.js.snap create mode 100644 client/components/contact-box/contact.js create mode 100644 client/components/contact-box/contact.test.js create mode 100644 client/components/contact-box/index.css create mode 100644 client/components/contact-box/index.js create mode 100644 client/components/status/__snapshots__/status.test.js.snap create mode 100644 client/components/status/index.css create mode 100644 client/components/status/index.js create mode 100644 client/components/status/status.js create mode 100644 client/components/status/status.test.js create mode 100644 client/utils/authenticate.js create mode 100644 server/controllers/validate-token-controller.js diff --git a/client/actions/actions.test.js b/client/actions/actions.test.js index 73caa7056..79c002197 100644 --- a/client/actions/actions.test.js +++ b/client/actions/actions.test.js @@ -4,6 +4,7 @@ import thunk from "redux-thunk"; import * as types from "../constants/action-types"; import testOrgConfig from "../test-config.json"; +import logout from "./logout"; import parseOrganizations from "./parse-organizations"; import setLanguage from "./set-language"; import setOrganization from "./set-organization"; @@ -11,7 +12,7 @@ import setOrganization from "./set-organization"; jest.mock("../utils/get-config"); const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); - +const cookies = {remove: jest.fn()}; describe("actions testing", () => { it("should create an action to parse organizations", () => { const expectedActions = [ @@ -59,8 +60,20 @@ describe("actions testing", () => { }, ]; const store = mockStore({language: "", organization: {}}); - store.dispatch(setOrganization(testOrgConfig[2].slug)); + store.dispatch(setOrganization(testOrgConfig[2].slug, cookies)); store.dispatch(setOrganization("invalid-slug")); expect(store.getActions()).toEqual(expectedActions); }); + it("should create an action to logout", () => { + const orgSlug = "default"; + const expectedActions = [ + { + type: types.SET_AUTHENTICATION_STATUS, + payload: false, + }, + ]; + const store = mockStore({organization: {configuration: {}}}); + store.dispatch(logout(cookies, orgSlug)); + expect(store.getActions()).toEqual(expectedActions); + }); }); diff --git a/client/actions/logout.js b/client/actions/logout.js new file mode 100644 index 000000000..8673e6fa3 --- /dev/null +++ b/client/actions/logout.js @@ -0,0 +1,12 @@ +import {SET_AUTHENTICATION_STATUS} from "../constants/action-types"; + +const logout = (cookies, orgSlug) => { + cookies.remove(`${orgSlug}_auth_token`, {path: "/"}); + cookies.remove(`${orgSlug}_username`, {path: "/"}); + + return { + type: SET_AUTHENTICATION_STATUS, + payload: false, + }; +}; +export default logout; diff --git a/client/actions/set-organization.js b/client/actions/set-organization.js index 72917976e..adb2f5eba 100644 --- a/client/actions/set-organization.js +++ b/client/actions/set-organization.js @@ -1,14 +1,17 @@ import merge from "deepmerge"; import { + SET_AUTHENTICATION_STATUS, SET_LANGUAGE, SET_ORGANIZATION_CONFIG, SET_ORGANIZATION_STATUS, } from "../constants/action-types"; +import authenticate from "../utils/authenticate"; import customMerge from "../utils/custom-merge"; import getConfig from "../utils/get-config"; +import logout from "./logout"; -const setOrganization = slug => { +const setOrganization = (slug, cookies) => { return dispatch => { const orgConfig = getConfig(slug); if (orgConfig) { @@ -28,6 +31,19 @@ const setOrganization = slug => { type: SET_ORGANIZATION_CONFIG, payload: config, }); + const autoLogin = config.auto_login; + if (autoLogin) { + if (authenticate(cookies, slug)) { + dispatch({ + type: SET_AUTHENTICATION_STATUS, + payload: true, + }); + } else { + logout(cookies, slug); + } + } else { + logout(cookies, slug); + } } else { dispatch({ type: SET_ORGANIZATION_STATUS, diff --git a/client/assets/default/facebook.svg b/client/assets/default/facebook.svg new file mode 100644 index 000000000..6aa72f81e --- /dev/null +++ b/client/assets/default/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/assets/default/favicon.png b/client/assets/default/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1b50f50fbdc62345663bbcddcba3906859513c GIT binary patch literal 44140 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 \ No newline at end of file diff --git a/client/assets/default/openwisp.svg b/client/assets/default/openwisp.svg new file mode 100644 index 000000000..9aa65570c --- /dev/null +++ b/client/assets/default/openwisp.svg @@ -0,0 +1,3 @@ + + +image/svg+xml \ No newline at end of file diff --git a/client/assets/default/twitter.svg b/client/assets/default/twitter.svg new file mode 100755 index 000000000..5325a0ef5 --- /dev/null +++ b/client/assets/default/twitter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/client/components/contact-box/__snapshots__/contact.test.js.snap b/client/components/contact-box/__snapshots__/contact.test.js.snap new file mode 100644 index 000000000..cd54a40be --- /dev/null +++ b/client/components/contact-box/__snapshots__/contact.test.js.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` rendering should render correctly 1`] = ` + +
+ + +`; diff --git a/client/components/contact-box/contact.js b/client/components/contact-box/contact.js new file mode 100644 index 000000000..988924893 --- /dev/null +++ b/client/components/contact-box/contact.js @@ -0,0 +1,81 @@ +/* eslint-disable camelcase */ +import "./index.css"; + +import PropTypes from "prop-types"; +import React from "react"; + +import getAssetPath from "../../utils/get-asset-path"; +import getText from "../../utils/get-text"; + +export default class Contact extends React.Component { + render() { + const {contactPage, language, orgSlug} = this.props; + const {email, helpdesk, social_links} = contactPage; + return ( + +
+
+
+
+ {getText(email.label, language)}: +
+ + {getText(email.value, language)} + +
+
+
+ {getText(helpdesk.label, language)}: +
+ + {getText(helpdesk.value, language)} + +
+
+ {social_links.map(link => { + return ( + + {getText(link.alt, + + ); + })} +
+
+
+
+
+ ); + } +} + +Contact.propTypes = { + language: PropTypes.string.isRequired, + orgSlug: PropTypes.string.isRequired, + contactPage: PropTypes.shape({ + social_links: PropTypes.array, + email: PropTypes.object, + helpdesk: PropTypes.object, + }).isRequired, +}; diff --git a/client/components/contact-box/contact.test.js b/client/components/contact-box/contact.test.js new file mode 100644 index 000000000..5bd0c257b --- /dev/null +++ b/client/components/contact-box/contact.test.js @@ -0,0 +1,25 @@ +import React from "react"; +import ShallowRenderer from "react-test-renderer/shallow"; + +import getConfig from "../../utils/get-config"; +import Contact from "./contact"; + +const defaultConfig = getConfig("default"); +const createTestProps = props => { + return { + language: "en", + orgSlug: "default", + contactPage: defaultConfig.components.contact_page, + ...props, + }; +}; + +describe(" rendering", () => { + let props; + it("should render correctly", () => { + props = createTestProps(); + const renderer = new ShallowRenderer(); + const component = renderer.render(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/client/components/contact-box/index.css b/client/components/contact-box/index.css new file mode 100644 index 000000000..b7c47ab8a --- /dev/null +++ b/client/components/contact-box/index.css @@ -0,0 +1,47 @@ +.owisp-contact-container { + background: rgb(74, 132, 86); + padding: 30px; + color: #ffffff; + border-radius: 2px; + width: 100%; + box-sizing: border-box; +} +.owisp-contact-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + font-size: 14px; + line-height: 1.8; +} +.owisp-contact-label { + font-weight: 700; + margin-right: 4px; +} +.owisp-contact-text { + color: #ffffff; +} +.owisp-contact-text:hover { + text-decoration: none; +} +.owisp-contact-links { + display: flex; + flex-direction: row; + justify-content: flex-start; + flex-wrap: wrap; + width: 100%; +} +.owisp-contact-link { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + color: #ffffff; +} +.owisp-contact-link:hover { + text-decoration: none; +} +.owisp-contact-image { + width: 50px; + height: auto; + margin: 10px 15px 0 0; +} diff --git a/client/components/contact-box/index.js b/client/components/contact-box/index.js new file mode 100644 index 000000000..266046a68 --- /dev/null +++ b/client/components/contact-box/index.js @@ -0,0 +1,15 @@ +import {connect} from "react-redux"; + +import Component from "./contact"; + +const mapStateToProps = state => { + return { + contactPage: state.organization.configuration.components.contact_page, + language: state.language, + orgSlug: state.organization.configuration.slug, + }; +}; +export default connect( + mapStateToProps, + null, +)(Component); diff --git a/client/components/header/__snapshots__/header.test.js.snap b/client/components/header/__snapshots__/header.test.js.snap index f2dbb38ac..1c4a39ab5 100644 --- a/client/components/header/__snapshots__/header.test.js.snap +++ b/client/components/header/__snapshots__/header.test.js.snap @@ -15,7 +15,18 @@ exports[`
rendering should render with links 1`] = ` >
+ > + + openwisp + +
rendering should render without links 1`] = ` >
+ > + + openwisp + +
- {logo.url ? ( + {logo && logo.url ? ( rendering", () => { }, }; props = createTestProps(links); - const component = renderer.create(
).toJSON(); + const component = renderer + .create( + +
+ , + ) + .toJSON(); expect(component).toMatchSnapshot(); }); it("should render with links", () => { - const component = renderer.create(
).toJSON(); + const component = renderer + .create( + +
+ , + ) + .toJSON(); expect(component).toMatchSnapshot(); }); it("should render 2 links", () => { @@ -53,19 +66,19 @@ describe("
rendering", () => { 0, ); }); - it("should not render logo", () => { - expect(wrapper.find(".owisp-header-logo-image")).toHaveLength(0); - }); it("should render logo", () => { + expect(wrapper.find(".owisp-header-logo-image")).toHaveLength(1); + }); + it("should not render logo", () => { const logo = { header: { ...props.header, - logo: {alternate_text: "test_alternate_text", url: "/test-logo.jpg"}, + logo: null, }, }; props = createTestProps(logo); wrapper = shallow(
); - expect(wrapper.find(".owisp-header-logo-image")).toHaveLength(1); + expect(wrapper.find(".owisp-header-logo-image")).toHaveLength(0); }); }); diff --git a/client/components/login/index.js b/client/components/login/index.js index 0094357b5..af5768bc6 100644 --- a/client/components/login/index.js +++ b/client/components/login/index.js @@ -1,5 +1,6 @@ import {connect} from "react-redux"; +import {SET_AUTHENTICATION_STATUS} from "../../constants/action-types"; import Component from "./login"; const mapStateToProps = state => { @@ -12,7 +13,14 @@ const mapStateToProps = state => { }; }; +const mapDispatchToProps = dispatch => { + return { + authenticate: status => { + dispatch({type: SET_AUTHENTICATION_STATUS, payload: status}); + }, + }; +}; export default connect( mapStateToProps, - null, + mapDispatchToProps, )(Component); diff --git a/client/components/login/login.js b/client/components/login/login.js index 1e87f2a25..fdb30c99c 100644 --- a/client/components/login/login.js +++ b/client/components/login/login.js @@ -30,7 +30,7 @@ export default class Login extends React.Component { handleSubmit(event) { event.preventDefault(); - const {orgSlug} = this.props; + const {orgSlug, authenticate} = this.props; const {username, password, errors} = this.state; const url = loginApiUrl.replace("{orgSlug}", orgSlug); this.setState({ @@ -48,11 +48,7 @@ export default class Login extends React.Component { }), }) .then(() => { - this.setState({ - errors: {}, - username: "", - password: "", - }); + authenticate(true); }) .catch(error => { const {data} = error.response; @@ -337,4 +333,5 @@ Login.propTypes = { title: PropTypes.object, content: PropTypes.object, }).isRequired, + authenticate: PropTypes.func.isRequired, }; diff --git a/client/components/login/login.test.js b/client/components/login/login.test.js index 02f42fd50..26b6d6c00 100644 --- a/client/components/login/login.test.js +++ b/client/components/login/login.test.js @@ -18,6 +18,7 @@ const createTestProps = props => { loginForm: defaultConfig.components.login_form, privacyPolicy: defaultConfig.privacy_policy, termsAndConditions: defaultConfig.terms_and_conditions, + authenticate: jest.fn(), ...props, }; }; @@ -120,6 +121,9 @@ describe(" interactions", () => { .handleSubmit(event) .then(() => { expect(wrapper.instance().state.errors).toEqual({}); + expect( + wrapper.instance().props.authenticate.mock.calls.length, + ).toBe(1); }); }); }); diff --git a/client/components/organization-wrapper/index.js b/client/components/organization-wrapper/index.js index 93f78e3fc..7eb1c5bff 100644 --- a/client/components/organization-wrapper/index.js +++ b/client/components/organization-wrapper/index.js @@ -13,8 +13,8 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => { return { - setOrganization: slug => { - dispatch(setOrganization(slug)); + setOrganization: (slug, cookies) => { + dispatch(setOrganization(slug, cookies)); }, }; }; diff --git a/client/components/organization-wrapper/organization-wrapper.js b/client/components/organization-wrapper/organization-wrapper.js index ed87ad2c5..ef34824ab 100644 --- a/client/components/organization-wrapper/organization-wrapper.js +++ b/client/components/organization-wrapper/organization-wrapper.js @@ -2,8 +2,9 @@ import "./index.css"; import PropTypes from "prop-types"; import React from "react"; +import {Cookies} from "react-cookie"; import {Helmet} from "react-helmet"; -import {Route, Switch} from "react-router-dom"; +import {Redirect, Route, Switch} from "react-router-dom"; import getAssetPath from "../../utils/get-asset-path"; import DoesNotExist from "../404"; @@ -13,25 +14,27 @@ import Login from "../login"; import PasswordConfirm from "../password-confirm"; import PasswordReset from "../password-reset"; import Registration from "../registration"; +import Status from "../status"; export default class OrganizationWrapper extends React.Component { constructor(props) { super(props); - const {match, setOrganization} = props; + const {match, setOrganization, cookies} = props; const organizationSlug = match.params.organization; - if (organizationSlug) setOrganization(organizationSlug); + if (organizationSlug) setOrganization(organizationSlug, cookies); } componentDidUpdate(prevProps) { - const {setOrganization, match} = this.props; + const {setOrganization, match, cookies} = this.props; if (prevProps.match.params.organization !== match.params.organization) { - if (match.params.organization) setOrganization(match.params.organization); + if (match.params.organization) + setOrganization(match.params.organization, cookies); } } render() { - const {organization, match} = this.props; - const {title, favicon} = organization.configuration; + const {organization, match, cookies} = this.props; + const {title, favicon, isAuthenticated} = organization.configuration; const orgSlug = organization.configuration.slug; const cssPath = organization.configuration.css_path; if (organization.exists === true) { @@ -40,20 +43,53 @@ export default class OrganizationWrapper extends React.Component {
} /> + { + return ; + }} + /> } + render={() => { + if (isAuthenticated) + return ; + return ; + }} /> } + render={props => { + if (isAuthenticated) + return ; + return ; + }} /> } + render={() => { + if (isAuthenticated) + return ; + return ; + }} + /> + { + if (isAuthenticated) + return ; + return ; + }} + /> + { + if (isAuthenticated) return ; + return ; + }} /> - } />
} />
@@ -113,7 +149,9 @@ OrganizationWrapper.propTypes = { css_path: PropTypes.string, slug: PropTypes.string, favicon: PropTypes.string, + isAuthenticated: PropTypes.bool, }), exists: PropTypes.bool, }).isRequired, + cookies: PropTypes.instanceOf(Cookies).isRequired, }; diff --git a/client/components/organization-wrapper/organization-wrapper.test.js b/client/components/organization-wrapper/organization-wrapper.test.js index d7f259962..d63ad7e18 100644 --- a/client/components/organization-wrapper/organization-wrapper.test.js +++ b/client/components/organization-wrapper/organization-wrapper.test.js @@ -1,6 +1,7 @@ /* eslint-disable camelcase */ import {shallow} from "enzyme"; import React from "react"; +import {Cookies} from "react-cookie"; import OrganizationWrapper from "./organization-wrapper"; @@ -17,6 +18,7 @@ const createTestProps = props => { exists: true, }, setOrganization: jest.fn(), + cookies: new Cookies(), ...props, }; }; diff --git a/client/components/registration/index.js b/client/components/registration/index.js index 3029cefad..6f1c16e72 100644 --- a/client/components/registration/index.js +++ b/client/components/registration/index.js @@ -1,5 +1,6 @@ import {connect} from "react-redux"; +import {SET_AUTHENTICATION_STATUS} from "../../constants/action-types"; import Component from "./registration"; const mapStateToProps = state => { @@ -11,8 +12,15 @@ const mapStateToProps = state => { orgSlug: state.organization.configuration.slug, }; }; +const mapDispatchToProps = dispatch => { + return { + authenticate: status => { + dispatch({type: SET_AUTHENTICATION_STATUS, payload: status}); + }, + }; +}; export default connect( mapStateToProps, - null, + mapDispatchToProps, )(Component); diff --git a/client/components/registration/registration.js b/client/components/registration/registration.js index 877234405..3e4125cfc 100644 --- a/client/components/registration/registration.js +++ b/client/components/registration/registration.js @@ -32,7 +32,7 @@ export default class Registration extends React.Component { handleSubmit(event) { event.preventDefault(); - const {registration, orgSlug} = this.props; + const {registration, orgSlug, authenticate} = this.props; const {input_fields} = registration; const {username, email, password1, password2, errors} = this.state; if (input_fields.password_confirm) { @@ -70,6 +70,7 @@ export default class Registration extends React.Component { password2: "", success: true, }); + authenticate(true); }) .catch(error => { const {data} = error.response; @@ -393,4 +394,5 @@ Registration.propTypes = { title: PropTypes.object, content: PropTypes.object, }).isRequired, + authenticate: PropTypes.func.isRequired, }; diff --git a/client/components/registration/registration.test.js b/client/components/registration/registration.test.js index 9cc00d3aa..a603cd4b6 100644 --- a/client/components/registration/registration.test.js +++ b/client/components/registration/registration.test.js @@ -19,6 +19,7 @@ const createTestProps = props => { registration: defaultConfig.components.registration_form, privacyPolicy: defaultConfig.privacy_policy, termsAndConditions: defaultConfig.terms_and_conditions, + authenticate: jest.fn(), ...props, }; }; @@ -119,6 +120,9 @@ describe(" interactions", () => { expect( wrapper.find(".owisp-registration-form.success"), ).toHaveLength(1); + expect( + wrapper.instance().props.authenticate.mock.calls.length, + ).toBe(1); }); }); }); diff --git a/client/components/status/__snapshots__/status.test.js.snap b/client/components/status/__snapshots__/status.test.js.snap new file mode 100644 index 000000000..6adcfcff4 --- /dev/null +++ b/client/components/status/__snapshots__/status.test.js.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` rendering should render correctly 1`] = ` + +
+
+
+
+ WiFi Login Successful! +
+
+ You can now use the internet. +
+
+ You may leave this page open in case you want to log out. +
+ + + +
+
+ +
+
+
+
+`; diff --git a/client/components/status/index.css b/client/components/status/index.css new file mode 100644 index 000000000..f9250a138 --- /dev/null +++ b/client/components/status/index.css @@ -0,0 +1,81 @@ +.owisp-status-container { + flex-grow: 1; + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; + width: 100%; + box-sizing: border-box; + padding-bottom: 27px; +} +.owisp-status-inner { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + width: 80%; +} +.owisp-status-content-div { + background: rgb(74, 132, 86); + padding: 30px; + color: #ffffff; + border-radius: 2px; + margin-top: 27px; + box-sizing: border-box; + width: 60%; +} +.owisp-status-contact-div { + margin-top: 27px; + width: 37%; +} +.owisp-status-content-line { + line-height: 1.9; +} +.owisp-status-logout-btn { + background-color: rgba(0, 0, 0, 0.3); + width: 100%; + line-height: 1.6; + color: #ffffff; + font-size: 1.6; + font-weight: 700; + margin-top: 10px; + padding: 8px 15px; + border-radius: 5px; + text-decoration: none; + cursor: pointer; + border: none; +} +.owisp-status-logout-btn:hover { + background-color: rgba(0, 0, 0, 0.4); +} +.owisp-status-logout-btn:focus { + outline: dotted 1px #ffffff; + outline-offset: 0.5px; +} +@media screen and (min-width: 767px) and (max-width: 1000px) { + .owisp-status-inner { + width: 98%; + justify-content: space-between; + } + .owisp-status-content-div { + width: 60%; + margin-right: 1%; + } + .owisp-status-contact-div { + width: 39%; + } +} +@media screen and (min-width: 0px) and (max-width: 767px) { + .owisp-status-inner { + width: 98%; + justify-content: space-between; + } + .owisp-status-content-div { + width: 100%; + margin-right: 0; + } + .owisp-status-contact-div { + width: 100%; + } +} diff --git a/client/components/status/index.js b/client/components/status/index.js new file mode 100644 index 000000000..2e1fb12c8 --- /dev/null +++ b/client/components/status/index.js @@ -0,0 +1,25 @@ +import {connect} from "react-redux"; + +import logout from "../../actions/logout"; +import Component from "./status"; + +const mapStateToProps = (state, ownProps) => { + return { + statusPage: state.organization.configuration.components.status_page, + language: state.language, + orgSlug: state.organization.configuration.slug, + cookies: ownProps.cookies, + }; +}; + +const mapDispatchToProps = dispatch => { + return { + logout: (cookies, slug) => { + dispatch(logout(cookies, slug)); + }, + }; +}; +export default connect( + mapStateToProps, + mapDispatchToProps, +)(Component); diff --git a/client/components/status/status.js b/client/components/status/status.js new file mode 100644 index 000000000..67c5b717e --- /dev/null +++ b/client/components/status/status.js @@ -0,0 +1,101 @@ +import "./index.css"; + +import axios from "axios"; +import PropTypes from "prop-types"; +import qs from "qs"; +import React from "react"; +import {Cookies} from "react-cookie"; + +import {validateApiUrl} from "../../constants"; +import getText from "../../utils/get-text"; +import Contact from "../contact-box"; + +export default class Status extends React.Component { + componentDidMount() { + const {cookies, orgSlug, logout} = this.props; + const token = cookies.get(`${orgSlug}_auth_token`); + const url = validateApiUrl.replace("{orgSlug}", orgSlug); + axios({ + method: "post", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + url, + data: qs.stringify({ + token, + }), + }) + .then(response => { + if (response.data["control:Auth-Type"] !== "Accept") { + logout(cookies, orgSlug); + } + }) + .catch(() => { + logout(cookies, orgSlug); + }); + } + + render() { + const {statusPage, language, orgSlug, logout, cookies} = this.props; + const {content, buttons} = statusPage; + const contentArr = getText(content, language).split("\n"); + return ( + +
+
+
+ {contentArr.map(text => { + if (text !== "") + return ( +
+ {text} +
+ ); + return null; + })} + {buttons.logout ? ( + <> + {buttons.logout.label ? ( + <> + + + ) : null} + logout(cookies, orgSlug)} + /> + + ) : null} +
+
+ +
+
+
+
+ ); + } +} + +Status.propTypes = { + statusPage: PropTypes.shape({ + buttons: PropTypes.shape({ + logout: PropTypes.object, + }), + content: PropTypes.object, + }).isRequired, + language: PropTypes.string.isRequired, + orgSlug: PropTypes.string.isRequired, + cookies: PropTypes.instanceOf(Cookies).isRequired, + logout: PropTypes.func.isRequired, +}; diff --git a/client/components/status/status.test.js b/client/components/status/status.test.js new file mode 100644 index 000000000..2cafc1e59 --- /dev/null +++ b/client/components/status/status.test.js @@ -0,0 +1,47 @@ +import axios from "axios"; +/* eslint-disable camelcase */ +import {shallow} from "enzyme"; +import React from "react"; +import {Cookies} from "react-cookie"; +import ShallowRenderer from "react-test-renderer/shallow"; + +import getConfig from "../../utils/get-config"; +import Status from "./status"; + +jest.mock("axios"); + +const defaultConfig = getConfig("default"); +const createTestProps = props => { + return { + language: "en", + orgSlug: "default", + statusPage: defaultConfig.components.status_page, + cookies: new Cookies(), + logout: jest.fn(), + ...props, + }; +}; + +describe(" rendering", () => { + let props; + it("should render correctly", () => { + props = createTestProps(); + const renderer = new ShallowRenderer(); + const component = renderer.render(); + expect(component).toMatchSnapshot(); + }); +}); + +describe(" interactions", () => { + let props; + let wrapper; + it("should call logout function when logout button is clicked", () => { + axios.mockImplementationOnce(() => { + return Promise.resolve({}); + }); + props = createTestProps(); + wrapper = shallow(); + wrapper.find("#owisp-status-logout-btn").simulate("click", {}); + expect(wrapper.instance().props.logout.mock.calls.length).toBe(1); + }); +}); diff --git a/client/constants/action-types.js b/client/constants/action-types.js index f3b138498..261d0de93 100644 --- a/client/constants/action-types.js +++ b/client/constants/action-types.js @@ -1,4 +1,5 @@ export const PARSE_ORGANIZATIONS = "PARSE_ORGANIZATIONS"; +export const SET_AUTHENTICATION_STATUS = "SET_AUTHENTICATION_STATUS"; +export const SET_LANGUAGE = "SET_LANGUAGE"; export const SET_ORGANIZATION_CONFIG = "SET_ORGANIZATION_CONFIG"; export const SET_ORGANIZATION_STATUS = "SET_ORGANIZATION_STATUS"; -export const SET_LANGUAGE = "SET_LANGUAGE"; diff --git a/client/constants/index.js b/client/constants/index.js index f65bcfa78..373d02e18 100644 --- a/client/constants/index.js +++ b/client/constants/index.js @@ -1,5 +1,6 @@ +export const confirmApiUrl = "/api/v1/{orgSlug}/account/password/reset/confirm"; +export const loginApiUrl = "/api/v1/{orgSlug}/account/token"; export const passwordConfirmError = "The two password fields didn't match."; export const registerApiUrl = "/api/v1/{orgSlug}/account/"; export const resetApiUrl = "/api/v1/{orgSlug}/account/password/reset/"; -export const confirmApiUrl = "/api/v1/{orgSlug}/account/password/reset/confirm"; -export const loginApiUrl = "/api/v1/{orgSlug}/account/token"; +export const validateApiUrl = "/api/v1/{orgSlug}/account/token/validate"; diff --git a/client/index.css b/client/index.css index e36f67e2c..2a6da5e1c 100644 --- a/client/index.css +++ b/client/index.css @@ -1,6 +1,8 @@ body { margin: 0; padding: 0; + font-family: "Montserrat", sans-serif; + font-size: 16px; } :focus { outline: none; diff --git a/client/reducers/organization.js b/client/reducers/organization.js index 4bc7abf35..e984f40d5 100644 --- a/client/reducers/organization.js +++ b/client/reducers/organization.js @@ -1,5 +1,6 @@ import { PARSE_ORGANIZATIONS, + SET_AUTHENTICATION_STATUS, SET_ORGANIZATION_CONFIG, SET_ORGANIZATION_STATUS, } from "../constants/action-types"; @@ -22,6 +23,14 @@ export const organization = ( return {...state, configuration: action.payload}; case SET_ORGANIZATION_STATUS: return {...state, exists: action.payload}; + case SET_AUTHENTICATION_STATUS: + return { + ...state, + configuration: { + ...state.configuration, + isAuthenticated: action.payload, + }, + }; default: return state; } diff --git a/client/utils/authenticate.js b/client/utils/authenticate.js new file mode 100644 index 000000000..f8c6ad9d3 --- /dev/null +++ b/client/utils/authenticate.js @@ -0,0 +1,6 @@ +const authenticate = (cookies, orgSlug) => { + const token = cookies.get(`${orgSlug}_auth_token`); + if (token) return true; + return false; +}; +export default authenticate; diff --git a/client/utils/utils.test.js b/client/utils/utils.test.js index 2d2f35882..a70ac5d70 100644 --- a/client/utils/utils.test.js +++ b/client/utils/utils.test.js @@ -1,3 +1,4 @@ +import authenticate from "./authenticate"; import customMerge from "./custom-merge"; import renderAdditionalInfo from "./render-additional-info"; @@ -70,3 +71,16 @@ describe("customMerge tests", () => { expect(customMerge(arr1, arr2)).toEqual(arr2); }); }); +describe("authenticate tests", () => { + const cookies = { + get: jest + .fn() + .mockImplementationOnce(() => true) + .mockImplementationOnce(() => false), + }; + const orgSlug = "test-org"; + it("should perform authentication", () => { + expect(authenticate(cookies, orgSlug)).toEqual(true); + expect(authenticate(cookies, orgSlug)).toEqual(false); + }); +}); diff --git a/org-configurations/default-configuration.yml b/org-configurations/default-configuration.yml index 7bc890f9a..116e0e14b 100644 --- a/org-configurations/default-configuration.yml +++ b/org-configurations/default-configuration.yml @@ -11,6 +11,9 @@ server: password_reset_confirm: "/api/v1/{org_slug}/account/password/reset/confirm" registration: "/api/v1/{org_slug}/account" user_auth_token: "/api/v1/{org_slug}/account/token" + validate_auth_token: "/api/v1/{org_slug}/account/token/validate" + authorize: "/api/v1/authorize" + uuid: organization_uuid secret_key: organization_secret_key timeout: 2 #request timeout period in seconds @@ -24,7 +27,7 @@ client: auto_login: True # path of favicon - favicon: null + favicon: "favicon.png" # path of the custom css file relative to organization's folder in # assets directory. @@ -36,8 +39,8 @@ client: components: header: logo: - url: null # logo url - alternate_text: null + url: "openwisp.svg" # logo url + alternate_text: "openwisp" links: - text: en: "link-1" #link text in english @@ -160,6 +163,43 @@ client: submit: en: 'change password' + contact_page: + email: + label: + en: "E-mail" + value: + en: "support@openwisp.co" + helpdesk: + label: + en: "Helpdesk" + value: + en: "+789 948 564" + social_links: + - alt: + en: "twitter" + icon: "twitter.svg" + url: "https://twitter.com/openwisp" + - alt: + en: "facebook" + icon: "facebook.svg" + url: "https://facebook.com/openwisp" + - alt: + en: "linkedIn" + icon: "linkedin.svg" + url: "https://www.linkedin.com/groups/4777261" + + status_page: + content: + en: | + WiFi Login Successful! + You can now use the internet. + You may leave this page open in case you want to log out. + buttons: + logout: + label: null + text: + en: "Logout" + login_form: header: en: " sign in" diff --git a/package-lock.json b/package-lock.json index f26b16f2e..9d2f83721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11106,6 +11106,14 @@ "object-assign": "^4.1.1" } }, + "universal-cookie-express": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/universal-cookie-express/-/universal-cookie-express-4.0.1.tgz", + "integrity": "sha512-XbyGQiZLU7TcFs5s4yI17wq2g4djnAmcJbrd1FJQjIcwoXPzRRSzRO+mcCccocE9Xl2wyAX393truwFElmk+Qg==", + "requires": { + "universal-cookie": "^4.0.0" + } + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 93d5537ec..629095133 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "react-redux": "^7.1.0", "react-router-dom": "^5.0.1", "redux": "^4.0.4", - "redux-thunk": "^2.3.0" + "redux-thunk": "^2.3.0", + "universal-cookie-express": "^4.0.1" }, "devDependencies": { "@babel/cli": "^7.5.5", diff --git a/readme.md b/readme.md index ccb4c35d3..6d6bdff2c 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,7 @@ # openwisp-wifi-login-pages + [![Build Status](https://travis-ci.org/openwisp/openwisp-wifi-login-pages.svg?branch=master)](https://travis-ci.org/openwisp/openwisp-wifi-login-pages) [![Coverage Status](https://coveralls.io/repos/github/openwisp/openwisp-wifi-login-pages/badge.svg)](https://coveralls.io/github/openwisp/openwisp-wifi-login-pages) @@ -42,6 +43,20 @@ or yarn ``` +### Setup + +Write configuration of the organization in a yml file in `org-configuration` directory. +List of variables required in organization configuration: + +- name +- slug +- uuid: uuid of the organization +- secret_key: token of the organization + +Copy all the assets to `client/assets/{slug}` directory +Run `$ npm run setup` +Start servers using `$ npm run start` + ### Usage List of NPM Commands: @@ -52,6 +67,7 @@ $ npm run setup # Discover Organization configs and generate config.json and a $ npm run build # Build the app $ npm run server # Run server $ npm run client # Run client +$ npm run coveralls # Run coveralls $ npm run lint # Run ESLint $ npm run lint:fix # Run ESLint with automatically fix problems option $ npm test # Run tests diff --git a/server/controllers/obtain-token-controller.js b/server/controllers/obtain-token-controller.js index 8786de54e..363e8c75a 100644 --- a/server/controllers/obtain-token-controller.js +++ b/server/controllers/obtain-token-controller.js @@ -30,25 +30,22 @@ const obtainToken = (req, res) => { }) .then(response => { // save token in signed cookie - const radTokenCookie = cookie.sign( - response.data.radius_user_token, - conf.secret_key, - ); const authTokenCookie = cookie.sign( response.data.key, conf.secret_key, ); + const usernameCookie = cookie.sign(username, conf.secret_key); // forward response res .status(response.status) .type("application/json") - .cookie(`${conf.slug}_radius_user_token`, radTokenCookie, { + .cookie(`${conf.slug}_auth_token`, authTokenCookie, { maxAge: 1000 * 60 * 60 * 24, }) - .cookie(`${conf.slug}_auth_token`, authTokenCookie, { + .cookie(`${conf.slug}_username`, usernameCookie, { maxAge: 1000 * 60 * 60 * 24, }) - .send(response.data); + .send(); }) .catch(error => { // forward error diff --git a/server/controllers/registration-controller.js b/server/controllers/registration-controller.js index 3115c21a5..6f9a69b2e 100644 --- a/server/controllers/registration-controller.js +++ b/server/controllers/registration-controller.js @@ -1,4 +1,5 @@ import axios from "axios"; +import cookie from "cookie-signature"; import merge from "deepmerge"; import qs from "qs"; @@ -34,11 +35,22 @@ const registration = (req, res) => { }), }) .then(response => { + const authTokenCookie = cookie.sign( + response.data.key, + conf.secret_key, + ); + const usernameCookie = cookie.sign(username, conf.secret_key); // forward response res .status(response.status) .type("application/json") - .send(response.data); + .cookie(`${conf.slug}_auth_token`, authTokenCookie, { + maxAge: 1000 * 60 * 60 * 24, + }) + .cookie(`${conf.slug}_username`, usernameCookie, { + maxAge: 1000 * 60 * 60 * 24, + }) + .send(); }) .catch(error => { // forward error diff --git a/server/controllers/validate-token-controller.js b/server/controllers/validate-token-controller.js new file mode 100644 index 000000000..69a47caba --- /dev/null +++ b/server/controllers/validate-token-controller.js @@ -0,0 +1,97 @@ +import axios from "axios"; +import cookie from "cookie-signature"; +import merge from "deepmerge"; +import qs from "qs"; + +import config from "../config.json"; +import defaultConfig from "../utils/default-config"; + +const validateToken = (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 validateTokenUrl = conf.proxy_urls.validate_auth_token; + // replacing org_slug param with the slug + validateTokenUrl = validateTokenUrl.replace("{org_slug}", org.slug); + const timeout = conf.timeout * 1000; + let {token} = req.body; + token = cookie.unsign(token, conf.secret_key); + // make AJAX request + axios({ + method: "post", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + url: `${host}${validateTokenUrl}/`, + timeout, + data: qs.stringify({token}), + }) + .then(response => { + const authorizeUrl = conf.proxy_urls.authorize; + const username = cookie.unsign( + req.universalCookies.get(`${org.slug}_username`), + conf.secret_key, + ); + axios({ + method: "post", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + url: `${host}${authorizeUrl}/`, + timeout, + params: { + uuid: conf.uuid, + token: conf.secret_key, + }, + data: qs.stringify({ + username, + password: response.data.radius_user_token, + }), + }) + .then(responseAuth => { + res + .status(responseAuth.status) + .type("application/json") + .send(responseAuth.data); + }) + .catch(errorAuth => { + res + .status(errorAuth.response.status) + .type("application/json") + .send(errorAuth.response.data); + }); + }) + .catch(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 validateToken; diff --git a/server/index.js b/server/index.js index 60055ddae..ab076444d 100644 --- a/server/index.js +++ b/server/index.js @@ -4,8 +4,10 @@ import express from "express"; import routes from "./routes"; const app = express(); +const cookiesMiddleware = require("universal-cookie-express"); app.use(cookieParser()); +app.use(cookiesMiddleware()); app.use(express.json()); app.use(express.urlencoded({extended: false})); app.use("/api/v1/:organization/account", routes.account); diff --git a/server/routes/account.js b/server/routes/account.js index 0e6aef22d..a94117698 100644 --- a/server/routes/account.js +++ b/server/routes/account.js @@ -5,10 +5,12 @@ import passwordChange from "../controllers/password-change-controller"; import passwordResetConfirm from "../controllers/password-reset-confirm-controller"; import passwordReset from "../controllers/password-reset-controller"; import registration from "../controllers/registration-controller"; +import validateToken from "../controllers/validate-token-controller"; const router = Router({mergeParams: true}); router.post("/token", obtainToken); +router.post("/token/validate", validateToken); router.post("/password/change", passwordChange); router.post("/password/reset/confirm/", passwordResetConfirm); router.post("/password/reset", passwordReset);