diff --git a/cgra-sso/.dockerignore b/cgra-sso/.dockerignore new file mode 100644 index 00000000..b512c09d --- /dev/null +++ b/cgra-sso/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/cgra-sso/.env b/cgra-sso/.env new file mode 100644 index 00000000..6f809cc2 --- /dev/null +++ b/cgra-sso/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true diff --git a/cgra-sso/.gitignore b/cgra-sso/.gitignore new file mode 100644 index 00000000..507886b2 --- /dev/null +++ b/cgra-sso/.gitignore @@ -0,0 +1,25 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.gdcatalogrc \ No newline at end of file diff --git a/cgra-sso/.prettierrc b/cgra-sso/.prettierrc new file mode 100644 index 00000000..5f1a4ce9 --- /dev/null +++ b/cgra-sso/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 110, + "tabWidth": 4, + "trailingComma": "all" +} diff --git a/cgra-sso/Dockerfile b/cgra-sso/Dockerfile new file mode 100644 index 00000000..544081a7 --- /dev/null +++ b/cgra-sso/Dockerfile @@ -0,0 +1,15 @@ +FROM nginx:1.19-alpine + +# the port nginx will listen on +# you can either change it here, or by using --env PORT=1234 when running the container +ENV PORT=8080 + +COPY ./docker/nginx.conf.template /etc/nginx/templates/default.conf.template +COPY ./build/ /usr/share/nginx/html + +# Uncomment the lines below if you are setting up HTTPS for localhost +# +# COPY ./docker/localhost.crt /etc/ssl/certs/localhost.crt +# COPY ./docker/localhost.key /etc/ssl/private/localhost.key + +CMD ["nginx", "-g", "daemon off;"] diff --git a/cgra-sso/HOWTO.md b/cgra-sso/HOWTO.md new file mode 100644 index 00000000..8e2ea85e --- /dev/null +++ b/cgra-sso/HOWTO.md @@ -0,0 +1,64 @@ +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `npm start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+You will also see any lint errors in the console. + +### `npm test` + +Launches the test runner in the interactive watch mode.
+See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `npm run build` + +Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `npm run eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). + +### Code Splitting + +This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting + +### Analyzing the Bundle Size + +This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size + +### Making a Progressive Web App + +This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app + +### Advanced Configuration + +This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration + +### `npm run build` fails to minify + +This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify diff --git a/cgra-sso/README.md b/cgra-sso/README.md new file mode 100644 index 00000000..87f21e43 --- /dev/null +++ b/cgra-sso/README.md @@ -0,0 +1,112 @@ +## GoodData.UI Accelerator Toolkit Application + +This project was bootstrapped with [GoodData.UI Accelerator Toolkit](https://sdk.gooddata.com/gooddata-ui/docs/create_new_application.html). + +- To start the application on your workstation run the `npm run start` command. +- To create a production build run the `npm run build` command. + +This project uses the [Create React App](https://github.com/facebook/create-react-app) (CRA) scripts and infrastructure, you +can find the original documentation for CRA in [HOWTO.md](./HOWTO.md). + +### Getting started + +Before you can create visualizations for data in your workspace, you need to export its Logical Data Model (LDM). You can +then use the exported LDM entities to define the visualizations. + +The export is simple: run the `npm run refresh-md` command. + +- This command will use information from [constants.js](./src/constants.js). It will connect to GoodData servers running + on the host specified in the `backend` property and [export](https://sdk.gooddata.com/gooddata-ui/docs/export_catalog.html) MD for the `workspace` of your choice. +- If your configuration does not specify `workspace`, the script will prompt you to choose one of the workspaces available in the `backend`. + +Once done, you will find that the [src/md/full.js](src/md/full.js) file will be populated with attribute and measure definitions +matching the MD defined in your workspace. You can then use these generated definitions as inputs to the different +[visualization components](https://sdk.gooddata.com/gooddata-ui/docs/start_with_visual_components.html) available in GoodData.UI SDK. + +**Note: Before running this script, please make sure `backend` is defined in `constants.js` file.** + +**Hint: To avoid constantly typing in credentials, you can create `.gdcatalogrc` file where you define your `username` and `password`.** + +### Deployment + +There are two ways to deploy your application. + +1. If your domain does not have CORS set up and you want to get up and running fast, you can use the pre-configured Docker image included with the app. +2. If the Docker way is not suitable for you, you can build and deploy the app manually (keep in mind that you will have to setup CORS on your GoodData domain so that it allows access from your application). + +#### Using the built-in Docker support + +The application comes with a simple Dockerfile. This image is a pre-configured nginx instance that both serves the application files and acts as a reverse proxy for your GoodData domain. In this deployment, your GoodData domain does not need any CORS setup because the application will only communicate with its origin server. +To use it, run these commands in your terminal: + +```bash +# build production version of your application +npm run build +# build the docker image +docker build -t your-tag . +# run the docker image +docker run \ + --rm \ + --publish 3000:8080 \ + --name your-name \ + --env BACKEND_HOST="secure.gooddata.com" \ + --env BACKEND_URL="https://secure.gooddata.com" \ + your-tag:latest +``` + +The meaning of the `docker run` parameters is: + +- `--publish 3000:8080` – expose the nginx running on port 8080 by default (you can change that if needed by adding `--env PORT=5000`, just make sure you update the `--publish` value accordingly), to port 3000 on your machine. +- `--name your-name` – assign a name to the container run. +- `--env BACKEND_HOST="secure.gooddata.com"` and `--env BACKEND_URL="https://secure.gooddata.com"` – set the host/URL where the GoodData analytical backend is running respectively. You need to change these values if you host GoodData on a different domain. + +**IMPORTANT**: The Docker image is not setup with SSL certificates and thus by default offers no support for HTTPS. Read on to learn more. + +##### HTTPS on localhost + +If you intend to use the Docker image on localhost and you need support for HTTPS, then you can use self-signed certificates. + +First generate the certificate and private key and store them in the `docker` directory. + +```bash +cd docker +openssl req -x509 -out localhost.crt -keyout localhost.key \ + -newkey rsa:2048 -nodes -sha256 \ + -subj '/CN=localhost' -extensions EXT -config <( \ + printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") +``` + +Then edit the [Dockerfile](./Dockerfile) and [docker/nginx.conf.template](./docker/nginx.conf.template) and uncomment the marked-up lines. + +##### HTTPS in production + +If you plan on hosting your application on platforms such as Heroku that solve the HTTPS transport and do the SSL termination +for you, then you do not have to set up anything - this image is good to go. + +**IMPORTANT**: If your hosting does not provide SSL termination then we strongly recommend to not use this image as is. Your data or other +sensitive information will be at serious risk. + +If you already own certificates issued by a trusted CA, then you can reconfigure the nginx to use them. The process is similar to +how you set up HTTPS on localhost. + +If you do not own certificates but have your own domain then you can use the [Let's Encrypt](https://letsencrypt.org/) Certificate Authority. We recommend +that you switch the base image in the [Dockerfile](./Dockerfile) and use the [nginx-certbot](https://hub.docker.com/r/jonasal/nginx-certbot); this will +automate the certificate acquisition and renewal process for you. + +#### Building and deploying manually + +To deploy the application without the use of the provided Dockerfile, you can run + +```bash +npm run build +``` + +which will create a `build` folder with all the build outputs that can you can then host anyway you want. Built like this, the application will assume that the GoodData Analytical Backend is hosted on the same host as the application itself. + +In case you want to host the application on a host other than the one you use to host the GoodData Analytical Backend, you should build the application like this + +```bash +npm run build-with-explicit-hostname +``` + +Built like this, the application will connect to the GoodData Analytical Backend hosted at the host specified in `src/constants.js` in `backend` field. diff --git a/cgra-sso/docker/nginx.conf.template b/cgra-sso/docker/nginx.conf.template new file mode 100644 index 00000000..8e15e409 --- /dev/null +++ b/cgra-sso/docker/nginx.conf.template @@ -0,0 +1,74 @@ +gzip on; +gzip_disable "msie6"; +gzip_vary on; +gzip_proxied any; +gzip_types image/jpeg image/bmp image/svg+xml text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript image/x-icon; + +client_max_body_size 10M; + +log_format json escape=json '{' + '"timestamp": "$time_iso8601", ' + '"remoteAddress": "$remote_addr", ' + '"method": "$request_method", ' + '"url": "$request_uri", ' + '"httpVersion": "$server_protocol", ' + '"statusCode": "$status", ' + '"body_bytes_sent":"$body_bytes_sent",' + '"requestTime": "$request_time", ' + '"upstreamTime": "$upstream_response_time", ' + '"userAgent": "$http_user_agent", ' + '"referer": "$http_referer", ' + '"requestSize": "$request_length", ' + '"responseSize": "$bytes_sent"' +'}'; + +map $sent_http_content_type $expires { + default off; + text/html -1; + application/json -1; + text/css max; + application/javascript max; +} + +server { + # + # Comment this out if you are setting up HTTPS for localhost + # + listen ${PORT}; + + # + # Uncomment the lines below if you are setting up HTTPS for localhost + # + # listen ${PORT} ssl; + # + # server_name localhost; + # ssl_certificate /etc/ssl/certs/localhost.crt; + # ssl_certificate_key /etc/ssl/private/localhost.key; + # + + root /usr/share/nginx/html; + index index.html; + access_log /var/log/nginx/access.log json; + + expires $expires; + + location / { + try_files $uri $uri/ /index.html; + } + + location ^~ /gdc { + proxy_pass ${BACKEND_URL}; + proxy_set_header x-forwarded-host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host ${BACKEND_HOST}; + proxy_set_header Referer ${BACKEND_URL}; + proxy_set_header Origin ""; + proxy_cookie_domain ${BACKEND_HOST} $host; + proxy_ssl_server_name on; + } + + location ~* \.(?:css|js)$ { + add_header Cache-Control "public"; + } + +} diff --git a/cgra-sso/jest.setup.js b/cgra-sso/jest.setup.js new file mode 100644 index 00000000..05cc9c70 --- /dev/null +++ b/cgra-sso/jest.setup.js @@ -0,0 +1,4 @@ +const enzyme = require("enzyme"); +const Adapter = require("enzyme-adapter-react-16"); + +enzyme.configure({ adapter: new Adapter() }); diff --git a/cgra-sso/jsconfig.json b/cgra-sso/jsconfig.json new file mode 100644 index 00000000..a01d49df --- /dev/null +++ b/cgra-sso/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "checkJs": true, + "jsx": "react" + }, + "exclude": ["node_modules"] +} diff --git a/cgra-sso/package.json b/cgra-sso/package.json new file mode 100644 index 00000000..76ca1163 --- /dev/null +++ b/cgra-sso/package.json @@ -0,0 +1,78 @@ +{ + "name": "cgra-sso", + "version": "0.1.0", + "private": true, + "dependencies": { + "@gooddata/sdk-backend-bear": "^8.5.0", + "@gooddata/sdk-model": "^8.5.0", + "@gooddata/sdk-ui": "^8.5.0", + "@gooddata/sdk-ui-charts": "^8.5.0", + "@gooddata/sdk-ui-ext": "^8.5.0", + "@gooddata/sdk-ui-filters": "^8.5.0", + "@gooddata/sdk-ui-geo": "^8.5.0", + "@gooddata/sdk-ui-pivot": "^8.5.0", + "classnames": "^2.3.1", + "formik": "^2.2.9", + "lodash": "^4.17.15", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "react-helmet": "^5.2.1", + "react-router-dom": "^5.0.1", + "react-router-use-location-state": "^2.3.1", + "react-scripts": "4.0.3", + "use-location-state": "^2.3.1", + "yup": "^0.32.9" + }, + "scripts": { + "start": "cross-env HTTPS=true PORT=3000 react-scripts start", + "build": "react-scripts build", + "build-with-explicit-hostname": "cross-env REACT_APP_SET_HOSTNAME=true npm run build", + "eject": "react-scripts eject", + "test": "react-scripts test --setupFilesAfterEnv ./jest.setup.js", + "testcafe-backend": "rm -R ./build & yarn build & serve -l 5000 build", + "testcafe": "cross-env TEST_BACKEND=http://localhost:5000 ./scripts/run-testcafe.js", + "testcafe-visual": "cross-env TEST_MODE=visual ./scripts/run-testcafe.js", + "refresh-md": "node ./scripts/refresh-md.js", + "buildgd": "cross-env REACT_APP_SET_HOSTNAME=true PUBLIC_URL=/cgra-sso yarn build", + "syncgd": "s3cmd --config .s3cfg sync --delete-removed ./build/ s3://gooddata-demo/cgra-sso/" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@babel/core": "^7.13.8", + "@babel/plugin-transform-typescript": "^7.9.6", + "@babel/preset-env": "^7.13.9", + "@babel/preset-typescript": "^7.13.0", + "@babel/register": "^7.13.8", + "@gooddata/catalog-export": "^8.5.0", + "babel-plugin-add-module-exports": "^1.0.4", + "cross-env": "^5.2.0", + "enzyme": "^3.10.0", + "enzyme-adapter-react-16": "^1.14.0", + "husky": "^3.0.0", + "node-sass": "^4.13.1", + "prettier": "^1.18.2", + "pretty-quick": "^1.11.1", + "serve": "^11.3.2", + "testcafe": "^1.5.0", + "typescript": "^4.1.5" + }, + "husky": { + "hooks": { + "pre-commit": "pretty-quick --staged --pattern \"**/*.*(js|jsx|ts|tsx|scss)\"" + } + } +} diff --git a/cgra-sso/public/favicon.ico b/cgra-sso/public/favicon.ico new file mode 100644 index 00000000..07bd39b1 Binary files /dev/null and b/cgra-sso/public/favicon.ico differ diff --git a/cgra-sso/public/index.html b/cgra-sso/public/index.html new file mode 100644 index 00000000..50dc50d0 --- /dev/null +++ b/cgra-sso/public/index.html @@ -0,0 +1,37 @@ + + + + + + + + + + + GoodData App + + + +
+ + diff --git a/cgra-sso/public/manifest.json b/cgra-sso/public/manifest.json new file mode 100644 index 00000000..7959d354 --- /dev/null +++ b/cgra-sso/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "GoodData App", + "name": "Create GoodData App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/cgra-sso/scripts/refresh-md.js b/cgra-sso/scripts/refresh-md.js new file mode 100644 index 00000000..ca813eae --- /dev/null +++ b/cgra-sso/scripts/refresh-md.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +require("@babel/register")({ + presets: ["@babel/preset-typescript", "@babel/preset-env"], + plugins: ["add-module-exports"], + extensions: [".ts", ".js"], +}); + +const { workspace, backend } = require("../src/constants"); +const path = "./src/md/full.js"; +process.argv.push("--workspace-id", workspace, "--hostname", backend, "--output", path); +require("../node_modules/@gooddata/catalog-export"); diff --git a/cgra-sso/scripts/run-testcafe.js b/cgra-sso/scripts/run-testcafe.js new file mode 100755 index 00000000..81a31c44 --- /dev/null +++ b/cgra-sso/scripts/run-testcafe.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +const createTestCafe = require("testcafe"); + +const LOCAL_CONCURRENCY = 4; +const ASSERTION_TIMEOUT = 15000; +const SELECTOR_TIMEOUT = 30000; +const WINDOW_WIDTH = 1920; +const WINDOW_HEIGHT = 1080; +// For some reason testcafe has problems using globstar so we need to specify +// maximum directory depth, otherwise it just picks up tests in the deepest directory. +const TESTS_PATH = "tests/**/**/**/*.spec.js"; + +const CHROME_HEADLESS_LOCAL = `chrome:headless --window-size='${WINDOW_WIDTH},${WINDOW_HEIGHT}' --no-sandbox`; +const CHROME_LOCAL = `chrome --window-size='${WINDOW_WIDTH},${WINDOW_HEIGHT}' --no-sandbox --disable-background-timer-throttling`; + +let testcafe = null; + +createTestCafe("localhost") + .then(tc => { + testcafe = tc; + const runner = testcafe.createRunner(); + const mode = process.env.TEST_MODE; + + const runnerBase = runner.src([TESTS_PATH]); + + if (mode === "visual") { + console.log("Starting TestCafe in visual-local mode without concurrency."); + return runnerBase.browsers([CHROME_LOCAL]).run({ + assertionTimeout: ASSERTION_TIMEOUT, + selectorTimeout: SELECTOR_TIMEOUT, + debugOnFail: true, + }); + } + + console.log(`Starting TestCafe in local mode with concurrency ${LOCAL_CONCURRENCY}.`); + return runnerBase + .browsers([CHROME_HEADLESS_LOCAL]) + .concurrency(LOCAL_CONCURRENCY) + .run({ + assertionTimeout: ASSERTION_TIMEOUT, + selectorTimeout: SELECTOR_TIMEOUT, + }); + }) + .then(failedCount => { + testcafe.close(); + if (failedCount > 0) { + process.exit(1); + } + }) + .catch(e => { + console.error(e.message); + testcafe.close(); + process.exit(1); + }); diff --git a/cgra-sso/src/App.js b/cgra-sso/src/App.js new file mode 100644 index 00000000..1bb9bf27 --- /dev/null +++ b/cgra-sso/src/App.js @@ -0,0 +1,20 @@ +import React from "react"; +import { BackendProvider } from "@gooddata/sdk-ui"; + +import AppRouter from "./routes/AppRouter"; +import { useAuth } from "./contexts/Auth"; +import { WorkspaceListProvider } from "./contexts/WorkspaceList"; + +function App() { + const { backend } = useAuth(); + + return ( + + + + + + ); +} + +export default App; diff --git a/cgra-sso/src/components/Auth/LoginForm.js b/cgra-sso/src/components/Auth/LoginForm.js new file mode 100644 index 00000000..ac4e342f --- /dev/null +++ b/cgra-sso/src/components/Auth/LoginForm.js @@ -0,0 +1,138 @@ +import React from "react"; +import cx from "classnames"; +import { withRouter } from "react-router-dom"; +import { withFormik } from "formik"; +import { string, object } from "yup"; + +import { backend } from "../../constants"; +import CustomLoading from "../CustomLoading"; +import Input from "../controls/Input"; +import Label from "../controls/Label"; +import Button from "../controls/Button"; + +import sharedStyles from "../../shared.module.scss"; +import styles from "./LoginForm.module.scss"; + +const LoginFormComponent = props => { + const { + values, + touched, + errors, + isSubmitting, + handleChange, + handleBlur, + handleSubmit, + loginError, + } = props; + + return ( + <> + {loginError &&
{loginError}
} +
+
+ GoodData +
+

+ Please sign in to the{" "} + + {backend.replace(/https?:\/\//, "")} + {" "} + domain +

+
+ + + + {errors.email && touched.email &&
{errors.email}
} +
+
+ + + + {errors.password && touched.password && ( +
{errors.password}
+ )} +
+
+ +
+
+ + ); +}; + +const formikConnector = withFormik({ + mapPropsToValues: ({ email = "", password = "" }) => ({ + email, + password, + }), + + validationSchema: object().shape({ + email: string() + .email("Invalid e-mail address") + .required("E-mail is required"), + password: string().required("Password is required"), + }), + + handleSubmit: ({ email, password }, { props: { login, history }, setFieldError, setSubmitting }) => { + return login(email, password).then( + () => history.push("/"), + error => { + setSubmitting(false); + if (error.response && error.response.status === 401) { + setFieldError("password", "E-mail or password is invalid"); + } else { + setFieldError("password", "Unknown error"); + } + }, + ); + }, +}); + +export default withRouter(formikConnector(LoginFormComponent)); diff --git a/cgra-sso/src/components/Auth/LoginForm.module.scss b/cgra-sso/src/components/Auth/LoginForm.module.scss new file mode 100644 index 00000000..04bb8777 --- /dev/null +++ b/cgra-sso/src/components/Auth/LoginForm.module.scss @@ -0,0 +1,119 @@ +@import "../../theme.scss"; +@import "../../mixins.scss"; + +.Login { + padding: $spacing; + background-color: $color-box; + margin: $spacing auto; + display: flex; + max-width: 300px; + flex-direction: column; + justify-content: center; + align-items: stretch; + align-self: center; + text-align: center; + border-radius: $border-radius; + box-shadow: rgba(0, 0, 0, 0.25) 0px $spacing $spacing/2 0px; + color: $color-text; +} + +.LoginLogo { + text-align: center; +} + +.InputBlock { + text-align: left; + margin: $spacing/2 0; + width: 300px; +} + +.Label { + margin-bottom: $spacing/4; +} + +.Error { + position: relative; + box-sizing: border-box; + display: block; + max-width: 100%; + margin-top: $spacing/4; + padding: $spacing/4 $spacing/2; + min-height: 1px; + border-radius: 3px; + font-family: inherit; + font-size: $font-size-body; + text-align: left; + line-height: in; + color: $color-error; + background: transparentize($color-error, 0.85); + pointer-events: all; +} + +.Action { + margin-top: $spacing/2; +} + +.Input.Input { + max-width: 100%; + padding: $spacing/4 $spacing/2; + border: 1px solid $color-border; + color: $color-text; + background: $color-paper; + border-radius: $border-radius; + box-shadow: inset 0 1px 1px 0 rgba(31, 53, 74, 0.15); + transition: 0.25s border-color ease, 0.25s color ease, 0.25s box-shadow ease; + + &:focus, + &:active { + border-color: $color-primary; + outline: 0; + box-shadow: inset 0 1px 1px 0 rgba(31, 53, 74, 0.2); + } + &::placeholder { + @include reset-appearance; + color: $color-text-muted; + } +} + +.Error { + box-shadow: inset 0 0 2px 0 transparentize($color-error, 0.5); +} + +// This button is styled so that it matches the usual login forms of the GoodData platform +.SubmitButton { + background-color: #fcfcfd; + border-radius: 3px; + border: 1px solid #ccd8e2; + box-shadow: 0 1px 1px 0 rgba(20, 56, 93, 0.15); + color: #778491; + cursor: pointer; + display: block; + font-family: avenir, "Helvetica Neue", arial, sans-serif; + font-size: 16px; + font-weight: 700; + height: 38px; + line-height: 22px; + outline: none; + padding: 7px 19px; + text-align: center; + transition-duration: 0.25s; + transition-property: all; + transition-timing-function: ease-in-out; + width: 100%; + + &:hover { + border-color: rgba(31, 52, 73, 0.2); + box-shadow: 0 1px 1px 0 rgba(20, 56, 93, 0.15), inset 0 -1px 0 0 rgba(177, 193, 209, 0.6); + color: #464e56; + background: #f5f8fa; + } + + &:active, + &:focus { + border-color: #b1c1d1; + box-shadow: inset 0 1px 0 0 rgba(177, 193, 209, 0.65); + color: #464e56; + background: #ecf0f5; + background-image: linear-gradient(to top, #dee6ef, #ecf0f5); + } +} diff --git a/cgra-sso/src/components/Auth/LogoutForm.js b/cgra-sso/src/components/Auth/LogoutForm.js new file mode 100644 index 00000000..87db0950 --- /dev/null +++ b/cgra-sso/src/components/Auth/LogoutForm.js @@ -0,0 +1,32 @@ +import React, { useEffect } from "react"; +import { withRouter } from "react-router-dom"; + +import CustomLoading from "../CustomLoading"; + +const AUTH_DOMAIN = "https://zajic.us.auth0.com"; +const CLIENT_ID = "wJtJSAm3wBsxSG0nQopkjkQnuQmxdFyS"; +const RETURN_URL = "https://gooddata-demo.s3.amazonaws.com/cgra-sso/index.html"; + +const LogoutForm = ({ history, logout }) => { + useEffect( + () => { + if (process.env.REACT_APP_SET_HOSTNAME) { + logout().then(() => + window.location.assign( + `${AUTH_DOMAIN}/v2/logout?client_id=${CLIENT_ID}&returnTo=${encodeURIComponent( + RETURN_URL, + )}`, + ), + ); + } else { + logout().then(() => history.push("/login")); + } + }, + // only call the logout on initial mount -> the empty array is correct here + [], // eslint-disable-line react-hooks/exhaustive-deps + ); + + return ; +}; + +export default withRouter(LogoutForm); diff --git a/cgra-sso/src/components/CustomLoading.js b/cgra-sso/src/components/CustomLoading.js new file mode 100644 index 00000000..da4101d3 --- /dev/null +++ b/cgra-sso/src/components/CustomLoading.js @@ -0,0 +1,72 @@ +import React from "react"; +import cx from "classnames"; + +import styles from "./CustomLoading.module.scss"; + +const baseAnimationDuration = 1.8; + +export const CustomLoading = ({ + label = null, + inline = false, + height = "100%", + width = undefined, + imageHeight = "38px", + imageWidth = undefined, + color = "#14b2e2", + speed = 1, + className = null, +}) => { + const barStyle = { + transformOrigin: "0 100%", + animation: `GDC-pump ${baseAnimationDuration / speed}s infinite`, + fill: color, + }; + + const barStyle1 = { + ...barStyle, + animationDelay: `${(baseAnimationDuration / speed) * (-2 / 3)}s`, + }; + + const barStyle2 = { + ...barStyle, + animationDelay: `${baseAnimationDuration / speed / -3}s`, + }; + + return ( +
+ + + + + + + {label ?

{label}

: null} +
+ ); +}; + +export default CustomLoading; diff --git a/cgra-sso/src/components/CustomLoading.module.scss b/cgra-sso/src/components/CustomLoading.module.scss new file mode 100644 index 00000000..e0c7124b --- /dev/null +++ b/cgra-sso/src/components/CustomLoading.module.scss @@ -0,0 +1,19 @@ +@import "../theme.scss"; + +.CustomLoading { + display: flex; + text-align: center; + flex-direction: column; + align-content: center; + justify-content: center; +} + +.Inline { + display: inline-flex; +} + +.SVG { + max-height: 100%; + max-width: 100%; + flex: 0 1 auto; +} diff --git a/cgra-sso/src/components/Footer.js b/cgra-sso/src/components/Footer.js new file mode 100644 index 00000000..1501a2f5 --- /dev/null +++ b/cgra-sso/src/components/Footer.js @@ -0,0 +1,63 @@ +import React from "react"; + +import styles from "./Footer.module.scss"; + +import githubUri from "../media/github-logo.png"; +import stackOverflowUri from "../media/stack-overflow-logo.png"; +import twitterUri from "../media/twitter-logo.png"; +import npmUri from "../media/npm-logo.png"; + +const SocialItem = ({ link, title, imageUri }) => ( + + + {title} + +); + +const Footer = () => { + return ( + + ); +}; + +export default Footer; diff --git a/cgra-sso/src/components/Footer.module.scss b/cgra-sso/src/components/Footer.module.scss new file mode 100644 index 00000000..a4aa33e1 --- /dev/null +++ b/cgra-sso/src/components/Footer.module.scss @@ -0,0 +1,54 @@ +.Footer { + font-size: 15px; + line-height: 24px; + background: rgba(89, 98, 107, 0.11); + color: #6d7680; + text-align: center; + + &Link { + color: inherit; + text-decoration: none; + + &:hover, + &:visited, + &:active, + &:focus { + color: inherit; + text-decoration: underline; + } + } +} + +.FooterLinks { + padding: 20px; + + &Header { + font-size: 1.5rem; + font-weight: 500; + margin: 1.5rem 0; + } +} + +.Social { + display: inline-block; + border-top: 1px solid rgba(109, 118, 128, 0.5); + margin: 0 auto; + padding: 1.5rem 0 0; + + .FooterLink { + margin-left: 10px; + margin-right: 10px; + } + + img { + height: 1.875rem; + margin-right: 0.6rem; + vertical-align: middle; + } +} + +.Copyright { + padding: 1rem 20px; + color: rgba(255, 255, 255, 0.8); + background-color: #404040; +} diff --git a/cgra-sso/src/components/Header/Aside.js b/cgra-sso/src/components/Header/Aside.js new file mode 100644 index 00000000..d18385be --- /dev/null +++ b/cgra-sso/src/components/Header/Aside.js @@ -0,0 +1,32 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import cx from "classnames"; + +import { useAuth } from "../../contexts/Auth"; +import { AuthStatus } from "../../contexts/Auth/state"; +import InlineLoading from "../InlineLoading"; + +import styles from "./Header.module.scss"; + +const Aside = () => { + const { authStatus } = useAuth(); + const { AUTHORIZED, LOGGING_IN, LOGGING_OUT, AUTHORIZING } = AuthStatus; + + return ( +
+ {[LOGGING_IN, LOGGING_OUT, AUTHORIZING].includes(authStatus) ? ( + + ) : authStatus === AUTHORIZED ? ( + + Logout + + ) : ( + + Login + + )} +
+ ); +}; + +export default Aside; diff --git a/cgra-sso/src/components/Header/Header.js b/cgra-sso/src/components/Header/Header.js new file mode 100644 index 00000000..0cbe60d0 --- /dev/null +++ b/cgra-sso/src/components/Header/Header.js @@ -0,0 +1,99 @@ +import React, { useState, useEffect, useCallback } from "react"; +import throttle from "lodash/throttle"; + +import { useAuth } from "../../contexts/Auth"; +import { AuthStatus } from "../../contexts/Auth/state"; +// uncomment the next line to use WorkspacePicker +// import WorkspacePicker from "../controls/WorkspacePicker"; + +import Logo from "./Logo"; +import Links from "./Links"; +import Aside from "./Aside"; + +import styles from "./Header.module.scss"; + +const VerticalDelimiter = ({ ...restProps }) =>
; + +// a wrapper for items visible to authenticated users only (e.g. WorkspacePicker) +const ProtectedItems = ({ children }) => { + const { authStatus } = useAuth(); + if (authStatus !== AuthStatus.AUTHORIZED) return null; + return <>{children || null}; +}; + +const BurgerMenu = () => { + const [isOpen, setIsOpen] = useState(false); + return ( +
+ + {isOpen && ( +
+ + + {/* Uncomment these lines to add a workspace picker into the burger menu */} + {/*
+ +
*/} +
+
+ )} +
+ ); +}; + +const Header = () => { + const [windowWidth, setWindowWidth] = useState(window ? window.innerWidth : null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleResize = useCallback( + throttle(() => { + if (window) { + setWindowWidth(window.innerWidth); + } + }, 200), + [], + ); + + useEffect(() => { + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [handleResize]); + + // You need to set this manually to the smallest window width that can still fit all menu items + const isTooSmall = windowWidth && windowWidth < 666; + return ( + + ); +}; + +export default Header; diff --git a/cgra-sso/src/components/Header/Header.module.scss b/cgra-sso/src/components/Header/Header.module.scss new file mode 100644 index 00000000..2729d756 --- /dev/null +++ b/cgra-sso/src/components/Header/Header.module.scss @@ -0,0 +1,121 @@ +@import "../../theme.scss"; +@import "../../mixins.scss"; + +.Header { + position: fixed; + top: 0px; + right: 0px; + left: 0px; + min-height: 4em; + display: flex; + flex-direction: row; + align-items: stretch; + z-index: 100; + padding: 0; + color: #4a4a4a; + background: #fdfdfd; + border-bottom: 1px solid $color-active; +} + +.Aside { + flex: 1 0 auto; + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: flex-end; + padding-right: 10px; +} + +.Logo { + font-weight: bold; + text-transform: uppercase; +} + +.LogoImage { + height: 35px; +} + +.VerticalDelimiter { + width: 2px; + background-color: $color-border; + align-self: stretch; + margin: $spacing 0; +} + +.Centered { + margin: 0 $spacing / 2; + padding: 0 $spacing; + display: flex; + flex: 0 0 auto; + flex-direction: column; + justify-content: center; +} + +.Link { + margin: 0 $spacing / 2; + padding: 0 $spacing; + display: flex; + flex: 0 0 auto; + flex-direction: column; + justify-content: center; + text-decoration: none; + color: rgba(0, 0, 0, 0.9); + font-size: 15px; + border-bottom: 5px solid transparent; + transition: $transition-fast; + + &:hover, + &:visited, + &:active, + &:focus { + color: inherit; + border-color: rgb(70, 78, 86); + } + + &Active, + &Active:hover, + &Active:visited { + border-color: $color-active; + } +} + +.BurgerMenu { + display: flex; + flex-direction: column; + justify-content: center; + position: relative; +} + +.BurgerToggle { + @include reset-appearance; + margin-left: $spacing; + font-size: 1.5em; + margin-top: -4px; + width: 2em; + height: 2em; + text-align: center; + cursor: pointer; + border-radius: $border-radius; + + &:hover { + background-color: $color-accent-lighter; + } +} + +.BurgerContent { + position: fixed; + left: 0; + top: 64px; + min-width: 230px; + max-width: 100vw; + background-color: $color-paper; + z-index: 1000; + padding-bottom: $spacing; + @include box-shadow; + + .Link, + .Centered { + padding-bottom: $spacing/4; + margin-top: $spacing; + } +} diff --git a/cgra-sso/src/components/Header/Links.js b/cgra-sso/src/components/Header/Links.js new file mode 100644 index 00000000..bb82d4ba --- /dev/null +++ b/cgra-sso/src/components/Header/Links.js @@ -0,0 +1,24 @@ +import React from "react"; +import cx from "classnames"; +import { NavLink } from "react-router-dom"; + +import styles from "./Header.module.scss"; + +const Links = () => { + return ( + <> + + Welcome + + + Home + + + ); +}; + +export default Links; diff --git a/cgra-sso/src/components/Header/Logo.js b/cgra-sso/src/components/Header/Logo.js new file mode 100644 index 00000000..078ef537 --- /dev/null +++ b/cgra-sso/src/components/Header/Logo.js @@ -0,0 +1,17 @@ +import React from "react"; +import cx from "classnames"; +import { NavLink } from "react-router-dom"; + +import { appName } from "../../constants"; + +import styles from "./Header.module.scss"; + +const Logo = () => { + return ( + + {appName} + + ); +}; + +export default Logo; diff --git a/cgra-sso/src/components/Header/tests/Logo.spec.js b/cgra-sso/src/components/Header/tests/Logo.spec.js new file mode 100644 index 00000000..141a6eb1 --- /dev/null +++ b/cgra-sso/src/components/Header/tests/Logo.spec.js @@ -0,0 +1,12 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import Logo from "../Logo"; +import { appName } from "../../../constants"; + +describe("Logo component", () => { + it("should render correctly app name", () => { + const wrapper = shallow(); + expect(wrapper.children().text()).toEqual(appName); + }); +}); diff --git a/cgra-sso/src/components/InlineLoading.js b/cgra-sso/src/components/InlineLoading.js new file mode 100644 index 00000000..5f2609a5 --- /dev/null +++ b/cgra-sso/src/components/InlineLoading.js @@ -0,0 +1,7 @@ +import React from "react"; + +import CustomLoading from "./CustomLoading"; + +const InlineLoading = () => ; + +export default InlineLoading; diff --git a/cgra-sso/src/components/Page.js b/cgra-sso/src/components/Page.js new file mode 100644 index 00000000..8018006b --- /dev/null +++ b/cgra-sso/src/components/Page.js @@ -0,0 +1,23 @@ +import React from "react"; +import Helmet from "react-helmet"; +import cx from "classnames"; + +import Header from "./Header/Header"; +import Footer from "./Footer"; + +import styles from "./Page.module.scss"; + +const Page = ({ children, className = null, mainClassName = null, title = "GoodData App" }) => { + return ( +
+ + {title} + +
+
{children}
+
+
+ ); +}; + +export default Page; diff --git a/cgra-sso/src/components/Page.module.scss b/cgra-sso/src/components/Page.module.scss new file mode 100644 index 00000000..2d76fd62 --- /dev/null +++ b/cgra-sso/src/components/Page.module.scss @@ -0,0 +1,28 @@ +@import "../theme.scss"; + +.Page { + display: flex; + flex-direction: column; + align-items: stretch; + min-height: 100vh; +} + +.Main { + flex: 1 0 auto; + padding: $spacing * 2 + 56px $spacing $spacing * 2 $spacing; + max-width: 1200px; + width: calc(100% - (#{$spacing} * 2)); + margin: 0 auto; +} + +.VerticalCenter { + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; +} + +.Inverse { + background-color: $color-paper-inverse; + color: $color-text-inverse; +} diff --git a/cgra-sso/src/components/controls/Button.js b/cgra-sso/src/components/controls/Button.js new file mode 100644 index 00000000..51801463 --- /dev/null +++ b/cgra-sso/src/components/controls/Button.js @@ -0,0 +1,14 @@ +import React from "react"; +import cx from "classnames"; + +import styles from "./Button.module.scss"; + +const Button = ({ className = null, type = "button", active = false, children, ...restProps }) => { + return ( + + ); +}; + +export default Button; diff --git a/cgra-sso/src/components/controls/Button.module.scss b/cgra-sso/src/components/controls/Button.module.scss new file mode 100644 index 00000000..6e8e6ac1 --- /dev/null +++ b/cgra-sso/src/components/controls/Button.module.scss @@ -0,0 +1,26 @@ +@import "../../theme.scss"; +@import "../../mixins.scss"; + +.Button { + @include reset-appearance; + min-width: calc(1em + #{$spacing/2}); + padding: 0 ($spacing/4); + text-align: center; + border-radius: $border-radius; + cursor: pointer; + color: $color-accent; + transition: 0.3 background-color ease, 0.3 color ease; + background: $color-paper; + + &.Active { + background-color: $color-accent; + color: $color-paper; + } + + &:hover, + :focus, + :active { + background-color: $color-accent-lighter; + color: $color-accent; + } +} diff --git a/cgra-sso/src/components/controls/Input.js b/cgra-sso/src/components/controls/Input.js new file mode 100644 index 00000000..44e1b41a --- /dev/null +++ b/cgra-sso/src/components/controls/Input.js @@ -0,0 +1,12 @@ +import React from "react"; +import cx from "classnames"; + +import styles from "./Input.module.scss"; + +const Input = ({ className = null, type = "text", hasError = false, ...restProps }) => { + return ( + + ); +}; + +export default Input; diff --git a/cgra-sso/src/components/controls/Input.module.scss b/cgra-sso/src/components/controls/Input.module.scss new file mode 100644 index 00000000..3d7b5c17 --- /dev/null +++ b/cgra-sso/src/components/controls/Input.module.scss @@ -0,0 +1,23 @@ +@import "../../theme.scss"; +@import "../../mixins.scss"; + +.Input { + @include reset-appearance; + width: 100%; + display: block; + font-size: inherit; + border-bottom: 2px solid $color-grey; + + &::placeholder { + color: $color-grey; + } + + &:hover, + &:focus { + border-color: $color-primary; + } +} + +.Error { + border-color: transparentize($color-error, 0.5); +} diff --git a/cgra-sso/src/components/controls/Label.js b/cgra-sso/src/components/controls/Label.js new file mode 100644 index 00000000..ab7af0f4 --- /dev/null +++ b/cgra-sso/src/components/controls/Label.js @@ -0,0 +1,14 @@ +import React from "react"; +import cx from "classnames"; + +import styles from "./Label.module.scss"; + +const Label = ({ className = null, type = "text", children, hasError = false, ...restProps }) => { + return ( + + ); +}; + +export default Label; diff --git a/cgra-sso/src/components/controls/Label.module.scss b/cgra-sso/src/components/controls/Label.module.scss new file mode 100644 index 00000000..ca8a6e56 --- /dev/null +++ b/cgra-sso/src/components/controls/Label.module.scss @@ -0,0 +1,13 @@ +@import "../../theme.scss"; + +.Label { + display: block; + color: $color-text-muted; + font-size: inherit; + text-transform: uppercase; + font-weight: 500; +} + +.Error { + color: desaturate($color-error, 25%); +} diff --git a/cgra-sso/src/components/controls/WorkspacePicker.js b/cgra-sso/src/components/controls/WorkspacePicker.js new file mode 100644 index 00000000..8dfab936 --- /dev/null +++ b/cgra-sso/src/components/controls/WorkspacePicker.js @@ -0,0 +1,62 @@ +import React from "react"; +import last from "lodash/last"; + +import InlineLoading from "../InlineLoading"; +import { useWorkspace } from "../../contexts/Workspace"; +import { useWorkspaceList } from "../../contexts/WorkspaceList"; + +import styles from "./WorkspacePicker.module.scss"; + +const getWorkspaceId = workspace => workspace && last(workspace.id.split("/")); + +const isInList = (workspaceId, workspaceList) => { + return ( + workspaceId && + workspaceList && + workspaceList.some(workspace => getWorkspaceId(workspace) === workspace.id) + ); +}; + +const workspaceOptions = workspaces => + workspaces.map(workspace => { + const constWorkspace = getWorkspaceId(workspace); + return ( + + ); + }); + +const WorkspacePicker = () => { + const { workspace, setWorkspace } = useWorkspace(); + const workspaceList = useWorkspaceList(); + + if (workspaceList.isLoading) return ; + + if (workspaceList.error) return
{"Error loading workspaces"}
; + + if (!workspaceList.data || !workspaceList.data.length) return
{"No workspaces available."}
; + + if (workspaceList.data.length === 1) + return
{workspaceList.data[0].title}
; + + return ( +
+ +
+ ); +}; + +export default WorkspacePicker; diff --git a/cgra-sso/src/components/controls/WorkspacePicker.module.scss b/cgra-sso/src/components/controls/WorkspacePicker.module.scss new file mode 100644 index 00000000..c1e78a92 --- /dev/null +++ b/cgra-sso/src/components/controls/WorkspacePicker.module.scss @@ -0,0 +1,36 @@ +@import "../../theme.scss"; + +$color-workspacepicker: rgba(75, 75, 75, 0.9); +$textsize: 15px; + +.WorkspacePickerContainer { + height: 100%; +} + +.WorkspacePicker { + width: 100%; + height: 100%; + font-size: $textsize; + cursor: pointer; + border-radius: 0; + background-color: #fdfdfd; + border: none; + color: $color-workspacepicker; + + &:hover, + &:visited, + &:active, + &:focus { + outline: none; + } + + /* For IE <= 11 */ + ::-ms-expand { + display: none; + } +} + +.OneWorkspace { + font-size: $textsize; + color: $color-workspacepicker; +} diff --git a/cgra-sso/src/components/controls/tests/Button.spec.js b/cgra-sso/src/components/controls/tests/Button.spec.js new file mode 100644 index 00000000..10badf3a --- /dev/null +++ b/cgra-sso/src/components/controls/tests/Button.spec.js @@ -0,0 +1,13 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import Button from "../Button"; + +describe("Button component", () => { + it("should render children", () => { + const childrenClassName = "children"; + const children =
; + const wrapper = shallow(