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 000000000..2f1b50f50 Binary files /dev/null and b/client/assets/default/favicon.png differ diff --git a/client/assets/default/linkedin.svg b/client/assets/default/linkedin.svg new file mode 100644 index 000000000..0536dddc7 --- /dev/null +++ b/client/assets/default/linkedin.svg @@ -0,0 +1 @@ + \ 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`] = ` + +
+
+
+
+ E-mail + : +
+ + support@openwisp.co + +
+
+
+ Helpdesk + : +
+ + +789 948 564 + +
+
+ + twitter + + + facebook + + + linkedIn + +
+
+
+
+ +`; 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);