From 9989f6906c4397ab7d49d764c3eb9a72e7bc074c Mon Sep 17 00:00:00 2001 From: Jack Valley Date: Wed, 13 May 2020 11:46:59 +0300 Subject: [PATCH] Feature/develop-to-experimental to feature/city-theme-temp (#12) * Fix double encoding #524; with caveat https://github.com/axios/axios/pull/2563 * Display all api results in react select components * Fix event publisher field name for user rights check * Fix #530 * Do not try to update deleted subevents * Do not try to edit past subevents * Refactor editability check; allow deleting and canceling series that contain deleted, canceled or past subevents * Remove unnecessary inlined sub_events at cancel to prevent API errors * Add instructions for internet events * Update snapshots * Add remote participation keyword to all internet events * Add notification about online events to cancel confirmation dialog * Add postpone button and badge * Mention postponing in cancel extra text * Add postpone button to editor too * Show postponed events in search instead of crashing * Update user rights managers * Add missing

* Do not remove subevents at cancel after all; deleted items removed by API PR https://github.com/City-of-Helsinki/linkedevents/pull/407 * Fix removed future time validation; fixes #536, #528 * Update snapshots * Feature/oidc auth (#8) * initial oidc integration commit * Removed Passport from user authentication Fixed Warning with History import and removed Passport authentications. * fetchUser and comments for OIDC Needs proper lifecycle methods. * Added some missing oidc features and updated tests * Added oidc settings to production build. * Added tests for user actions and reducer. * Updated readme by removing mentions to builtin auth server. * Updated node-sass package, added more tests and gitignored coverage folder. Co-authored-by: Ducky07 * Revert "Merge branch 'experimental/city-theme' into develop" This reverts commit f51da9b1af35b251f72032fd033bf86b74d51782, reversing changes made to a06088f7a05f65e1e7918d1781f2f8f2f68aa4c2. # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # On branch develop # Your branch is up to date with 'origin/develop'. # # You are currently reverting commit a06088f. # # Changes to be committed: # modified: config/appConfig.js # deleted: config/assetPath.js # modified: config/webpack/dev.js # modified: config/webpack/prod.js # modified: config_dev.json.example # modified: package.json # deleted: src/assets/default/assets/main.scss # deleted: src/assets/default/i18n/index.js # new file: src/assets/images/turkulogowhite.png # modified: src/components/FormFields/index.js # modified: src/components/FormFields/index.scss # deleted: src/components/Header/LanguageSelector.js # deleted: src/components/Header/LanguageSelector.test.js # modified: src/components/Header/index.scss # modified: src/components/HelFormFields/HelKeywordSelector/HelKeywordSelector.js # modified: src/components/HelFormFields/HelLabeledCheckboxGroup.scss # modified: src/components/HelFormFields/HelOffersField.js # modified: src/components/HelFormFields/HelTextField.js # modified: src/components/ImageEdit/index.js # modified: src/components/ImageEdit/index.scss # modified: src/components/ImagePicker/index.js # modified: src/components/ImagePicker/index.scss # modified: src/components/SearchBar/index.js # modified: src/components/SearchBar/index.scss # modified: src/i18n/index.js # modified: src/index.jade # new file: src/themes/material-ui-tku.js # new file: src/themes/tku/tku-brand-colors.js # modified: src/views/App/index.js # modified: yarn.lock Manually reverting changes that were accidentally merged into develop. Weirdly enough Github thought taking commits from develop would be the same as merging the two branches together, while not being able to understand PR reverting (aka only reverting one branch but not the other). Please don't do that again... Co-authored-by: Riku Oja Co-authored-by: Aleksi Salonen Co-authored-by: Riku Oja Co-authored-by: aceViilee <51813121+aceViilee@users.noreply.github.com> Co-authored-by: Santtu Alatalo --- .eslintrc | 3 +- .gitignore | 1 + README.md | 40 --- config/appConfig.js | 2 +- config/assetPath.js | 43 --- config/webpack/dev.js | 20 +- config/webpack/prod.js | 19 +- config_dev.json.example | 2 +- package.json | 11 +- server/auth.js | 59 ---- server/server.js | 7 - src/actions/user.js | 130 +++----- src/actions/userActions.test.js | 24 ++ src/actors/serializer.js | 9 +- src/api/client.js | 4 +- src/assets/default/assets/main.scss | 4 - src/assets/default/i18n/index.js | 1 - src/components/FormFields/index.js | 46 +-- src/components/FormFields/index.scss | 42 +-- src/components/Header/Header.test.js | 94 ++++++ src/components/Header/LanguageSelector.js | 90 ----- .../Header/LanguageSelector.test.js | 41 --- src/components/Header/index.scss | 310 ++++++------------ .../HelKeywordSelector/HelKeywordSelector.js | 7 +- .../HelLabeledCheckboxGroup.scss | 3 +- .../HelFormFields/HelOffersField.js | 10 +- src/components/HelFormFields/HelTextField.js | 9 +- src/components/ImageEdit/index.js | 7 +- src/components/ImageEdit/index.scss | 29 +- src/components/ImagePicker/index.js | 4 +- src/components/ImagePicker/index.scss | 7 - src/components/SearchBar/index.js | 28 +- src/components/SearchBar/index.scss | 25 +- src/i18n/index.js | 25 +- src/index.jade | 1 - src/index.js | 54 +-- src/reducers/index.js | 2 + src/reducers/user.js | 36 +- src/reducers/userReducer.test.js | 64 ++++ src/store.js | 5 +- src/utils/jestAppSettings.js | 4 + src/utils/userManager.js | 21 ++ src/views/App/App.test.js | 48 +++ src/views/App/index.js | 18 +- src/views/Auth/LoginCallback.js | 46 +++ src/views/Auth/LoginCallback.test.js | 75 +++++ src/views/Auth/LogoutCallback.js | 42 +++ src/views/Auth/LogoutCallback.test.js | 64 ++++ 48 files changed, 776 insertions(+), 860 deletions(-) delete mode 100644 config/assetPath.js delete mode 100644 server/auth.js create mode 100644 src/actions/userActions.test.js delete mode 100644 src/assets/default/assets/main.scss delete mode 100644 src/assets/default/i18n/index.js create mode 100644 src/components/Header/Header.test.js delete mode 100644 src/components/Header/LanguageSelector.js delete mode 100644 src/components/Header/LanguageSelector.test.js create mode 100644 src/reducers/userReducer.test.js create mode 100644 src/utils/userManager.js create mode 100644 src/views/App/App.test.js create mode 100644 src/views/Auth/LoginCallback.js create mode 100644 src/views/Auth/LoginCallback.test.js create mode 100644 src/views/Auth/LogoutCallback.js create mode 100644 src/views/Auth/LogoutCallback.test.js diff --git a/.eslintrc b/.eslintrc index c3b462a9e..12b081e9c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,7 +8,8 @@ "parser": "babel-eslint", "globals": { "_": true, - "appSettings": true + "appSettings": true, + "oidcSettings": true }, "rules": { "no-console": "off", diff --git a/.gitignore b/.gitignore index 2bd485e85..ef96ab0ad 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ server.config yarn-error.log config_dev.json package-lock.json +/coverage/ diff --git a/README.md b/README.md index 03d65a11b..82d3d0efc 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,6 @@ The UI is now compatible with the `courses` extension for the Linked Events API. If you wish to include the extra fields specified in the `courses` extension, please change the `ui_mode` setting from `events` to `courses`. -Note that authentication server is not nicely configurable. If you wish to -use your own authentication server, you will need code changes in server/auth.js. - ## Running development server ``` @@ -55,10 +52,6 @@ if you'd like to change the base address for Linkedevents API, you would: export api_base="https://api.hel.fi/linkedevents/v1" ``` -Note that the configuration is used in the different phases. Some settings -need to be defined during build and other settings for running the -authentication server (see below) - Most if not all build automation tools provide for setting environment variables. Check the documentation for the one you are using. If you are testing locally you can `source config_build_example.sh` to get started. @@ -75,36 +68,3 @@ $ yarn build You should now have the bundled javascript + some non-bundled assets in `dist`. You can serve these using your favorite web server at whatever address suits your fancy. - -You will still need the source tree for the authentication server (below) - -### Setting up the runtime - -In addition to serving the files built in previous step, you will need to -run the built-in authentication server (or proxy really). Although -linkedevents-ui runs completely in client, it currently uses authentication -code based OAuth2 Authorization Code flow. This is a historical accident, -that will be remedied one day. - -We recommend running the authentication server with some sort of process -manager, possibly one specialized in running Node applications. Your system -process manager, like systemd, is another good candidate - -The authentication server will need configuration passed in through -environment variables (see Congiration). If you use a process manager to -run the server, it should provide for setting them. - -The server is run by executing `npm run production`. If your process -manager wants to run node by itself, you can also run specify `server` as -the script (that will actually run server/index.js). In this case you will -also need to set environment variable `NODE_ENV=production` by yourself. - -After you have the authentication server running, you will need to set up a -web server to serve the files in `dist` and forward authentication requests -to the authentication server. The table below shows what needs to served: -| URL | what is served | -| /auth | forward to authentication server | -| filename | serve from dist-directory | -| unknown files | serve index.html from dist-directory | - -The last part is needed for deep linking into the application. \ No newline at end of file diff --git a/config/appConfig.js b/config/appConfig.js index ab52cd778..f6fa7f2ee 100644 --- a/config/appConfig.js +++ b/config/appConfig.js @@ -36,7 +36,7 @@ nconf.defaults({ /** * Function to retrieve value from config - * @param {undefined|string|string[]} keys + * @param {undefined|string|string[]} keys */ function getConfig(keys) { // Return all config if no keys provided diff --git a/config/assetPath.js b/config/assetPath.js deleted file mode 100644 index 87ec62d2c..000000000 --- a/config/assetPath.js +++ /dev/null @@ -1,43 +0,0 @@ -const path = require('path'); -const nconf = require('nconf'); - -let cityConfig; -let cityAssets; -let cityImages; -let cityi18n; -/** - * city_theme package could have the following folders - * some-ui / - * assets / - * -----> images / - * --------------> images that used for scss, can be imported directly - * -----> main.scss - * -----> some scss files that are imported to main.scss - * i18n / - * -----> language .json files that override the default ones - * -----> fi.json - * plus whatever - */ - -if (nconf.get('city_theme')) { - // used in development, checks from config_dev.json - cityConfig = path.resolve(__dirname, `../node_modules/${nconf.get('city_theme')}/`); - cityAssets = path.resolve(cityConfig, 'assets/'); - cityImages = path.resolve(cityAssets, 'images/'); - cityi18n = path.resolve(cityConfig, 'i18n/'); -} -else if (process.env.city_theme) { - // used in production(???), checks from process.env - cityConfig = path.resolve(__dirname, `../node_modules/${process.env.city_theme}/`); - cityAssets = path.resolve(cityConfig, 'assets/'); - cityImages = path.resolve(cityAssets, 'images/'); - cityi18n = path.resolve(cityConfig, 'i18n/'); -} -else { - // used when no city_theme is available - cityConfig = path.resolve(__dirname, '../src/assets/default/'); - cityAssets = path.resolve(cityConfig, 'assets/'); - cityImages = path.resolve(cityAssets, 'images/'); - cityi18n = path.resolve(cityConfig, 'i18n/'); -} -module.exports = {cityConfig, cityAssets, cityImages, cityi18n}; diff --git a/config/webpack/dev.js b/config/webpack/dev.js index 6c668181b..dd3572702 100644 --- a/config/webpack/dev.js +++ b/config/webpack/dev.js @@ -3,7 +3,7 @@ import common from './common.js'; import webpack from 'webpack'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import {readConfig} from '../appConfig'; -import assetPath from '../assetPath'; + const publicUrl = readConfig('publicUrl') const ui_mode = readConfig('ui_mode') @@ -23,17 +23,12 @@ export default { resolve: { modules: [common.paths.ROOT, 'node_modules'], extensions: ['.', '.webpack.js', '.web.js', '.jsx', '.js'], - alias: { - '@city-assets': assetPath.cityAssets, - '@city-images': assetPath.cityImages, - '@city-i18n': assetPath.cityi18n, - }, }, module: { rules: [ { - test: /\.(js|jsx)?$/, - exclude: /node_modules/, + test: /\.(js|jsx)?$/, + exclude: /node_modules/, enforce: 'pre', use: ['babel-loader', 'eslint-loader'], }, @@ -46,7 +41,7 @@ export default { { loader: 'sass-loader', options: { - data: "$ui-mode: " + ui_mode + " !global;", + data: '$ui-mode: ' + ui_mode + ' !global;', }, }, ], @@ -72,6 +67,13 @@ export default { jQuery: 'jquery', 'window.jQuery': 'jquery', }), + new webpack.DefinePlugin({ + oidcSettings: { + client_id: JSON.stringify(readConfig('client_id')), + openid_audience: JSON.stringify(readConfig('openid_audience')), + openid_authority: JSON.stringify(readConfig('openid_authority')), + }, + }), ], mode: 'development', }; diff --git a/config/webpack/prod.js b/config/webpack/prod.js index 1ce556055..6b314d419 100644 --- a/config/webpack/prod.js +++ b/config/webpack/prod.js @@ -3,7 +3,6 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const GitRevisionPlugin = require('git-revision-webpack-plugin'); const common = require('./common'); const appConfig = require('../appConfig'); -const assetPath = require('../assetPath'); // There are defined in common.js as well, but that is not available without // transpilation, which is not done for webpack configuration file @@ -34,17 +33,12 @@ const config = { resolve: { modules: [common.paths.ROOT, 'node_modules'], extensions: ['.', '.webpack.js', '.web.js', '.jsx', '.js'], - alias: { - '@city-assets': assetPath.cityAssets, - '@city-images': assetPath.cityImages, - '@city-i18n': assetPath.cityi18n, - }, }, module: { rules: [ { - test: /\.(js|jsx)?$/, - exclude: /node_modules/, + test: /\.(js|jsx)?$/, + exclude: /node_modules/, enforce: 'pre', use: ['babel-loader', 'eslint-loader'], }, @@ -54,7 +48,7 @@ const config = { use: [ {loader: 'style-loader'}, {loader: 'css-loader'}, - {loader: 'sass-loader', options: {data: "$ui-mode: " + ui_mode + " !global;"}}, + {loader: 'sass-loader', options: {data: '$ui-mode: ' + ui_mode + ' !global;'}}, ], }, {test: /\.css$/, use: ['style-loader', 'css-loader']}, @@ -81,6 +75,13 @@ const config = { inject: true, templateContent: indexTemplate, }), + new webpack.DefinePlugin({ + oidcSettings: { + client_id: JSON.stringify(appConfig.readConfig('client_id')), + openid_audience: JSON.stringify(appConfig.readConfig('openid_audience')), + openid_authority: JSON.stringify(appConfig.readConfig('openid_authority')), + }, + }), ], }; diff --git a/config_dev.json.example b/config_dev.json.example index de7efdc1a..4f595e63f 100644 --- a/config_dev.json.example +++ b/config_dev.json.example @@ -9,7 +9,7 @@ "publicUrl": "http://localhost:8080", "sessionSecret": "dev-secret-do-not-use-in-production", "ui_mode": "events", - "client_id": "CLIENT_ID", + "client_id": "CLIENT_ID", "openid_audience": "OPENID_AUDIENCE", "openid_authority": "OPENID_AUTHORITY", "city_theme": "city_theme" diff --git a/package.json b/package.json index 6f1e5734a..f19dcf27c 100644 --- a/package.json +++ b/package.json @@ -52,15 +52,14 @@ "moment": "^2.24.0", "moment-timezone": "^0.5.27", "nconf": "^0.9.1", + "node-sass": "^4.14.1", "object-assign": "^4.1.1", - "passport": "^0.3.2", - "passport-helsinki": "https://github.com/City-of-Helsinki/passport-helsinki.git", + "oidc-client": "^1.10.1", "progress": "^1.1.8", "prop-types": "^15.7.2", "raven-js": "^2.3.0", "react": "16.12.0", "react-addons-pure-render-mixin": "^15.6.2", - "react-bootstrap": "^1.0.1", "react-copy-to-clipboard": "^5.0.1", "react-dom": "16.12.0", "react-helmet": "^5.2.1", @@ -71,9 +70,9 @@ "react-router-dom": "^4.2.2", "react-router-redux": "^5.0.0-alpha.9", "react-select": "^3.0.8", - "reactstrap": "^8.4.1", "redux": "4.0.4", "redux-actions": "^0.9.0", + "redux-oidc": "^4.0.0-beta1", "redux-thunk": "^2.3.0", "typeahead.js": "^0.11.1" }, @@ -106,7 +105,6 @@ "js-yaml": "^3.13.1", "json-loader": "^0.5.4", "morgan": "^1.6.1", - "node-sass": "^4.13.1", "pug": "^2.0.3", "pug-loader": "^2.4.0", "react-transform-hmr": "^1.0.4", @@ -126,6 +124,7 @@ "jest": { "setupTestFrameworkScriptFile": "src/setupTests.js", "testEnvironment": "jsdom", + "testURL": "http://localhost/", "moduleFileExtensions": [ "js", "jsx", @@ -136,7 +135,7 @@ "node_modules" ], "moduleNameMapper": { - "^.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "identity-obj-proxy" + "^.+\\.(css|styl|less|sass|scss|png|jpg|svg|ttf|woff|woff2)$": "identity-obj-proxy" }, "snapshotSerializers": [ "enzyme-to-json/serializer" diff --git a/server/auth.js b/server/auth.js deleted file mode 100644 index 5a09aad8b..000000000 --- a/server/auth.js +++ /dev/null @@ -1,59 +0,0 @@ -import {Passport} from 'passport' -import HelsinkiStrategy from 'passport-helsinki' -import _debug from 'debug' - -const debug = _debug('auth') - -export function getPassport(settings) { - const passport = new Passport() - - const helsinkiStrategy = new HelsinkiStrategy({ - clientID: settings.helsinkiAuthId, - clientSecret: settings.helsinkiAuthSecret, - callbackURL: settings.publicUrl + '/auth/login/helsinki/return', - }, (accessToken, refreshToken, profile, done) => { - debug('access token:', accessToken) - debug('refresh token:', refreshToken) - debug('acquiring token from api...') - - helsinkiStrategy.getAPIToken(accessToken, settings.helsinkiTargetApp, (token) => { - profile.token = token - return done(null, profile) - }) - }) - - passport.use(helsinkiStrategy) - - passport.serializeUser((user, done) => done(null, user)) - passport.deserializeUser((user, done) => done(null, user)) - - return passport; -} - -function successfulLoginHandler(req, res) { - const js = - `setTimeout(function() { - if(window.opener) { - window.close(); - } - else { location.href = "/"; } - }, 300);` - res.send('Login successful.'); -} - -export function addAuth(server, passport, settings) { - server.use(passport.initialize()); - server.use(passport.session()); - server.get('/auth/login/helsinki', passport.authenticate('helsinki')); - server.get('/auth/login/helsinki/return', passport.authenticate('helsinki'), successfulLoginHandler); - server.get('/auth/logout', (req, res) => { - res.send('
'); - }); - server.post('/auth/logout', (req, res) => { - req.logout(); - res.send('OK'); - }); - server.get('/auth/me', (req, res) => { - res.json(req.user || {}); - }); -} diff --git a/server/server.js b/server/server.js index e1567ca2a..06d82f393 100644 --- a/server/server.js +++ b/server/server.js @@ -7,7 +7,6 @@ import cookieSession from 'cookie-session' import getSettings from './getSettings' import express from 'express' -import {getPassport, addAuth} from './auth' import webpack from 'webpack' import webpackMiddleware from 'webpack-dev-middleware' @@ -16,17 +15,11 @@ import config from '../config/webpack/dev.js' const settings = getSettings() const app = express() -const passport = getPassport(settings) - app.use(cookieParser()); app.use(bodyParser.urlencoded({extended: true})); app.use(cookieSession({name: 's', secret: settings.sessionSecret, maxAge: 86400 * 1000})); -app.use(passport.initialize()); -app.use(passport.session()); -addAuth(app, passport, settings); - if(process.env.NODE_ENV !== 'development') { app.use('/', express.static(path.resolve(__dirname, '..', 'dist'))); app.get('*', function (req, res) { diff --git a/src/actions/user.js b/src/actions/user.js index f56bbf2d4..24250ccf7 100644 --- a/src/actions/user.js +++ b/src/actions/user.js @@ -1,9 +1,7 @@ import constants from '../constants.js' -import fetch from 'isomorphic-fetch' import {set, get} from 'lodash' import {setEditorAuthFlashMsg} from './editor' import client from '../api/client' -import axios from 'axios' import {getAdminOrganizations, getRegularOrganizations} from '../utils/user' const {RECEIVE_USERDATA, CLEAR_USERDATA, USER_TYPE} = constants @@ -32,97 +30,53 @@ const getUserType = (permissions) => { } } -// Adds an expiration time for user and saves it to localStorage. -function saveUserToLocalStorage(user) { - let modifiedUser = Object.assign({}, user) - - let expiryDate = new Date() - let expiryTime = appSettings.local_storage_user_expiry_time || 12 - expiryDate.setHours(expiryDate.getHours() + expiryTime) - modifiedUser.localStorageExpires = expiryDate.toISOString() - localStorage.setItem('user', JSON.stringify(modifiedUser)) -} - -export const retrieveUserFromSession = () => async (dispatch) => { +// Handles getting user data from backend api with given id. +export const fetchUser = (id) => async (dispatch) => { try { - const meResponse = await axios.get(`/auth/me?${+new Date()}`) - const user = meResponse.data - - if (user.token) { - const userResponse = await client.get(`user/${user.username}`, {}, { - headers: {Authorization: `JWT ${user.token}`}, - }) - const userData = userResponse.data - const permissions = [] + // try to get user data from user endpoint + const userResponse = await client.get(`user/${id}`) + const userData = userResponse.data - if (get(userData, 'admin_organizations', []).length > 0) { - permissions.push(USER_TYPE.ADMIN) - } - if (get(userData, 'organization_memberships', []).length > 0) { - permissions.push(USER_TYPE.REGULAR) - } - - const mergedUser = { - ...user, - organization: get(userData, 'organization', null), - adminOrganizations: get(userData, 'admin_organizations', null), - organizationMemberships: get(userData, 'organization_memberships', null), - permissions, - userType: getUserType(permissions), - } - - const adminOrganizations = await Promise.all(getAdminOrganizations(mergedUser)) - const regularOrganizations = await Promise.all(getRegularOrganizations(mergedUser)) - - // store data of all the organizations that the user is admin in - mergedUser.adminOrganizationData = adminOrganizations - .reduce((acc, organization) => set(acc, `${organization.data.id}`, organization.data), {}) - // store data of all the organizations where the user is a regular user - mergedUser.regularOrganizationData = regularOrganizations - .reduce((acc, organization) => set(acc, `${organization.data.id}`, organization.data), {}) - // get organizations with regular users - mergedUser.organizationsWithRegularUsers = adminOrganizations - .filter(organization => get(organization, ['data', 'has_regular_users'], false)) - .map(organization => organization.data.id) + // add correct permissions to user based on user's organizations + const permissions = [] + if (get(userData, 'admin_organizations', []).length > 0) { + permissions.push(USER_TYPE.ADMIN) + } + if (get(userData, 'organization_memberships', []).length > 0) { + permissions.push(USER_TYPE.REGULAR) + } - saveUserToLocalStorage(mergedUser) - dispatch(receiveUserData(mergedUser)) - dispatch(setEditorAuthFlashMsg()) + // add all desired user data in an object which will be stored into redux store + const mergedUser = { + id: get(userData, 'uuid', null), + displayName: get(userData, 'display_name', null), + firstName: get(userData, 'first_name', null), + lastName: get(userData, 'last_name', null), + username: get(userData, 'username', null), + email: get(userData, 'email', null), + organization: get(userData, 'organization', null), + adminOrganizations: get(userData, 'admin_organizations', null), + organizationMemberships: get(userData, 'organization_memberships', null), + permissions, + userType: getUserType(permissions), } + + const adminOrganizations = await Promise.all(getAdminOrganizations(mergedUser)) + const regularOrganizations = await Promise.all(getRegularOrganizations(mergedUser)) + // store data of all the organizations that the user is admin in + mergedUser.adminOrganizationData = adminOrganizations + .reduce((acc, organization) => set(acc, `${organization.data.id}`, organization.data), {}) + // store data of all the organizations where the user is a regular user + mergedUser.regularOrganizationData = regularOrganizations + .reduce((acc, organization) => set(acc, `${organization.data.id}`, organization.data), {}) + // get organizations with regular users + mergedUser.organizationsWithRegularUsers = adminOrganizations + .filter(organization => get(organization, ['data', 'has_regular_users'], false)) + .map(organization => organization.data.id) + + dispatch(receiveUserData(mergedUser)) + dispatch(setEditorAuthFlashMsg()) } catch (e) { throw Error(e) } } - -export function login() { - return (dispatch) => { - return new Promise((resolve) => { - if (typeof window === 'undefined') { // Not in DOM? Just try to get an user then and see how that goes. - return resolve(true); - } - const loginPopup = window.open( - '/auth/login/helsinki', - 'kkLoginWindow', - 'location,scrollbars=on,width=720,height=600' - ); - const wait = function wait() { - if (loginPopup.closed) { // Is our login popup gone? - return resolve(true); - } - setTimeout(wait, 500); // Try again in a bit... - }; - wait(); - }).then(() => { - return dispatch(retrieveUserFromSession()); - }); - }; -} - -export function logout() { - return (dispatch) => { - fetch('/auth/logout', {method: 'POST', credentials: 'same-origin'}) // Fire-and-forget - localStorage.removeItem('user') - dispatch(clearUserData()) - dispatch(setEditorAuthFlashMsg()) - }; -} diff --git a/src/actions/userActions.test.js b/src/actions/userActions.test.js new file mode 100644 index 000000000..cc38e2c3a --- /dev/null +++ b/src/actions/userActions.test.js @@ -0,0 +1,24 @@ +import {receiveUserData, clearUserData} from './user'; +import constants from '../constants.js' + + +const {RECEIVE_USERDATA, CLEAR_USERDATA} = constants + +describe('actions/user', () => { + describe('receiveUserData', () => { + test('returns object with correct type and payload', () => { + const data = {testData: 123}; + const expectedResult = {type: RECEIVE_USERDATA, payload: data}; + const result = receiveUserData(data); + expect(result).toEqual(expectedResult); + }); + }); + + describe('clearUserData', () => { + test('returns object with correct type', () => { + const expectedResult = {type: CLEAR_USERDATA}; + const result = clearUserData(); + expect(result).toEqual(expectedResult); + }); + }); +}); diff --git a/src/actors/serializer.js b/src/actors/serializer.js index 858a7d54c..edb9c7317 100644 --- a/src/actors/serializer.js +++ b/src/actors/serializer.js @@ -7,14 +7,7 @@ let arg = {}; window.ARG = arg; -var jwtDecode = require('jwt-decode'); - export default (store) => { - const state = _.cloneDeep(_.omit(store.getState(), ['userEvents'])); - if (state.user) { - let token = jwtDecode(state.user.token); - state.user.token = null; // JWT token is of not to be saved into Sentry - state.user.exp = token.exp; - } + const state = _.cloneDeep(_.omit(store.getState(), ['userEvents', 'auth'])); window.ARG = state; } diff --git a/src/api/client.js b/src/api/client.js index 50d20a0ba..b462a7052 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -8,7 +8,7 @@ let authToken const getToken = () => { const state = store.getState() - return get(state, 'user.token') + return get(state, 'auth.user.id_token') } store.subscribe(() => { @@ -32,7 +32,7 @@ export class ApiClient { getHeaders = () => ({ ...CONSTANTS.API_HEADERS, ...(authToken - ? {Authorization: `JWT ${authToken}`} + ? {Authorization: `Bearer ${authToken}`} : {}), }) diff --git a/src/assets/default/assets/main.scss b/src/assets/default/assets/main.scss deleted file mode 100644 index fec3d0536..000000000 --- a/src/assets/default/assets/main.scss +++ /dev/null @@ -1,4 +0,0 @@ -// THIS IS A DUMMY SCSS FILE THAT IS IMPORTED WHEN NO CITY_THEME IS INSTALLED -body { - background-color: green; -} diff --git a/src/assets/default/i18n/index.js b/src/assets/default/i18n/index.js deleted file mode 100644 index ff8b4c563..000000000 --- a/src/assets/default/i18n/index.js +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/src/components/FormFields/index.js b/src/components/FormFields/index.js index 7ad149e6f..6509f3f5e 100644 --- a/src/components/FormFields/index.js +++ b/src/components/FormFields/index.js @@ -17,8 +17,8 @@ import { HelKeywordSelector, } from 'src/components/HelFormFields' import RecurringEvent from 'src/components/RecurringEvent' -import {Button,Form, FormGroup, Label, Input} from 'reactstrap'; -import {Add, Autorenew} from '@material-ui/icons' +import {Button, TextField} from '@material-ui/core' +import {Add, Autorenew, FileCopyOutlined} from '@material-ui/icons' import {mapKeywordSetToForm, mapLanguagesSetToForm} from '../../utils/apiDataMapping' import {setEventData, setData} from '../../actions/editor' import {get, isNull, pickBy} from 'lodash' @@ -26,10 +26,10 @@ import API from '../../api' import CONSTANTS from '../../constants' import OrganizationSelector from '../HelFormFields/OrganizationSelector'; import UmbrellaSelector from '../HelFormFields/UmbrellaSelector/UmbrellaSelector' +import {HelMaterialTheme} from '../../themes/material-ui' import moment from 'moment' import HelVideoFields from '../HelFormFields/HelVideoFields/HelVideoFields' - let FormHeader = (props) => (
{ props.children } @@ -296,20 +296,25 @@ class FormFields extends React.Component { /> }
@@ -328,29 +333,24 @@ class FormFields extends React.Component {
- - -
- - - - - -
- - + - div > .row { justify-content: space-between; - } .clipboard-copy-button { - padding: 3px; + padding: 4px; position: absolute; box-shadow: 0 1px 6px rgba(0,0,0,0.12), 0 1px 4px rgba(0,0,0,0.12); - border-radius: 0px; - margin-right: -25px; - - } .location-row { @@ -29,30 +24,6 @@ top: 65px; } } -.glyphicon{ - font-size: 22px; - padding-left: 15px; - padding-top: 1px; - margin-right: 0px; - -} -.place-id{ - margin-top: 10px; - border-bottom-style:dashed; - border-bottom-width: 2px; - border-radius: 0px; - border-color:rgb(84,84,84); - font-size: 16px; - .form-control:disabled, .form-control[readonly]{ - background-color: #fff; - font-size: 16px; - - } - .form-control{ - border: 0px; - } -} - @media (max-width: 544px) { .location-row { @@ -98,14 +69,3 @@ } } } -.btn-block{ - color: #fff; - background-color:#0072c6 ; - margin-top: 15px; - font-size: 15.75px; - border-radius: 2px; - -} -.glyphicon{ - margin-right:20px; -} diff --git a/src/components/Header/Header.test.js b/src/components/Header/Header.test.js new file mode 100644 index 000000000..3f97c12b1 --- /dev/null +++ b/src/components/Header/Header.test.js @@ -0,0 +1,94 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import {Button} from '@material-ui/core' + +import {mockUser} from '__mocks__/mockData'; +import {UnconnectedHeaderBar} from './index'; + +import userManager from '../../utils/userManager' +userManager.settings.authority = 'test authority' + + +describe('components/Header/index', () => { + + function getWrapper(props) { + const defaultProps = { + user: mockUser, + //routerPush: () => {}, + userLocale: {locale: 'fi'}, + //setLocale: () => {}, + location: window.location, + //navBarOpen: false, + //showModerationLink: false, + clearUserData: () => {}, + auth: {user: {id_token: 'test-id-token'}}, + } + return shallow() + } + + describe('handleLoginClick', () => { + test('calls usermanager.signinRedirect with correct params', () => { + const instance = getWrapper().instance(); + const spy = jest.spyOn(userManager, 'signinRedirect'); + const expectedParams = { + data: { + redirectUrl: '/', + }, + extraQueryParams: { + ui_locales: instance.props.userLocale.locale, + }, + } + instance.handleLoginClick(); + + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0]).toEqual(expectedParams); + }); + }); + + describe('handleLogoutClick', () => { + test('calls clearUserData, removeUser and signoutRedirect with correct params', () => { + const clearUserData = jest.fn(); + const instance = getWrapper({clearUserData}).instance(); + const signoutRedirectSpy = jest.spyOn(userManager, 'signoutRedirect'); + const removeUserSpy = jest.spyOn(userManager, 'removeUser'); + const expectedParams = { + id_token_hint: instance.props.auth.user.id_token, + }; + instance.handleLogoutClick(); + + expect(clearUserData).toHaveBeenCalled(); + expect(removeUserSpy).toHaveBeenCalled(); + expect(signoutRedirectSpy).toHaveBeenCalled(); + expect(signoutRedirectSpy.mock.calls[0][0]).toEqual(expectedParams); + }); + }); + + describe('Button functions', () => { + describe('Login button', () => { + test('calls handleLoginClick', () => { + const user = undefined; + const wrapper = getWrapper({user}); + const handleLoginClick = jest.fn(); + wrapper.instance().handleLoginClick = handleLoginClick; + wrapper.instance().forceUpdate(); // update to register mocked function + const loginButton = wrapper.find('.helsinki-bar__login-button').find(Button); + expect(loginButton).toHaveLength(1); + loginButton.prop('onClick')(); + expect(handleLoginClick).toHaveBeenCalled(); + }); + }); + + describe('Logout button', () => { + test('calls handleLogoutClick', () => { + const wrapper = getWrapper(); + const handleLogoutClick = jest.fn(); + wrapper.instance().handleLogoutClick = handleLogoutClick; + wrapper.instance().forceUpdate(); // update to register mocked function + const logoutButton = wrapper.find('.helsinki-bar__login-button').find(Button); + expect(logoutButton).toHaveLength(1); + logoutButton.prop('onClick')(); + expect(handleLogoutClick).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/components/Header/LanguageSelector.js b/src/components/Header/LanguageSelector.js deleted file mode 100644 index f63723bb6..000000000 --- a/src/components/Header/LanguageSelector.js +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -class LanguageSelector extends React.Component { - constructor(props) { - super(props); - this.toggle = this.toggle.bind(this); - this.state = { - isOpen: false, - }; - } - - componentDidMount() { - document.addEventListener('click', this.handleClick, false); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleClick, false); - } - - handleClick = (event) => { - if (!this.node.contains(event.target)) { - this.handleOutsideClick(); - } - } - - handleOutsideClick() { - if ( this.state.isOpen) { - this.setState({isOpen: false}); - } - } - - toggle(e) { - e.preventDefault(); - this.setState({isOpen: !this.state.isOpen}); - } - - handleLanguageChange(lang, e) { - e.preventDefault(); - this.props.changeLanguage(lang); - this.setState({isOpen: false}) - - } - - - - /** - * Returns true if language is same as current locale - * @param {object} language - * @return {boolean} - */ - isActiveLanguage(language) { - const {userLocale} = this.props; - return language.label === userLocale.locale.toUpperCase(); - } - - render() { - const {userLocale} = this.props; - const activeLocale = userLocale.locale.toUpperCase(); - return ( - - -
this.node = node} className='LanguageMain'> - -
    - {this.props.languages.map((language, index) => { - return ( -
  • - {language.label} -
  • - ) - })} -
-
-
- ) - } -} - -LanguageSelector.propTypes = { - languages: PropTypes.array, - userLocale: PropTypes.object, - changeLanguage: PropTypes.func, -} -export default LanguageSelector; diff --git a/src/components/Header/LanguageSelector.test.js b/src/components/Header/LanguageSelector.test.js deleted file mode 100644 index a77bcc1e4..000000000 --- a/src/components/Header/LanguageSelector.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import {shallow} from 'enzyme'; -import LanguageSelector from './LanguageSelector'; - -const defaultProps = { - languages: [ - { - label: 'fi', - }, - { - label: 'en', - }, - { - label: 'sv', - }, - ], - userLocale: { - locale: 'fi', - }, - changeLanguage : () => null, -}; - -describe('languageSelector', () => { - function getWrapper(props) { - return shallow() - } - describe('acutal test', () => { - test('is default locale', () => { - const element = getWrapper().find('div'); - expect(element).toHaveLength(2); - const sec = element.at(1).find('a'); - expect(sec.text()).toBe('FI'); - }) - - test('activeLocale when en', () => { - const element = getWrapper({userLocale:{locale:'en'}}).find('div').at(1).find('a'); - expect(element.text()).toBe('EN'); - }) - }) - -}) diff --git a/src/components/Header/index.scss b/src/components/Header/index.scss index e341109c0..ebea06cc2 100644 --- a/src/components/Header/index.scss +++ b/src/components/Header/index.scss @@ -9,224 +9,108 @@ $linkedEventsTextSize: 19px; $logoPaddingRight: 32px; $addEventPadding: 6px 12px; $languageMarginRight: 10px; -$iconMarginLeft: 24px; +$iconMarginLeft: 24px; .main-navbar { - height: $headerBarHeight; - .navbar-brand { - padding: 0px 32px 0px 0px; - font-size: unset; - } - button { - font-weight: bold; - font-size: $linkFontSize; - background-color: transparent; - border: none; - height: 55px; - } - a { - color: inherit; - &:hover, - &:active, - &:focus { - color: inherit; - } - } - .bar { - height: $helsinkiBarHeight; - width: 100%; - background: $hel-theme; - padding-left: 24px; - padding-right: 24px; - padding-top: 0px; - padding-bottom: 0px; - &__logo { - height: $helsinkiLogoHeight; - width: 100px; - white-space: nowrap; - background-image: url('../../assets/images/helsinki-logo.svg'); - background-size: contain; - background-repeat: no-repeat; - } - &__login-and-language { - display: flex; - flex-direction: row; - .btn-secondary { - &:hover { - background-color: rgba(0, 0, 0, 0.08); - } - &:focus { - outline: 5px auto -webkit-focus-ring-color; - color: #fff; - box-shadow: none; - } - } - .glyphicon { - color: #fff; - margin-right: 10px; - } - } - .language-selector { - display: flex; - align-items: center; - margin-right: $languageMarginRight; - .language-icon { - color: white; - margin-right: $languageMarginRight; - } - .language-select-box { - background-color: transparent; - color: white; - text-transform: uppercase; - &::before { - display: none; - } - } - } - } - .linked-events-bar { - height: $helsinkiBarHeight; - width: 100%; - display: flex; - background: $hel-white; - align-items: center; - &__logo { - padding-right: $logoPaddingRight; - span { - font-size: $linkedEventsTextSize; - font-weight: bold; - color: $hel-theme; - } - } - &__icon { - margin-left: $iconMarginLeft; - height: 100%; - } - &__links { - display: flex; - flex-grow: 1; - justify-content: space-between; - &__create-events { - color: #0072c6; - border: 1px solid rgba(0, 0, 0, 0.23); - padding: 5px 15px; - border-color: #0072c6; - border-width: 2px; - min-width: 64px; - &:hover { - background-color: rgba(0, 0, 0, 0.08); - } - .glyphicon { - margin-right: 10px; - } - } - &__list { - .btn { - color: black; - border: none; - &:hover { - background-color: rgba(0, 0, 0, 0.08); - } - &:focus { - color: inherit; - outline: 5px auto -webkit-focus-ring-color; - box-shadow: none; - } - .text { - padding: 6px 8px; - } - } - } - } - } -} + height: $headerBarHeight !important; -//LanguageSelector.js -.LanguageMain { - display: flex; - flex-direction: column; - color: white; - font-weight: bold; - padding: .375rem .75rem; - align-items: center; - .currentLanguage { - padding: 0.375rem 0 0.375rem 0 - } -} + button { + font-weight: bold; + font-size: $linkFontSize; + } -ul.language { - color: black; - padding-left: 0; - width: 55px; - position: absolute; - top: 100%; - z-index: 1; - li { - display: none; - position: relative; - } - &.open { - border: 1px solid black; - li { - background-color: white; - display: block; - text-align: center; - padding-top: 0.375rem; - padding-bottom: 0.375rem; - z-index: 99999; - &.active { - background-color: lightgrey; - } - } - } -} + a { + color: inherit; -.caret { - display: inline-block; - width: 0; - height: 0; - margin-left: 2px; - vertical-align: middle; - border-top: 4px dashed; - border-top: 4px solid \9; - border-right: 4px solid transparent; - border-left: 4px solid transparent; -} + &:hover, + &:active, + &:focus { + color: inherit; + } + } + + .helsinki-bar { + height: $helsinkiBarHeight; + width: 100%; + display: flex; + background: $hel-theme; + justify-content: space-between; + align-items: center; + + &__logo { + height: $helsinkiLogoHeight; + white-space: nowrap; + + img { + height: $helsinkiLogoHeight; + } + } + + &__login-button { + display: flex; + flex-direction: row; + } -//Hamburger for Mobile -.navbar-toggler-icon { - background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(0,0,0)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 8h24M4 16h24M4 24h24'/%3E%3C/svg%3E") !important; + &__language-button { + margin-right: $languageMarginRight; + display: flex; + align-items: center; + + .language-selector { + display: flex; + align-items: center; + + .language-icon { + color: white; + margin-right: $languageMarginRight; + } + .language-select-box { + background-color: transparent; + color: white; + text-transform: uppercase; + &::before{ + display: none; + } + } + } + } + } + + .linked-events-bar { + height: $helsinkiBarHeight; + width: 100%; + display: flex; + background: $hel-white; + align-items: center; + + &__logo { + padding-right: $logoPaddingRight; + span { + font-size: $linkedEventsTextSize; + font-weight: bold; + color: $hel-theme; + } + } + + &__icon { + margin-left: $iconMarginLeft; + height: 100%; + } + + &__links { + display: flex; + flex-grow: 1; + justify-content: space-between; + + &__mobile { + display: flex; + } + } + } } -//Mobile -@media only screen and (max-width: 991px) { - .main-navbar { - height: auto !important; - .navbar-brand { - padding: 0px; - font-size: unset; - } - .bar { - height: auto; - flex-wrap: nowrap; - .language-selector { - white-space: nowrap; - } - &__login-and-language { - .btn { - white-space: normal; - display: flex; - align-items: center; - height: auto; - } - } - } - .linked-events-bar { - height: auto; - &__links { - display: block; - text-align: center; - } - } - } -} \ No newline at end of file +.menu-drawer-mobile { + display: flex; + flex-direction: column; + width: auto; +} diff --git a/src/components/HelFormFields/HelKeywordSelector/HelKeywordSelector.js b/src/components/HelFormFields/HelKeywordSelector/HelKeywordSelector.js index 927e7a13d..2224ea4ff 100644 --- a/src/components/HelFormFields/HelKeywordSelector/HelKeywordSelector.js +++ b/src/components/HelFormFields/HelKeywordSelector/HelKeywordSelector.js @@ -9,8 +9,7 @@ import {setData as setDataAction} from '../../../actions/editor' import PropTypes from 'prop-types' import {connect} from 'react-redux' import {CopyToClipboard} from 'react-copy-to-clipboard' - - +import {FileCopyOutlined} from '@material-ui/icons' const handleKeywordChange = (checkedOptions, keywords, mainCategoryOptions, setData) => { if (isNil(checkedOptions)) { @@ -90,8 +89,8 @@ const HelKeywordSelector = ({intl, editor, setDirtyState, setData}) => { customOnChangeHandler={(selectedOption) => handleKeywordChange(selectedOption, keywords, mainCategoryOptions, setData)} /> -
) diff --git a/src/components/HelFormFields/HelTextField.js b/src/components/HelFormFields/HelTextField.js index f1f51dce4..69190f8d4 100644 --- a/src/components/HelFormFields/HelTextField.js +++ b/src/components/HelFormFields/HelTextField.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React,{Fragment, Component} from 'react' +import React from 'react' import {setData} from 'src/actions/editor.js' import {TextField} from '@material-ui/core' @@ -7,10 +7,9 @@ import validationRules from 'src/validation/validationRules'; import ValidationPopover from 'src/components/ValidationPopover' import constants from '../../constants' - const {VALIDATION_RULES, CHARACTER_LIMIT} = constants -class HelTextField extends Component { +class HelTextField extends React.Component { constructor(props) { super(props) @@ -188,7 +187,7 @@ class HelTextField extends Component { } = this.props return ( - + - + ) } } diff --git a/src/components/ImageEdit/index.js b/src/components/ImageEdit/index.js index 57b2b8486..88aacceca 100644 --- a/src/components/ImageEdit/index.js +++ b/src/components/ImageEdit/index.js @@ -3,8 +3,8 @@ import './index.scss' import React, {useState} from 'react'; import PropTypes from 'prop-types' import {injectIntl, FormattedMessage} from 'react-intl' -import {Button} from 'reactstrap'; import { + Button, IconButton, Dialog, DialogTitle, @@ -213,7 +213,7 @@ const ImageEdit = (props) => { >   - +
@@ -221,11 +221,12 @@ const ImageEdit = (props) => { {altText}
diff --git a/src/components/ImageEdit/index.scss b/src/components/ImageEdit/index.scss index 005d1f536..67903e39a 100644 --- a/src/components/ImageEdit/index.scss +++ b/src/components/ImageEdit/index.scss @@ -1,28 +1,5 @@ .image-edit-dialog { - &--image { - object-fit: contain; - } -} -.file-upload { - .btn { - padding: 16px 16px; - border-radius: 2px; - font-size: 15.75px; - background-color: #0072c6; - } - .btn-primary.disabled:hover { - background-color: #6c757d; - } -} -.button-row { - .btn { - padding: 16px 16px; - border-radius: 2px; - font-size: 15.75px; - background-color: #0072c6; - } -} -.disabled:disabled { - background-color: #6c757d; - border: none; + &--image { + object-fit: contain; + } } diff --git a/src/components/ImagePicker/index.js b/src/components/ImagePicker/index.js index 728f1d82a..033e4b884 100644 --- a/src/components/ImagePicker/index.js +++ b/src/components/ImagePicker/index.js @@ -4,8 +4,7 @@ import React from 'react'; import PropTypes from 'prop-types' import {FormattedMessage, injectIntl} from 'react-intl' -import {Button} from 'reactstrap'; -import {IconButton, CircularProgress, Dialog, DialogTitle, DialogContent, Typography, TextField} from '@material-ui/core' +import {Button, IconButton, CircularProgress, Dialog, DialogTitle, DialogContent, Typography, TextField} from '@material-ui/core' import {Close, ErrorOutline, Publish} from '@material-ui/icons' import {deleteImage} from 'src/actions/userImages.js' import {connect} from 'react-redux' @@ -201,6 +200,7 @@ export class ImagePicker extends React.Component {
-
- - - setSearchQuery(e.target.value)} - onKeyPress={(e) => handleKeyPress(e, startDate, endDate, onFormSubmit, setSearchQuery)} - /> - -
+ setSearchQuery(e.target.value)} + onKeyPress={(e) => handleKeyPress(e, startDate, endDate, onFormSubmit, setSearchQuery)} + style={{margin: 0}} + />