├── .env ├── client ├── .env ├── src │ ├── react-app-env.d.ts │ ├── config │ │ ├── index.ts │ │ ├── SimpleSchema.ts │ │ ├── conflictStrategy.ts │ │ └── clientConfig.ts │ ├── hooks │ │ ├── index.ts │ │ └── useSubscribeToMore.ts │ ├── pages │ │ ├── index.ts │ │ ├── OfflineQueuePage.tsx │ │ ├── AddTaskPage.tsx │ │ ├── TaskPage.tsx │ │ ├── ProfilePage.tsx │ │ ├── UpdateTaskPage.tsx │ │ └── ViewTaskPage.tsx │ ├── auth │ │ ├── KeycloakRoute.tsx │ │ └── keycloakAuth.ts │ ├── components │ │ ├── Loading.tsx │ │ ├── NetworkBadge.tsx │ │ ├── index.ts │ │ ├── Empty.tsx │ │ ├── OfflineQueueBadge.tsx │ │ ├── Router.tsx │ │ ├── OfflineList.tsx │ │ ├── TaskList.tsx │ │ ├── Task.tsx │ │ └── Header.tsx │ ├── AuthContext.tsx │ ├── index.tsx │ ├── helpers │ │ ├── index.ts │ │ ├── ConflictLogger.ts │ │ ├── mutationOptions.ts │ │ ├── subscriptionOptions.ts │ │ └── CapacitorNetworkStatus.ts │ ├── theme │ │ ├── index.ts │ │ ├── styles.css │ │ └── variables.css │ ├── forms │ │ ├── TaskForm.tsx │ │ └── task.ts │ ├── App.tsx │ ├── declarations.ts │ ├── AppContainer.tsx │ ├── graphql │ │ └── generated.ts │ └── serviceWorker.ts ├── public │ ├── assets │ │ ├── icon │ │ │ ├── icon.png │ │ │ ├── aerogear.png │ │ │ ├── favicon.png │ │ │ ├── keycloak.png │ │ │ └── avatar.svg │ │ └── shapes.svg │ ├── keycloak.example.json │ ├── manifest.json │ └── index.html ├── ionic.config.json ├── capacitor.config.json ├── .gitignore ├── tsconfig.json ├── README.adoc └── package.json ├── server ├── .dockerignore ├── .gitignore ├── src │ ├── resolvers │ │ ├── custom.ts │ │ └── scalars.ts │ ├── config │ │ ├── _keycloak.json │ │ ├── auth.ts │ │ ├── playground.gql │ │ └── config.ts │ ├── db.ts │ ├── pubsub.ts │ ├── index.ts │ ├── graphql.ts │ ├── auth.ts │ ├── crudServiceCreator.ts │ └── schema │ │ └── schema.graphql ├── Dockerfile ├── integrations │ ├── mqtt │ │ ├── docker-compose.yml │ │ └── configureAMQ.js │ └── keycloak │ │ ├── docker-compose.yml │ │ ├── getToken.js │ │ └── initKeycloak.js ├── .env ├── docker-compose.yml ├── README.md ├── model │ └── task.graphql ├── tsconfig.json └── package.json ├── walkthroughs-config.json ├── walkthroughs └── 1-exploring-datasync-codeready │ ├── images │ └── arch.png │ ├── walkthrough.json │ └── walkthrough.adoc ├── renovate.json ├── scripts ├── create_walkthrough.sh └── publish_container.sh ├── .github ├── ISSUE_TEMPLATE │ ├── 3-ask-for-help.md │ ├── 1-bug-report.md │ └── 2-feature-request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .vscode └── launch.json ├── .graphqlrc.yml ├── LICENSE ├── package.json ├── .openshift ├── amq-topics.yml ├── README.md └── datasync-app-template.yml ├── .circleci └── config.yml ├── devfile.yaml └── README.adoc /.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export { clientConfig } from './clientConfig'; 2 | -------------------------------------------------------------------------------- /client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useSubscribeToMore } from './useSubscribeToMore'; -------------------------------------------------------------------------------- /walkthroughs-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettyName": "Data Sync Solution Pattern" 3 | } 4 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | types 4 | yarn.lock 5 | yarn-error.log 6 | data/* 7 | -------------------------------------------------------------------------------- /client/public/assets/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/client/public/assets/icon/icon.png -------------------------------------------------------------------------------- /client/public/assets/icon/aerogear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/client/public/assets/icon/aerogear.png -------------------------------------------------------------------------------- /client/public/assets/icon/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/client/public/assets/icon/favicon.png -------------------------------------------------------------------------------- /client/public/assets/icon/keycloak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/client/public/assets/icon/keycloak.png -------------------------------------------------------------------------------- /client/ionic.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "appId": "com.datasync.starter", 4 | "integrations": { 5 | "capacitor": {} 6 | }, 7 | "type": "react" 8 | } 9 | -------------------------------------------------------------------------------- /walkthroughs/1-exploring-datasync-codeready/images/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/walkthroughs/1-exploring-datasync-codeready/images/arch.png -------------------------------------------------------------------------------- /server/src/resolvers/custom.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Custom resolvers for business logic that is not supported out of the box 5 | * by Graphback. 6 | */ 7 | export default { 8 | 9 | } -------------------------------------------------------------------------------- /walkthroughs/1-exploring-datasync-codeready/walkthrough.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "repos": [], 4 | "managedServices": [ 5 | ], 6 | "serviceInstances": [] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/capacitor.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "io.ionic.starter", 3 | "appName": "client", 4 | "bundledWebRuntime": true, 5 | "npmClient": "npm", 6 | "webDir": "build", 7 | "cordova": {} 8 | } 9 | -------------------------------------------------------------------------------- /client/src/config/SimpleSchema.ts: -------------------------------------------------------------------------------- 1 | import SimpleSchema from "simpl-schema"; 2 | 3 | // add the uniforms property to SimpleSchema 4 | SimpleSchema.extendOptions(['uniforms']); 5 | 6 | export default SimpleSchema; -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | COPY . . 7 | RUN npm install 8 | 9 | VOLUME ./files 10 | 11 | EXPOSE 4000 12 | CMD [ "npm", "start" ] 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":pinVersions" 5 | ], 6 | "groupName": "all", 7 | "automerge": true, 8 | "major": { 9 | "automerge": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/src/config/_keycloak.json: -------------------------------------------------------------------------------- 1 | { 2 | "realm": "datasync-starter", 3 | "bearer-only": true, 4 | "auth-server-url": "http://localhost:8080/auth/", 5 | "ssl-required": "external", 6 | "resource": "datasync-starter-server", 7 | "confidential-port": 0 8 | } -------------------------------------------------------------------------------- /server/integrations/mqtt/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | # Mosca is a simple MQTT Broker 4 | # In OpenShift/Production we would use the Red Hat AMQ broker 5 | mosca: 6 | image: eclipse-mosquitto:latest 7 | ports: 8 | - "1883:1883" # MQTT -------------------------------------------------------------------------------- /client/src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { TaskPage } from './TaskPage'; 2 | export { ProfilePage } from './ProfilePage'; 3 | export { AddTaskPage } from './AddTaskPage'; 4 | export { UpdateTaskPage } from './UpdateTaskPage'; 5 | export { OfflineQueuePage } from './OfflineQueuePage'; 6 | 7 | -------------------------------------------------------------------------------- /client/public/keycloak.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "realm": "", 3 | "auth-server-url": "https://your-server/auth", 4 | "ssl-required": "none", 5 | "resource": "", 6 | "public-client": true, 7 | "use-resource-role-mappings": true, 8 | "confidential-port": 0 9 | } -------------------------------------------------------------------------------- /server/integrations/keycloak/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | keycloak: 5 | image: jboss/keycloak:10.0.2 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | DB_VENDOR: h2 10 | KEYCLOAK_USER: admin 11 | KEYCLOAK_PASSWORD: admin -------------------------------------------------------------------------------- /client/src/auth/KeycloakRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route } from 'react-router-dom' 3 | 4 | export const KeycloakRoute: React.FC = ({ component: Component, ...rest }) => { 5 | return ( 6 | 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /server/src/config/auth.ts: -------------------------------------------------------------------------------- 1 | import { CrudServicesAuthConfig } from "@graphback/keycloak-authz" 2 | 3 | export const authConfig: CrudServicesAuthConfig = { 4 | Task: { 5 | create: { roles: [] }, 6 | read: { roles: [] }, 7 | update: { roles: [] }, 8 | delete: { roles: ['admin'] }, 9 | } 10 | } -------------------------------------------------------------------------------- /client/public/assets/icon/avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IonLoading } from '@ionic/react'; 3 | import { ILoadingProps } from '../declarations'; 4 | 5 | export const Loading: React.FC = ({ loading }) => { 6 | return ; 10 | }; -------------------------------------------------------------------------------- /client/src/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { KeycloakInstance, KeycloakProfile } from 'keycloak-js'; 3 | 4 | export interface IAuthContext { 5 | keycloak?: KeycloakInstance | undefined 6 | profile?: KeycloakProfile | undefined 7 | } 8 | 9 | export const AuthContext = React.createContext({ keycloak: undefined }); -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { App } from './App'; 4 | import { AppContainer } from './AppContainer'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | serviceWorker.register(); 10 | -------------------------------------------------------------------------------- /server/src/resolvers/scalars.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DateTimeResolver, 3 | JSONResolver, 4 | ObjectIDResolver 5 | } from 'graphql-scalars'; 6 | 7 | 8 | /** 9 | * Default scalars that are supported by DataSync starter 10 | */ 11 | export default { 12 | ObjectID: ObjectIDResolver, 13 | DateTime: DateTimeResolver, 14 | JSON: JSONResolver 15 | } -------------------------------------------------------------------------------- /client/src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import * as mutationOptions from './mutationOptions'; 2 | import * as subscriptionOptions from './subscriptionOptions'; 3 | 4 | export { ConflictLogger } from './ConflictLogger'; 5 | 6 | const { globalCacheUpdates } = mutationOptions; 7 | 8 | export { 9 | mutationOptions, 10 | globalCacheUpdates, 11 | subscriptionOptions, 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/hooks/useSubscribeToMore.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export const useSubscribeToMore: any = ({ options, subscribeToMore } : { options: any, subscribeToMore: Function }) => { 4 | 5 | useEffect(()=>{ 6 | options.forEach((option: any) => { 7 | subscribeToMore(option); 8 | }); 9 | }, [options, subscribeToMore]); 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /server/.env: -------------------------------------------------------------------------------- 1 | ## Mongo 2 | MONGO_USER=user 3 | MONGO_PASSWORD=password 4 | MONGO_ADMIN_PASSWORD=password 5 | MONGO_DATABASE=showcase 6 | MONGO_HOST= 7 | 8 | ## MQTT 9 | MQTT_HOST= 10 | MQTT_PORT= 11 | MQTT_PASSWORD= 12 | MQTT_USERNAME= 13 | MQTT_PROTOCOL= 14 | 15 | # Hack to enable keycloak with self signed certs 16 | # don't do it in production 17 | NODE_TLS_REJECT_UNAUTHORIZED=0 -------------------------------------------------------------------------------- /server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | mongodb: 4 | image: centos/mongodb-34-centos7 5 | container_name: "mongodb" 6 | environment: 7 | - MONGODB_USER=user 8 | - MONGODB_PASSWORD=password 9 | - MONGODB_ADMIN_PASSWORD=password 10 | - MONGODB_DATABASE=showcase 11 | ports: 12 | - 27017:27017 -------------------------------------------------------------------------------- /client/src/components/NetworkBadge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IonBadge } from '@ionic/react'; 3 | 4 | export const NetworkBadge: React.FC<{ isOnline?: boolean}> = ({ isOnline }) => { 5 | 6 | return (isOnline) 7 | ?Online 8 | :Offline; 9 | 10 | }; -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .vscode 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /client/src/components/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export { Task } from './Task'; 3 | export { Empty } from './Empty'; 4 | export { Header } from './Header'; 5 | export { Router } from './Router'; 6 | export { Loading } from './Loading'; 7 | export { TaskList } from './TaskList'; 8 | export { OfflineList } from './OfflineList'; 9 | export { KeycloakRoute } from '../auth/KeycloakRoute'; 10 | export { NetworkBadge } from './NetworkBadge'; 11 | export { OfflineQueueBadge } from './OfflineQueueBadge'; -------------------------------------------------------------------------------- /scripts/create_walkthrough.sh: -------------------------------------------------------------------------------- 1 | 2 | set -e 3 | 4 | echo "Creating branch. " 5 | echo "This command will reset your master branch and remove generated files" 6 | echo "Press any key or cancel" 7 | 8 | read choice 9 | 10 | git checkout master 11 | git reset --hard origin/master 12 | rm -Rf ./client/src/graphql 13 | rm -Rf ./server/src/resolvers 14 | rm -Rf ./server/src/schema 15 | git add --all 16 | git commit -m"Walktrough cleanup" 17 | git push origin +master:walkthrough 18 | 19 | echo "Successfully created branch" 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-ask-for-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "⁉️ Need some help with this project?" 3 | about: Please file an issue in our repo. 4 | 5 | --- 6 | 7 | ## Help Wanted 8 | 9 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /client/src/pages/OfflineQueuePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useApolloOfflineClient } from 'react-offix-hooks'; 3 | import { Header, OfflineList } from '../components'; 4 | import { RouteComponentProps } from 'react-router'; 5 | 6 | export const OfflineQueuePage: React.FC = ({ match }) => { 7 | 8 | const { queue } = useApolloOfflineClient(); 9 | 10 | return ( 11 | <> 12 |
13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /server/integrations/mqtt/configureAMQ.js: -------------------------------------------------------------------------------- 1 | const execSync = require("child_process").execSync; 2 | 3 | const AMQURL = execSync( 4 | `oc get addressspace datasync -o jsonpath='{.status.endpointStatuses[?(@.name=="messaging")].externalHost}'`, 5 | { encoding: "utf8" } 6 | ); 7 | 8 | 9 | const message = ` 10 | Obtained your credentials from the AMQ server. 11 | Please update your .env file with following config: 12 | 13 | MQTT_HOST = ${AMQURL} 14 | MQTT_PORT = 443 15 | MQTT_PASSWORD = Password1 16 | MQTT_USERNAME = messaging-user 17 | MQTT_PROTOCOL = tls 18 | ` 19 | console.log(message); -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ### Description 9 | 10 | 11 | 12 | ##### Checklist 13 | 14 | 15 | - [ ] `npm test` passes 16 | - [ ] `npm run build` works 17 | - [ ] tests are included 18 | - [ ] documentation is changed or added 19 | -------------------------------------------------------------------------------- /client/src/components/Empty.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IonContent, IonGrid, IonRow, IonCol, IonText } from '@ionic/react'; 3 | 4 | export const Empty: React.FC = ({ message }) => { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | { message } 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /server/src/config/playground.gql: -------------------------------------------------------------------------------- 1 | mutation create { 2 | createTask(input: { title: "test", 3 | description: "" }) { 4 | id 5 | } 6 | } 7 | 8 | 9 | mutation update { 10 | updateTask(input:{id:"5ee7a3e5e1479c83e4f1748f", 11 | title: "test", description: ""}){ 12 | id 13 | type 14 | } 15 | } 16 | 17 | 18 | 19 | query syncTasks{ 20 | syncTasks(lastSync: "1592239061067"){ 21 | items{ 22 | id 23 | title 24 | } 25 | lastSync 26 | } 27 | } 28 | 29 | 30 | query find { 31 | findTasks{ 32 | items{ 33 | id 34 | title 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | ## Bug Report 8 | 9 | 17 | 18 | * **Version/Commit**: 19 | * **Ionic Version**: 20 | * **Node.js / npm versions**: 21 | 22 | -------------------------------------------------------------------------------- /client/src/config/conflictStrategy.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ObjectState, ConflictResolutionData } from "offix-client"; 3 | 4 | export class TimeStampState implements ObjectState { 5 | 6 | public assignServerState(client: any, server: any): void { 7 | client.updatedAt = server.updatedAt.toString(); 8 | } 9 | public hasConflict(client: any, server: any): boolean { 10 | return client.updatedAt !== server.updatedAt; 11 | } 12 | public getStateFields(): string[] { 13 | return ["updatedAt"]; 14 | } 15 | 16 | public currentState(currentObjectState: ConflictResolutionData) { 17 | return currentObjectState.updatedAt; 18 | } 19 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature request" 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | ## Feature Request 8 | 9 | 14 | 15 | **Is your feature request related to a problem? Please describe.** 16 | Please describe the problem you are trying to solve. 17 | 18 | **Describe the solution you'd like** 19 | Please describe the desired behavior. 20 | 21 | **Describe alternatives you've considered** 22 | Please describe alternative solutions or features you have considered. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifies intentionally untracked files to ignore when using Git 2 | # http://git-scm.com/docs/gitignore 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | *.log 7 | *.tmp 8 | *.tmp.* 9 | log.txt 10 | *.sublime-project 11 | *.sublime-workspace 12 | npm-debug.log* 13 | 14 | .idea/ 15 | .ionic/ 16 | .sourcemaps/ 17 | .sass-cache/ 18 | .tmp/ 19 | .versions/ 20 | coverage/ 21 | www/ 22 | node_modules/ 23 | tmp/ 24 | temp/ 25 | platforms/ 26 | plugins/ 27 | plugins/android.json 28 | plugins/ios.json 29 | $RECYCLE.BIN/ 30 | 31 | .DS_Store 32 | Thumbs.db 33 | UserInterfaceState.xcuserstate 34 | package-lock.json 35 | 36 | server/website 37 | yarn.lock 38 | 39 | client/ios/ 40 | client/android/ 41 | client/.gradle/ -------------------------------------------------------------------------------- /server/integrations/keycloak/getToken.js: -------------------------------------------------------------------------------- 1 | const tokenRequester = require('keycloak-request-token') 2 | 3 | const username = process.argv[2] 4 | const password = process.argv[3] 5 | 6 | const baseUrl = 'http://localhost:8080/auth'; 7 | const settings = { 8 | username: username || 'developer', 9 | password: password || 'developer', 10 | grant_type: 'password', 11 | client_id: 'datasync-starter-client', 12 | realmName: 'datasync-starter' 13 | } 14 | 15 | tokenRequester(baseUrl, settings) 16 | .then((token) => { 17 | const headers = { 18 | Authorization: `Bearer ${token}` 19 | } 20 | console.log(JSON.stringify(headers)) 21 | }).catch((err) => { 22 | console.log('err', err) 23 | }) -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # DataSync Full Stack Server 2 | 3 | Node.js template using Graphback 4 | 5 | ## Integrations 6 | 7 | - Graphback (Apollo GraphQL template) 8 | - Keycloak (Authentication) 9 | - AMQ Online (MQTT) 10 | - MongoDB 11 | 12 | ## Usage 13 | 14 | This project has been created using Graphback. 15 | Run the project using the following steps: 16 | 17 | - Install 18 | 19 | ```sh 20 | yarn install 21 | ``` 22 | 23 | - Start the Mongo database and MQTT client 24 | 25 | ```sh 26 | docker-compose up -d 27 | ``` 28 | 29 | - Generate resources(schema and resolvers) and create database 30 | 31 | ```sh 32 | yarn graphback generate 33 | ``` 34 | 35 | - Start the server 36 | 37 | ```sh 38 | yarn start:server 39 | ``` 40 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch server", 11 | "args": ["${workspaceFolder}/server/src/index.ts"], 12 | "sourceMaps": true, 13 | "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], 14 | "outFiles": ["${workspaceFolder}/server/dist/**/*.js"], 15 | "cwd": "${workspaceRoot}/server", 16 | "protocol": "inspector", 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "AeroGear DataSync Starter", 3 | "name": "AeroGear DataSync Starter", 4 | "icons": [ 5 | { 6 | "src": "assets/icon/aerogear.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "assets/icon/aerogear.png", 12 | "type": "image/png", 13 | "sizes": "512x512", 14 | "purpose": "maskable" 15 | }, 16 | { 17 | "src": "assets/icon/avatar.svg", 18 | "type": "image/svg", 19 | "sizes": "512x512", 20 | "purpose": "maskable" 21 | } 22 | ], 23 | "start_url": ".", 24 | "display": "standalone", 25 | "theme_color": "#ffffff", 26 | "background_color": "#ffffff" 27 | } 28 | -------------------------------------------------------------------------------- /client/src/theme/index.ts: -------------------------------------------------------------------------------- 1 | /* Core CSS required for Ionic components to work properly */ 2 | import '@ionic/react/css/core.css'; 3 | 4 | /* Basic CSS for apps built with Ionic */ 5 | import '@ionic/react/css/normalize.css'; 6 | import '@ionic/react/css/structure.css'; 7 | import '@ionic/react/css/typography.css'; 8 | 9 | /* Optional CSS utils that can be commented out */ 10 | import '@ionic/react/css/padding.css'; 11 | import '@ionic/react/css/float-elements.css'; 12 | import '@ionic/react/css/text-alignment.css'; 13 | import '@ionic/react/css/text-transformation.css'; 14 | import '@ionic/react/css/flex-utils.css'; 15 | import '@ionic/react/css/display.css'; 16 | 17 | /* Theme variables */ 18 | import './variables.css'; 19 | 20 | import './styles.css'; 21 | -------------------------------------------------------------------------------- /server/model/task.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | @model 3 | @datasync 4 | """ 5 | type Task { 6 | """@id""" 7 | id: ObjectID! 8 | title: String! 9 | description: String! 10 | status: TaskStatus 11 | type: String 12 | priority: Int 13 | public: Boolean 14 | startDate: DateTime 15 | payload: JSON 16 | 17 | """ 18 | @oneToMany(field: 'note') 19 | """ 20 | comments: [Comment]! 21 | } 22 | 23 | """ 24 | @model 25 | @crud(delete: false) 26 | @crud(update: false) 27 | """ 28 | type Comment { 29 | """@id""" 30 | id: ObjectID! 31 | message: String! 32 | author: String! 33 | } 34 | 35 | enum TaskStatus { 36 | OPEN 37 | ASSIGNED 38 | COMPLETE 39 | } 40 | 41 | ### Custom types used by model 42 | 43 | scalar DateTime 44 | scalar JSON 45 | scalar ObjectID 46 | -------------------------------------------------------------------------------- /server/src/db.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './config/config'; 2 | import e = require('express'); 3 | 4 | const MongoClient = require('mongodb').MongoClient; 5 | 6 | export async function connect(config: Config) { 7 | // TODO config 8 | let url: string; 9 | 10 | if (config.db.user && config.db.password) { 11 | url = `mongodb://${config.db.user}:${config.db.password}@${config.db.host}:${config.db.port}/${config.db.database}`; 12 | } else { 13 | url = `mongodb://${config.db.host}:${config.db.port}/${config.db.database}` 14 | } 15 | 16 | // Use connect method to connect to the server 17 | const client = await MongoClient.connect(url, { useUnifiedTopology: true }); 18 | const db = client.db(config.db.database); 19 | return db; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /client/src/forms/TaskForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AutoForm, AutoFields, ErrorsField, SubmitField } from "uniforms-ionic"; 3 | import { taskEditSchema, taskViewSchema } from "./task"; 4 | 5 | export function TaskForm(props: { model: any, handleSubmit: any }) { 6 | // Workaround for missing types for submit 7 | const Submit = SubmitField as any; 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | export function TaskView(props: { model: any }) { 18 | return ( 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /.graphqlrc.yml: -------------------------------------------------------------------------------- 1 | ## GraphQL Config Generated by Graphback 2 | ## Configuration is being used to generate client side queries 3 | schema: ./server/src/schema/schema.graphql 4 | documents: ./client/src/graphql/**/*.graphql 5 | extensions: 6 | graphback: 7 | model: ./server/model/task.graphql 8 | crud: 9 | create: true 10 | update: true 11 | findAll: true 12 | find: true 13 | delete: true 14 | subCreate: true 15 | subUpdate: true 16 | subDelete: true 17 | plugins: 18 | ## Schema for preview only - server is not using it as it is generated at runtime 19 | graphback-schema: 20 | format: graphql 21 | outputPath: ./server/src/schema 22 | ## Client side queries 23 | graphback-client: 24 | format: 'ts' 25 | outputFile: ./client/src/graphql/generated.ts -------------------------------------------------------------------------------- /scripts/publish_container.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$CI" ] && echo "This script is meant to run only from CircleCI." && exit 1; 4 | [ -z "$DOCKERHUB_USERNAME" ] && echo "Undefined DOCKERHUB_USERNAME, skipping publish" && exit 1; 5 | [ -z "$DOCKERHUB_PASSWORD" ] && echo "Undefined DOCKERHUB_PASSWORD, skipping publish" && exit 1; 6 | 7 | docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD 8 | 9 | #use TAG env. variable to create the container with the given tag 10 | TAG="${TAG:-latest}" 11 | 12 | NAMESPACE="aerogear" 13 | CONTAINER="$NAMESPACE/datasync-starter:$TAG" 14 | CONTAINER_LATEST="$NAMESPACE/datasync-starter:latest" 15 | 16 | echo "Building docker container $CONTAINER" 17 | docker build -f Dockerfile -t "$CONTAINER" . && docker push "$CONTAINER" && \ 18 | docker tag "$CONTAINER" "$CONTAINER_LATEST" && docker push "$CONTAINER_LATEST" 19 | -------------------------------------------------------------------------------- /server/src/pubsub.ts: -------------------------------------------------------------------------------- 1 | import mqtt from 'mqtt' 2 | import { PubSub } from 'apollo-server-express' 3 | import { MQTTPubSub } from '@aerogear/graphql-mqtt-subscriptions' 4 | import { config } from "./config/config" 5 | 6 | export function connectToPubSub() { 7 | if (config.mqttConfig) { 8 | const mqttOptions = config.mqttConfig; 9 | // Types are broken 10 | const client = mqtt.connect(mqttOptions.mqttHost, mqttOptions as any) 11 | 12 | console.log(`attempting to connect to messaging service ${mqttOptions.mqttHost}`) 13 | 14 | client.on('connect', () => { 15 | console.log('connected to messaging service') 16 | }) 17 | 18 | client.on('error', (error) => { 19 | console.log('error with mqtt connection') 20 | console.log(error) 21 | }) 22 | 23 | return new MQTTPubSub({ client }) 24 | } 25 | console.log('Using In Memory PubSub') 26 | return new PubSub() 27 | } -------------------------------------------------------------------------------- /client/src/helpers/ConflictLogger.ts: -------------------------------------------------------------------------------- 1 | import { ConflictListener } from 'offix-client'; 2 | 3 | export class ConflictLogger implements ConflictListener { 4 | conflictOccurred(operationName:any, resolvedData:any, server:any, client:any) { 5 | console.log("Conflict occurred with the following:") 6 | console.log(` 7 | data: ${JSON.stringify(resolvedData)}, 8 | server: ${JSON.stringify(server)}, 9 | client: ${JSON.stringify(client)}, 10 | operation: ${JSON.stringify(operationName)} 11 | `); 12 | } 13 | mergeOccurred(operationName:any, resolvedData:any, server:any, client:any) { 14 | console.log("Merge occurred with the following:") 15 | console.log(` 16 | data: ${JSON.stringify(resolvedData)}, 17 | server: ${JSON.stringify(server)}, 18 | client: ${JSON.stringify(client)}, 19 | operation: ${JSON.stringify(operationName)} 20 | `); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/components/OfflineQueueBadge.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { IonLabel, IonButton, IonBadge } from '@ionic/react'; 3 | import { useApolloOfflineClient } from 'react-offix-hooks'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | export const OfflineQueueBadge: React.FC = () => { 7 | 8 | const client = useApolloOfflineClient(); 9 | const [queue, setQueue] = useState(0); 10 | 11 | // eslint-disable-next-line 12 | useEffect(() => { 13 | setQueue(client.queue.entries.length); 14 | }); 15 | 16 | return ( 17 | 18 | 19 | 20 | Offline changes 21 | 22 | 23 | 24 | { queue } 25 | 26 | 27 | ); 28 | 29 | } -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "declarationDir": "types", 5 | "lib": [ 6 | "esnext", 7 | "esnext.asynciterable", 8 | "es2015", 9 | "es2018.promise", 10 | "dom", 11 | "es2016" 12 | ], 13 | "resolveJsonModule": true, 14 | "noImplicitAny": false, 15 | "preserveConstEnums": true, 16 | "strict": false, 17 | "strictNullChecks": true, 18 | "esModuleInterop": true, 19 | "target": "es6", 20 | "module": "commonjs", 21 | "moduleResolution": "node", 22 | "allowSyntheticDefaultImports": true, 23 | "importHelpers": true, 24 | "alwaysStrict": false, 25 | "sourceMap": true, 26 | "declaration": true, 27 | "noImplicitReturns": true, 28 | "noUnusedLocals": false, 29 | "noUnusedParameters": false, 30 | "noImplicitThis": false 31 | }, 32 | "exclude": [ 33 | "node_modules", 34 | "dist", 35 | "types" 36 | ] 37 | } -------------------------------------------------------------------------------- /client/src/helpers/mutationOptions.ts: -------------------------------------------------------------------------------- 1 | import { getUpdateFunction, CacheOperation } from 'offix-cache'; 2 | import { findTasks } from '../graphql/generated'; 3 | 4 | export const createTask = { 5 | updateQuery: findTasks, 6 | returnType: 'Task', 7 | mutationName: 'createTask', 8 | operationType: CacheOperation.ADD, 9 | returnField: 'items' 10 | }; 11 | 12 | export const updateTask = { 13 | updateQuery: findTasks, 14 | returnType: 'Task', 15 | mutationName: 'updateTask', 16 | operationType: CacheOperation.REFRESH, 17 | returnField: 'items' 18 | }; 19 | 20 | export const deleteTask = { 21 | updateQuery: findTasks, 22 | returnType: 'Task', 23 | mutationName: 'deleteTask', 24 | operationType: CacheOperation.DELETE, 25 | returnField: 'items' 26 | }; 27 | 28 | export const globalCacheUpdates = { 29 | createTask: getUpdateFunction(createTask), 30 | updateTask: getUpdateFunction(updateTask), 31 | deleteTask: getUpdateFunction(deleteTask), 32 | } 33 | -------------------------------------------------------------------------------- /client/README.adoc: -------------------------------------------------------------------------------- 1 | = React Client 2 | 3 | Example React Web application using AeroGear DataSync GraphQL capabilities 4 | 5 | == Getting Started 6 | 7 | Requirements: 8 | 9 | - Node.js 12.x or above to run server 10 | - (optional) Keycloack server 11 | 12 | === Running the Client 13 | 14 | . Install Ionic 15 | + 16 | ```shell 17 | npm install -g @ionic/cli 18 | ``` 19 | 20 | . Install dependencies 21 | + 22 | ```shell 23 | npm install 24 | ``` 25 | 26 | . Start the app 27 | + 28 | ```shell 29 | npm run start 30 | ``` 31 | 32 | === Adding keycloak integration to the client 33 | 34 | Rename the `keycloak.example.json` to `keycloak.json` and update the fields 35 | accordingly. 36 | 37 | [source,js] 38 | ---- 39 | { 40 | "realm": "", 41 | "auth-server-url": "https://your-server/auth", 42 | "ssl-required": "none", 43 | "resource": "", 44 | "public-client": true, 45 | "use-resource-role-mappings": true, 46 | "confidential-port": 0 47 | } 48 | ---- 49 | 50 | -------------------------------------------------------------------------------- /client/src/helpers/subscriptionOptions.ts: -------------------------------------------------------------------------------- 1 | import { createSubscriptionOptions, CacheOperation } from 'offix-cache'; 2 | import { newTask, updatedTask, deletedTask, findTasks, getTask, newComment } from '../graphql/generated'; 3 | 4 | export const add = createSubscriptionOptions({ 5 | subscriptionQuery: newTask, 6 | cacheUpdateQuery: findTasks, 7 | operationType: CacheOperation.ADD, 8 | returnField: 'items' 9 | }); 10 | 11 | export const edit = createSubscriptionOptions({ 12 | subscriptionQuery: updatedTask, 13 | cacheUpdateQuery: findTasks, 14 | operationType: CacheOperation.REFRESH, 15 | returnField: 'items' 16 | }); 17 | 18 | export const remove = createSubscriptionOptions({ 19 | subscriptionQuery: deletedTask, 20 | cacheUpdateQuery: findTasks, 21 | operationType: CacheOperation.DELETE, 22 | returnField: 'items' 23 | }); 24 | 25 | export const addComment = createSubscriptionOptions({ 26 | subscriptionQuery: newComment, 27 | cacheUpdateQuery: getTask, 28 | operationType: CacheOperation.ADD, 29 | returnField: 'comments' 30 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datasync-starter", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Mono repository for DataSync Starter", 6 | "main": "index.js", 7 | "devDependencies": { 8 | "del-cli": "3.0.1", 9 | "graphql": "15.3.0", 10 | "graphback-cli": "0.14.0" 11 | }, 12 | "scripts": { 13 | "start:server": "cd server && yarn start", 14 | "start:client": "cd client && yarn start", 15 | "build:server": "cd server && yarn build", 16 | "build:client": "cd client && yarn build", 17 | "build:clientGeneric": "cd client/ && yarn build:generic", 18 | "prepare:client": "del ./client/build ./server/website ; yarn build:clientGeneric && mv ./client/build/ ./server/website", 19 | "build": "yarn workspaces run build", 20 | "unlock": "yarn workspaces run del package-lock.json && del yarn.lock", 21 | "clean": "yarn workspaces run del ./dist && del ./types", 22 | "walkthrough": "./scripts/create_walkthrough.sh" 23 | }, 24 | "workspaces": [ 25 | "client", 26 | "server" 27 | ], 28 | "resolutions": { 29 | "@types/react": "16.9.36" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/public/assets/shapes.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/components/Router.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | HashRouter as AppRouter, 4 | Switch, 5 | Redirect, 6 | Route, 7 | } from 'react-router-dom'; 8 | import { IonApp } from '@ionic/react'; 9 | import { TaskPage, AddTaskPage, OfflineQueuePage, UpdateTaskPage, ProfilePage } from '../pages'; 10 | import { ViewTaskPage } from '../pages/ViewTaskPage'; 11 | 12 | export const Router: React.FC = () => { 13 | return ( 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | } /> 28 | 29 | 30 | 31 | ); 32 | } -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Router } from './components'; 3 | import { useApolloOfflineClient } from "react-offix-hooks"; 4 | 5 | // load all styles 6 | import './theme'; 7 | import { ConflictListener } from 'offix-client'; 8 | import { IonToast } from '@ionic/react'; 9 | 10 | export const App: React.FC = () => { 11 | 12 | const client = useApolloOfflineClient(); 13 | const [showConflict, setShowConflict] = useState(false); 14 | 15 | useEffect(() => { 16 | const conflictListener: ConflictListener = { 17 | mergeOccurred() { 18 | console.log("Merge occured! ") 19 | }, 20 | conflictOccurred() { 21 | setShowConflict(true); 22 | } 23 | }; 24 | client.addConflictListener(conflictListener); 25 | 26 | return function cleanup() { 27 | client.removeConflictListener(conflictListener); 28 | } 29 | }, [client]); 30 | 31 | return ( 32 | <> 33 | 34 | setShowConflict(false)} 37 | message="Conflict Occurred 👌👌👌" 38 | duration={2000} 39 | /> 40 | 41 | ); 42 | }; 43 | 44 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AeroGear DataSync Starter 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | // Setup env variables 3 | dotenv.config() 4 | 5 | import cors from 'cors'; 6 | import express from 'express'; 7 | import http from 'http' 8 | import { config } from './config/config' 9 | import { createApolloServer } from './graphql'; 10 | 11 | async function start() { 12 | const app = express(); 13 | 14 | app.use(cors()); 15 | app.use('/', express.static('website')) 16 | app.get('/health', (req, res) => res.sendStatus(200)); 17 | 18 | const apolloServer = await createApolloServer(app, config); 19 | const httpServer = http.createServer(app) 20 | apolloServer.installSubscriptionHandlers(httpServer) 21 | 22 | httpServer.listen(config.port, () => { 23 | console.log(`\n *********************************************************** 24 | 🎮 Ionic PWA application available at http://localhost:${config.port} 25 | 🚀 GraphQL Playground is available at http://localhost:${config.port}/graphql 26 | ***********************************************************`) 27 | }) 28 | } 29 | 30 | start().catch((err) => { 31 | console.error(err); 32 | process.exit(1); 33 | }) 34 | 35 | 36 | process.on('unhandledRejection', (error: any) => { 37 | console.error(error.message, error.stack) 38 | process.exit(1) 39 | }) 40 | -------------------------------------------------------------------------------- /client/src/helpers/CapacitorNetworkStatus.ts: -------------------------------------------------------------------------------- 1 | import { NetworkStatus, NetworkStatusChangeCallback } from "offix-offline"; 2 | import { Plugins } from '@capacitor/core'; 3 | const { Network } = Plugins; 4 | 5 | /** 6 | * Web networks status implementation based on: Mozilla 7 | * See: https://developer.mozilla.org/en-US/docs/Web/API/NavigatorOnLine/onLine 8 | */ 9 | export class CapacitorNetworkStatus implements NetworkStatus { 10 | 11 | listeners: NetworkStatusChangeCallback[] = []; 12 | 13 | constructor() { 14 | Network.addListener("networkStatusChange", this.handleNetworkStatusChange.bind(this)); 15 | } 16 | 17 | public addListener(listener: NetworkStatusChangeCallback): void { 18 | this.listeners.push(listener); 19 | } 20 | 21 | public removeListener(listener: NetworkStatusChangeCallback): void { 22 | const index = this.listeners.indexOf(listener); 23 | if (index >= 0) { 24 | this.listeners.splice(index, 1); 25 | } 26 | } 27 | 28 | public isOffline(): Promise { 29 | return new Promise(async (resolve) => { 30 | let status = await Network.getStatus(); 31 | // @ts-ignore 32 | resolve(!status.connected); 33 | }); 34 | } 35 | 36 | private handleNetworkStatusChange(status: any) { 37 | const online = status.connected; 38 | this.listeners.forEach((listener) => { 39 | listener({ online }); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/declarations.ts: -------------------------------------------------------------------------------- 1 | import { KeycloakInstance } from "keycloak-js"; 2 | import { ApolloOfflineClient } from "offix-client"; 3 | 4 | export interface ITask { 5 | id: string; 6 | version: number; 7 | title: string; 8 | description: string; 9 | status: TaskStatus; 10 | __typename?: string; 11 | }; 12 | 13 | export enum TaskStatus { 14 | OPEN = 'OPEN', 15 | ASSIGNED = 'ASSIGNED', 16 | COMPLETE = 'COMPLETE' 17 | }; 18 | 19 | export interface AllTasks { 20 | allTasks: ITask[]; 21 | task: ITask; 22 | taskAdded: ITask; 23 | taskDeleted: ITask; 24 | taskUpdated: ITask; 25 | }; 26 | 27 | export enum MutationType { 28 | CREATED = 'CREATED', 29 | MUTATED = 'MUTATED', 30 | DELETED = 'DELETED', 31 | }; 32 | 33 | export interface IOfflineStore { 34 | offlineStore: [ITask] 35 | } 36 | 37 | export interface ITaskListProps { 38 | tasks: [ITask] 39 | } 40 | 41 | export interface IOfflineListProps { 42 | offlineStore: Array 43 | }; 44 | 45 | export interface ITaskProps { 46 | task: ITask, 47 | updateTask: Function, 48 | deleteTask: Function 49 | }; 50 | 51 | export interface IContainerProps { 52 | app: React.FC 53 | }; 54 | 55 | export interface ILoadingProps { 56 | loading: boolean 57 | }; 58 | 59 | export interface IAuthHeaders { 60 | headers: { 61 | Authorization: String 62 | } 63 | } 64 | 65 | export interface ILogoutParams { 66 | keycloak: Keycloak.KeycloakInstance | undefined, 67 | client: ApolloOfflineClient 68 | } -------------------------------------------------------------------------------- /client/src/AppContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { ApolloOfflineClient } from 'offix-client'; 3 | import { ApolloOfflineProvider } from 'react-offix-hooks'; 4 | import { ApolloProvider } from '@apollo/react-hooks'; 5 | import { AuthContext } from './AuthContext'; 6 | import { clientConfig } from './config'; 7 | import { Loading } from './components/Loading'; 8 | import { IContainerProps } from './declarations'; 9 | import { getKeycloakInstance } from './auth/keycloakAuth'; 10 | 11 | let keycloak: any; 12 | const apolloClient = new ApolloOfflineClient(clientConfig); 13 | 14 | export const AppContainer: React.FC = ({ app: App }) => { 15 | 16 | const [initialized, setInitialized] = useState(false); 17 | 18 | // Initialize the client 19 | useEffect(() => { 20 | const init = async () => { 21 | keycloak = await getKeycloakInstance(); 22 | await apolloClient.init(); 23 | if (keycloak) { 24 | await keycloak?.loadUserProfile(); 25 | } 26 | setInitialized(true); 27 | } 28 | init(); 29 | }, []); 30 | 31 | if (!initialized) return ; 32 | 33 | // return container with keycloak provider 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | 44 | 45 | }; 46 | -------------------------------------------------------------------------------- /client/src/components/OfflineList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IonItem, IonLabel, IonItemGroup, IonBadge, IonList, IonContent } from '@ionic/react'; 3 | import { Empty } from './Empty'; 4 | 5 | export const OfflineList: React.FC = ({ offlineStore }) => { 6 | 7 | if (!offlineStore) return

Loading...

; 8 | 9 | if (offlineStore.length === 0) { 10 | const message = (

You currently have no changes
staged offline.

); 11 | return ( 12 | 13 | ) 14 | }; 15 | 16 | return ( 17 | 18 | 19 | { 20 | offlineStore.map(({ operation }: any, index: any) => { 21 | const { context, variables } = operation.op; 22 | const keys = Object.keys(variables.input); 23 | return ( 24 | 25 | 26 | 27 |

28 | Mutation type: 29 | 30 | {context.operationName} 31 | 32 |

33 |
    34 | { keys.map((key, i) =>
  • { variables.input[key] }
  • ) } 35 |
36 |
37 |
38 |
39 | ); 40 | }) 41 | } 42 |
43 |
44 | ); 45 | 46 | 47 | } -------------------------------------------------------------------------------- /.openshift/amq-topics.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Template 3 | labels: 4 | template: amq-online-topic-creator 5 | metadata: 6 | name: amq-online-topic-creator 7 | objects: 8 | - apiVersion: enmasse.io/v1beta1 9 | kind: AddressSpace 10 | metadata: 11 | name: datasync 12 | spec: 13 | type: brokered 14 | plan: brokered-single-broker 15 | - apiVersion: enmasse.io/v1beta1 16 | kind: Address 17 | metadata: 18 | name: datasync.${AMQ_ADDRESS} 19 | spec: 20 | address: ${AMQ_ADDRESS} 21 | type: topic 22 | plan: brokered-topic 23 | - apiVersion: user.enmasse.io/v1beta1 24 | kind: MessagingUser 25 | metadata: 26 | name: datasync.${AMQ_USERNAME} 27 | spec: 28 | username: ${AMQ_USERNAME} 29 | authentication: 30 | type: password 31 | password: ${AMQ_USER_PASSWORD} 32 | authorization: 33 | - addresses: ["*"] 34 | operations: ["send", "recv"] 35 | parameters: 36 | - description: Messaging user created in AMQ Online. The showcase server will authenticate with AMQ as this user. 37 | displayName: AMQ Messaging User Name 38 | name: AMQ_USERNAME 39 | value: messaging-user 40 | - description: Create your own password with `$ echo | base64` - the default password is Password1 41 | displayName: AMQ Messaging User Password 42 | name: AMQ_USER_PASSWORD 43 | value: UGFzc3dvcmQx # (base64 encoded) Password1 44 | - description: Address 45 | displayName: AMQ Messaging Address 46 | name: AMQ_ADDRESS 47 | value: graphql 48 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datasync-server-starter", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "ts-node src/index.ts", 7 | "startMQTT": "MQTT_HOST=127.0.0.1:1883 ts-node src/index.ts", 8 | "build": "tsc", 9 | "keycloak": "docker-compose -f ./integrations/keycloak/docker-compose.yml up", 10 | "keycloak:init": "node ./integrations/keycloak/initKeycloak.js", 11 | "mqtt": "docker-compose -f ./integrations/mqtt/docker-compose.yml up", 12 | "amq:config": "node ./integrations/mqtt/configureAMQ.js" 13 | }, 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/cors": "2.8.6", 17 | "@types/express": "4.17.6", 18 | "@types/node": "13.13.12", 19 | "keycloak-request-token": "0.1.0", 20 | "ts-node": "9.0.0", 21 | "ts-node-dev": "1.0.0-pre.44", 22 | "tslint": "6.1.2", 23 | "typescript": "4.0.2" 24 | }, 25 | "dependencies": { 26 | "graphql": "15.3.0", 27 | "@aerogear/graphql-mqtt-subscriptions": "1.1.3", 28 | "graphql-subscriptions": "1.1.0", 29 | "graphback": "0.14.0", 30 | "@graphback/keycloak-authz": "0.14.0", 31 | "@graphback/datasync": "0.14.0", 32 | "@graphback/runtime-mongo": "0.14.0", 33 | "@graphql-tools/graphql-file-loader": "6.0.15", 34 | "@graphql-tools/load": "6.0.15", 35 | "@types/react": "16.9.36", 36 | "apollo-server-express": "2.17.0", 37 | "cors": "2.8.5", 38 | "dotenv": "8.2.0", 39 | "express": "4.17.1", 40 | "express-session": "1.17.1", 41 | "graphql-scalars": "1.1.3", 42 | "graphql-tag": "2.11.0", 43 | "keycloak-connect": "11.0.2", 44 | "keycloak-connect-graphql": "0.6.0", 45 | "mongodb": "3.5.9" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/aerogear 5 | docker: 6 | - image: circleci/node:lts 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | key: dependency-cache-{{ checksum "client/package.json" }}-{{checksum "server/package.json"}} 11 | - run: 12 | name: install-dependencies 13 | command: yarn 14 | - run: 15 | name: install-package-dependencies 16 | command: yarn 17 | - save_cache: 18 | key: dependency-cache-{{ checksum "client/package.json" }}-{{checksum "server/package.json"}} 19 | paths: 20 | - ./node_modules 21 | - run: 22 | name: run build 23 | command: "yarn build" 24 | 25 | publish_container: 26 | docker: 27 | # image for building docker containers 28 | - image: circleci/node:lts 29 | steps: 30 | - checkout 31 | - run: 32 | name: install-dependencies 33 | command: yarn 34 | # special workaround to allow running docker in docker https://circleci.com/docs/2.0/building-docker-images/ 35 | - setup_remote_docker: 36 | version: 17.05.0-ce 37 | - run: | 38 | yarn 39 | yarn prepare:client 40 | - run: | 41 | cd server 42 | TAG=$CIRCLE_TAG ../scripts/publish_container.sh 43 | workflows: 44 | version: 2 45 | build_and_release: 46 | jobs: 47 | - build: 48 | filters: 49 | tags: 50 | only: /.*/ 51 | - publish_container: 52 | filters: 53 | tags: 54 | only: /.*/ 55 | branches: 56 | only: ignored 57 | 58 | 59 | -------------------------------------------------------------------------------- /client/src/pages/AddTaskPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { RouteComponentProps } from 'react-router-dom' 3 | import { IonContent, IonToast, IonCard } from '@ionic/react'; 4 | import { useOfflineMutation } from 'react-offix-hooks'; 5 | import { Header } from '../components/Header'; 6 | import { createTask } from '../graphql/generated'; 7 | import { TaskForm } from '../forms/TaskForm'; 8 | import { mutationOptions } from '../helpers'; 9 | 10 | 11 | export const AddTaskPage: React.FC = ({ history, match }) => { 12 | 13 | const [showToast, setShowToast] = useState(false); 14 | const [errorMessage, setErrorMessage] = useState(''); 15 | 16 | const [createTaskMutation] = useOfflineMutation(createTask, mutationOptions.createTask); 17 | 18 | const handleError = (error: any) => { 19 | if (error.offline) { 20 | error.watchOfflineChange(); 21 | history.push('/'); 22 | return; 23 | } 24 | setErrorMessage(error.message); 25 | setShowToast(true); 26 | }; 27 | 28 | const submit = (model: any) => { 29 | createTaskMutation({ 30 | variables: { input: { ...model } } 31 | }) 32 | .then(() => history.push('/')) 33 | .catch(handleError); 34 | }; 35 | 36 | return ( 37 | <> 38 |
39 | 40 | 41 | 42 | 43 | setShowToast(false)} 46 | message={errorMessage} 47 | position="top" 48 | color="danger" 49 | duration={2000} 50 | /> 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /client/src/theme/styles.css: -------------------------------------------------------------------------------- 1 | .list-md { 2 | padding: 0; 3 | } 4 | 5 | .task-item { 6 | padding-bottom: 0; 7 | } 8 | 9 | .item ion-label { 10 | overflow: visible; 11 | white-space: pre-wrap; 12 | } 13 | 14 | ion-note { 15 | padding: 10px; 16 | line-height: 21px; 17 | white-space: pre-wrap; 18 | } 19 | 20 | ion-badge { 21 | vertical-align: middle; 22 | white-space: pre-line; 23 | text-align: left; 24 | } 25 | 26 | ion-footer div { 27 | margin: 10px; 28 | } 29 | 30 | .create-button { 31 | margin-left:15px; 32 | z-index: 9999; 33 | } 34 | 35 | .trash-button { 36 | margin-left:5px; 37 | z-index: 9999; 38 | } 39 | 40 | .network-badge { 41 | float: right; 42 | height: calc(2.1em + 4px); 43 | font-size: 13px; 44 | text-transform: uppercase; 45 | vertical-align: middle; 46 | line-height: calc(2.1em + 4px); 47 | --padding-top: 0; 48 | --padding-bottom: 0; 49 | --padding-start: 0.9em; 50 | --padding-end: 0.9em; 51 | } 52 | 53 | .offline-queue-button { 54 | -webkit-margin-start: 0px; 55 | margin-inline-start: 0px; 56 | } 57 | 58 | .offline-queue-badge { 59 | margin-left: -7px; 60 | vertical-align: top; 61 | } 62 | 63 | .task-item { 64 | padding-bottom: 0; 65 | } 66 | 67 | .list-md { 68 | padding: 0; 69 | } 70 | 71 | .item .sc-ion-label-md-h { 72 | overflow: visible; 73 | white-space: pre-wrap; 74 | } 75 | 76 | ion-note { 77 | padding: 10px; 78 | line-height: 21px; 79 | white-space: pre-wrap; 80 | } 81 | 82 | ion-badge { 83 | vertical-align: middle; 84 | white-space: pre-line; 85 | text-align: left; 86 | } 87 | 88 | .queue-empty { 89 | height: 100%; 90 | display: flex; 91 | align-items: center; 92 | text-align: center; 93 | } 94 | 95 | .queue-empty p { 96 | line-height: 150% 97 | } 98 | 99 | .submit-btn { 100 | margin-top: 30px; 101 | } -------------------------------------------------------------------------------- /server/src/graphql.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { connect } from './db'; 3 | import { Config } from './config/config'; 4 | import { ApolloServer, ApolloServerExpressConfig } from "apollo-server-express"; 5 | import { Express } from "express"; 6 | import scalars from './resolvers/scalars'; 7 | import customResolvers from './resolvers/custom'; 8 | import { buildKeycloakApolloConfig } from './auth'; 9 | import { createCRUDService } from './crudServiceCreator' 10 | import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader' 11 | import { loadSchemaSync } from '@graphql-tools/load' 12 | import { buildGraphbackAPI } from "graphback" 13 | import { DataSyncPlugin, createDataSyncMongoDbProvider } from "@graphback/datasync" 14 | 15 | /** 16 | * Creates Apollo server 17 | */ 18 | export const createApolloServer = async function (app: Express, config: Config) { 19 | const db = await connect(config); 20 | 21 | const modelDefs = loadSchemaSync(resolve(__dirname, '../model/task.graphql'), { 22 | loaders: [ 23 | new GraphQLFileLoader() 24 | ] 25 | }) 26 | 27 | const { typeDefs, resolvers, contextCreator } = buildGraphbackAPI(modelDefs, { 28 | serviceCreator: createCRUDService(), 29 | dataProviderCreator: createDataSyncMongoDbProvider(db), 30 | plugins: [ 31 | new DataSyncPlugin() 32 | ] 33 | }); 34 | 35 | let apolloConfig: ApolloServerExpressConfig = { 36 | typeDefs: typeDefs, 37 | resolvers: Object.assign(resolvers, customResolvers, scalars), 38 | playground: true, 39 | context: contextCreator 40 | } 41 | 42 | if (config.keycloakConfig) { 43 | apolloConfig = buildKeycloakApolloConfig(app, apolloConfig) 44 | } 45 | 46 | apolloConfig.resolvers = { ...apolloConfig.resolvers, ...scalars, ...customResolvers }; 47 | 48 | const apolloServer = new ApolloServer(apolloConfig) 49 | apolloServer.applyMiddleware({ app }); 50 | 51 | return apolloServer; 52 | } 53 | -------------------------------------------------------------------------------- /server/src/config/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | export class Config { 5 | public port: string | number 6 | public db: { database: string; user?: string; password?: string; host: string, port: number | string } 7 | public keycloakConfigPath: string 8 | public keycloakConfig: any 9 | public playgroundConfig: { tabs: { endpoint: string; variables: {}; query: string }[] } 10 | public mqttConfig: any; 11 | 12 | constructor() { 13 | this.port = process.env.PORT || 4000 14 | 15 | this.db = { 16 | database: process.env.MONGO_COLLECTION || 'showcase', 17 | host: process.env.MONGO_HOST || '127.0.0.1', 18 | user: process.env.MONGO_USER, 19 | password: process.env.MONGO_PASSWORD, 20 | port: process.env.MONGO_PORT || 27017 21 | } 22 | 23 | const mqttHost = process.env.MQTT_HOST 24 | 25 | if (mqttHost) { 26 | console.log('Using MQTT PubSub') 27 | this.mqttConfig = { 28 | host: mqttHost, 29 | servername: mqttHost, // needed to work in OpenShift. Lookup SNI. 30 | username: process.env.MQTT_USERNAME || '', 31 | password: process.env.MQTT_PASSWORD || '', 32 | port: process.env.MQTT_PORT || '1883', 33 | protocol: process.env.MQTT_PROTOCOL || 'mqtt', 34 | rejectUnauthorized: false 35 | } 36 | } 37 | 38 | this.keycloakConfigPath = process.env.KEYCLOAK_CONFIG || path.resolve(__dirname, './keycloak.json') 39 | this.keycloakConfig = readConfig(this.keycloakConfigPath) 40 | 41 | this.playgroundConfig = { 42 | tabs: [ 43 | { 44 | endpoint: `/graphql`, 45 | variables: {}, 46 | query: fs.readFileSync(path.resolve(__dirname, './playground.gql'), 'utf8') 47 | } 48 | ] 49 | } 50 | } 51 | } 52 | 53 | function readConfig(path) { 54 | try { 55 | return JSON.parse(fs.readFileSync(path, 'utf8')) 56 | } catch (e) { 57 | console.error(`Warning: couldn't find keycloak config at ${path}`) 58 | } 59 | } 60 | 61 | export const config = new Config() -------------------------------------------------------------------------------- /client/src/components/TaskList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Task } from './Task'; 3 | import { IonList, IonToast } from '@ionic/react'; 4 | import { useOfflineMutation } from 'react-offix-hooks'; 5 | import { ITask } from '../declarations'; 6 | import { Empty } from './Empty'; 7 | import { mutationOptions } from '../helpers'; 8 | import { updateTask, deleteTask } from '../graphql/generated'; 9 | 10 | export const TaskList: React.FC = ({ tasks }) => { 11 | 12 | const [updateTaskMutation] = useOfflineMutation(updateTask, mutationOptions.updateTask); 13 | const [deleteTaskMutation] = useOfflineMutation(deleteTask, mutationOptions.deleteTask); 14 | 15 | const [showToast, setShowToast] = useState(false); 16 | const [errorMessage, setErrorMessage] = useState(''); 17 | 18 | const handleError = (error: any) => { 19 | if (error.offline) { 20 | error.watchOfflineChange(); 21 | } 22 | if (error.graphQLErrors) { 23 | console.log(error.graphQLErrors); 24 | setErrorMessage(error.message); 25 | setShowToast(true); 26 | } 27 | } 28 | 29 | const handleDelete = (task: ITask) => { 30 | const { comments, __typename, createdAt, ...input } = task as any; 31 | deleteTaskMutation({ 32 | variables: { input } 33 | }).catch(handleError); 34 | }; 35 | 36 | const handleUpdate = (task: ITask) => { 37 | const { comments, __typename, ...input } = task as any; 38 | updateTaskMutation({ 39 | variables: { input } 40 | }) 41 | .catch(handleError); 42 | } 43 | 44 | if (tasks.length < 1) { 45 | const message = (

You currently have no tasks.

); 46 | return 47 | }; 48 | 49 | return ( 50 | <> 51 | 52 | { 53 | tasks.map((task: ITask) => { 54 | return ; 55 | }) 56 | } 57 | 58 | setShowToast(false)} 61 | message={errorMessage} 62 | position="top" 63 | color="danger" 64 | duration={2000} 65 | /> 66 | 67 | ); 68 | 69 | }; 70 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datasync-starter-client", 3 | "version": "0.0.1", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/react-hooks": "3.1.5", 7 | "@capacitor/core": "2.4.1", 8 | "@ionic/react": "5.1.1", 9 | "@ionic/react-router": "5.1.1", 10 | "@testing-library/jest-dom": "5.9.0", 11 | "@testing-library/react": "11.0.4", 12 | "@testing-library/user-event": "12.1.5", 13 | "@types/jest": "26.0.13", 14 | "@types/node": "12.12.47", 15 | "@types/react": "16.9.36", 16 | "@types/react-dom": "16.9.8", 17 | "@types/react-router": "5.1.7", 18 | "@types/react-router-dom": "5.1.5", 19 | "apollo-link-context": "1.0.20", 20 | "apollo-link-ws": "1.0.20", 21 | "graphql": "15.3.0", 22 | "graphql-tag": "2.11.0", 23 | "ionicons": "5.0.1", 24 | "keycloak-js": "11.0.2", 25 | "offix-cache": "0.16.0-alpha2", 26 | "offix-client": "0.16.0-alpha2", 27 | "react": "16.13.1", 28 | "react-dom": "16.13.1", 29 | "react-offix-hooks": "0.16.0-alpha2", 30 | "react-router": "5.2.0", 31 | "react-router-dom": "5.2.0", 32 | "react-scripts": "3.4.1", 33 | "simpl-schema": "1.7.3", 34 | "subscriptions-transport-ws": "0.9.16", 35 | "typescript": "4.0.2", 36 | "uniforms": "3.0.0-alpha.4", 37 | "uniforms-bridge-simple-schema-2": "3.0.0-alpha.4", 38 | "uniforms-ionic": "0.1.0" 39 | }, 40 | "scripts": { 41 | "start": "npm run build && cap serve", 42 | "dev": "react-scripts start", 43 | "build": "react-scripts build", 44 | "build:generic": "REACT_APP_URI_FORMAT=RELATIVEURI react-scripts build", 45 | "run:android": "cap copy android && cap open android", 46 | "run:ios": "cap copy ios && cap open ios", 47 | "eject": "react-scripts eject" 48 | }, 49 | "eslintConfig": { 50 | "extends": "react-app" 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | }, 64 | "devDependencies": { 65 | "@capacitor/cli": "2.4.1", 66 | "@types/simpl-schema": "0.2.7" 67 | }, 68 | "description": "An Ionic project" 69 | } 70 | -------------------------------------------------------------------------------- /.openshift/README.md: -------------------------------------------------------------------------------- 1 | ## OpenShift templates 2 | 3 | ### DataSync Starter App template 4 | 5 | Name: `datasync-app-template.yml` 6 | 7 | This template starts datasync container on top of the mongodb instances: 8 | 9 | #### Prerequisites 10 | 11 | 1. Running MongoDB instance 12 | 2. Connection details to MongoDB server 13 | 14 | #### Steps 15 | 16 | 1. Add template to your openshift 17 | 2. Provide MongoDB connection details 18 | 3. Wait for the pods to start 19 | 20 | # Deploying Server with AMQ 21 | 22 | Prerequisites 23 | 24 | * AMQ Online is installed in the cluster 25 | 26 | 27 | This section describes how to deploy the application in an OpenShift cluster by using the supplied `amq-topics.yml` template file. 28 | * The template is already prefilled with all of the necessary values that can be inspected 29 | * The only field you might want to change is `AMQ Messaging User Password`. 30 | * The default value is `Password1` in base64 encoding 31 | * The value *must* be base64 encoded 32 | * A custom value can be created in the terminal using `$ echo | base64` 33 | * Execute template on your openshift instance by `oc process -f amq-topics.yml | oc create -f -` 34 | 35 | The hostname for the AMQ Online Broker is only made available after the resources from the the template have been provisioned. One more step is needed to supply extra environment variables to running server. 36 | 37 | * From the terminal, ensure you have the correct namespace selected. 38 | 39 | ``` 40 | oc project 41 | ``` 42 | 43 | * Update the deployment to add the `MQTT_HOST` variable. 44 | 45 | ``` 46 | oc get addressspace datasync -o jsonpath='{.status.endpointStatuses[?(@.name=="messaging")].serviceHost}' 47 | ``` 48 | 49 | If you want to use service outside the OpenShift cluster please request external URL: 50 | ``` 51 | oc get addressspace datasync -o jsonpath='{.status.endpointStatuses[?(@.name=="messaging")].externalHost}' 52 | ``` 53 | 54 | Provide set of the environment variables required to connect to the running AMQ 55 | 56 | ``` 57 | MQTT_HOST=messaging-nj2y0929dk-redhat-rhmi-amq-online.apps.youropenshift.io 58 | MQTT_PORT=443 59 | MQTT_PASSWORD=Password1 60 | MQTT_USERNAME=messaging-user 61 | MQTT_PROTOCOL=tls 62 | ``` 63 | 64 | Check `../server/.env` file for all available variables 65 | -------------------------------------------------------------------------------- /server/src/auth.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import { config } from './config/config'; 3 | 4 | import { 5 | KeycloakTypeDefs, 6 | KeycloakSchemaDirectives, 7 | KeycloakSubscriptionContext, 8 | KeycloakSubscriptionHandler, 9 | KeycloakContext 10 | } from 'keycloak-connect-graphql' 11 | 12 | const session = require('express-session') 13 | const Keycloak = require('keycloak-connect') 14 | 15 | export function buildKeycloakApolloConfig(app: Express, apolloConfig: any) { 16 | const graphqlPath = `/graphql`; 17 | console.log("Using keycloak configuration") 18 | 19 | const memoryStore = new session.MemoryStore() 20 | app.use(session({ 21 | secret: process.env.SESSION_SECRET_STRING || 'this should be a long secret', 22 | resave: false, 23 | saveUninitialized: true, 24 | store: memoryStore 25 | })) 26 | 27 | const keycloak = new Keycloak({ 28 | store: memoryStore 29 | }, config.keycloakConfig); 30 | const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak }) 31 | 32 | app.use(keycloak.middleware()) 33 | 34 | app.use(graphqlPath, keycloak.protect()); 35 | 36 | return { 37 | typeDefs: [KeycloakTypeDefs, apolloConfig.typeDefs], // 1. Add the Keycloak Type Defs 38 | schemaDirectives: KeycloakSchemaDirectives, 39 | resolvers: apolloConfig.resolvers, 40 | playground: apolloConfig.playground, 41 | path: graphqlPath, 42 | context: (context) => { 43 | return { 44 | ...apolloConfig.context(context), 45 | kauth: new KeycloakContext({ req: context.req }) // 3. add the KeycloakContext to `kauth` 46 | } 47 | }, 48 | subscriptions: { 49 | onConnect: async (connectionParams, websocket, connectionContext) => { 50 | const token = await keycloakSubscriptionHandler.onSubscriptionConnect(connectionParams) 51 | if (!token) { 52 | throw new Error("Cannot build keycloak token. Connection will be terminated") 53 | } 54 | return { 55 | ...apolloConfig.context, 56 | kauth: new KeycloakSubscriptionContext(token) 57 | } 58 | } 59 | }, 60 | } 61 | } 62 | 63 | 64 | -------------------------------------------------------------------------------- /devfile.yaml: -------------------------------------------------------------------------------- 1 | metadata: 2 | name: datasync-starter 3 | projects: 4 | - name: datasync-starter 5 | source: 6 | location: 'https://github.com/aerogear/datasync-starter.git' 7 | type: git 8 | branch: walkthrough 9 | components: 10 | - id: che-incubator/typescript/latest 11 | memoryLimit: 512Mi 12 | type: chePlugin 13 | - mountSources: true 14 | endpoints: 15 | - name: server 16 | port: 4000 17 | - name: devclient 18 | port: 3333 19 | memoryLimit: 1500Mi 20 | type: dockerimage 21 | alias: nodejs 22 | image: 'registry.redhat.io/codeready-workspaces/stacks-node-rhel8:2.0' 23 | env: 24 | - value: 220fd770-c028-480d-8f95-f84353c7d55a 25 | name: SECRET 26 | - endpoints: 27 | - name: mongodb-34-rhel7 28 | port: 27017 29 | attributes: 30 | discoverable: 'true' 31 | public: 'false' 32 | memoryLimit: 512Mi 33 | type: dockerimage 34 | volumes: 35 | - name: mongo-storage 36 | containerPath: /var/lib/mongodb/data 37 | alias: mongo 38 | image: registry.redhat.io/rhscl/mongodb-34-rhel7 39 | env: 40 | - value: user 41 | name: MONGODB_USER 42 | - value: password 43 | name: MONGODB_PASSWORD 44 | - value: showcase 45 | name: MONGODB_DATABASE 46 | - value: password 47 | name: MONGODB_ADMIN_PASSWORD 48 | apiVersion: 1.0.0 49 | commands: 50 | - name: install dependencies 51 | actions: 52 | - workdir: '${CHE_PROJECTS_ROOT}/datasync-starter' 53 | type: exec 54 | command: git checkout walkthrough && npm install -g yarn && yarn install 55 | component: nodejs 56 | - name: generate source code 57 | actions: 58 | - workdir: '${CHE_PROJECTS_ROOT}/datasync-starter' 59 | type: exec 60 | command: 'yarn graphback generate' 61 | component: nodejs 62 | - name: prepare client 63 | actions: 64 | - workdir: '${CHE_PROJECTS_ROOT}/datasync-starter' 65 | type: exec 66 | command: 'yarn prepare:client' 67 | component: nodejs 68 | - name: start server 69 | actions: 70 | - workdir: '${CHE_PROJECTS_ROOT}/datasync-starter' 71 | type: exec 72 | command: >- 73 | MONGO_USER=user MONGO_PASSWORD=password MONGO_HOST=mongodb-34-rhel7 74 | yarn start:server 75 | component: nodejs 76 | -------------------------------------------------------------------------------- /client/src/components/Task.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent, SyntheticEvent } from 'react'; 2 | import { 3 | IonItem, 4 | IonButton, 5 | IonLabel, 6 | IonNote, 7 | IonBadge, 8 | IonIcon, 9 | IonCheckbox, 10 | IonButtons 11 | } from '@ionic/react'; 12 | import { create, trash, navigate } from 'ionicons/icons'; 13 | import { ITask } from '../declarations'; 14 | import { Link, useHistory } from 'react-router-dom'; 15 | 16 | export const Task: React.FC = ({ task, updateTask, deleteTask }) => { 17 | const history = useHistory(); 18 | const onDeleteClick = (event: MouseEvent) => { 19 | event.preventDefault(); 20 | deleteTask(task); 21 | }; 22 | 23 | const onViewClick = (event: MouseEvent) => { 24 | event.preventDefault(); 25 | history.push(`/viewTask/${task.id}`); 26 | }; 27 | 28 | const check = (event: SyntheticEvent) => { 29 | event.preventDefault(); 30 | let status = (task.status === 'COMPLETE') ? 'OPEN' : 'COMPLETE'; 31 | updateTask({ 32 | ...task, 33 | status 34 | }); 35 | } 36 | 37 | const isChecked = (task: ITask) => { 38 | if (task.status === 'COMPLETE') { 39 | return true; 40 | } 41 | return false; 42 | } 43 | 44 | return ( 45 | 46 | 47 | 48 |

{task.title}

49 | 50 | {task.description} 51 | 52 |
53 | 54 | 55 | Server timestamp: {new Date(Number.parseInt(task.updatedAt)).toUTCString()} 56 | 57 | 58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
76 | ); 77 | 78 | }; 79 | -------------------------------------------------------------------------------- /client/src/auth/keycloakAuth.ts: -------------------------------------------------------------------------------- 1 | import Keycloak, { KeycloakInstance } from 'keycloak-js'; 2 | import { ILogoutParams } from '../declarations'; 3 | 4 | export let keycloak: KeycloakInstance | undefined; 5 | 6 | /** 7 | * Get keycloak instance 8 | * 9 | * @return an initiated keycloak instance or `undefined` 10 | * if keycloak isn't configured 11 | * 12 | */ 13 | export const getKeycloakInstance = async () => { 14 | if (!keycloak) await init(); 15 | return keycloak; 16 | } 17 | 18 | /** 19 | * Initiate keycloak instance. 20 | * 21 | * Set keycloak to undefined if 22 | * keycloak isn't configured 23 | * 24 | */ 25 | export const init = async () => { 26 | try { 27 | keycloak = new (Keycloak as any )(); 28 | if (keycloak) { 29 | await keycloak.init({ 30 | onLoad: 'login-required' 31 | }); 32 | } 33 | } catch { 34 | keycloak = undefined; 35 | console.warn('Auth: Unable to initialize keycloak. Client side will not be configured to use authentication'); 36 | } 37 | } 38 | 39 | 40 | /** 41 | * This function keeps getting called by wslink 42 | * connection param function, so carry out 43 | * an early return if keycloak is not initialized 44 | * otherwise get the auth token 45 | * 46 | * @return authorization header or empty string 47 | * 48 | */ 49 | export const getAuthHeader = async () => { 50 | if (!keycloak) return ''; 51 | return { 52 | 'authorization': `Bearer ${await getKeyCloakToken()}` 53 | }; 54 | }; 55 | 56 | 57 | /** 58 | * Use keycloak update token function to retrieve 59 | * keycloak token 60 | * 61 | * @return keycloak token or empty string if keycloak 62 | * isn't configured 63 | * 64 | */ 65 | const getKeyCloakToken = async () => { 66 | await keycloak?.updateToken(50); 67 | if (keycloak?.token) return keycloak.token; 68 | console.error('No keycloak token available'); 69 | return ''; 70 | } 71 | 72 | /** 73 | * logout of keycloak, clear cache and offline store then redirect to 74 | * keycloak login page 75 | * 76 | * @param keycloak the keycloak instance 77 | * @param client offix client 78 | * 79 | */ 80 | export const logout = async ({ keycloak, client: apolloClient } : ILogoutParams) => { 81 | if(keycloak) { 82 | await keycloak.logout(); 83 | // clear offix client offline store 84 | await apolloClient.resetStore(); 85 | // clear offix client cache 86 | await apolloClient.cache.reset(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /client/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import { IonHeader, IonToolbar, IonButtons, IonTitle, IonToast, IonButton, IonIcon } from '@ionic/react'; 3 | import { person, exit, arrowBack } from 'ionicons/icons'; 4 | import { AuthContext } from '../AuthContext'; 5 | import { logout } from '../auth/keycloakAuth'; 6 | import { useApolloOfflineClient } from 'react-offix-hooks'; 7 | import { Link } from 'react-router-dom'; 8 | 9 | export const Header : React.FC<{ title: string, backHref?: string, match: any, isOnline?: boolean }> = ({ title, backHref, match, isOnline }) => { 10 | 11 | const { url } = match; 12 | 13 | const client = useApolloOfflineClient(); 14 | const { keycloak } = useContext(AuthContext); 15 | const [ showToast, setShowToast ] = useState(false); 16 | 17 | const handleLogout = async () => { 18 | if (isOnline) { 19 | await logout({ keycloak, client }); 20 | return; 21 | } 22 | setShowToast(true); 23 | } 24 | 25 | // if keycloak is not configured, don't display logout and 26 | // profile icons. Only show login and profile icons on the home 27 | // screen 28 | const buttons = (!keycloak || url !== '/tasks') ? <> : ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | 41 | return ( 42 | <> 43 | 44 | 45 | { 46 | url !== '/tasks' && 47 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | } 59 | { title } 60 | { buttons } 61 | 62 | 63 | setShowToast(false)} 66 | message="You are currently offline. Unable to logout." 67 | position="top" 68 | color="danger" 69 | duration={1000} 70 | /> 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /server/src/crudServiceCreator.ts: -------------------------------------------------------------------------------- 1 | import { GraphbackOperationType, CRUDService } from '@graphback/core' 2 | import { KeycloakCrudService, CrudServicesAuthConfig } from '@graphback/keycloak-authz' 3 | import { ModelDefinition, GraphbackCRUDService, CRUDServiceConfig, GraphbackDataProvider } from 'graphback'; 4 | import { DataSyncProvider, DataSyncCRUDService } from '@graphback/datasync'; 5 | import { connectToPubSub } from './pubsub'; 6 | import { config } from "./config/config" 7 | import { authConfig } from "./config/auth" 8 | import { PubSub } from 'graphql-subscriptions'; 9 | import e from 'express'; 10 | 11 | /** 12 | * Creates Graphback service with following capabilities: 13 | * 14 | * - DataSync 15 | * - Keycloak 16 | * - AMQ custom topics 17 | * 18 | * This functions tries to enable various capabilities based on the config provided 19 | */ 20 | export function createCRUDService(globalServiceConfig?: CRUDServiceConfig) { 21 | let pubSub; 22 | if (config.mqttConfig) { 23 | pubSub = connectToPubSub(); 24 | } else { 25 | pubSub = new PubSub(); 26 | } 27 | 28 | return (model: ModelDefinition, dataProvider: DataSyncProvider): GraphbackCRUDService => { 29 | const serviceConfig: CRUDServiceConfig = { 30 | pubSub, 31 | ...globalServiceConfig, 32 | crudOptions: model.crudOptions 33 | } 34 | let service 35 | if (config.mqttConfig) { 36 | service = new AMQCRUDDataSyncService(model.graphqlType.name, dataProvider, serviceConfig); 37 | } else { 38 | service = new DataSyncCRUDService(model.graphqlType.name, dataProvider, serviceConfig); 39 | } 40 | 41 | if (config.keycloakConfig) { 42 | const objConfig = authConfig[model.graphqlType.name]; 43 | const keycloakService = new KeycloakCrudService({ service, authConfig: objConfig }); 44 | 45 | return keycloakService; 46 | } 47 | 48 | return service; 49 | } 50 | } 51 | 52 | /** 53 | * Service that allows you to configure how AMQ topics are build 54 | */ 55 | export class AMQCRUDDataSyncService extends DataSyncCRUDService { 56 | constructor(modelName: string, db: DataSyncProvider, config: CRUDServiceConfig) { 57 | super(modelName, db, config); 58 | } 59 | protected subscriptionTopicMapping(triggerType: GraphbackOperationType, objectName: string) { 60 | // Support AMQ topic creation format 61 | return `graphql/${objectName}_${triggerType}` 62 | } 63 | } -------------------------------------------------------------------------------- /client/src/pages/TaskPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useQuery } from '@apollo/react-hooks'; 3 | import { add } from 'ionicons/icons'; 4 | import { 5 | IonPage, 6 | IonSegment, 7 | IonSegmentButton, 8 | IonLabel, 9 | IonIcon, 10 | IonFooter, 11 | IonLoading, 12 | IonFab, 13 | IonFabButton, 14 | IonContent, 15 | } from '@ionic/react'; 16 | import { subscriptionOptions, } from '../helpers'; 17 | import { Empty, TaskList, NetworkBadge, OfflineQueueBadge, Header } from '../components'; 18 | import { RouteComponentProps } from 'react-router'; 19 | import { findTasks } from '../graphql/generated'; 20 | import { Link } from 'react-router-dom'; 21 | import { useNetworkStatus } from 'react-offix-hooks'; 22 | 23 | export const TaskPage: React.FC = ({match}) => { 24 | 25 | const [subscribed, setSubscribed] = useState(false); 26 | const { loading, error, data, subscribeToMore } = useQuery(findTasks, { 27 | fetchPolicy: 'cache-and-network' 28 | }); 29 | 30 | const isOnline = useNetworkStatus(); 31 | 32 | useEffect(() => { 33 | if (!subscribed) { 34 | subscribeToMore(subscriptionOptions.add); 35 | subscribeToMore(subscriptionOptions.edit); 36 | subscribeToMore(subscriptionOptions.remove); 37 | setSubscribed(true); 38 | } 39 | }, [subscribed, setSubscribed, subscribeToMore]) 40 | 41 | if (error && !error.networkError) { 42 | return
{ JSON.stringify(error) }
43 | }; 44 | 45 | if (loading) return ; 49 | 50 | const content = (data && data.findTasks && data.findTasks.items) 51 | ? 52 | : No tasks available

} />; 53 | 54 | return ( 55 | 56 |
57 | 58 | 59 | 60 | All Tasks 61 | 62 | 63 | { content } 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 |
74 | 75 | 76 |
77 |
78 | 79 | ); 80 | 81 | }; 82 | -------------------------------------------------------------------------------- /client/src/theme/variables.css: -------------------------------------------------------------------------------- 1 | /* Ionic Variables and Theming. For more info, please see: 2 | http://ionicframework.com/docs/theming/ */ 3 | 4 | /** Ionic CSS Variables **/ 5 | :root { 6 | /** primary **/ 7 | --ion-color-primary: #3880ff; 8 | --ion-color-primary-rgb: 56, 128, 255; 9 | --ion-color-primary-contrast: #ffffff; 10 | --ion-color-primary-contrast-rgb: 255, 255, 255; 11 | --ion-color-primary-shade: #3171e0; 12 | --ion-color-primary-tint: #4c8dff; 13 | 14 | /** secondary **/ 15 | --ion-color-secondary: #0cd1e8; 16 | --ion-color-secondary-rgb: 12, 209, 232; 17 | --ion-color-secondary-contrast: #ffffff; 18 | --ion-color-secondary-contrast-rgb: 255, 255, 255; 19 | --ion-color-secondary-shade: #0bb8cc; 20 | --ion-color-secondary-tint: #24d6ea; 21 | 22 | /** tertiary **/ 23 | --ion-color-tertiary: #7044ff; 24 | --ion-color-tertiary-rgb: 112, 68, 255; 25 | --ion-color-tertiary-contrast: #ffffff; 26 | --ion-color-tertiary-contrast-rgb: 255, 255, 255; 27 | --ion-color-tertiary-shade: #633ce0; 28 | --ion-color-tertiary-tint: #7e57ff; 29 | 30 | /** success **/ 31 | --ion-color-success: #10dc60; 32 | --ion-color-success-rgb: 16, 220, 96; 33 | --ion-color-success-contrast: #ffffff; 34 | --ion-color-success-contrast-rgb: 255, 255, 255; 35 | --ion-color-success-shade: #0ec254; 36 | --ion-color-success-tint: #28e070; 37 | 38 | /** warning **/ 39 | --ion-color-warning: #ffce00; 40 | --ion-color-warning-rgb: 255, 206, 0; 41 | --ion-color-warning-contrast: #ffffff; 42 | --ion-color-warning-contrast-rgb: 255, 255, 255; 43 | --ion-color-warning-shade: #e0b500; 44 | --ion-color-warning-tint: #ffd31a; 45 | 46 | /** danger **/ 47 | --ion-color-danger: #f04141; 48 | --ion-color-danger-rgb: 245, 61, 61; 49 | --ion-color-danger-contrast: #ffffff; 50 | --ion-color-danger-contrast-rgb: 255, 255, 255; 51 | --ion-color-danger-shade: #d33939; 52 | --ion-color-danger-tint: #f25454; 53 | 54 | /** dark **/ 55 | --ion-color-dark: #222428; 56 | --ion-color-dark-rgb: 34, 34, 34; 57 | --ion-color-dark-contrast: #ffffff; 58 | --ion-color-dark-contrast-rgb: 255, 255, 255; 59 | --ion-color-dark-shade: #1e2023; 60 | --ion-color-dark-tint: #383a3e; 61 | 62 | /** medium **/ 63 | --ion-color-medium: #989aa2; 64 | --ion-color-medium-rgb: 152, 154, 162; 65 | --ion-color-medium-contrast: #ffffff; 66 | --ion-color-medium-contrast-rgb: 255, 255, 255; 67 | --ion-color-medium-shade: #86888f; 68 | --ion-color-medium-tint: #a2a4ab; 69 | 70 | /** light **/ 71 | --ion-color-light: #f4f5f8; 72 | --ion-color-light-rgb: 244, 244, 244; 73 | --ion-color-light-contrast: #000000; 74 | --ion-color-light-contrast-rgb: 0, 0, 0; 75 | --ion-color-light-shade: #d7d8da; 76 | --ion-color-light-tint: #f5f6f9; 77 | } 78 | -------------------------------------------------------------------------------- /client/src/pages/ProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { 3 | IonContent, 4 | IonCard, 5 | IonCardHeader, 6 | IonCardTitle, 7 | IonCardSubtitle, 8 | IonCardContent, 9 | IonItemGroup, 10 | IonItemDivider, 11 | IonList, 12 | IonItem, 13 | IonLabel 14 | } from '@ionic/react'; 15 | import { Header } from '../components'; 16 | import { AuthContext } from '../AuthContext'; 17 | import { RouteComponentProps } from 'react-router'; 18 | 19 | const userInit = { 20 | username: 'unknown', 21 | email: 'unknown', 22 | firstName: 'unknown', 23 | lastName: 'unknown', 24 | emailVerified: false, 25 | } 26 | 27 | export const ProfilePage: React.FC = ({ match }) => { 28 | const { keycloak, profile } = useContext(AuthContext); 29 | 30 | if (!keycloak || !profile) return ( 31 | 32 | 33 | Authentication not configured 34 | IDM service required 35 | 36 | 37 | Profile page cannot be displayed. 38 | Please enable Auth SDK by providing configuration pointing to your IDM service 39 | 40 | 41 | ); 42 | 43 | const user = Object.assign(userInit, profile); 44 | 45 | const fullName = (user.firstName !== 'unknown' && user.lastName !== 'unknown') 46 | ? `${user.firstName} ${user.lastName}` 47 | : 'unknown'; 48 | 49 | const roles = keycloak.realmAccess?.roles.map((role, index) => { 50 | return {role} 51 | }); 52 | 53 | return ( 54 | <> 55 |
56 | 57 | 58 | 59 | 60 |

Provider

61 |
62 | 63 |
Full Name: {fullName}
64 |
65 |
66 | 67 |
Email: {user.email}
68 |
69 |
70 | 71 |
Username: {user.username}
72 |
73 |
74 | 75 | Email Verified: {user.emailVerified ? 'Yes' : 'No'} 76 | 77 |
78 | 79 | 80 | 81 |

Assigned Roles

82 |
83 | {roles} 84 |
85 |
86 |
87 |
88 | 89 | ); 90 | 91 | }; 92 | -------------------------------------------------------------------------------- /client/src/pages/UpdateTaskPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { RouteComponentProps } from 'react-router-dom' 3 | import { 4 | IonContent, 5 | IonLoading, 6 | IonToast, 7 | IonCard, 8 | } from '@ionic/react'; 9 | import { useOfflineMutation } from 'react-offix-hooks'; 10 | import { useQuery } from '@apollo/react-hooks'; 11 | import { Header } from '../components/Header'; 12 | import { Empty } from '../components/Empty'; 13 | import { getTask, updateTask } from '../graphql/generated'; 14 | import { TaskForm } from '../forms/TaskForm'; 15 | import { subscriptionOptions, mutationOptions } from '../helpers'; 16 | 17 | export interface IUpdateMatchParams { 18 | id: string 19 | } 20 | 21 | export const UpdateTaskPage: React.FC> = ({ history, match }) => { 22 | 23 | const { id } = match.params; 24 | const [mounted, setMounted] = useState(false); 25 | const [showToast, setShowToast] = useState(false); 26 | const [errorMessage, setErrorMessage] = useState(''); 27 | const { loading, error, data, subscribeToMore } = useQuery(getTask, { 28 | variables: { id }, 29 | fetchPolicy: 'cache-only', 30 | }); 31 | 32 | useEffect(() => { 33 | if (mounted) { 34 | subscribeToMore(subscriptionOptions.addComment) 35 | } 36 | setMounted(true); 37 | return () => setMounted(false); 38 | }, [mounted, setMounted, subscribeToMore]); 39 | 40 | const [updateTaskMutation] = useOfflineMutation( 41 | updateTask, mutationOptions.updateTask, 42 | ); 43 | 44 | const handleError = (error: any) => { 45 | if (error.offline) { 46 | error.watchOfflineChange(); 47 | history.push('/'); 48 | return; 49 | } 50 | console.log(error); 51 | setErrorMessage(error.message); 52 | setShowToast(true); 53 | } 54 | 55 | const submit = (model: any) => { 56 | const { __typename, comments, createdAt, ...input } = model; 57 | updateTaskMutation({ 58 | variables: { input } 59 | }) 60 | .then(() => history.push('/')) 61 | .catch(handleError); 62 | } 63 | 64 | if (error) return
{JSON.stringify(error)}
; 65 | 66 | if (loading) return ; 70 | 71 | if (data && data.getTask) { 72 | const task = data.getTask; 73 | return ( 74 | <> 75 |
76 | 77 | 78 | 79 | 80 | setShowToast(false)} 83 | message={errorMessage} 84 | position="top" 85 | color="danger" 86 | duration={2000} 87 | /> 88 | 89 | 90 | ) 91 | }; 92 | 93 | return ( 94 | <> 95 |
96 | No task found

} /> 97 | 98 | ); 99 | 100 | } 101 | -------------------------------------------------------------------------------- /client/src/forms/task.ts: -------------------------------------------------------------------------------- 1 | import { SimpleSchema2Bridge } from "uniforms-bridge-simple-schema-2"; 2 | import { LongTextField } from "uniforms-ionic"; 3 | import SimpleSchema from "../config/SimpleSchema"; 4 | 5 | const taskForm = { 6 | title: { 7 | type: String 8 | }, 9 | description: { 10 | type: String, 11 | uniforms: { 12 | component: LongTextField, 13 | }, 14 | }, 15 | 16 | status: { 17 | type: String, 18 | defaultValue: "OPEN", 19 | allowedValues: ["OPEN", "ASSIGNED", "COMPLETE"], 20 | }, 21 | 22 | startDate: { 23 | type: Date, 24 | defaultValue: new Date() 25 | }, 26 | 27 | public: { 28 | type: Boolean, 29 | required: false 30 | }, 31 | 32 | type: { 33 | type: String, 34 | allowedValues: ["External", "ByAppointment", "Remote"], 35 | required: false 36 | }, 37 | 38 | priority: { 39 | type: Number, 40 | allowedValues: [1, 2, 3, 4, 5], 41 | required: false 42 | }, 43 | 44 | } as any 45 | 46 | const taskView = { 47 | title: { 48 | type: String, 49 | uniforms: { 50 | readonly: true 51 | } 52 | }, 53 | description: { 54 | type: String, 55 | uniforms: { 56 | component: LongTextField, 57 | readonly: true 58 | }, 59 | }, 60 | status: { 61 | type: String, 62 | defaultValue: "OPEN", 63 | allowedValues: ["OPEN", "ASSIGNED", "COMPLETE"], 64 | required: false, 65 | uniforms: { 66 | readonly: true 67 | } 68 | }, 69 | 70 | startDate: { 71 | type: Date, 72 | required: false, 73 | defaultValue: new Date(), 74 | uniforms: { 75 | readonly: true 76 | } 77 | }, 78 | 79 | public: { 80 | required: false, 81 | type: Boolean, 82 | uniforms: { 83 | readonly: true 84 | } 85 | }, 86 | 87 | type: { 88 | type: String, 89 | allowedValues: ["External", "ByAppointment", "Remote"], 90 | required: false, 91 | uniforms: { 92 | readonly: true 93 | } 94 | }, 95 | 96 | priority: { 97 | type: Number, 98 | allowedValues: [1, 2, 3, 4, 5], 99 | required: false, 100 | uniforms: { 101 | readonly: true 102 | } 103 | }, 104 | 105 | lastUpdated: { 106 | type: String, 107 | defaultValue: "0", 108 | uniforms: { 109 | readonly: true 110 | } 111 | }, 112 | 113 | comments: { 114 | type: Array, 115 | required: false 116 | }, 117 | 118 | 'comments.$': { 119 | type: Object, 120 | uniforms: { 121 | readonly: true 122 | } 123 | }, 124 | 125 | 'comments.$.message': { 126 | type: String, 127 | uniforms: { 128 | component: LongTextField, 129 | readonly: true 130 | } 131 | } 132 | } as any 133 | 134 | 135 | const commentForm = { 136 | author: { 137 | type: String, 138 | uniforms: { 139 | readonly: true 140 | } 141 | }, 142 | message: { 143 | type: String, 144 | uniforms: { 145 | component: LongTextField, 146 | } 147 | } 148 | } as any 149 | 150 | const schemaEdit = new SimpleSchema(taskForm) 151 | export const taskEditSchema = new SimpleSchema2Bridge(schemaEdit); 152 | 153 | const schemaView = new SimpleSchema(taskView) 154 | export const taskViewSchema = new SimpleSchema2Bridge(schemaView); 155 | 156 | const commentEdit = new SimpleSchema(commentForm) 157 | export const commentViewSchema = new SimpleSchema2Bridge(commentEdit); -------------------------------------------------------------------------------- /client/src/config/clientConfig.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink } from 'apollo-link'; 2 | import { HttpLink } from 'apollo-link-http'; 3 | import { WebSocketLink } from 'apollo-link-ws'; 4 | import { setContext } from 'apollo-link-context'; 5 | import { getMainDefinition } from 'apollo-utilities'; 6 | import { InMemoryCache } from 'apollo-cache-inmemory'; 7 | import { globalCacheUpdates, ConflictLogger } from '../helpers'; 8 | import { getAuthHeader } from '../auth/keycloakAuth'; 9 | import { ApolloOfflineClientOptions } from 'offix-client'; 10 | import { Capacitor } from '@capacitor/core'; 11 | import { CapacitorNetworkStatus } from '../helpers/CapacitorNetworkStatus'; 12 | import { TimeStampState } from './conflictStrategy'; 13 | 14 | 15 | let httpUri = 'http://localhost:4000/graphql'; 16 | let wsUri = 'ws://localhost:4000/graphql'; 17 | 18 | if (Capacitor.isNative && Capacitor.platform === 'android') { 19 | httpUri = 'http://10.0.2.2:4000/graphql'; 20 | wsUri = 'ws://10.0.2.2:4000/graphql'; 21 | } 22 | 23 | if (process.env.REACT_APP_URI_FORMAT === 'RELATIVEURI') { 24 | httpUri = "/graphql"; 25 | const protocol = window.location.protocol === "https:" ? "wss://" : "ws://"; 26 | const port = window.location.port !== "" ? `:${window.location.port}` : ""; 27 | wsUri = `${protocol}${window.location.hostname}${port}${httpUri}` 28 | } 29 | 30 | /** 31 | * Create websocket link and 32 | * define websocket link options 33 | */ 34 | const wsLink = new WebSocketLink({ 35 | uri: wsUri, 36 | options: { 37 | reconnect: true, 38 | lazy: true, 39 | // returns auth header or empty string 40 | connectionParams: async () => (await getAuthHeader()) 41 | }, 42 | }); 43 | 44 | const httpLink = new HttpLink({ 45 | uri: httpUri, 46 | }); 47 | 48 | /** 49 | * add authorization headers for queries 50 | * to grapqhql backend 51 | * 52 | */ 53 | const authLink = setContext(async (_, { headers }) => { 54 | return { 55 | headers: { 56 | ...headers, 57 | // returns auth header or empty string 58 | ...await getAuthHeader() 59 | } 60 | } 61 | }); 62 | 63 | /** 64 | * split queries and subscriptions. 65 | * send subscriptions to websocket url & 66 | * queries to http url 67 | * 68 | */ 69 | const splitLink = ApolloLink.split( 70 | ({ query }) => { 71 | const { kind, operation }: any = getMainDefinition(query); 72 | return kind === 'OperationDefinition' && operation === 'subscription'; 73 | }, 74 | wsLink, 75 | httpLink, 76 | ); 77 | 78 | /** 79 | * Instantiate cache object 80 | * and define cache redirect queries 81 | * 82 | */ 83 | const cache = new InMemoryCache({ 84 | // cache redirects are used 85 | // to query the cache for individual Task item 86 | cacheRedirects: { 87 | Query: { 88 | getTask: (_, { id }, { getCacheKey }) => getCacheKey({ __typename: 'Task', id }), 89 | }, 90 | }, 91 | }); 92 | 93 | export const clientConfig: ApolloOfflineClientOptions = { 94 | link: authLink.concat(splitLink), 95 | cache: cache, 96 | conflictListener: new ConflictLogger(), 97 | mutationCacheUpdates: globalCacheUpdates, 98 | networkStatus: new CapacitorNetworkStatus(), 99 | conflictProvider: new TimeStampState(), 100 | inputMapper: { 101 | deserialize: (variables: any) => { 102 | return (variables && variables.input) ? variables.input : variables; 103 | }, 104 | serialize: (variables: any) => { 105 | return { input: variables } 106 | } 107 | } 108 | }; 109 | -------------------------------------------------------------------------------- /client/src/pages/ViewTaskPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useEffect } from 'react' 2 | import { RouteComponentProps } from 'react-router-dom' 3 | import { IonContent, IonLoading, IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonList, IonItemGroup, IonItem, IonLabel, IonAvatar, IonToast } from '@ionic/react'; 4 | import { Header } from '../components/Header'; 5 | import { useQuery, useMutation } from '@apollo/react-hooks'; 6 | import { Empty } from '../components'; 7 | import { createComment, getTask, findTasks } from '../graphql/generated'; 8 | import { commentViewSchema, taskViewSchema } from '../forms/task'; 9 | import { AutoForm, TextField } from "uniforms-ionic"; 10 | import { AuthContext } from '../AuthContext'; 11 | import { subscriptionOptions } from '../helpers'; 12 | import { useNetworkStatus } from 'react-offix-hooks'; 13 | 14 | export interface ViewMatchParams { 15 | id: string 16 | } 17 | 18 | export const ViewTaskPage: React.FC> = ({ history, match }) => { 19 | const [showToast, setShowToast] = useState(false); 20 | const offline = !useNetworkStatus() 21 | const { id } = match.params; 22 | const [mounted, setMounted] = useState(false); 23 | const { profile } = useContext(AuthContext); 24 | const [createCommentMutation] = useMutation( 25 | createComment, { refetchQueries: [{ query: findTasks }] } 26 | ); 27 | const userName = profile?.username || "Anonymous User"; 28 | 29 | const submit = (model: any) => { 30 | if (offline) { 31 | setShowToast(offline) 32 | } else { 33 | createCommentMutation({ 34 | variables: { input: { ...model, noteId: id } } 35 | }).then((comment) => { 36 | console.log("comment created") 37 | }).catch((error) => { 38 | console.log(error) 39 | }) 40 | } 41 | 42 | } 43 | 44 | const { loading, error, data, subscribeToMore } = useQuery(getTask, { 45 | variables: { id }, 46 | fetchPolicy: 'cache-only', 47 | }); 48 | 49 | useEffect(() => { 50 | if (mounted) { 51 | subscribeToMore(subscriptionOptions.addComment) 52 | } 53 | setMounted(true); 54 | return () => setMounted(false); 55 | }, [mounted, setMounted, subscribeToMore]); 56 | 57 | if (error) return
{JSON.stringify(error)}
; 58 | 59 | if (loading) return ; 63 | 64 | if (data && data.getTask) { 65 | const task = data.getTask; 66 | const Text = TextField as any; 67 | return ( 68 | <> 69 |
70 | 71 | 72 | 73 | Current Task 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | Create comment 83 | 84 | 85 | 86 | 87 | 88 | Comments 89 | 90 | 91 | 92 | 93 | { 94 | task.comments && task.comments.map((comment: any, key: number) => { 95 | return ( 96 | 97 | 98 | 99 | 100 | 101 |

{comment.author}

102 |

{comment.message}

103 |
104 |
105 | ); 106 | }) 107 | } 108 |
109 |
110 |
111 |
112 | setShowToast(false)} 115 | message="Cannot add comment when offline" 116 | position="bottom" 117 | color="danger" 118 | duration={4000} 119 | /> 120 |
121 | 122 | ) 123 | } 124 | return ( 125 | <> 126 |
127 | No task found

} /> 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /client/src/graphql/generated.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag" 2 | 3 | export const TaskFragment = gql` 4 | fragment TaskFields on Task { 5 | id 6 | title 7 | description 8 | status 9 | type 10 | priority 11 | public 12 | startDate 13 | payload 14 | updatedAt 15 | } 16 | ` 17 | 18 | 19 | export const TaskExpandedFragment = gql` 20 | fragment TaskExpandedFields on Task { 21 | id 22 | title 23 | description 24 | status 25 | type 26 | priority 27 | public 28 | startDate 29 | payload 30 | comments { 31 | id 32 | message 33 | author 34 | } 35 | updatedAt 36 | } 37 | ` 38 | 39 | 40 | 41 | 42 | export const CommentFragment = gql` 43 | fragment CommentFields on Comment { 44 | id 45 | message 46 | author 47 | 48 | } 49 | ` 50 | 51 | 52 | export const CommentExpandedFragment = gql` 53 | fragment CommentExpandedFields on Comment { 54 | id 55 | message 56 | author 57 | note { 58 | id 59 | title 60 | description 61 | status 62 | type 63 | priority 64 | public 65 | startDate 66 | payload 67 | } 68 | updatedAt 69 | } 70 | ` 71 | 72 | 73 | export const findTasks = gql` 74 | query findTasks($filter: TaskFilter, $page: PageRequest, $orderBy: OrderByInput) { 75 | findTasks(filter: $filter, page: $page, orderBy: $orderBy) { 76 | items { 77 | ...TaskExpandedFields 78 | } 79 | offset 80 | limit 81 | count 82 | } 83 | } 84 | 85 | ${TaskExpandedFragment} 86 | ` 87 | 88 | 89 | export const getTask = gql` 90 | query getTask($id: ID!) { 91 | getTask(id: $id) { 92 | ...TaskExpandedFields 93 | } 94 | } 95 | 96 | ${TaskExpandedFragment} 97 | ` 98 | 99 | 100 | export const findComments = gql` 101 | query findComments($filter: CommentFilter, $page: PageRequest, $orderBy: OrderByInput) { 102 | findComments(filter: $filter, page: $page, orderBy: $orderBy) { 103 | items { 104 | ...CommentExpandedFields 105 | } 106 | offset 107 | limit 108 | count 109 | } 110 | } 111 | 112 | ${CommentExpandedFragment} 113 | ` 114 | 115 | 116 | export const getComment = gql` 117 | query getComment($id: ID!) { 118 | getComment(id: $id) { 119 | ...CommentExpandedFields 120 | } 121 | } 122 | 123 | ${CommentExpandedFragment} 124 | ` 125 | 126 | 127 | export const createTask = gql` 128 | mutation createTask($input: CreateTaskInput!) { 129 | createTask(input: $input) { 130 | ...TaskFields 131 | } 132 | } 133 | 134 | 135 | ${TaskFragment} 136 | ` 137 | 138 | 139 | export const updateTask = gql` 140 | mutation updateTask($input: MutateTaskInput!) { 141 | updateTask(input: $input) { 142 | ...TaskFields 143 | } 144 | } 145 | 146 | 147 | ${TaskFragment} 148 | ` 149 | 150 | 151 | export const deleteTask = gql` 152 | mutation deleteTask($input: MutateTaskInput!) { 153 | deleteTask(input: $input) { 154 | ...TaskFields 155 | } 156 | } 157 | 158 | 159 | ${TaskFragment} 160 | ` 161 | 162 | 163 | export const createComment = gql` 164 | mutation createComment($input: CreateCommentInput!) { 165 | createComment(input: $input) { 166 | ...CommentFields 167 | } 168 | } 169 | 170 | 171 | ${CommentFragment} 172 | ` 173 | 174 | 175 | export const updateComment = gql` 176 | mutation updateComment($input: MutateCommentInput!) { 177 | updateComment(input: $input) { 178 | ...CommentFields 179 | } 180 | } 181 | 182 | 183 | ${CommentFragment} 184 | ` 185 | 186 | 187 | export const deleteComment = gql` 188 | mutation deleteComment($input: MutateCommentInput!) { 189 | deleteComment(input: $input) { 190 | ...CommentFields 191 | } 192 | } 193 | 194 | 195 | ${CommentFragment} 196 | ` 197 | 198 | 199 | export const newTask = gql` 200 | subscription newTask($filter: TaskSubscriptionFilter) { 201 | newTask(filter: $filter) { 202 | ...TaskExpandedFields 203 | } 204 | } 205 | 206 | ${TaskExpandedFragment} 207 | ` 208 | 209 | 210 | export const updatedTask = gql` 211 | subscription updatedTask($filter: TaskSubscriptionFilter) { 212 | updatedTask(filter: $filter) { 213 | ...TaskFields 214 | } 215 | } 216 | 217 | ${TaskFragment} 218 | ` 219 | 220 | 221 | export const deletedTask = gql` 222 | subscription deletedTask($filter: TaskSubscriptionFilter) { 223 | deletedTask(filter: $filter) { 224 | ...TaskFields 225 | } 226 | } 227 | 228 | ${TaskFragment} 229 | ` 230 | 231 | 232 | export const newComment = gql` 233 | subscription newComment($filter: CommentSubscriptionFilter) { 234 | newComment(filter: $filter) { 235 | ...CommentFields 236 | } 237 | } 238 | 239 | ${CommentFragment} 240 | ` 241 | 242 | 243 | export const updatedComment = gql` 244 | subscription updatedComment($filter: CommentSubscriptionFilter) { 245 | updatedComment(filter: $filter) { 246 | ...CommentFields 247 | } 248 | } 249 | 250 | ${CommentFragment} 251 | ` 252 | 253 | 254 | export const deletedComment = gql` 255 | subscription deletedComment($filter: CommentSubscriptionFilter) { 256 | deletedComment(filter: $filter) { 257 | ...CommentFields 258 | } 259 | } 260 | 261 | ${CommentFragment} 262 | ` 263 | -------------------------------------------------------------------------------- /server/src/schema/schema.graphql: -------------------------------------------------------------------------------- 1 | ## NOTE: This schema was generated by Graphback and should not be changed manually 2 | 3 | """Exposes a URL that specifies the behaviour of this scalar.""" 4 | directive @specifiedBy( 5 | """The URL that specifies the behaviour of this scalar.""" 6 | url: String! 7 | ) on SCALAR 8 | 9 | input BooleanInput { 10 | ne: Boolean 11 | eq: Boolean 12 | } 13 | 14 | """ 15 | @model 16 | @crud(delete: false) 17 | @crud(update: false) 18 | """ 19 | type Comment { 20 | """@id""" 21 | id: ObjectID! 22 | message: String! 23 | author: String! 24 | 25 | """@manyToOne(field: 'comments', key: 'noteId')""" 26 | note: Task 27 | } 28 | 29 | input CommentFilter { 30 | id: ObjectIDInput 31 | message: StringInput 32 | author: StringInput 33 | noteId: ObjectIDInput 34 | and: [CommentFilter] 35 | or: [CommentFilter] 36 | not: CommentFilter 37 | } 38 | 39 | type CommentResultList { 40 | items: [Comment]! 41 | offset: Int 42 | limit: Int 43 | count: Int 44 | } 45 | 46 | input CommentSubscriptionFilter { 47 | id: ObjectID 48 | message: String 49 | author: String 50 | } 51 | 52 | input CreateCommentInput { 53 | id: ObjectID 54 | message: String! 55 | author: String! 56 | noteId: ObjectID 57 | } 58 | 59 | input CreateTaskInput { 60 | id: ObjectID 61 | title: String! 62 | description: String! 63 | status: TaskStatus 64 | type: String 65 | priority: Int 66 | public: Boolean 67 | startDate: DateTime 68 | payload: JSON 69 | } 70 | 71 | scalar DateTime 72 | 73 | input DateTimeInput { 74 | ne: DateTime 75 | eq: DateTime 76 | le: DateTime 77 | lt: DateTime 78 | ge: DateTime 79 | gt: DateTime 80 | in: [DateTime] 81 | between: [DateTime] 82 | } 83 | 84 | input IntInput { 85 | ne: Int 86 | eq: Int 87 | le: Int 88 | lt: Int 89 | ge: Int 90 | gt: Int 91 | in: [Int] 92 | between: [Int] 93 | } 94 | 95 | scalar JSON 96 | 97 | input JSONInput { 98 | ne: JSON 99 | eq: JSON 100 | le: JSON 101 | lt: JSON 102 | ge: JSON 103 | gt: JSON 104 | in: [JSON] 105 | between: [JSON] 106 | } 107 | 108 | input MutateCommentInput { 109 | id: ObjectID! 110 | message: String 111 | author: String 112 | noteId: ObjectID 113 | } 114 | 115 | input MutateTaskInput { 116 | id: ObjectID! 117 | title: String 118 | description: String 119 | status: TaskStatus 120 | type: String 121 | priority: Int 122 | public: Boolean 123 | startDate: DateTime 124 | payload: JSON 125 | } 126 | 127 | type Mutation { 128 | createTask(input: CreateTaskInput!): Task! 129 | updateTask(input: MutateTaskInput!): Task! 130 | deleteTask(input: MutateTaskInput!): Task! 131 | createComment(input: CreateCommentInput!): Comment! 132 | updateComment(input: MutateCommentInput!): Comment! 133 | deleteComment(input: MutateCommentInput!): Comment! 134 | } 135 | 136 | scalar ObjectID 137 | 138 | input ObjectIDInput { 139 | ne: ObjectID 140 | eq: ObjectID 141 | le: ObjectID 142 | lt: ObjectID 143 | ge: ObjectID 144 | gt: ObjectID 145 | in: [ObjectID] 146 | between: [ObjectID] 147 | } 148 | 149 | input OrderByInput { 150 | field: String! 151 | order: SortDirectionEnum = ASC 152 | } 153 | 154 | input PageRequest { 155 | limit: Int 156 | offset: Int 157 | } 158 | 159 | type Query { 160 | getTask(id: ID!): Task 161 | findTasks(filter: TaskFilter, page: PageRequest, orderBy: OrderByInput): TaskResultList! 162 | getComment(id: ID!): Comment 163 | findComments(filter: CommentFilter, page: PageRequest, orderBy: OrderByInput): CommentResultList! 164 | } 165 | 166 | enum SortDirectionEnum { 167 | DESC 168 | ASC 169 | } 170 | 171 | input StringInput { 172 | ne: String 173 | eq: String 174 | le: String 175 | lt: String 176 | ge: String 177 | gt: String 178 | in: [String] 179 | contains: String 180 | startsWith: String 181 | endsWith: String 182 | } 183 | 184 | type Subscription { 185 | newTask(filter: TaskSubscriptionFilter): Task! 186 | updatedTask(filter: TaskSubscriptionFilter): Task! 187 | deletedTask(filter: TaskSubscriptionFilter): Task! 188 | newComment(filter: CommentSubscriptionFilter): Comment! 189 | updatedComment(filter: CommentSubscriptionFilter): Comment! 190 | deletedComment(filter: CommentSubscriptionFilter): Comment! 191 | } 192 | 193 | """ 194 | @model 195 | @datasync 196 | """ 197 | type Task { 198 | """@id""" 199 | id: ObjectID! 200 | title: String! 201 | description: String! 202 | status: TaskStatus 203 | type: String 204 | priority: Int 205 | public: Boolean 206 | startDate: DateTime 207 | payload: JSON 208 | 209 | """@oneToMany(field: 'note', key: 'noteId')""" 210 | comments(filter: CommentFilter): [Comment]! 211 | } 212 | 213 | input TaskFilter { 214 | id: ObjectIDInput 215 | title: StringInput 216 | description: StringInput 217 | status: StringInput 218 | type: StringInput 219 | priority: IntInput 220 | public: BooleanInput 221 | startDate: DateTimeInput 222 | payload: JSONInput 223 | and: [TaskFilter] 224 | or: [TaskFilter] 225 | not: TaskFilter 226 | } 227 | 228 | type TaskResultList { 229 | items: [Task]! 230 | offset: Int 231 | limit: Int 232 | count: Int 233 | } 234 | 235 | enum TaskStatus { 236 | OPEN 237 | ASSIGNED 238 | COMPLETE 239 | } 240 | 241 | input TaskSubscriptionFilter { 242 | id: ObjectID 243 | title: String 244 | description: String 245 | status: TaskStatus 246 | type: String 247 | priority: Int 248 | public: Boolean 249 | startDate: DateTime 250 | payload: JSON 251 | } -------------------------------------------------------------------------------- /client/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready.then(registration => { 142 | registration.unregister(); 143 | }); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /.openshift/datasync-app-template.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | apiVersion: template.openshift.io/v1 4 | kind: Template 5 | labels: 6 | template: datasync-starter-server 7 | metadata: 8 | name: datasync-starter-server 9 | annotations: 10 | openshift.io/display-name: DataSync Starter 11 | description: |- 12 | This template allows for the deployment of the Data Sync Example DataSync Starter App. 13 | The Data Sync Example App contains an example Node.js server implementation that 14 | connects to a MongoDB database. 15 | For more information, see https://github.com/aerogear/datasync-starter/ 16 | tags: sync, mobile, nodejs 17 | iconClass: icon-nodejs 18 | openshift.io/provider-display-name: Red Hat, Inc. 19 | openshift.io/documentation-url: https://access.redhat.com/documentation/en-us/red_hat_managed_integration/1/html-single/developing_a_data_sync_app/index 20 | openshift.io/support-url: https://access.redhat.com 21 | template.openshift.io/bindable: 'false' 22 | aerogear.org/datasync-template-version: '0.9.3' 23 | objects: 24 | 25 | - apiVersion: v1 26 | kind: DeploymentConfig 27 | metadata: 28 | annotations: 29 | openshift.io/generated-by: OpenShiftNewApp 30 | creationTimestamp: null 31 | labels: 32 | app: datasync-starter-server 33 | name: datasync-starter-server 34 | spec: 35 | replicas: 1 36 | selector: 37 | app: datasync-starter-server 38 | deploymentconfig: datasync-starter-server 39 | strategy: 40 | type: Recreate 41 | template: 42 | metadata: 43 | annotations: 44 | openshift.io/generated-by: OpenShiftNewApp 45 | creationTimestamp: null 46 | labels: 47 | app: datasync-starter-server 48 | deploymentconfig: datasync-starter-server 49 | spec: 50 | containers: 51 | - env: 52 | - name: MONGO_HOST 53 | value: "${DATABASE_HOST}" 54 | - name: MONGO_USER 55 | value: "${DATABASE_USER}" 56 | - name: MONGO_PASSWORD 57 | value: "${DATABASE_PASSWORD}" 58 | - name: MQTT_HOST 59 | value: mosquitto-mqtt-broker 60 | image: docker.io/aerogear/datasync-starter:latest 61 | name: datasync-starter-server 62 | ports: 63 | - containerPort: 4000 64 | protocol: TCP 65 | resources: {} 66 | triggers: 67 | - type: ConfigChange 68 | 69 | - apiVersion: v1 70 | kind: Service 71 | metadata: 72 | annotations: 73 | openshift.io/generated-by: OpenShiftNewApp 74 | creationTimestamp: null 75 | labels: 76 | app: datasync-starter-server 77 | name: datasync-starter-server 78 | spec: 79 | ports: 80 | - name: 4000-tcp 81 | port: 4000 82 | protocol: TCP 83 | targetPort: 4000 84 | selector: 85 | app: datasync-starter-server 86 | deploymentconfig: datasync-starter-server 87 | 88 | 89 | - apiVersion: v1 90 | kind: Route 91 | metadata: 92 | labels: 93 | app: datasync-starter-server 94 | name: "${SERVER_SERVICE_NAME}" 95 | spec: 96 | host: "" 97 | to: 98 | kind: Service 99 | name: "${SERVER_SERVICE_NAME}" 100 | tls: 101 | termination: edge 102 | insecureEdgeTerminationPolicy: Allow 103 | 104 | - kind: DeploymentConfig 105 | apiVersion: v1 106 | name: mosquitto-mqtt-broker 107 | metadata: 108 | annotations: 109 | openshift.io/generated-by: OpenShiftNewApp 110 | creationTimestamp: null 111 | labels: 112 | app: datasync-starter-server 113 | name: mosquitto-mqtt-broker 114 | spec: 115 | replicas: 1 116 | selector: 117 | app: datasync-starter-server 118 | deploymentconfig: mosquitto-mqtt-broker 119 | template: 120 | metadata: 121 | annotations: 122 | openshift.io/generated-by: OpenShiftNewApp 123 | creationTimestamp: null 124 | labels: 125 | app: datasync-starter-server 126 | deploymentconfig: mosquitto-mqtt-broker 127 | spec: 128 | containers: 129 | - name: mosquitto-mqtt-broker 130 | image: eclipse-mosquitto:latest 131 | ports: 132 | - containerPort: 1883 133 | protocol: TCP 134 | strategy: 135 | resources: {} 136 | type: Rolling 137 | paused: false 138 | revisionHistoryLimit: 2 139 | minReadySeconds: 0 140 | 141 | - apiVersion: v1 142 | kind: Service 143 | metadata: 144 | annotations: 145 | openshift.io/generated-by: OpenShiftNewApp 146 | creationTimestamp: null 147 | labels: 148 | app: datasync-starter-server 149 | name: mosquitto-mqtt-broker 150 | spec: 151 | ports: 152 | - name: 1883-tcp 153 | port: 1883 154 | protocol: TCP 155 | targetPort: 1883 156 | selector: 157 | app: datasync-starter-server 158 | deploymentconfig: mosquitto-mqtt-broker 159 | status: 160 | loadBalancer: {} 161 | 162 | parameters: 163 | - description: Maximum amount of memory the container can use. 164 | displayName: Memory Limit 165 | name: MEMORY_LIMIT 166 | value: 512Mi 167 | - description: The OpenShift Namespace where the ImageStream resides. 168 | displayName: Namespace 169 | name: NAMESPACE 170 | value: openshift 171 | - description: The name of the OpenShift Service exposed for the database. 172 | displayName: Database Service Name 173 | name: DATABASE_HOST 174 | value: mongodb 175 | - description: Username for MongoDB user that will be used for accessing the database. 176 | displayName: Mongo Connection Username 177 | name: DATABASE_USER 178 | value: 179 | - description: Password for the MongoDB connection user. 180 | displayName: MongoDB Connection Password 181 | name: DATABASE_PASSWORD 182 | value: postgres 183 | - description: The name of the OpenShift Service exposed for the Server. 184 | displayName: Server Service name 185 | name: SERVER_SERVICE_NAME 186 | value: datasync-starter-server 187 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = AeroGear DataSync Starter 2 | 3 | AeroGear DataSync GraphQL based server and React Client 4 | 5 | == DataSync Starter 6 | 7 | Starter includes: 8 | 9 | - Integration with Graphback (http://graphback.dev) that helps you to generate your backend and client side queries based on user provided business model 10 | - Example React application that uses Offix client (http://offix.dev) to give you fully featured offline experience. 11 | - Integration with Keycloak (SSO) for server and client Authentication and User management. 12 | - GraphQL Subscriptions backed by an MQTT broker (AMQ) 13 | 14 | === GraphQL Client 15 | 16 | The mobile application uses https://github.com/aerogear/offix[Offix Client] to provide additional offline capabilities on top of Apollo GraphQL. 17 | 18 | === GraphQL Server 19 | 20 | The GraphQL server uses https://github.com/aerogear/graphback[Graphback] to provide GraphQL capabilities. 21 | 22 | == Getting Started 23 | 24 | Requirements: 25 | 26 | - Docker and docker-compose 27 | - Node.js 12.x or above 28 | - (optional) access to a running OpenShift instance 29 | 30 | === Running app and server 31 | 32 | Install dependencies 33 | 34 | . Build client 35 | 36 | + 37 | ```shell 38 | yarn 39 | ``` 40 | + 41 | 42 | . Build client 43 | + 44 | ```shell 45 | yarn 46 | yarn prepare:client 47 | ``` 48 | + 49 | 50 | . Start the server 51 | + 52 | ```shell 53 | cd ./server 54 | docker-compose up -d 55 | yarn start 56 | ``` 57 | 58 | 59 | === Running the Server with the Keycloak integration 60 | 61 | Follow these instructions to set up Keycloak for Authentication/Authorization. 62 | 63 | 64 | . Start Keycloak Server 65 | + 66 | ```shell 67 | cd server 68 | npm run keycloak 69 | ``` 70 | 71 | . Configure the Keycloak Server 72 | + 73 | ```shell 74 | cd server 75 | npm run keycloak:init 76 | ``` 77 | 78 | This command creates the necessary resources in Keycloak and prints instructions *you must follow to enable the integration.* 79 | 80 | Follow the instructions and copy the JSON configurations to the appropriate locations. 81 | The DataSync Starter app and server will read these configurations and the integration will be enabled when they are started. 82 | 83 | By default, two users that can log into the application are created. 84 | 85 | - username: `developer`, password: `developer` 86 | - username: `admin`, password: `admin` 87 | 88 | ==== Using the GraphQL playground with Keycloak 89 | 90 | The GraphQL playground is available after a Keycloak login screen. Initially the following error will be displayed. 91 | 92 | ``` 93 | { 94 | "error": "Failed to fetch schema. Please check your connection" 95 | } 96 | ``` 97 | 98 | The playground must be configured to send the Keycloak `Authorization` header with requests to the GraphQL server. 99 | 100 | In the bottom left corner of the playground there is a field called **HTTP Headers** which will be added to requests sent by the playground. 101 | 102 | Use `scripts/getToken.js` to get a valid header for the `developer` user. 103 | 104 | The following script can be used to get a token for the default Keycloak credentials 105 | 106 | ``` 107 | cd server/scripts/keycloak 108 | node getToken.js 109 | ``` 110 | 111 | Alternatively, the user-defined username and password can be passed into the script as arguments, as below 112 | 113 | ``` 114 | node getToken.js 115 | ``` 116 | 117 | The output will be in the form of a JSON object 118 | 119 | ``` 120 | {"Authorization":"Bearer "} 121 | ``` 122 | 123 | Copy the entire JSON object, then paste it into the HTTP Headers field in the playground. 124 | The error message should disappear and it is now possible to use the playground. 125 | 126 | NOTE: The GraphQL server is using a `public` Keycloak client to redirect browsers to the login page. This is useful for testing the server locally but **it is not recommended for production**. For production GraphQL server applications you should use a `bearer` client. 127 | 128 | [NOTE] 129 | ==== 130 | If Keycloak integration is enabled on the server, and the Keycloak server is running using a self-signed certificate, please make sure set this environment variable before running the server: 131 | 132 | ```shell 133 | export NODE_TLS_REJECT_UNAUTHORIZED=0 134 | ``` 135 | ==== 136 | 137 | 138 | === Running the Client 139 | 140 | . Install Ionic 141 | + 142 | ```shell 143 | npm install -g @ionic/cli 144 | ``` 145 | 146 | . Change directory 147 | 148 | + 149 | ```shell 150 | cd client 151 | ``` 152 | + 153 | 154 | . Install dependencies 155 | + 156 | ```shell 157 | npm install 158 | ``` 159 | + 160 | . Start the app 161 | + 162 | ```shell 163 | npm run start 164 | ``` 165 | + 166 | 167 | 168 | === Adding keycloak integration to the client 169 | 170 | Rename `keycloak.example.json` file in the `public` directory to `keycloak.json`. Replace the contents of the file 171 | with the keycloak json object generated during the keycloak integration init script. 172 | 173 | [source,js] 174 | ---- 175 | { 176 | "realm": "", 177 | "auth-server-url": "https://your-server/auth", 178 | "ssl-required": "none", 179 | "resource": "", 180 | "public-client": true, 181 | "use-resource-role-mappings": true, 182 | "confidential-port": 0 183 | } 184 | ---- 185 | 186 | > NOTE: When running in cloud, developers can swap this file dynamically using config-map or openshift secret 187 | 188 | === Running Native projects 189 | 190 | ==== IOS 191 | ----- 192 | cd client 193 | yarn cap add ios 194 | yarn run:ios 195 | ----- 196 | 197 | ==== Android: 198 | ----- 199 | cd client 200 | yarn cap add android 201 | yarn run:android 202 | ----- 203 | 204 | When running locally you will need to also enable http traffic. 205 | For example for android add `android:usesCleartextTraffic="true"` to AndroidManifest.xml 206 | 207 | Project should stard in IDE and can be launched as any other native application 208 | 209 | == Using MQTT for GraphQL subscriptions 210 | 211 | 1. Go to scripts ./mqtt 212 | 2. Execute docker-compose up 213 | 3. Set MQTT_HOST environment variable in .env file 214 | 215 | MQTT_HOST=127.0.0.1 216 | 217 | === Running On OpenShift 218 | 219 | Please check link:./openshift[.openshift] folder for more information. 220 | -------------------------------------------------------------------------------- /server/integrations/keycloak/initKeycloak.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script helps set up a local keycloak environment 3 | * It assumes keycloak is already running and available at 4 | * http://localhost:8080 5 | * It does the following: 6 | * * Imports a new realm located in ./realm-export.json. This realm has two clients 7 | * * datasync-starter-client- public client used by the mobile application 8 | * * datasync-starter-server - bearer client used by the server to authenticate requests 9 | * * Creates the realm roles and client roles defined in the realmRoleNames and clientRoleNames lists 10 | * * Creates the users defined in the users list 11 | * * Assigns the client and realm roles to the users 12 | */ 13 | 14 | const axios = require("axios"); 15 | const realmToImport = require("./realm-export.json"); 16 | const { fstat, writeFileSync } = require("fs"); 17 | 18 | // the keycloak server we're working against 19 | const KEYCLOAK_URL = process.env.KEYCLOAK_URL || "http://localhost:8080/auth"; 20 | 21 | // name of the realm 22 | const APP_REALM = process.env.KEYCLOAK_REALM || "datasync-starter"; 23 | 24 | // name of the admin realm 25 | const ADMIN_REALM = "master"; 26 | 27 | const RESOURCE = "admin-cli"; 28 | const ADMIN_USERNAME = process.env.KEYCLOAK_ADMIN_USERNAME || "admin"; 29 | const ADMIN_PASSWORD = process.env.KEYCLOAK_ADMIN_PASSWORD || "admin"; 30 | let token = ""; 31 | 32 | // The keycloak client used by the sample app 33 | const PUBLIC_CLIENT_NAME = "datasync-starter-client"; 34 | const BEARER_CLIENT_NAME = "datasync-starter-server"; 35 | let PUBLIC_CLIENT; 36 | 37 | // The client roles you want created for the BEARER_CLIENT_NAME client 38 | const clientRoleNames = ["admin", "developer"]; 39 | 40 | // The realm roles we want for the realm 41 | const realmRoleNames = ["admin", "developer"]; 42 | 43 | let realmRoles; 44 | 45 | // The users we want to create 46 | const users = [ 47 | { 48 | name: "admin", 49 | password: "admin", 50 | realmRoles: ["admin"], 51 | clientRoles: ["admin"], 52 | }, 53 | { 54 | name: "developer", 55 | password: "developer", 56 | realmRoles: ["developer"], 57 | clientRoles: ["developer"], 58 | }, 59 | ]; 60 | 61 | const writeConfig = false; 62 | 63 | // This is called by an immediately invoked function expression 64 | // at the bottom of the file 65 | async function prepareKeycloak() { 66 | try { 67 | console.log("Authenticating with keycloak server"); 68 | token = await authenticateKeycloak(); 69 | 70 | // Always do a hard reset first just to keep things tidy 71 | console.log("Going to reset keycloak"); 72 | await resetKeycloakConfiguration(); 73 | 74 | console.log("Importing sample realm into keycloak"); 75 | await importRealm(); 76 | 77 | console.log("Fetching available clients from keycloak"); 78 | const clients = await getClients(); 79 | 80 | // Get the public client object from keycloak 81 | // Need this for the ID assigned by keycloak 82 | PUBLIC_CLIENT = clients.find( 83 | (client) => client.clientId === PUBLIC_CLIENT_NAME 84 | ); 85 | BEARER_CLIENT = clients.find( 86 | (client) => client.clientId === BEARER_CLIENT_NAME 87 | ); 88 | 89 | console.log("creating client roles"); 90 | for (let roleName of clientRoleNames) { 91 | await createClientRole(BEARER_CLIENT, roleName); 92 | await createClientRole(PUBLIC_CLIENT, roleName); 93 | } 94 | 95 | console.log("creating realm roles"); 96 | for (let roleName of realmRoleNames) { 97 | await createRealmRole(roleName); 98 | } 99 | 100 | // get the actual role objects from keycloak after creating them 101 | // need to get the ids that were created on them 102 | realmRoles = await getRealmRoles(); 103 | bearerClientRoles = await getClientRoles(BEARER_CLIENT); 104 | publicClientRoles = await getClientRoles(PUBLIC_CLIENT); 105 | 106 | for (let user of users) { 107 | // Create a new user 108 | console.log(`creating user ${user.name} with password ${user.password}`); 109 | const userIdUrl = await createUser(user.name, user.password); 110 | 111 | // Assign roles to the user 112 | await assignRealmRolesToUser(user, userIdUrl); 113 | await assignClientRolesToUser( 114 | user, 115 | BEARER_CLIENT, 116 | bearerClientRoles, 117 | userIdUrl 118 | ); 119 | await assignClientRolesToUser( 120 | user, 121 | PUBLIC_CLIENT, 122 | publicClientRoles, 123 | userIdUrl 124 | ); 125 | } 126 | 127 | const publicInstallation = await getClientInstallation(PUBLIC_CLIENT); 128 | 129 | const bearerInstallation = await getClientInstallation(BEARER_CLIENT); 130 | if (writeConfig) { 131 | writeFileSync( 132 | `../client/public/keycloak.json`, 133 | JSON.stringify(publicInstallation, null, 2) 134 | ); 135 | writeFileSync( 136 | `../server/src/config/keycloak.json`, 137 | JSON.stringify(bearerInstallation, null, 2) 138 | ); 139 | } 140 | console.log(); 141 | console.log( 142 | "Your keycloak server is set up for local usage and development" 143 | ); 144 | console.log(); 145 | console.log("Copy the following app config into the following files:"); 146 | console.log("- client/public/keycloak.json"); 147 | console.log(); 148 | console.log(JSON.stringify(publicInstallation, null, 2)); 149 | console.log(); 150 | console.log("- server/src/config/keycloak.json"); 151 | console.log(); 152 | console.log(JSON.stringify(bearerInstallation, null, 2)); 153 | console.log(); 154 | console.log( 155 | "Done. Please follow the instructions printed above to ensure your environment is set up properly." 156 | ); 157 | } catch (e) { 158 | console.error(e); 159 | process.exit(1); 160 | } 161 | } 162 | 163 | async function getClientInstallation( 164 | client, 165 | installationType = "keycloak-oidc-keycloak-json" 166 | ) { 167 | if (client) { 168 | const res = await axios({ 169 | method: "GET", 170 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/clients/${client.id}/installation/providers/${installationType}`, 171 | headers: { Authorization: token }, 172 | }); 173 | return res.data; 174 | } 175 | throw new Error("client is undefined"); 176 | } 177 | 178 | async function assignRealmRolesToUser(user, userIdUrl) { 179 | for (let roleToAssign of user.realmRoles) { 180 | console.log(`Assigning realm role ${roleToAssign} to user ${user.name}`); 181 | const selectedRealmRole = realmRoles.find( 182 | (role) => role.name === roleToAssign 183 | ); 184 | 185 | if (selectedRealmRole) { 186 | await assignRealmRoleToUser(userIdUrl, selectedRealmRole); 187 | } else { 188 | console.error(`realm role ${roleToAssign} does not exist`); 189 | } 190 | } 191 | } 192 | 193 | async function assignClientRolesToUser(user, client, clientRoles, userIdUrl) { 194 | for (let roleToAssign of user.clientRoles) { 195 | console.log( 196 | `assigning client role ${roleToAssign} from client ${client.clientId} on user ${user.name}` 197 | ); 198 | const selectedClientRole = clientRoles.find( 199 | (clientRole) => clientRole.name === roleToAssign 200 | ); 201 | if (selectedClientRole) { 202 | await assignClientRoleToUser(userIdUrl, client, selectedClientRole); 203 | } else { 204 | console.error( 205 | `client role ${roleToAssign} does not exist on client ${client.clientId}` 206 | ); 207 | } 208 | } 209 | } 210 | 211 | async function authenticateKeycloak() { 212 | console.log( 213 | `client_id=${RESOURCE}&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}&grant_type=password` 214 | ); 215 | const res = await axios({ 216 | method: "POST", 217 | url: `${KEYCLOAK_URL}/realms/${ADMIN_REALM}/protocol/openid-connect/token`, 218 | data: `client_id=${RESOURCE}&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}&grant_type=password`, 219 | }); 220 | return `Bearer ${res.data["access_token"]}`; 221 | } 222 | 223 | async function importRealm() { 224 | return await axios({ 225 | method: "POST", 226 | url: `${KEYCLOAK_URL}/admin/realms`, 227 | data: realmToImport, 228 | headers: { Authorization: token, "Content-Type": "application/json" }, 229 | }); 230 | } 231 | 232 | async function getRealmRoles() { 233 | const res = await axios({ 234 | method: "GET", 235 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/roles`, 236 | headers: { Authorization: token }, 237 | }); 238 | return res.data; 239 | } 240 | 241 | async function createClientRole(client, roleName) { 242 | try { 243 | return await axios({ 244 | method: "POST", 245 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/clients/${client.id}/roles`, 246 | headers: { Authorization: token }, 247 | data: { 248 | clientRole: true, 249 | name: roleName, 250 | }, 251 | }); 252 | } catch (e) { 253 | if ( 254 | e.response.data.errorMessage === 255 | `Role with name ${roleName} already exists` 256 | ) { 257 | console.log(e.response.data.errorMessage); 258 | } else { 259 | throw e; 260 | } 261 | } 262 | } 263 | 264 | async function createRealmRole(roleName) { 265 | try { 266 | return await axios({ 267 | method: "POST", 268 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/roles`, 269 | headers: { Authorization: token }, 270 | data: { 271 | clientRole: false, 272 | name: roleName, 273 | }, 274 | }); 275 | } catch (e) { 276 | if ( 277 | e.response.data.errorMessage === 278 | `Role with name ${roleName} already exists` 279 | ) { 280 | console.log(e.response.data.errorMessage); 281 | } else { 282 | throw e; 283 | } 284 | } 285 | } 286 | 287 | async function getClients() { 288 | const res = await axios({ 289 | method: "GET", 290 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/clients`, 291 | headers: { Authorization: token }, 292 | }); 293 | return res.data; 294 | } 295 | 296 | async function getClientRoles(client) { 297 | const res = await axios({ 298 | method: "GET", 299 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/clients/${client.id}/roles`, 300 | headers: { Authorization: token }, 301 | }); 302 | return res.data; 303 | } 304 | 305 | async function createUser(name, password) { 306 | const res = await axios({ 307 | method: "post", 308 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/users`, 309 | data: { 310 | username: name, 311 | credentials: [{ type: "password", value: password, temporary: false }], 312 | enabled: true, 313 | }, 314 | headers: { Authorization: token, "Content-Type": "application/json" }, 315 | }); 316 | if (res) { 317 | return res.headers.location; 318 | } 319 | } 320 | 321 | async function assignRealmRoleToUser(userIdUrl, role) { 322 | const res = await axios({ 323 | method: "POST", 324 | url: `${userIdUrl}/role-mappings/realm`, 325 | data: [role], 326 | headers: { Authorization: token, "Content-Type": "application/json" }, 327 | }); 328 | return res.data; 329 | } 330 | 331 | async function assignClientRoleToUser(userIdUrl, client, role) { 332 | const res = await axios({ 333 | method: "POST", 334 | url: `${userIdUrl}/role-mappings/clients/${client.id}`, 335 | data: [role], 336 | headers: { Authorization: token, "Content-Type": "application/json" }, 337 | }); 338 | return res.data; 339 | } 340 | 341 | async function resetKeycloakConfiguration() { 342 | try { 343 | await axios({ 344 | method: "DELETE", 345 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}`, 346 | headers: { Authorization: token }, 347 | }); 348 | } catch (e) { 349 | if (e.response.status !== 404) { 350 | throw e; 351 | } 352 | console.log(`404 while deleting realm ${APP_REALM} - ignoring`); 353 | } 354 | } 355 | 356 | function getMobileServicesConfig(installationConfig) { 357 | return { 358 | id: "some-id", 359 | name: "keycloak", 360 | type: "keycloak", 361 | url: KEYCLOAK_URL, 362 | config: installationConfig, 363 | }; 364 | } 365 | 366 | (async () => { 367 | await prepareKeycloak(); 368 | })(); 369 | -------------------------------------------------------------------------------- /walkthroughs/1-exploring-datasync-codeready/walkthrough.adoc: -------------------------------------------------------------------------------- 1 | // update the component versions for each release 2 | :rhmi-version: 1 3 | 4 | // URLs 5 | :openshift-console-url: {openshift-host}/console 6 | :sso-realm-url: {user-sso-url}/auth/admin/solution-patterns/console/index.html 7 | :data-sync-documentation-url: https://access.redhat.com/documentation/en-us/red_hat_managed_integration/{rhmi-version}/html-single/developing_a_data_sync_app/index 8 | 9 | //attributes 10 | :integreatly-name: Managed Integration 11 | :data-sync-name: Data Sync 12 | :data-sync-starter: Data Sync Starter 13 | :customer-sso-name: SSO 14 | :standard-fail-text: Verify that you followed all the steps. If you continue to have issues, contact your administrator. 15 | 16 | //id syntax is used here for the custom IDs because that is how the Solution Explorer sorts these within groups 17 | [id='5-adding-data-sync-graphql'] 18 | = Creating your Data Sync Application in CodeReady 19 | 20 | // word count that fits best is 15-22, with 20 really being the sweet spot. Character count for that space would be 100-125 21 | Learn how to build applications that can perform realtime data synchronization with DataSync and GraphQL. 22 | 23 | This solution pattern will show you how to: 24 | 25 | * Build a DataSync server based on your business model 26 | * Protect the application's frontend and backend using {customer-sso-name}. 27 | * Explore all the capabilities provided by {data-sync-name}. 28 | 29 | The following diagram shows the architecture of the {data-sync-starter}: 30 | 31 | image::images/arch.png[architecture, role="integr8ly-img-responsive"] 32 | 33 | [type=walkthroughResource, serviceName=openshift] 34 | .Red Hat OpenShift 35 | **** 36 | * link:{openshift-console-url}[Console, window="_blank"] 37 | * link:https://docs.openshift.com/dedicated/4/welcome/index.html[OpenShift Documentation, window="_blank"] 38 | * link:https://blog.openshift.com/[OpenShift Blog, window="_blank"] 39 | **** 40 | 41 | [type=walkthroughResource] 42 | .Data Sync 43 | **** 44 | * link:{data-sync-documentation-url}[Getting Started with {data-sync-name}, window="_blank"] 45 | **** 46 | 47 | :sectnums: 48 | 49 | [time=15] 50 | == Creating your DataSync project using DataSync Starter template 51 | 52 | {data-sync-name} allows you to focus on your business model by giving you the ability 53 | to generate a fully functional GraphQL based API and Node.js Server and Client Side components. 54 | 55 | Developers can use GraphQL types to define the models their application deals with 56 | and generate the underlying backend that works out of the box with the RHMI services like SSO or AMQ Online. 57 | 58 | Using GraphQL subscriptions, your application can receive live updates thanks to GraphQL and the DataSync client allows your application to operate independently of the network connection. 59 | 60 | DataSync-Starter is an application template that gives developers 61 | opiniated implementation for React Ionic PWA and Mobile application backend by Node.js Server. 62 | 63 | DataSync as framework consist of the two major components: 64 | 65 | * DataSync client (upstream https://offix.dev) 66 | + 67 | Can be added to any web or mobile application 68 | to provide DataSynchronization capabilities 69 | 70 | * Node.js Server with code generation capabilties (upstream https://graphback.dev) 71 | + 72 | Server is providing ability to dynamically generate Node.js GraphQL server with DataSynchronization and Conflict resolution capabilties 73 | 74 | [time=30] 75 | === Setting your DataSync project using in CodeReady 76 | 77 | In this section we will use CodeReady Workspaces as a development environment to work with the DataSync Starter project. 78 | 79 | . Navigate to the solution explorer 80 | . Go to the "All Services" tab 81 | . Go to "Online IDE" and select "Open Console" 82 | . Login to the CodeReady Workspaces dashboard 83 | . Choose `NodeJS MongoDB Web Application` from list of the available stacks 84 | . Enter `https://github.com/aerogear/datasync-starter#walkthrough` for the repository URL 85 | . In the right corner press down arrow and select `Create and Start Editing` 86 | . In the new window select the `Dev File` tab and the copy dev file from 87 | https://raw.githubusercontent.com/aerogear/datasync-starter/master/devfile.yaml 88 | . Press `Create` 89 | . Wait for the workspace to start 90 | . Press `Open` to open CodeReady editor 91 | . In the active workspace, run the `Install dependencies` command. 92 | + 93 | The commands are listed in the `MyWorkspace` view available by selecting the box icon in the right top corner of the screen. 94 | 95 | === Creating your DataSync model 96 | 97 | Based on a user defined **Model** DataSync generates the underlying GraphQL API and database resources. 98 | The starter template uses MongoDB as the default datasource for storing application data. 99 | 100 | . In your project explorer view on the left, go to the `./model/task.graphql` file. 101 | This file contains an link:https://graphql.org/learn/schema/#object-types-and-fields[GraphQL Schema, window="_blank"]. Here you can define types of the data your application will work with. Graphback will use your model and generate underlying data access methods in the form of GraphQL Queries and Mutations 102 | . Please add a `@datasync` annotation under the `@model` annotation on the `Task` type. 103 | Those annotations control various behaviour of DataSync. 104 | `@model` will create standard data access methods, while `@datasync` will provide data synchronization capabilities. 105 | . Models can be changed by adding new types or fields. Add a new field to the `Task` type by adding `address: String` 106 | . Your task model should be as follows 107 | ---- 108 | """ 109 | @model 110 | @datasync 111 | """ 112 | type Task { 113 | id: ID! 114 | title: String! 115 | description: String! 116 | status: TaskStatus 117 | address: String 118 | } 119 | ---- 120 | 121 | === Generating your DataSync Node.JS server and React App 122 | 123 | DataSync provides code generation capabilities that transform your model into a fully functional client and server application. 124 | The datasync-starter template contains the following folders: 125 | 126 | * `./server`- contains a Node.js server application written in TypeScript. `server/src/index.ts` is the entrypoint to the server application. 127 | * `./client` - contains a React application written in TypeScript. `client/src/index.tsx` is the entrypoint to the client application. 128 | * `.graphqlrc.yml` - 129 | 130 | . Review the `.graphqlrc.yml` file. This is a config file that 131 | ** determines what types of CRUD operations will be generated into GraphQL Queries and Mutations in your application. 132 | ** enables and configures various plugins that are used during the code generation process. 133 | . Make sure that all fields in the `crud` section are enabled 134 | . Execute graphback cli command to generate source code: 135 | `yarn graphback generate`. You can also execute it as predefined `generate source code` command in CodeReady 136 | . Review `./server/src/schema/schema.qraphql`. 137 | This file has the original model and it also contains generated Queries and Mutations. The types of queries and mutations included are based on the `crud` fields in `.graphqlrc.yml`. 138 | . Review the generated resolver files in `./server/src/resolvers/resolvers.ts` 139 | This file contains methods used to fetch and modify data. Each individual method uses a 140 | preconfigured `MongoDataProvider` which is an abstraction over a MongoDB client. Developers can point resolvers to any datasource. 141 | Currently Postgres and MongoDB are supported. 142 | . Review your `./client/src/graphql/` folder containing client side queries for your data. These queries are automatically generated based on the server schema, and are used within the client application. Generating the client side queries helps developers get their client apps up and running quicker and helps them stay up to date as the server schema evolves. 143 | 144 | === Running DataSync client and server applications 145 | 146 | . Open a new terminal window 147 | . Execute the `prepare client` command in the new terminal. Client side application will be build and started. This can take a couple of minutes. 148 | . Execute the `start server` command. This command starts the GraphQL server which also serves the client application for simplicity. 149 | . The application should be opened in a preview window after build is finished. 150 | 151 | [type=verification] 152 | **** 153 | . Check if the website was loaded properly 154 | . Select the + icon to create a new item 155 | . On the new screen enter a `name` and `description` and create the task. 156 | . New task should appear in the task list. 157 | ---- 158 | **** 159 | 160 | [type=verificationFail] 161 | **** 162 | Check the logs of the console 163 | Verify that you followed each step in the procedure above. 164 | If you are still having issues, contact your administrator. 165 | **** 166 | 167 | === Interacting with the GraphQL Playground 168 | 169 | The GraphQL Playground is an in browser GraphQL IDE that lets you directly perform queries and mutations against your GraphQL API. 170 | It's a convenient way to interact with your GraphQL API without using a client application. 171 | It is served directly by your server application as a developer tool and can be disabled in production. 172 | In this section we will focus on using the playground. 173 | 174 | . Open a new terminal window 175 | . Execute `yarn start:server` 176 | . Open the GraphQL Playground URL printed in console. 177 | You can use the GraphQL playground to interact with the server API as described in the next step. 178 | . Go to the Playground interface and replace the text in the left pane of the screen with the following query and mutation: 179 | 180 | ---- 181 | query listTasks { 182 | findAllTasks { 183 | title, 184 | description, 185 | address, 186 | id 187 | } 188 | } 189 | 190 | mutation createTask { 191 | createTask(input: {title: "complete the walkthrough", description: "complete the GraphQL walkthrough", address: "NA"}) { 192 | title, 193 | description, 194 | version, 195 | address, 196 | id 197 | } 198 | } 199 | ---- 200 | 201 | [type=verification] 202 | **** 203 | . Click the Run icon in the middle of the playground screen. 204 | . Choose `createTask` from the menu. 205 | The system should create a task and the result is displayed in the panel on the right side. 206 | . Choose `listTasks` from the Run menu. 207 | . Check that the following is displayed in the right hand panel: 208 | . You should also see the `address` field that we have added in previous steps. 209 | + 210 | ---- 211 | { 212 | "data": { 213 | "allTasks": [ 214 | { 215 | "title": "complete the walkthrough", 216 | "description": "complete the GraphQL walkthrough", 217 | "id": "1", 218 | "address": "NA" 219 | } 220 | ] 221 | } 222 | } 223 | ---- 224 | **** 225 | 226 | [type=verificationFail] 227 | **** 228 | Check the logs of the `ionic-showcase-server` pod. 229 | 230 | It should include the string `+connected to messaging service+`. 231 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator. 232 | **** 233 | 234 | [time=5] 235 | == Running and verifying your DataSync server 236 | 237 | The {data-sync-starter} provides: 238 | 239 | - Offline operation support 240 | - Realtime updates through GraphQL Subscriptions 241 | - Conflict detection and resolution 242 | 243 | In this guide we will explore the capabilities of DataSync by using the 244 | generated server application and the sample frontend application available as part of {data-sync-starter}. 245 | The frontend application is a Todo style app that uses the `Task` model. 246 | 247 | . Go back to the application opened in the previous step. 248 | . Create a task by clicking on the plus icon in the bottom right-hand side of the screen. 249 | . Add a title and description, of your choosing, to the task and click *Create*. 250 | . Copy the current url and paste it in a different tab, browser or mobile browser. 251 | . Change the status of the task by clicking/unclicking the text box beside the task. 252 | 253 | 254 | [type=verification] 255 | **** 256 | Verify that the status of the task is synced across all tabs in real-time. 257 | **** 258 | 259 | [type=verificationFail] 260 | **** 261 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator. 262 | **** 263 | 264 | [time=10] 265 | == Exploring data sync features using the Data Sync showcase application 266 | 267 | To explore data sync features, you should run multiple instances of the {data-sync-starter} using different browsers. 268 | For example, use the browser on your mobile device as well as using the browser on your laptop. 269 | 270 | === Exploring real-time sync 271 | 272 | . On your laptop: 273 | .. Create a new task using *+* icon. 274 | .. Enter some task text and click *Create*. 275 | 276 | . On your second device: 277 | .. Check that the same task appears in the tasks page 278 | .. Make some changes to the task. 279 | 280 | . On your laptop: 281 | .. Check that the task changes are synchronized. 282 | 283 | 284 | [type=verification] 285 | **** 286 | Did the tasks appear as expected? 287 | **** 288 | 289 | [type=verificationFail] 290 | **** 291 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator. 292 | **** 293 | 294 | === Exploring offline support 295 | 296 | DataSync provides offline and conflict resolution for client side applications 297 | like React, Angular or Vue. Sample application implements `Task` model 298 | generated from server and utilizes Offix (http://offix.dev) client to enable 299 | offline and conflict capabilities. 300 | 301 | . On your mobile device: 302 | .. Activate airplane mode or disable network connectivity. 303 | .. Create a new task. 304 | The task should be created and the *Offline Changes* button in the footer should contain one change. 305 | .. Make a few more changes by either editing existing tasks, or creating new ones. 306 | .. Review all the changes by clicking the *Offline Changes* button. 307 | 308 | . On your laptop: 309 | You do not see any of the changes from the mobile device. 310 | 311 | . On your second device: 312 | .. Restore connectivity or deactivate airplane mode. 313 | .. Watch the status of the tasks change. 314 | 315 | . On your laptop: 316 | .. Check that all the tasks are synchronized. 317 | 318 | 319 | [type=verification] 320 | **** 321 | Did the tasks appear as expected? 322 | **** 323 | 324 | [type=verificationFail] 325 | **** 326 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator. 327 | **** 328 | 329 | === Resolving conflicts 330 | 331 | . On your second device: 332 | .. Create a task `todo A`. 333 | .. Activate airplane mode or disable network connectivity. 334 | .. Edit the task description to add the text `edited on mobile`. 335 | 336 | . On your laptop: 337 | .. Simulate offline mode. For example, in Chrome, press F12 to open *Developer Tools* and select *offline* in the *Network* tab. 338 | .. Edit the `todo A` task, change the text to `todo B`. 339 | 340 | . Bring both of your devices back online, the tasks should sync without a conflict. 341 | 342 | . On your mobile device: 343 | .. Activate airplane mode or disable network connectivity. 344 | .. Edit task `todo B` change the description to: 345 | + 346 | ---- 347 | Conflicting description from mobile 348 | ---- 349 | 350 | . On your laptop: 351 | .. Simulate offline mode. For example, in Chrome, press F12 to open *Developer Tools* and select *offline* in the *Network* tab. 352 | .. Edit task `todo B` change the description to: 353 | + 354 | ---- 355 | Conflicting description from laptop 356 | ---- 357 | 358 | . Bring both of your devices back online, a popup window should appear warning you about conflicts. 359 | 360 | [type=verification] 361 | **** 362 | Did the tasks sync as expected? 363 | **** 364 | 365 | [type=verificationFail] 366 | **** 367 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator. 368 | **** 369 | 370 | . Close terminal window running server application 371 | 372 | [time=15] 373 | == Add authentication and authorization to the Data Sync application using Red Hat SSO 374 | 375 | In this section, we will configure both the frontend and the backend of the 376 | {data-sync-starter} with the {customer-sso-name}. 377 | 378 | DataSync starter has authentication and autorization enabled out of the box. 379 | Developers need to configure server and client application to use their keycloak instance 380 | and add required authorization rules to their model. 381 | 382 | == Add authorization rule for Task deletion 383 | 384 | . Go to your GraphQL Schema `./server/src/config/auth.ts`. 385 | This file contains auth rules for all the operations we support. 386 | . Change role from `delete: { roles: ['admin'] }` to delete: { roles: ['test'] }, 387 | This will only allow deletion for test role that we haven't created. 388 | This operation will prevent us from deleting items from the list. 389 | 390 | === Configuring Authentication for Keycloak (SSO) 391 | 392 | . In solution explorer open the User SSO service. 393 | . Login using your own credentials (You might need to open this tab in incognito mode). 394 | . In menu on the left hover over realm name. 395 | . Select `Add new realm` 396 | . Put `DataSync Example` as name and press `Create` 397 | . Select *Clients* from the vertical navigation menu on the left side of the screen. 398 | . Click the *Create* button on the top right of the Clients screen. 399 | . On the *Add Client* screen: 400 | .. In the *Client ID* field, enter 401 | + 402 | [subs="attributes+"] 403 | ---- 404 | public-datasync 405 | ---- 406 | .. Verify the *Client Protocol* is set to *openid-connect*. 407 | .. Click *Save*. 408 | . You will see the *Settings* screen for the *{client-name}* client if the save is successful. 409 | . on the *Settings* page: 410 | .. Change `Valid Redirect URIs` to hostname used to run your server application with `*` at the end. 411 | For example `https://routex9wvywuq-codeready-workspaces.apps.openshift.io*` 412 | .. Change `Web Origins` to `*` 413 | .. Click on the *Save* button 414 | .. Click on the *Installation* tab, and select `Keycloak OIDC JSON` format. Copy the content displayed or use the `Download` button to save the configuration file. 415 | . Create new users for testing: 416 | .. Select *Users* on the left menu, and click on *View all users*. 417 | .. Click on *Add user* to create a new user. Pick a username you like for the *Username* field and click *Save*. 418 | .. Select the *Credentials* tab and set a password for this user. Set *Temporary* option to *OFF*. 419 | .. Click *Reset Password* 420 | 421 | === Testing Keycloak Authentication and Authorization 422 | 423 | . Close all opened terminals in Code Ready environment 424 | . Copy `Keycloak OIDC JSON` file into: 425 | .. `server/website/keycloak.json` 426 | .. `server/src/config/keycloak.json` 427 | . Execute `start server`. This command will start GraphQL server with embedded client. 428 | . Open Preview URL in the new window 429 | . Login window should appear. 430 | . Login using credentials you have choosen in keycloak 431 | . Press User icon in the top right corner. 432 | . You should see admin user profile with his roles 433 | . Go back to the task screen 434 | . Try to delete one of the created tasks 435 | . User will not be permitted to delete task as it does not have test role. 436 | --------------------------------------------------------------------------------