├── .circleci └── config.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── client ├── .dockerignore ├── .env ├── .gitignore ├── .storybook │ ├── addons.js │ ├── config.js │ └── preview-head.html ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── cypress.json ├── cypress │ ├── .gitignore │ ├── fixtures │ │ └── example.json │ ├── integration │ │ └── 1-add-new-note_spec.js │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ └── index.js ├── package-lock.json ├── package.json ├── public │ └── index.html ├── scripts │ └── setup.js ├── src │ ├── actions │ │ ├── auth.js │ │ ├── index.js │ │ ├── notes.js │ │ ├── ui.js │ │ └── users.js │ ├── components │ │ ├── App.js │ │ ├── ErrorDisplay.js │ │ ├── LoginPage.js │ │ ├── MainPage.js │ │ ├── Navbar.js │ │ ├── NewGroup.js │ │ ├── NewNote.js │ │ ├── Note.js │ │ ├── NoteLayout.css │ │ ├── NoteLayout.js │ │ ├── NoteList.css │ │ ├── NoteList.js │ │ ├── PrivateRoute.js │ │ ├── ShareGroup.js │ │ ├── ShareNote.js │ │ ├── ShareSelect.js │ │ └── UserProfile.js │ ├── constants │ │ ├── auth.js │ │ ├── index.js │ │ ├── notes.js │ │ ├── ui.js │ │ └── users.js │ ├── history.js │ ├── index.js │ ├── reducers │ │ ├── alert.js │ │ ├── auth.js │ │ ├── index.js │ │ ├── modals.js │ │ ├── notes.js │ │ └── users.js │ ├── services │ │ ├── auth.js │ │ ├── index.js │ │ ├── notes.js │ │ ├── users.js │ │ └── utils.js │ ├── store.js │ └── utils.js └── stories │ └── index.stories.js ├── k8s ├── client.yaml ├── ingress-controller-preprod.yaml ├── ingress.yaml ├── production-issuer.yaml ├── server.yaml └── staging-issuer.yaml ├── scripts ├── build-images.sh └── push-images.sh ├── server ├── .dockerignore ├── .gitignore ├── .realize.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── go.mod ├── go.sum ├── jwt.go ├── key.pem ├── main.go ├── main_test.go ├── notes-db │ └── .gitkeep ├── notes.go ├── public.pem └── users.go └── skaffold.yaml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build-server: 4 | docker: 5 | - image: circleci/golang:1.12 6 | working_directory: /home/circleci/notes 7 | steps: 8 | - checkout 9 | - attach_workspace: 10 | at: ./ 11 | - run: ( cd server && go test && go build . ) 12 | - persist_to_workspace: 13 | root: ./ 14 | paths: 15 | - ./server 16 | client-install: 17 | docker: 18 | - image: circleci/node:11 19 | working_directory: /home/circleci/notes 20 | steps: 21 | - checkout 22 | - attach_workspace: 23 | at: ./ 24 | - run: npm ci --prefix client 25 | - persist_to_workspace: 26 | root: ./ 27 | paths: 28 | - ./client 29 | create-app: 30 | docker: 31 | - image: circleci/node:11 32 | environment: 33 | DATAPEPS_API_HOST: preprod-api.datapeps.com 34 | steps: 35 | - attach_workspace: 36 | at: ./ 37 | - run: 38 | command: | 39 | cd server 40 | openssl genrsa -out key.pem 2048 41 | openssl rsa -in key.pem -outform PEM -pubout -out public.pem 42 | - run: node client/scripts/setup.js 43 | - run: npm run build --prefix client 44 | - persist_to_workspace: 45 | root: ./ 46 | paths: 47 | - ./client 48 | - ./server 49 | test-e2e: 50 | docker: 51 | - image: cypress/base:10 52 | environment: 53 | ## this enables colors in the output 54 | TERM: xterm 55 | steps: 56 | - attach_workspace: 57 | at: ./ 58 | - run: 59 | command: | 60 | ( cd server ; ./server & ) 61 | ( cd client ; npm i serve ; node_modules/.bin/serve -s build -l 3000 & ) 62 | cd client && node_modules/.bin/cypress install && node_modules/.bin/cypress run 63 | - store_artifacts: 64 | path: client/cypress/videos 65 | - store_artifacts: 66 | path: client/cypress/screenshots 67 | workflows: 68 | version: 2 69 | build: 70 | jobs: 71 | - build-server 72 | - client-install 73 | - create-app: 74 | requires: 75 | - build-server 76 | - client-install 77 | - test-e2e: 78 | requires: 79 | - create-app 80 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "authorizator", 4 | "cors", 5 | "crossorigin", 6 | "datapeps", 7 | "genrsa", 8 | "getall", 9 | "gonic", 10 | "gorm", 11 | "jinzhu", 12 | "js", 13 | "keypair", 14 | "minified", 15 | "nbsp", 16 | "openssl", 17 | "outform", 18 | "priv", 19 | "pubout", 20 | "qor", 21 | "sqlite", 22 | "unclip", 23 | "uncomment", 24 | "unscoped", 25 | "xmlns" 26 | ], 27 | "spellright.language": ["en"], 28 | "spellright.documentTypes": ["markdown", "latex", "plaintext"] 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 WALLIX 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | Notes is simple note-taking application, which serves as a demo for [DataPeps](https://datapeps.com). This application may also be a good starter for anyone interested in the Go/React stack. 4 | 5 | ![Mandatory screenshot](https://user-images.githubusercontent.com/33936597/50092430-604d7f00-020e-11e9-9284-7b2b142c7b5d.png) 6 | 7 | # Directory structure 8 | 9 | - `/server` contains a REST service built with Go and SQLite 10 | - `/client` contains a web client built with React 11 | 12 | Please refer to each directory README to build and run. 13 | 14 | # Adding DataPeps 15 | 16 | Notes was built as a tutorial for the implementention of [End-to-End Encryption (E2EE)](https://en.wikipedia.org/wiki/End-to-end_encryption) with [DataPeps](https://github.com/wallix/datapeps-sdk-js). Thanks to E2EE, Notes will be strongly protected with encryption performed directly on client devices. Anyone that can access servers legally (admins, ...) or not (attackers, ...) will not be able to read user information. 17 | 18 | _This branch already has DataPeps support built-in. What was done from the parent commit is detailed below._ 19 | 20 | Adding DataPeps support requires **no modification of the server code**. Only the `client/` needs to be updated. 21 | 22 | To add DataPeps support, awaiting the forthcoming blog post: 23 | 24 | 1. Checkout the datapeps branch `git checkout datapeps` 25 | 2. To review the datapeps integration `git diff master datapeps` 26 | 27 | There are [wonderful slides](https://github.com/wallix/notes/files/2686280/DataPeps.Notes.Demo.pdf) available as well. And if they're not, please tell us in the issues! 28 | 29 | # Warning 30 | 31 | Passwords are stored unencrypted in the database. It is not a problem when using Notes with DataPeps, but it should not be run as such without. 32 | 33 | # License 34 | 35 | Released under the Apache License 36 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile 3 | 4 | .env.local 5 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=https://demo-notes-api.datapeps.com 2 | REACT_APP_DATAPEPS_API=https://api.datapeps.com 3 | REACT_APP_DATAPEPS_APP_ID=demo-note 4 | REACT_APP_GROUP_SEED=E 5 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # production 7 | build 8 | 9 | # misc 10 | .DS_Store 11 | npm-debug.log 12 | 13 | .env.* 14 | -------------------------------------------------------------------------------- /client/.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /client/.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from "@storybook/react"; 2 | 3 | // automatically import all files ending in *.stories.js 4 | const req = require.context("../stories", true, /\.stories\.js$/); 5 | function loadStories() { 6 | req.keys().forEach(filename => req(filename)); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /client/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 7 | 33 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11-alpine 2 | 3 | WORKDIR /home/node 4 | 5 | ADD package.json . 6 | ADD package-lock.json . 7 | RUN npm ci --only=prod 8 | 9 | ADD . /home/node 10 | RUN npm run build 11 | 12 | FROM node:11-alpine 13 | RUN npm install -g serve 14 | COPY --from=0 /home/node/build /build 15 | 16 | ENTRYPOINT [ "serve", "-s", "build" ] 17 | -------------------------------------------------------------------------------- /client/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 WALLIX 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | A React/redux client for [demo-note-server](https://github.com/wallix/demo-note-server). 4 | This project will be part of the standard WALLIX DataPeps demo. 5 | 6 | ## Available Scripts 7 | 8 | In the project directory, you can run: 9 | 10 | ### `npm start` 11 | 12 | Runs the app in the development mode.
13 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 14 | 15 | The page will reload if you make edits.
16 | You will also see any lint errors in the console. 17 | 18 | ### `npm run build` 19 | 20 | Builds the app for production to the `build` folder.
21 | It correctly bundles React in production mode and optimizes the build for the best performance. 22 | 23 | The build is minified and the filenames include the hashes.
24 | Your app is ready to be deployed! 25 | 26 | ### `npm run eject` 27 | 28 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 29 | 30 | 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. 31 | 32 | 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. 33 | 34 | 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. 35 | 36 | # About 37 | 38 | Licensed under the Apache license. 39 | 40 | (c) WALLIX, written by Henri Binsztok. 41 | -------------------------------------------------------------------------------- /client/TODO.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## Before DataPeps 4 | 5 | - Use `react-router` to implement `/login` DONE 6 | - Add sign in DONE 7 | - Get notes from server DONE 8 | - Post note to server DONE 9 | - Actual IDs from server DONE 10 | - Implement soft-delete DONE 11 | - Implement password update DONE 12 | - Implement user subscribe DONE 13 | - Note Sharing WIP 14 | - Cleanup code, rewrite w/ best practices WIP 15 | 16 | ## DataPeps 17 | 18 | - Add DataPeps SDK DONE 19 | - Integrate DataPeps application changes 20 | - User account "migration" 21 | - Share Notes with DataPeps: Left as an exercise to the reader 22 | 23 | # Bugs 24 | 25 | # UI-UX 26 | 27 | - Create note in Modal from 'New Note +' button in NavBar DONE 28 | - Align button in NavBar DONE 29 | - Delete button (x) in top right only DONE 30 | - 3 columns (depending on width) DONE 31 | - Better and more error messages 32 | 33 | # Source code 34 | 35 | - Rename Todo to Note DONE 36 | - Rename completed to IsDeleted DONE 37 | - Remove Formik DONE 38 | - Remove distinction between containers and components DONE 39 | - Use async/await WIP 40 | 41 | - Use 'import PropTypes from "prop-types";' MAYBE 42 | - Write tests in .spec.js files MAYBE 43 | -------------------------------------------------------------------------------- /client/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "viewportWidth": 1000, 4 | "viewportHeight": 800 5 | } 6 | -------------------------------------------------------------------------------- /client/cypress/.gitignore: -------------------------------------------------------------------------------- 1 | screenshots/ 2 | videos/ 3 | -------------------------------------------------------------------------------- /client/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /client/cypress/integration/1-add-new-note_spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const crypto = require("crypto"), 4 | shasum = crypto.createHash("sha1"); 5 | 6 | shasum.update(new Date().getTime() + ""); 7 | 8 | let seed = shasum.digest("hex").substring(0, 7); 9 | // Change seed by using command : CYPRESS_SEED="blabla" cypress open 10 | seed = Cypress.env("SEED") || seed; 11 | 12 | const username = `alice.${seed}`; 13 | const password = `password1234=?`; 14 | 15 | const formatedDate = new Intl.DateTimeFormat("en-US", { 16 | year: "numeric", 17 | month: "long", 18 | day: "numeric", 19 | hour: "numeric", 20 | minute: "numeric", 21 | second: "numeric", 22 | hour12: false 23 | }).format(new Date()); 24 | const encryptedContent = `Here is a new encrypted note -- ${formatedDate}`; 25 | const encryptedSharedContent = `Here is a new encrypted shared note -- ${formatedDate}`; 26 | 27 | describe(`Notes creation ${seed}`, function() { 28 | for (let name of ["alice", "bob", "charlie"]) { 29 | it(`${name} create an account`, function() { 30 | cy.visit("/"); 31 | 32 | let login = `${name}.${seed}`; 33 | 34 | cy.contains("Create an account").click(); 35 | cy.get('.modal-body [name="username"]').type(login); 36 | cy.get('[name="password1"]').type(password); 37 | cy.get('[name="password2"]').type(password); 38 | cy.get('[data-test="create"]').click(); 39 | 40 | // login 41 | cy.get('.panel-body [name="username"]').type(login); 42 | cy.get('[name="password"]').type(password); 43 | cy.get('[data-test="login-btn"]').click(); 44 | cy.get('[data-test="create"]').should("not.exist"); 45 | 46 | cy.visit("/"); 47 | cy.login(login, password); 48 | }); 49 | } 50 | 51 | it("Login error should appear and disapear", () => { 52 | const errorMsg = "incorrect Username or Password"; 53 | cy.visit("/"); 54 | cy.login("toto", "atat", false); 55 | cy.contains(errorMsg).should("exist"); 56 | cy.get(".alert").click(); 57 | cy.login(`alice.${seed}`, password); 58 | cy.contains("div.alert", errorMsg).should("not.exist"); 59 | }); 60 | 61 | it("alice sign in an create new note", function() { 62 | cy.visit("/"); 63 | 64 | cy.get('[name="username"]').type(username); 65 | cy.get('[name="password"]').type(password); 66 | cy.get('[data-test="login-btn"]').click(); 67 | 68 | // cy.get("div.modal-content", { timeout: 30000 }).then(modalNewPassword => { 69 | // if (modalNewPassword.find('[name="password1"]').length > 0) { 70 | // cy.get('[name="password1"]').type(password2); 71 | // cy.get('[name="password2"]').type(password2); 72 | // cy.get('[data-test="create"]').click(); 73 | // } 74 | // }); 75 | 76 | // Create new note 77 | cy.contains("New Note").click(); 78 | cy.get('[name="title"]').type("New note encrypted"); 79 | cy.get('[name="content"]').type(encryptedContent); 80 | cy.get('[data-test="save"]').click(); 81 | 82 | for (let content of [encryptedContent]) { 83 | cy.contains("div.panel-body", content, { timeout: 20000 }).should( 84 | "exist" 85 | ); 86 | } 87 | 88 | cy.get("#basic-nav-dropdown").click(); 89 | cy.contains("Logout").click(); 90 | }); 91 | 92 | // it("Alice sign with her old password and get an error", function() { 93 | // cy.visit("/"); 94 | 95 | // cy.get('[name="username"]').type(username); 96 | // cy.get('[name="password"]').type(password); 97 | // cy.get('[data-test="login-btn"]').click(); 98 | 99 | // cy.contains("div.alert-danger", "Incorrect Password", { 100 | // timeout: 30000 101 | // }).should("exist"); 102 | // }); 103 | 104 | it("alice sign in and find her note", function() { 105 | cy.visit("/"); 106 | 107 | cy.login(username, password); 108 | 109 | cy.contains("div.panel-body", encryptedContent, { 110 | timeout: 20000 111 | }).should("exist"); 112 | 113 | // cy.get("#basic-nav-dropdown", { timeout: 10000 }).click(); 114 | // cy.contains("Logout").click(); 115 | }); 116 | }); 117 | 118 | describe(`Notes sharing ${seed}`, function() { 119 | it(`alice.${seed} share a new note`, function() { 120 | cy.visit("/"); 121 | 122 | cy.login(`alice.${seed}`, password); 123 | 124 | // Test if precedent title exists 125 | cy.contains("div", "New note encrypted", { 126 | timeout: 10000 127 | }).should("exist"); 128 | 129 | // In case of lots of notes, I need to wait them to be all decrypted 130 | // cy.get("li.list-group-item", { timeout: 30000 }).each(() => { 131 | // cy.wait(800); 132 | // }); 133 | 134 | cy.contains("button", "New Note", { 135 | timeout: 20000 136 | }).click(); 137 | 138 | cy.get('[name="title"]').type("New note encrypted and shared"); 139 | cy.get('[name="content"]').type(encryptedSharedContent); 140 | 141 | const shareWith = `charlie.${seed}`; 142 | 143 | cy.shareWith(shareWith); 144 | cy.contains("Save").click(); 145 | 146 | cy.contains(".modal-footer", "Save", { timeout: 60000 }).should( 147 | "not.exist" 148 | ); 149 | 150 | // New note should appear and use .shared css class 151 | cy.contains("div.panel-body", encryptedSharedContent, { timeout: 20000 }) 152 | .parentsUntil("li") 153 | .find(".shared"); 154 | }); 155 | 156 | it(`charlie.${seed} find his notes`, function() { 157 | cy.visit("/"); 158 | 159 | cy.login(`charlie.${seed}`, password); 160 | 161 | // Test if precedent title exists 162 | cy.contains("div", "New note encrypted and shared", { 163 | timeout: 10000 164 | }).should("exist"); 165 | cy.contains("div.panel-body", encryptedSharedContent, { 166 | timeout: 20000 167 | }).should("exist"); 168 | }); 169 | 170 | it("alice sign in and extends share with bob", function() { 171 | cy.visit("/"); 172 | 173 | cy.login(username, password); 174 | 175 | cy.contains("div.panel-body", encryptedSharedContent, { timeout: 20000 }) 176 | .parentsUntil("li") 177 | .find(".shared") 178 | .parent() 179 | .click(); 180 | 181 | // Search bob 182 | const shareWith = `bob.${seed}`; 183 | 184 | cy.shareWith(shareWith); 185 | 186 | cy.contains("Save").click(); 187 | cy.contains("Save").should("not.exist"); 188 | 189 | // Click on shared button 190 | cy.contains("div.panel-body", encryptedSharedContent, { timeout: 20000 }) 191 | .parentsUntil("li") 192 | .find(".shared") 193 | .parent() 194 | .click(); 195 | 196 | cy.contains("span", "bob", { timeout: 5000 }).should("exist"); 197 | cy.contains("Cancel").click(); 198 | }); 199 | 200 | it(`bob.${seed} find his notes`, function() { 201 | cy.visit("/"); 202 | 203 | cy.login(`bob.${seed}`, password); 204 | 205 | cy.contains("div.panel-body", encryptedSharedContent, { 206 | timeout: 10000 207 | }).should("exist"); 208 | }); 209 | }); 210 | 211 | describe(`Sharing with groups ${seed}`, () => { 212 | const group1name = `Group test -- 🎲 ${Math.floor(Math.random() * 100)}`; 213 | 214 | const notes = new Array(2).fill(1).map((x, idx) => ({ 215 | title: `Group note #${idx + 1}`, 216 | content: `Note #${idx + 217 | 1} group ${group1name} containing text -- ${formatedDate}` 218 | })); 219 | 220 | it(`alice ${seed} create a group`, () => { 221 | cy.visit("/"); 222 | cy.login(username, password); 223 | cy.get('[data-test="new-group"]').click(); 224 | cy.get(".modal-body").within(() => { 225 | cy.get("input:first").should("have.attr", "placeholder", "Name"); 226 | cy.get("input:first").type(group1name); 227 | cy.shareWith(`bob.${seed}`); 228 | cy.shareWith(`charlie.${seed}`); 229 | }); 230 | 231 | cy.get('[data-test="save"]').click(); 232 | cy.contains(group1name).click(); 233 | 234 | for (let note of notes) { 235 | cy.contains("button", "New Note", { 236 | timeout: 20000 237 | }).click(); 238 | cy.get(".modal-body").within(() => { 239 | cy.get("input").should("have.attr", "placeholder", "Title"); 240 | cy.get("input").type(note.title); 241 | cy.get("textarea").should("have.attr", "placeholder", "Content"); 242 | cy.get("textarea").type(note.content); 243 | }); 244 | cy.contains("Save").click(); 245 | cy.contains(".panel-body", note.content, { timeout: 30000 }).should( 246 | "exist" 247 | ); 248 | } 249 | }); 250 | 251 | it(`alice ${seed} access to the group`, () => { 252 | cy.visit("/"); 253 | cy.login(username, password); 254 | // Select the group 255 | cy.contains(group1name).click(); 256 | // Create notes 257 | 258 | for (let note of notes) { 259 | cy.contains(".panel-body", note.content, { timeout: 30000 }).should( 260 | "exist" 261 | ); 262 | } 263 | }); 264 | 265 | it(`bob ${seed} access to the group`, () => { 266 | cy.visit("/"); 267 | cy.login(`bob.${seed}`, password); 268 | // Select the group 269 | cy.contains(group1name).click(); 270 | // See the note 271 | cy.contains(".panel-body", notes[0].content, { timeout: 30000 }).should( 272 | "exist" 273 | ); 274 | }); 275 | 276 | it(`charlie ${seed} access to the group`, () => { 277 | cy.visit("/"); 278 | cy.login(`charlie.${seed}`, password); 279 | // Select the group 280 | cy.contains(group1name).click(); 281 | // See the note 282 | cy.contains(".panel-body", notes[0].content, { timeout: 30000 }).should( 283 | "exist" 284 | ); 285 | }); 286 | 287 | it(`alice ${seed} remove charlie from the group`, () => { 288 | cy.visit("/"); 289 | cy.login(username, password); 290 | // Select the group 291 | cy.contains(group1name) 292 | .parent() 293 | .within(() => { 294 | cy.get('[data-test="edit-group"]').click(); 295 | }); 296 | 297 | cy.contains(`charlie.${seed}`) 298 | .parent() 299 | .within(() => { 300 | cy.get("svg").click(); 301 | }); 302 | 303 | // cy.shareWith(`alice.${seed}`); 304 | // cy.shareWith(`bob.${seed}`); 305 | cy.contains("Save").click(); 306 | cy.contains(".modal-footer", "Save", { timeout: 5000 }).should("not.exist"); 307 | // Wait for the request to be executed 308 | cy.wait(2000); 309 | 310 | // Select the group 311 | // Note buggy on purpose for this point. Charlie is removed from the group only on Datapeps 312 | // cy.contains(group1name) 313 | // .parent() 314 | // .within(() => { 315 | // cy.get('[data-test="edit-group"]').click(); 316 | // }); 317 | // cy.contains(`charlie.${seed}`).should("not.exist"); 318 | }); 319 | 320 | it(`charlie ${seed} stop seeing the note`, () => { 321 | cy.visit("/"); 322 | cy.login(`charlie.${seed}`, password); 323 | // Select the group 324 | cy.contains(group1name).click(); 325 | cy.wait(5000); 326 | // Don't see the note 327 | cy.get(".panel-body").should("not.contain", notes[0].content); 328 | }); 329 | 330 | it(`Alice ${seed} delete the second group's note`, () => { 331 | cy.visit("/"); 332 | cy.login(username, password); 333 | // Select the group 334 | cy.contains(group1name).click(); 335 | 336 | cy.contains(".panel-body", notes[1].content, { timeout: 30000 }).should( 337 | "exist" 338 | ); 339 | cy.contains(notes[1].content) 340 | .parent(".panel") 341 | .within(() => { 342 | cy.get("button").click(); 343 | }); 344 | // Note should display in red and hide delete button 345 | cy.contains(notes[1].content) 346 | .parent(".panel") 347 | .within(() => { 348 | cy.get("button").should("not.exist"); 349 | }); 350 | // Force refresh, note should disapear 351 | cy.get('[data-test="refresh"]').click(); 352 | cy.contains(notes[1].content).should("not.exist"); 353 | }); 354 | it(`charlie ${seed} stop seeing the second note`, () => { 355 | cy.visit("/"); 356 | cy.login(`bob.${seed}`, password); 357 | // Select the group 358 | cy.contains(group1name).click(); 359 | 360 | cy.wait(5000); 361 | 362 | cy.contains(notes[0].content).should("exist"); 363 | 364 | // Don't see the second note 365 | cy.get(".panel-body").should("not.contain", notes[1].content); 366 | }); 367 | }); 368 | -------------------------------------------------------------------------------- /client/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /client/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | Cypress.Commands.add("login", (login, pw, should_success = true) => { 28 | cy.get('[name="username"]') 29 | .clear() 30 | .type(login); 31 | cy.get('[name="password"]') 32 | .clear() 33 | .type(pw); 34 | cy.get('[data-test="login-btn"]').click(); 35 | 36 | if (should_success) 37 | cy.contains("button", "New Note", { timeout: 20000 }).should("exist"); 38 | else 39 | cy.contains("div.alert", "incorrect Username or Password", { 40 | timeout: 20000 41 | }).should("exist"); 42 | }); 43 | 44 | Cypress.Commands.add("shareWith", shareWith => { 45 | cy.get("#ShareSelect > div > div:first-child").click(); 46 | cy.wait(500); 47 | cy.get("#ShareSelect input").type(shareWith, { force: true }); 48 | cy.wait(500); 49 | cy.get("#ShareSelect > div:nth-of-type(2) > div:nth-of-type(1)").should( 50 | "contain", 51 | shareWith 52 | ); 53 | cy.get("#ShareSelect > div:nth-of-type(2) > div:nth-of-type(1)").click(); 54 | }); 55 | -------------------------------------------------------------------------------- /client/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notes", 3 | "version": "0.0.1", 4 | "private": true, 5 | "devDependencies": { 6 | "@types/node": "^11.13.2", 7 | "cypress": "^3.2.0", 8 | "node-fetch": "^2.3.0", 9 | "@storybook/react": "^5.0.6", 10 | "@storybook/addon-actions": "^5.0.6", 11 | "@storybook/addon-links": "^5.0.6", 12 | "@storybook/addons": "^5.0.6", 13 | "@babel/core": "^7.4.3", 14 | "babel-loader": "^8.0.5" 15 | }, 16 | "dependencies": { 17 | "datapeps-sdk": "^1.0.4", 18 | "history": "^4.9.0", 19 | "long": "^4.0.0", 20 | "react": "^16.8.6", 21 | "react-bootstrap": "^0.32.4", 22 | "react-dom": "^16.8.6", 23 | "react-redux": "^7.0.1", 24 | "react-router-bootstrap": "^0.25.0", 25 | "react-router-dom": "^5.0.0", 26 | "react-scripts": "^2.1.8", 27 | "react-select": "^2.4.2", 28 | "redux": "^4.0.1", 29 | "redux-logger": "^3.0.6", 30 | "redux-thunk": "^2.3.0" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "build": "react-scripts build", 35 | "eject": "react-scripts eject", 36 | "test": "cypress run", 37 | "storybook": "start-storybook -p 6006", 38 | "build-storybook": "build-storybook" 39 | }, 40 | "browserslist": [ 41 | ">0.2%", 42 | "not dead", 43 | "not ie <= 11", 44 | "not op_mini all" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Notes 7 | 8 | 14 | 15 | 41 | 42 | 43 | 44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /client/scripts/setup.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | /// 3 | if (global["fetch"] === undefined) { 4 | global["fetch"] = require("node-fetch"); 5 | } 6 | if (global["Headers"] === undefined) { 7 | global["Headers"] = require("node-fetch").Headers; 8 | } 9 | 10 | const DataPeps = require("datapeps-sdk"); 11 | const fs = require("fs"); 12 | const crypto = require("crypto"), 13 | shasum = crypto.createHash("sha1"); 14 | 15 | let APIHost = "https://api.datapeps.com"; 16 | 17 | if (process.env.DATAPEPS_API_HOST) { 18 | process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; 19 | APIHost = "https://" + process.env.DATAPEPS_API_HOST; 20 | DataPeps.configure(APIHost); 21 | } 22 | 23 | const login = process.env.DATAPEPS_LOGIN || "cypress.tester"; 24 | const password = process.env.DATAPEPS_PASSWORD || "Azertyuiop33"; 25 | 26 | const createApp = async () => { 27 | const key = fs.readFileSync(__dirname + "/../../server/public.pem", { 28 | encoding: "utf-8" 29 | }); 30 | shasum.update(key); 31 | const seed = shasum.digest("hex").substring(0, 8); 32 | 33 | const loginApp = "Notes." + seed; 34 | 35 | let session = await DataPeps.Session.login(login, password); 36 | let created = false; 37 | 38 | try { 39 | await new DataPeps.IdentityAPI(session).create( 40 | { 41 | login: loginApp, 42 | kind: "pepsswarm/3", 43 | name: `My Private Note (${seed})`, 44 | payload: new TextEncoder().encode( 45 | JSON.stringify({ 46 | description: `My Private Note #${seed}` 47 | }) 48 | ) 49 | }, 50 | { sharingGroup: [login] } 51 | ); 52 | created = true; 53 | } catch (e) { 54 | if ("kind" in e && e.kind === DataPeps.ServerError.IdentityAlreadyExists) { 55 | // Ok that's fine 56 | } else { 57 | throw e; 58 | } 59 | } 60 | 61 | const dotEnv = `REACT_APP_DATAPEPS_API=${APIHost}\nREACT_APP_API_URL=http://localhost:8080\nREACT_APP_DATAPEPS_APP_ID=${loginApp}`; 62 | 63 | fs.writeFileSync(__dirname + "/../.env.local", dotEnv); 64 | fs.writeFileSync(__dirname + "/../.env.test", dotEnv); 65 | 66 | await new DataPeps.ApplicationAPI(session).putConfig(loginApp, { 67 | jwt: { 68 | key: new TextEncoder().encode(key), 69 | signAlgorithm: DataPeps.ApplicationJWT.Algorithm.RS256, 70 | claimForLogin: "id" 71 | } 72 | }); 73 | 74 | return { login: loginApp, success: true, created }; 75 | }; 76 | 77 | createApp() 78 | .then(app => { 79 | console.log(JSON.stringify(app)); 80 | process.exit(); 81 | }) 82 | .catch(e => { 83 | console.error(e); 84 | process.exit(1); 85 | }); 86 | -------------------------------------------------------------------------------- /client/src/actions/auth.js: -------------------------------------------------------------------------------- 1 | import { authConstants, uiConstants } from "../constants"; 2 | import { authService } from "../services"; 3 | import { uiActions } from "./index"; 4 | 5 | function login(username, password) { 6 | return async dispatch => { 7 | dispatch(request()); 8 | try { 9 | dispatch(success(await authService.login(username, password))); 10 | dispatch(uiActions.clear()); 11 | } catch (error) { 12 | dispatch(failure(error)); 13 | dispatch(uiActions.error(error.message)); 14 | } 15 | }; 16 | 17 | function request() { 18 | return { type: authConstants.LOGIN_REQUEST }; 19 | } 20 | function success({ user, datapeps }) { 21 | return { type: authConstants.LOGIN_SUCCESS, user, datapeps }; 22 | } 23 | function failure(error) { 24 | return { type: authConstants.LOGIN_FAILURE, error }; 25 | } 26 | } 27 | 28 | function logout() { 29 | return async dispatch => { 30 | await authService.logout(); 31 | return dispatch({ type: authConstants.LOGOUT }); 32 | }; 33 | } 34 | 35 | function changePassword(p1, p2, modalName) { 36 | return async dispatch => { 37 | if (p1 === p2) { 38 | dispatch(request()); 39 | try { 40 | await authService.updatePassword(p1); 41 | dispatch(success()); 42 | } catch (e) { 43 | dispatch(failure(e)); 44 | } 45 | dispatch(uiActions.closeModal(modalName)); 46 | } 47 | }; 48 | 49 | function request() { 50 | return { type: authConstants.CHANGE_REQUEST }; 51 | } 52 | function success() { 53 | return { type: authConstants.CHANGE_SUCCESS }; 54 | } 55 | function failure(error) { 56 | return { type: authConstants.CHANGE_FAILURE, error }; 57 | } 58 | } 59 | 60 | function subscribe(username, p1, p2) { 61 | return dispatch => { 62 | if (p1 === p2) { 63 | dispatch(request()); 64 | dispatch(uiActions.closeModal(uiConstants.UserSubscribeModal)); 65 | authService 66 | .subscribe(username, p1) 67 | .then(ok => dispatch(success()), error => dispatch(failure(error))); 68 | } 69 | }; 70 | function request() { 71 | return { type: authConstants.SUBSCRIBE_REQUEST }; 72 | } 73 | function success() { 74 | return { type: authConstants.SUBSCRIBE_SUCCESS }; 75 | } 76 | function failure(error) { 77 | return { type: authConstants.SUBSCRIBE_FAILURE, error }; 78 | } 79 | } 80 | 81 | export const authActions = { 82 | login, 83 | logout, 84 | changePassword, 85 | subscribe 86 | }; 87 | -------------------------------------------------------------------------------- /client/src/actions/index.js: -------------------------------------------------------------------------------- 1 | export * from "./auth"; 2 | export * from "./notes"; 3 | export * from "./users"; 4 | export * from "./ui"; 5 | -------------------------------------------------------------------------------- /client/src/actions/notes.js: -------------------------------------------------------------------------------- 1 | import { notesConstants, uiConstants } from "../constants"; 2 | import { notesService } from "../services"; 3 | import { uiActions } from "./ui"; 4 | 5 | function addNote(Title, Content, sharedIds) { 6 | return (dispatch, getState) => { 7 | let note = { 8 | type: notesConstants.ADD_NOTE, 9 | Title, 10 | Content 11 | }; 12 | const group = getState().selectedGroup; 13 | if (group == null) { 14 | dispatch(postNote(note, sharedIds)); 15 | } else { 16 | dispatch(postNote(note, sharedIds, group.ID)); 17 | } 18 | dispatch(uiActions.closeModal(uiConstants.NewNoteModal)); 19 | }; 20 | } 21 | 22 | function shareNote(note, sharers) { 23 | return async dispatch => { 24 | try { 25 | await notesService.shareNote(note, sharers); 26 | const newNote = await notesService.getNote(note.ID); 27 | dispatch(success(newNote)); 28 | } catch (error) { 29 | dispatch(failure(error)); 30 | } 31 | dispatch(uiActions.closeModal(uiConstants.ShareNoteModal)); 32 | }; 33 | function success(note) { 34 | return { type: notesConstants.SHARE_SUCCESS, note }; 35 | } 36 | function failure(error) { 37 | return { type: notesConstants.SHARE_FAILURE, error }; 38 | } 39 | } 40 | 41 | function deleteNote(id) { 42 | return async (dispatch, getState) => { 43 | let del = { 44 | type: notesConstants.DELETE_NOTE, 45 | id 46 | }; 47 | const group = getState().selectedGroup; 48 | try { 49 | if (group) { 50 | await notesService.deleteGroupNote(id, group.ID); 51 | } else { 52 | await notesService.deleteNote(id); 53 | } 54 | dispatch(del); 55 | } catch (error) { 56 | dispatch(failure(error)); 57 | } 58 | }; 59 | function failure(error) { 60 | return { type: notesConstants.DEL_FAILURE, error }; 61 | } 62 | } 63 | 64 | function postNote(note, users, groupID) { 65 | return async dispatch => { 66 | dispatch(request()); 67 | 68 | try { 69 | const response = await notesService.postNote(note, groupID, users); 70 | note.ID = response.noteID; 71 | await notesService.shareNote(note, users); 72 | const newNote = await notesService.getNote(note.ID, groupID); 73 | dispatch(success(newNote)); 74 | } catch (error) { 75 | dispatch(failure(error)); 76 | } 77 | }; 78 | function request() { 79 | return { type: notesConstants.POST_REQUEST }; 80 | } 81 | function success(note) { 82 | return { type: notesConstants.POST_SUCCESS, note }; 83 | } 84 | function failure(error) { 85 | return { type: notesConstants.POST_FAILURE, error }; 86 | } 87 | } 88 | 89 | function getNotes(group) { 90 | return dispatch => { 91 | dispatch(request()); 92 | if (group == null) { 93 | getUserNotes(dispatch); 94 | } else { 95 | getGroupNotes(dispatch, group.ID); 96 | } 97 | }; 98 | 99 | function request() { 100 | return { type: notesConstants.GETALL_REQUEST }; 101 | } 102 | function success(notes) { 103 | return { type: notesConstants.GETALL_SUCCESS, notes }; 104 | } 105 | function failure(error) { 106 | return { type: notesConstants.GETALL_FAILURE, error }; 107 | } 108 | 109 | async function getUserNotes(dispatch) { 110 | try { 111 | let { notes } = await notesService.getNotes(); 112 | dispatch(success({ notes })); 113 | } catch (error) { 114 | dispatch(failure(error)); 115 | } 116 | } 117 | 118 | async function getGroupNotes(dispatch, groupID) { 119 | try { 120 | let { notes } = await notesService.getGroupNotes(groupID); 121 | dispatch(success({ notes })); 122 | } catch (error) { 123 | dispatch(failure(error)); 124 | } 125 | } 126 | } 127 | 128 | export const noteActions = { 129 | addNote, 130 | shareNote, 131 | deleteNote, 132 | getNotes 133 | }; 134 | -------------------------------------------------------------------------------- /client/src/actions/ui.js: -------------------------------------------------------------------------------- 1 | import { alertConstants, uiConstants } from "../constants"; 2 | 3 | export const uiActions = { 4 | success, 5 | error, 6 | clear, 7 | openModal, 8 | closeModal 9 | }; 10 | 11 | function success(message) { 12 | return { type: alertConstants.SUCCESS, message }; 13 | } 14 | 15 | function error(error) { 16 | let message = error; 17 | if (error && typeof message !== "string" && error.message != null) { 18 | message = error.message; 19 | } 20 | return { type: alertConstants.ERROR, message }; 21 | } 22 | 23 | function clear() { 24 | return { type: alertConstants.CLEAR }; 25 | } 26 | 27 | function openModal(id, payload) { 28 | return { 29 | type: uiConstants.OPEN_MODAL, 30 | id, 31 | payload 32 | }; 33 | } 34 | 35 | function closeModal(id) { 36 | return { 37 | type: uiConstants.CLOSE_MODAL, 38 | id 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /client/src/actions/users.js: -------------------------------------------------------------------------------- 1 | import { usersConstants, uiConstants, alertConstants } from "../constants"; 2 | import { usersService } from "../services"; 3 | import { uiActions } from "./ui"; 4 | import { noteActions } from "./notes"; 5 | 6 | function getGroups() { 7 | return async dispatch => { 8 | dispatch(request()); 9 | 10 | try { 11 | const response = await usersService.getGroups(); 12 | dispatch(success(response.groups)); 13 | } catch (error) { 14 | dispatch(failure(error)); 15 | } 16 | }; 17 | function request() { 18 | return { type: usersConstants.GETGROUPLIST_REQUEST }; 19 | } 20 | function success(groups) { 21 | return { type: usersConstants.GETGROUPLIST_SUCCESS, groups }; 22 | } 23 | function failure(error) { 24 | return { type: usersConstants.GETGROUPLIST_FAILURE, error }; 25 | } 26 | } 27 | 28 | function addGroup(group) { 29 | return dispatch => { 30 | dispatch(postGroup(group)); 31 | dispatch(uiActions.closeModal(uiConstants.NewGroupModal)); 32 | }; 33 | } 34 | 35 | function postGroup(group) { 36 | return async dispatch => { 37 | dispatch(request()); 38 | 39 | try { 40 | const response = await usersService.postGroup(group); 41 | dispatch(success({ ...group, ID: response.id })); 42 | } catch (error) { 43 | dispatch(failure(error)); 44 | } 45 | }; 46 | function request() { 47 | return { type: usersConstants.POSTGROUPLIST_REQUEST }; 48 | } 49 | function success(group) { 50 | return { type: usersConstants.POSTGROUPLIST_SUCCESS, group }; 51 | } 52 | function failure(error) { 53 | return { type: usersConstants.POSTGROUPLIST_FAILURE, error }; 54 | } 55 | } 56 | 57 | function selectGroup(group) { 58 | return async dispatch => { 59 | try { 60 | dispatch(success(group)); 61 | dispatch(noteActions.getNotes(group)); 62 | } catch (error) { 63 | dispatch(failure(error)); 64 | } 65 | }; 66 | function success(group) { 67 | return { 68 | type: 69 | group == null 70 | ? usersConstants.SELECT_MY_NOTES 71 | : usersConstants.SELECT_GROUP_NOTE, 72 | group 73 | }; 74 | } 75 | function failure(error) { 76 | return { error }; 77 | } 78 | } 79 | 80 | function refresh() { 81 | return async (dispatch, getState) => { 82 | try { 83 | const group = getState().selectedGroup; 84 | dispatch(success(group)); 85 | dispatch(noteActions.getNotes(group)); 86 | dispatch(getGroups()); 87 | } catch (error) { 88 | dispatch(failure(error)); 89 | } 90 | }; 91 | function success(group) { 92 | return { 93 | type: 94 | group == null 95 | ? usersConstants.SELECT_MY_NOTES 96 | : usersConstants.SELECT_GROUP_NOTE, 97 | group 98 | }; 99 | } 100 | function failure(error) { 101 | return { type: alertConstants.ERROR, error }; 102 | } 103 | } 104 | 105 | export const usersActions = { 106 | getGroups, 107 | addGroup, 108 | selectGroup, 109 | refresh 110 | }; 111 | -------------------------------------------------------------------------------- /client/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Router } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | 5 | import { uiActions } from "../actions"; 6 | import { history } from "../history"; 7 | 8 | // Components 9 | import PrivateRoute from "./PrivateRoute"; 10 | import MainPage from "./MainPage"; 11 | import ErrorDisplay from "./ErrorDisplay"; 12 | 13 | class App extends React.Component { 14 | constructor(props) { 15 | super(props); 16 | 17 | const { dispatch } = this.props; 18 | history.listen((location, action) => { 19 | // clear alert on location change 20 | dispatch(uiActions.clear()); 21 | }); 22 | } 23 | 24 | render() { 25 | return ( 26 |
27 | 28 | 29 |
30 |
31 | 32 |
33 |
34 |
38 | 44 | 45 | 46 | 47 | 48 | 49 |   50 | wallix/notes 51 | 52 |
53 |
54 |
55 |
56 |
57 | ); 58 | } 59 | } 60 | 61 | export default connect()(App); 62 | -------------------------------------------------------------------------------- /client/src/components/ErrorDisplay.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Modal } from "react-bootstrap"; 4 | import { uiActions } from "../actions"; 5 | 6 | const ErrorDisplay = ({ alert, dispatch }) => { 7 | return ( 8 | dispatch(uiActions.clear())} 11 | className={`alert ${alert.type}`} 12 | > 13 | {alert.message} 14 | 15 | ); 16 | }; 17 | 18 | function mapStateToProps(state) { 19 | const { alert } = state; 20 | return { 21 | alert 22 | }; 23 | } 24 | 25 | export default connect(mapStateToProps)(ErrorDisplay); 26 | -------------------------------------------------------------------------------- /client/src/components/LoginPage.js: -------------------------------------------------------------------------------- 1 | // Adapted from http://jasonwatmore.com/post/2017/12/07/react-redux-jwt-authentication-tutorial-example 2 | 3 | import React from "react"; 4 | import { connect } from "react-redux"; 5 | import { Button, Jumbotron, Panel } from "react-bootstrap"; 6 | 7 | import { uiConstants } from "../constants"; 8 | import { authActions, uiActions } from "../actions"; 9 | import UserProfile from "./UserProfile"; 10 | 11 | class LoginPage extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | 15 | // reset login status 16 | this.props.dispatch(authActions.logout()); 17 | 18 | this.state = { 19 | username: "", 20 | password: "", 21 | submitted: false 22 | }; 23 | 24 | this.handleChange = this.handleChange.bind(this); 25 | this.handleSubmit = this.handleSubmit.bind(this); 26 | } 27 | 28 | handleChange(e) { 29 | const { name, value } = e.target; 30 | this.setState({ [name]: value }); 31 | } 32 | 33 | handleSubmit(e) { 34 | e.preventDefault(); 35 | 36 | this.setState({ submitted: true }); 37 | const { username, password } = this.state; 38 | const { dispatch } = this.props; 39 | if (username && password) { 40 | dispatch(authActions.login(username, password)); 41 | } 42 | } 43 | 44 | render() { 45 | // const { loggingIn } = this.props; 46 | const { username, password, submitted } = this.state; 47 | return ( 48 |
49 | 50 | 51 | 52 |
53 |

Notes

54 |

55 | Notes is simple note-taking application, which serves as a demo 56 | for DataPeps. This client is 57 | implemented with React, while the accompanying REST service is 58 | built with Go. 59 |

60 |

61 | 70 |

71 |
72 |
73 |
74 | 75 | 76 | Login 77 | 78 | 79 |
80 |
85 | 86 | 94 | {submitted && !username && ( 95 |
Username is required
96 | )} 97 |
98 |
103 | 104 | 112 | {submitted && !password && ( 113 |
Password is required
114 | )} 115 |
116 |
117 | 120 |
121 |
122 |
123 |
124 |
125 |
126 | ); 127 | } 128 | } 129 | 130 | function mapStateToProps(state) { 131 | const { loggingIn } = state.auth; 132 | return { 133 | loggingIn 134 | }; 135 | } 136 | 137 | export default connect(mapStateToProps)(LoginPage); 138 | -------------------------------------------------------------------------------- /client/src/components/MainPage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | import { noteActions, usersActions } from "../actions"; 5 | import NoteList from "./NoteList"; 6 | import Navigation from "./Navbar"; 7 | import "./NoteList.css"; 8 | 9 | class MainPage extends React.Component { 10 | componentDidMount() { 11 | this.props.dispatch(noteActions.getNotes()); 12 | this.props.dispatch(usersActions.getGroups()); 13 | } 14 | 15 | render() { 16 | return ( 17 |
18 | 19 | 20 |
21 | ); 22 | } 23 | } 24 | 25 | function mapStateToProps(state) { 26 | return { 27 | user: state.auth.user, 28 | notes: state.notes, 29 | group: state.selectedGroup 30 | }; 31 | } 32 | 33 | export default connect(mapStateToProps)(MainPage); 34 | -------------------------------------------------------------------------------- /client/src/components/Navbar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { 4 | Button, 5 | Navbar, 6 | Nav, 7 | NavDropdown, 8 | MenuItem, 9 | Glyphicon 10 | } from "react-bootstrap"; 11 | 12 | import { parseJWT } from "../utils"; 13 | import { uiConstants, authConstants } from "../constants"; 14 | import { uiActions, usersActions } from "../actions"; 15 | import NewNote from "./NewNote"; 16 | import ShareNote from "./ShareNote"; 17 | import UserProfile from "./UserProfile"; 18 | 19 | class Navigation extends React.Component { 20 | render() { 21 | const { user } = this.props; 22 | // this should not fail... 23 | const username = user ? parseJWT(user.token).id : "User"; 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | Notes 33 | 34 | 35 | 36 | 37 | 38 | 47 | 56 | 57 | 86 | 87 | 88 |
89 | ); 90 | } 91 | } 92 | 93 | function mapStateToProps(state) { 94 | const { notes, auth } = state; 95 | const { user } = auth; 96 | return { 97 | user, 98 | notes 99 | }; 100 | } 101 | 102 | export default connect(mapStateToProps)(Navigation); 103 | -------------------------------------------------------------------------------- /client/src/components/NewGroup.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Modal, Button, Form, FormControl } from "react-bootstrap"; 4 | import ShareSelect from "./ShareSelect"; 5 | 6 | import { uiConstants } from "../constants"; 7 | import { uiActions, usersActions } from "../actions"; 8 | import { parseJWT } from "../utils"; 9 | 10 | class NewGroup extends React.Component { 11 | constructor(props, context) { 12 | super(props, context); 13 | 14 | this.changeName = this.changeName.bind(this); 15 | this.changeSharingGroup = this.changeSharingGroup.bind(this); 16 | this.validate = this.validate.bind(this); 17 | this.onAddGroup = this.onAddGroup.bind(this); 18 | 19 | this.state = { 20 | name: "", 21 | users: [] 22 | }; 23 | } 24 | 25 | changeName(e) { 26 | this.setState({ name: e.target.value }); 27 | } 28 | changeSharingGroup(list) { 29 | this.setState({ users: list }); 30 | } 31 | validate() { 32 | return this.state.name !== ""; 33 | } 34 | 35 | render() { 36 | const { modals, closeModal } = this.props; 37 | return ( 38 |
39 | closeModal(uiConstants.NewGroupModal)} 42 | > 43 | 44 | New Group 45 | 46 | 47 |
48 | 54 | 55 | 56 |
57 | 58 | 61 | 70 | 71 |
72 |
73 | ); 74 | } 75 | 76 | async onAddGroup() { 77 | const user = this.props.user; 78 | const username = user ? parseJWT(user.token).id : "User"; 79 | await this.props.addGroup({ 80 | name: this.state.name, 81 | users: [username].concat(this.state.users) 82 | }); 83 | } 84 | } 85 | 86 | function mapStateToProps(state) { 87 | return { 88 | modals: state.modals.modals, 89 | user: state.auth.user 90 | }; 91 | } 92 | const mapDispatchToProps = { 93 | ...uiActions, 94 | ...usersActions 95 | }; 96 | 97 | export default connect( 98 | mapStateToProps, 99 | mapDispatchToProps 100 | )(NewGroup); 101 | -------------------------------------------------------------------------------- /client/src/components/NewNote.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Modal, Button, Form, FormControl } from "react-bootstrap"; 4 | 5 | import ShareSelect from "./ShareSelect"; 6 | 7 | import { uiConstants } from "../constants"; 8 | import { noteActions, uiActions } from "../actions"; 9 | 10 | class NewNote extends React.Component { 11 | constructor(props, context) { 12 | super(props, context); 13 | 14 | this.changeTitle = this.changeTitle.bind(this); 15 | this.changeContent = this.changeContent.bind(this); 16 | this.changeProtection = this.changeProtection.bind(this); 17 | this.changeSharingGroup = this.changeSharingGroup.bind(this); 18 | this.validate = this.validate.bind(this); 19 | this.onAddNote = this.onAddNote.bind(this); 20 | 21 | this.state = { 22 | title: "", 23 | content: "", 24 | protected: true, 25 | sharingList: [] 26 | }; 27 | } 28 | 29 | changeTitle(e) { 30 | this.setState({ title: e.target.value }); 31 | } 32 | changeContent(e) { 33 | this.setState({ content: e.target.value }); 34 | } 35 | changeProtection(e) { 36 | this.setState({ protected: e.target.checked }); 37 | } 38 | changeSharingGroup(list) { 39 | this.setState({ sharingList: list }); 40 | } 41 | validate() { 42 | return this.state.title !== ""; 43 | } 44 | 45 | render() { 46 | const { modals, closeModal, group } = this.props; 47 | return ( 48 |
49 | closeModal(uiConstants.NewNoteModal)} 52 | > 53 | 54 | New Note 55 | 56 | 57 |
58 | 64 | 71 | {group != null ? null : ( 72 | 73 | )} 74 | 75 |
76 | 77 | 80 | 89 | 90 |
91 |
92 | ); 93 | } 94 | 95 | async onAddNote() { 96 | let title = this.state.title; 97 | let content = this.state.content; 98 | this.props.addNote(title, content, this.state.sharingList); 99 | } 100 | } 101 | 102 | const mapStateToProps = state => ({ 103 | modals: state.modals.modals, 104 | group: state.selectedGroup 105 | }); 106 | const mapDispatchToProps = { 107 | ...uiActions, 108 | ...noteActions 109 | }; 110 | 111 | export default connect( 112 | mapStateToProps, 113 | mapDispatchToProps 114 | )(NewNote); 115 | -------------------------------------------------------------------------------- /client/src/components/Note.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { noteActions, uiActions } from "../actions"; 4 | import { NoteLayout } from "./NoteLayout"; 5 | import { uiConstants } from "../constants"; 6 | 7 | class Note extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | Title: props.Title, 12 | Content: props.Content, 13 | style: "info" 14 | }; 15 | } 16 | 17 | render() { 18 | const { DeletedAt, ID, deleteNote, Users, group, Error } = this.props; 19 | const { Title, Content, style } = this.state; 20 | 21 | return ( 22 | { 33 | this.props.openModal(uiConstants.ShareNoteModal, { 34 | note: this.props 35 | }); 36 | }, 37 | group 38 | }} 39 | /> 40 | ); 41 | } 42 | } 43 | 44 | const mapStateToProps = state => ({ 45 | group: state.selectedGroup 46 | }); 47 | const mapDispatchToProps = { 48 | ...noteActions, 49 | ...uiActions 50 | }; 51 | 52 | export default connect( 53 | mapStateToProps, 54 | mapDispatchToProps 55 | )(Note); 56 | -------------------------------------------------------------------------------- /client/src/components/NoteLayout.css: -------------------------------------------------------------------------------- 1 | div.note-item .notshared { 2 | color: lightgray; 3 | } 4 | div.note-item .shared { 5 | color: #f0ad4e; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/components/NoteLayout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Alert, Panel, Button, ButtonGroup, Glyphicon } from "react-bootstrap"; 3 | import "./NoteLayout.css"; 4 | 5 | export const NoteLayout = ({ 6 | DeletedAt, 7 | ID, 8 | deleteNote, 9 | Title, 10 | Content, 11 | Error, 12 | style, 13 | Users, 14 | openShareModal, 15 | group 16 | }) => ( 17 | 18 | 19 | {Title} 20 | 21 | 22 | {Content} 23 | {Error && {Error}} 24 | 25 | {DeletedAt || ( 26 | 27 | 28 | {group != null ? null : ( 29 | 35 | )} 36 | 39 | 40 | 41 | )} 42 | 43 | ); 44 | -------------------------------------------------------------------------------- /client/src/components/NoteList.css: -------------------------------------------------------------------------------- 1 | .notes-sidebar .nav-item { 2 | border-radius: 10px; 3 | background-color: #f8f8f8; 4 | border-color: #e7e7e7; 5 | border-width: 1px; 6 | border-style: solid; 7 | margin: 15px 10px; 8 | } 9 | 10 | .notes-sidebar .nav-item.active { 11 | background-color: #d9edf7; 12 | border-color: #bce8f1; 13 | } 14 | 15 | .notes-sidebar .nav-link { 16 | text-align: center; 17 | position: relative; 18 | display: block; 19 | padding: 10px 15px; 20 | width: 100%; 21 | } 22 | 23 | .notes-sidebar .nav { 24 | background-color: #f8f8f8; 25 | border-color: #e7e7e7; 26 | border-width: 1px; 27 | border-style: solid; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/components/NoteList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Row, Col, Glyphicon } from "react-bootstrap"; 4 | 5 | import "./NoteList.css"; 6 | 7 | import Note from "./Note"; 8 | import { uiActions, usersActions } from "../actions"; 9 | import { uiConstants } from "../constants"; 10 | import NewGroup from "./NewGroup"; 11 | import ShareGroup from "./ShareGroup"; 12 | 13 | const NoteList = ({ notes, selectedGroup, groups, dispatch }) => { 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 |
    26 |
  • 27 | { 31 | dispatch(usersActions.selectGroup()); 32 | }} 33 | > 34 | My Notes 35 | 36 |
  • 37 |
38 |
    44 |
  • 45 | 46 | {groups.length} groups 47 | 48 |
  • 49 | {groups.map(group => ( 50 |
  • 58 | { 61 | dispatch(usersActions.selectGroup(group)); 62 | }} 63 | > 64 | {group.name} 65 | { 68 | dispatch(usersActions.refresh()); 69 | }} 70 | > 71 | { 74 | e.stopPropagation(); 75 | dispatch( 76 | uiActions.openModal(uiConstants.ShareGroupModal, { 77 | group 78 | }) 79 | ); 80 | }} 81 | glyph="edit" 82 | /> 83 | 84 | 85 |
  • 86 | ))} 87 |
  • 88 | { 91 | dispatch(uiActions.openModal(uiConstants.NewGroupModal)); 92 | }} 93 | data-test="new-group" 94 | > 95 | 96 | 97 |
  • 98 |
99 | 100 | 101 |
    102 | {notes.map(note => ( 103 |
  • 107 | 108 |
  • 109 | ))} 110 |
111 | 112 |
113 |
114 | ); 115 | }; 116 | 117 | const mapStateToProps = state => ({ 118 | notes: state.notes, 119 | groups: state.groups, 120 | selectedGroup: state.selectedGroup 121 | }); 122 | 123 | export default connect(mapStateToProps)(NoteList); 124 | -------------------------------------------------------------------------------- /client/src/components/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Route } from "react-router-dom"; 4 | 5 | import LoginPage from "./LoginPage"; 6 | 7 | const PrivateRoute = ({ component: Component, auth, ...rest }) => ( 8 | 11 | auth.user != null ? : 12 | } 13 | /> 14 | ); 15 | 16 | const mapStateToProps = state => ({ 17 | auth: state.auth 18 | }); 19 | 20 | export default connect(mapStateToProps)(PrivateRoute); 21 | -------------------------------------------------------------------------------- /client/src/components/ShareGroup.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Modal, Button, Form } from "react-bootstrap"; 4 | import ShareSelect from "./ShareSelect"; 5 | 6 | import { uiConstants } from "../constants"; 7 | import { uiActions } from "../actions"; 8 | import { usersService } from "../services"; 9 | 10 | class ShareGroup extends React.Component { 11 | constructor(props, context) { 12 | super(props, context); 13 | this.changeSharingGroup = this.changeSharingGroup.bind(this); 14 | this.onShareGroup = this.onShareGroup.bind(this); 15 | 16 | this.state = { 17 | sharingList: [] 18 | }; 19 | } 20 | 21 | changeSharingGroup(list) { 22 | this.setState({ sharingList: list }); 23 | } 24 | 25 | render() { 26 | const { modals, payload, closeModal } = this.props; 27 | if (payload == null) { 28 | return null; 29 | } 30 | const { group } = payload; 31 | if (group == null) { 32 | return null; 33 | } 34 | let currentSharer = []; 35 | if (group.users) { 36 | currentSharer = group.users.map(u => u.username); 37 | } 38 | return ( 39 |
40 | closeModal(uiConstants.ShareGroupModal)} 43 | > 44 | 45 | Edit group "{group.name}" 46 | 47 | 48 |
49 | ({ 51 | label: user, 52 | value: user 53 | }))} 54 | onChange={this.changeSharingGroup} 55 | /> 56 | 57 |
58 | 59 | 62 | 71 | 72 |
73 |
74 | ); 75 | } 76 | 77 | async onShareGroup() { 78 | usersService.shareGroup( 79 | this.props.payload.group.ID, 80 | this.state.sharingList 81 | ); 82 | this.props.closeModal(uiConstants.ShareGroupModal); 83 | } 84 | } 85 | 86 | const mapStateToProps = state => ({ 87 | modals: state.modals.modals, 88 | payload: state.modals.payload 89 | }); 90 | const mapDispatchToProps = { 91 | ...uiActions 92 | }; 93 | 94 | export default connect( 95 | mapStateToProps, 96 | mapDispatchToProps 97 | )(ShareGroup); 98 | -------------------------------------------------------------------------------- /client/src/components/ShareNote.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Modal, Button, Form } from "react-bootstrap"; 4 | import ShareSelect from "./ShareSelect"; 5 | 6 | import { uiConstants } from "../constants"; 7 | import { noteActions, uiActions } from "../actions"; 8 | 9 | class ShareNote extends React.Component { 10 | constructor(props, context) { 11 | super(props, context); 12 | this.changeSharingGroup = this.changeSharingGroup.bind(this); 13 | this.onShareNote = this.onShareNote.bind(this); 14 | 15 | this.state = { 16 | sharingList: [], 17 | users: [] 18 | }; 19 | } 20 | 21 | changeSharingGroup(list) { 22 | this.setState({ sharingList: list }); 23 | } 24 | 25 | render() { 26 | const { modals, closeModal } = this.props; 27 | 28 | let currentSharer = []; 29 | if ( 30 | this.props.payload && 31 | this.props.payload.note && 32 | this.props.payload.note.Users 33 | ) { 34 | currentSharer = this.props.payload.note.Users.map(u => u.username); 35 | } 36 | 37 | return ( 38 | closeModal(uiConstants.ShareNoteModal)} 41 | > 42 | 43 | Extends share of note with... 44 | 45 | 46 |

Shared with:

47 |
48 | {currentSharer.map(login => ( 49 | 50 | {login} 51 | 52 | ))} 53 |
54 |
55 | ({ 58 | // label: user, 59 | // value: user 60 | // }))} 61 | onChange={this.changeSharingGroup} 62 | /> 63 | 64 |
65 | 66 | 69 | 78 | 79 |
80 | ); 81 | } 82 | 83 | async onShareNote() { 84 | try { 85 | const { 86 | payload: { note } 87 | } = this.props; 88 | this.props.shareNote(note, this.state.sharingList); 89 | } catch (e) { 90 | console.log(e); 91 | } 92 | } 93 | } 94 | 95 | const mapStateToProps = state => ({ 96 | modals: state.modals.modals, 97 | payload: state.modals.payload, 98 | notes: state.notes 99 | }); 100 | const mapDispatchToProps = { 101 | ...uiActions, 102 | ...noteActions 103 | }; 104 | 105 | export default connect( 106 | mapStateToProps, 107 | mapDispatchToProps 108 | )(ShareNote); 109 | -------------------------------------------------------------------------------- /client/src/components/ShareSelect.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Select from "react-select"; 3 | import { usersService } from "../services"; 4 | 5 | class ShareSelect extends React.Component { 6 | constructor(props, context) { 7 | super(props, context); 8 | this.state = { 9 | users: [] 10 | }; 11 | this.handleInputChange = this.handleInputChange.bind(this); 12 | } 13 | 14 | render() { 15 | return ( 16 |