Compare commits

...

50 Commits

Author SHA1 Message Date
4ee3de773c Possibilité de configurer les options du cookie 2020-10-13 14:59:25 +02:00
d4ca478b44 Mise à jour goweb-oidc 2020-10-13 14:56:28 +02:00
d10ce7c7ad Correction affichage profil 2020-10-13 14:27:40 +02:00
655ecd1a0f Mise en forme basique du loader de module 2020-10-13 13:47:29 +02:00
70fe86a9a5 Mise à jour goweb-oidc 2020-10-13 13:21:29 +02:00
17b44170d0 Redémarrage du serveur webpack lors d'une modification de la configuration 2020-10-13 12:09:04 +02:00
f2d6a72204 Correction chargement dynamique 2020-10-13 12:08:43 +02:00
f752865d33 Découpage du code et extraction des sourcemaps 2020-10-13 11:37:37 +02:00
85008d3265 Mise à jour goweb-oidc 2020-10-13 11:07:26 +02:00
f34b7e4439 Affichage multilignes des colonnes 2020-10-13 11:04:49 +02:00
b36ae791cb Persistence de l'UUID de conférence dans le localStorage 2020-10-13 11:04:32 +02:00
50ec72fcf4 Heartbeat de présence configurable dans la salle de conférence 2020-10-13 09:38:09 +02:00
0b93b0875e Conference: correction détection déconnexion 2020-10-12 21:56:19 +02:00
6f757002b1 Merge branch 'feature/conference-room' of Cadoles/daddy into develop 2020-10-12 21:14:32 +02:00
18dc4135c4 Salle de conférence expérimentale 2020-10-12 21:13:28 +02:00
54e8cf23f7 Merge branch 'feature/dsf-tab-deep-linking' of Cadoles/daddy into develop 2020-10-12 15:58:54 +02:00
5649cd2aad Gestion des liens profonds sur les tabs dans la page DAD 2020-10-12 15:58:19 +02:00
f032e83e71 Affichage des dossiers associés dans la page groupe de travail 2020-10-12 14:56:22 +02:00
19f0c8e0a4 Merge branch 'feature/sentry' of Cadoles/daddy into develop 2020-10-12 14:35:43 +02:00
d0cd9842ea Intégration de Sentry 2020-10-12 14:35:02 +02:00
27458b5b94 Merge branch 'feature/vote-report' of Cadoles/daddy into develop 2020-10-12 13:29:27 +02:00
e5152aa652 Ajout des dossiers votés dans la newsletter 2020-10-12 13:11:57 +02:00
1eaaa9065f Ajout d'un champ de rapport basique pour décrire la prise de décision
- Enregistrement et prise en compte dans l'affichage des évènements de
  vote/clotûre d'un DAD
2020-10-12 12:44:30 +02:00
7d0831ee57 Merge branch 'feature/unauthorized-page' of Cadoles/daddy into develop 2020-10-12 10:06:02 +02:00
0859202987 Ajout d'une page 'Non autorisée' et redirection automatique vers celle ci en cas d'accès via un compte non autorisé 2020-10-12 10:05:04 +02:00
7a6eedab9d Forcer l'utilisation du réseau pour les requêtes d'autorisation 2020-10-05 17:03:01 +02:00
89a147565c Correction DOM invalide 2020-10-05 16:37:34 +02:00
9b8adafe60 Ajout du suivi des opérations spécifiques dans les vues DAD et GdT 2020-10-05 16:04:10 +02:00
a3fa793706 Correction mise à forme de la timeline 2020-10-05 15:50:01 +02:00
27720219ee Correction détection de la session 2020-10-05 15:49:32 +02:00
f4528dd087 Correction affichage nom utilisateur dans la newsletter 2020-10-05 15:49:09 +02:00
fb954a3e5b Réorganisation visuelle du tableau de bord 2020-10-05 15:18:35 +02:00
92efdbd568 Merge branch 'feature/newsletter' of Cadoles/daddy into develop 2020-10-05 14:19:04 +02:00
137709adea Ajout d'une newsletter basique
La newsletter effectue une collecte des évènements sur une période de
temps donné et envoi un récapitulatif à l'ensemble des utilisateurs de
Daddy.

Actuellement, sont collectés et présentés:

- Les créations de groupes de travail
- Les créations de dossiers d'aide à la décision
- Les dossiers dont le statut à été modifié et prêt à voté
2020-10-05 14:16:25 +02:00
6cdbea92d1 Merge branch 'feature/events' of Cadoles/daddy into develop 2020-10-02 16:39:19 +02:00
f169169bc7 Enregistrement et affichage d'un flux d'évènements
- Ajout d'une nouvelle entité "Event"
- Affichage d'une "timeline" sur le tableau de bord
- Création semi-automatique des évènements lors des modifications par
  les utilisateurs
2020-10-02 16:37:24 +02:00
61eacefd6c Formatage des dates dans l'interface 2020-10-01 11:44:29 +02:00
11f54ab66e Utilisation de la stratégie de cache 'cache puis réseau' 2020-09-10 19:28:08 +02:00
772b09381c Ajout label 'Déconnexion' 2020-09-10 19:27:43 +02:00
978cc65c41 Dissimulation du bouton enregistrer en mode lecture seule 2020-09-10 19:27:17 +02:00
596108b4f4 Panel DADs: ajout onglets 'à voter' et 'votés' 2020-09-10 19:26:41 +02:00
6845e1ce50 Ajout types génériques pour autocomplétion 2020-09-10 19:25:52 +02:00
04b32772fc Merge branch 'feature/dsf-front-acl' of Cadoles/daddy into develop 2020-09-10 11:20:16 +02:00
17747e998d Passage en lecture seule du DAD lorsqu'il appartient à un groupe
différent
2020-09-10 11:12:58 +02:00
12151ff613 Merge branch 'feature/authorization' of Cadoles/daddy into develop 2020-09-08 10:18:06 +02:00
71102cfb3b Conservation de l'état connecté entre 2 rafraichissement de page
L'état de connexion est conservé dans le sessionStorage et réutilisé par
défaut lors du rafraichissement de la page.

Si une erreur 401 survient lors d'un appel à l'API alors l'utilisateur
est redirigé vers la page d'accueil.
2020-09-04 17:10:23 +02:00
7dad33b6e4 Correction récupération/fusion des Workgroups 2020-09-04 12:28:38 +02:00
9c6ebae9bc Ajout d'une query GraphQL pour vérifier les autorisations côté serveur
- Intégration des vérifications de droits sur la page de
  création/modification des groupes de travail
2020-09-04 11:19:24 +02:00
3ef495445a Mise en place d'un système de vérification des autorisations côté
serveur

- Création d'un service d'autorisation dynamique basé sur des "voter" (à
  la Symfony)
- Mise en place des autorisations sur les principales queries/mutations
  de l'API GraphQL
2020-09-04 10:10:32 +02:00
bc56c9dbae Ajout URLs manquantes directement gérées par le client 2020-08-31 16:13:10 +02:00
97 changed files with 3950 additions and 543 deletions

132
client/package-lock.json generated
View File

@ -1125,9 +1125,9 @@
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
},
"@fortawesome/fontawesome-free": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz",
"integrity": "sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg==",
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.0.tgz",
"integrity": "sha512-wXetjQBNMTP59MAYNR1tdahMDOLx3FYj3PKdso7PLFLDpTvmAIqhSSEqnSTmWKahRjD+Sh5I5635+5qaoib5lw==",
"dev": true
},
"@nodelib/fs.scandir": {
@ -3197,6 +3197,11 @@
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz",
"integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ=="
},
"bulma-timeline": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/bulma-timeline/-/bulma-timeline-3.0.4.tgz",
"integrity": "sha512-gCUOcSUuzHoeVMkCpLF49j5Z5yl78XQ+KgJcT+1ju5WIGgBgVytRUob/dw5NHAxPLO2rmcvwYNbCJFp7w4WT4Q=="
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@ -4030,6 +4035,11 @@
"randomfill": "^1.0.3"
}
},
"crypto-js": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz",
"integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg=="
},
"css": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
@ -4484,9 +4494,9 @@
"dev": true
},
"elliptic": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
"dev": true,
"requires": {
"bn.js": "^4.4.0",
@ -5414,6 +5424,11 @@
"integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==",
"dev": true
},
"get-browser-rtc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.0.2.tgz",
"integrity": "sha1-u81AyEUaftTvXDc7gWmkCd0dEdk="
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -6050,8 +6065,7 @@
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ini": {
"version": "1.3.5",
@ -6371,6 +6385,11 @@
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
"dev": true
},
"isomorphic.js": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.1.5.tgz",
"integrity": "sha512-MkX5lLQApx/8IAIU31PKvpAZosnu2Jqcj1rM8TzxyA4CR96tv3SgMKQNTCxL58G7696Q57zd7ubHV/hTg+5fNA=="
},
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
@ -6491,6 +6510,14 @@
"leven": "^3.1.0"
}
},
"lib0": {
"version": "0.2.34",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.34.tgz",
"integrity": "sha512-cqsVIMPgFlDtgQcpkt7HOY6W3sbYPIe3qxMnbRSwHTgiQancgm+TRDPx28mC6GUZ6lG6Nr0bIWf4Nog6dWUNUg==",
"requires": {
"isomorphic.js": "^0.1.3"
}
},
"load-json-file": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
@ -6551,9 +6578,9 @@
}
},
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
},
"loglevel": {
"version": "1.6.8",
@ -7052,9 +7079,9 @@
}
},
"node-forge": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz",
"integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz",
"integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==",
"dev": true
},
"node-gyp": {
@ -8001,11 +8028,15 @@
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==",
"dev": true
},
"queue-microtask": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.1.4.tgz",
"integrity": "sha512-eY/4Obve9cE5FK8YvC1cJsm5cr7XvAurul8UtBDJ2PR1p5NmAwHtvAt5ftcLtwYRCUKNhxCneZZlxmUDFoSeKA=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"requires": {
"safe-buffer": "^5.1.0"
}
@ -8745,12 +8776,12 @@
"dev": true
},
"selfsigned": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.7.tgz",
"integrity": "sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==",
"version": "1.10.8",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.8.tgz",
"integrity": "sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==",
"dev": true,
"requires": {
"node-forge": "0.9.0"
"node-forge": "^0.10.0"
}
},
"semver": {
@ -8963,6 +8994,30 @@
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
"dev": true
},
"simple-peer": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.7.2.tgz",
"integrity": "sha512-xeMyxa9B4V0eA6mf17fVr8nm2QhAYFu+ZZv8zkSFFTjJETGF227CshwobrIYZuspJglMD63egcevQXGOrTIsuA==",
"requires": {
"debug": "^4.0.1",
"get-browser-rtc": "^1.0.0",
"queue-microtask": "^1.1.0",
"randombytes": "^2.0.3",
"readable-stream": "^3.4.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
}
}
},
"slash": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
@ -9392,7 +9447,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.0"
}
@ -10109,8 +10163,7 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"dev": true
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
},
"util.promisify": {
"version": "1.0.0",
@ -10806,6 +10859,33 @@
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true
},
"y-protocols": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.1.tgz",
"integrity": "sha512-QP3fCM7c2gGfUi2nqf8gspyO4VW23zv3kNqPNdD3wNxMbuNQenMyoDVZYEo12jzR4RQ3aaDfPK62Sf31SVOmfg==",
"requires": {
"lib0": "^0.2.28"
}
},
"y-webrtc": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.1.6.tgz",
"integrity": "sha512-b3pTIv9LcPuMb4nbDT3/kkgmcuQoTrBmaPbBqPH1LJMzI8HwYnMK8p5r0fBQJBI0YRor+i8BT15Evv1nQBP0zg==",
"requires": {
"lib0": "^0.2.32",
"simple-peer": "^9.7.2",
"ws": "^7.2.0",
"y-protocols": "^1.0.0"
},
"dependencies": {
"ws": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz",
"integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==",
"optional": true
}
}
},
"y18n": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
@ -10931,6 +11011,14 @@
}
}
},
"yjs": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.4.1.tgz",
"integrity": "sha512-kIh0sprCTzIm2qyr1VsovkvjKzD2GR4WcU/McJpLAEvImCJHA78Q3S6uSLnhZX0i7FQdrLPCRT8DtTPEH73jnw==",
"requires": {
"lib0": "^0.2.33"
}
},
"zen-observable": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",

View File

@ -25,7 +25,7 @@
"@babel/plugin-transform-runtime": "^7.7.4",
"@babel/preset-env": "^7.7.1",
"@babel/preset-react": "^7.7.4",
"@fortawesome/fontawesome-free": "^5.11.2",
"@fortawesome/fontawesome-free": "^5.14.0",
"@types/node": "^13.13.4",
"@types/react-dom": "^16.9.7",
"@types/react-redux": "^7.1.7",
@ -55,6 +55,8 @@
"@types/qs": "^6.9.3",
"bs58": "^4.0.1",
"bulma": "^0.9.0",
"bulma-timeline": "^3.0.4",
"crypto-js": "^4.0.0",
"graphql": "^15.3.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
@ -65,6 +67,8 @@
"redux-saga": "^1.1.3",
"styled-components": "^4.4.1",
"subscriptions-transport-ws": "^0.9.17",
"typescript": "^3.8.3"
"typescript": "^3.8.3",
"y-webrtc": "^10.1.6",
"yjs": "^13.4.1"
}
}

View File

@ -1,49 +1,87 @@
import React, { FunctionComponent, useState } from 'react';
import React, { FunctionComponent, useState, useEffect, Suspense } from 'react';
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
import { HomePage } from './HomePage/HomePage';
import { ProfilePage } from './ProfilePage/ProfilePage';
import { WorkgroupPage } from './WorkgroupPage/WorkgroupPage';
import { DecisionSupportFilePage } from './DecisionSupportFilePage/DecisionSupportFilePage';
import { DashboardPage } from './DashboardPage/DashboardPage';
import { useUserProfile } from '../gql/queries/profile';
import { LoggedInContext } from '../hooks/useLoggedIn';
import { LoggedInContext, getSavedLoggedIn, saveLoggedIn } from '../hooks/useLoggedIn';
import { PrivateRoute } from './PrivateRoute';
import { useKonamiCode } from '../hooks/useKonamiCode';
import { Modal } from './Modal';
import { createClient } from '../util/apollo';
import { ApolloProvider } from '@apollo/client';
import { AppLoader } from './AppLoader';
const LazyHomePage = React.lazy(() => import(/* webpackChunkName: "HomePage" */'./HomePage/HomePage'));
const LazyDashboardPage = React.lazy(() => import(/* webpackChunkName: "DashboardPage" */'./DashboardPage/DashboardPage'));
const LazyUnauthorizedPage = React.lazy(() => import(/* webpackChunkName: "UnauthorizedPage" */'./UnauthorizedPage/UnauthorizedPage'));
const LazyConferencePage = React.lazy(() => import(/* webpackChunkName: "ConferencePage" */'./ConferencePage/ConferencePage'));
const LazyDecisionSupportFilePage = React.lazy(() => import(/* webpackChunkName: "DecisionSupportFilePage" */'./DecisionSupportFilePage/DecisionSupportFilePage'));
const LazyProfilePage = React.lazy(() => import(/* webpackChunkName: "ProfilePage" */'./ProfilePage/ProfilePage'));
const LazyWorkgroupPage = React.lazy(() => import(/* webpackChunkName: "WorkgroupPage" */'./WorkgroupPage/WorkgroupPage'));
const LazyLogoutPage = React.lazy(() => import(/* webpackChunkName: "LogoutPage" */'./LogoutPage'));
export interface AppProps {
}
export const App: FunctionComponent<AppProps> = () => {
const { user } = useUserProfile();
const [ loggedIn, setLoggedIn ] = useState(getSavedLoggedIn());
const client = createClient((loggedIn) => {
setLoggedIn(loggedIn);
});
useEffect(() => {
saveLoggedIn(loggedIn);
}, [loggedIn]);
const [ showBoneyM, setShowBoneyM ] = useState(false);
useKonamiCode(() => setShowBoneyM(true));
return (
<LoggedInContext.Provider value={user.id !== ''}>
<BrowserRouter>
<Switch>
<Route path="/" exact component={HomePage} />
<PrivateRoute path="/profile" exact component={ProfilePage} />
<PrivateRoute path="/workgroups/:id" exact component={WorkgroupPage} />
<PrivateRoute path="/decisions/:id" exact component={DecisionSupportFilePage} />
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
<Route component={() => <Redirect to="/" />} />
</Switch>
</BrowserRouter>
{
showBoneyM ?
<Modal active={true} showCloseButton={true} onClose={() => setShowBoneyM(false)}>
<iframe width={560} height={315}
frameBorder={0}
allowFullScreen={true}
src="https://www.youtube.com/embed/uVzT5QEEQ2c?autoplay=1" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture">
</iframe>
</Modal> :
null
}
</LoggedInContext.Provider>
<Suspense fallback={<AppLoader />}>
<ApolloProvider client={client}>
<LoggedInContext.Provider value={loggedIn}>
<UserSessionCheck setLoggedIn={setLoggedIn} />
<BrowserRouter>
<Switch>
<Route path="/" exact component={LazyHomePage} />
<Route path="/unauthorized" exact component={LazyUnauthorizedPage} />
<PrivateRoute path="/profile" exact component={LazyProfilePage} />
<PrivateRoute path="/conference" exact component={LazyConferencePage} />
<PrivateRoute path="/workgroups/:id" exact component={LazyWorkgroupPage} />
<PrivateRoute path="/decisions/:id" component={LazyDecisionSupportFilePage} />
<PrivateRoute path="/dashboard" exact component={LazyDashboardPage} />
<PrivateRoute path="/logout" exact component={LazyLogoutPage} />
<Route component={() => <Redirect to="/" />} />
</Switch>
</BrowserRouter>
{
showBoneyM ?
<Modal active={true} showCloseButton={true} onClose={() => setShowBoneyM(false)}>
<iframe width={560} height={315}
frameBorder={0}
allowFullScreen={true}
src="https://www.youtube.com/embed/uVzT5QEEQ2c?autoplay=1" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture">
</iframe>
</Modal> :
null
}
</LoggedInContext.Provider>
</ApolloProvider>
</Suspense>
);
}
}
interface UserSessionCheckProps {
setLoggedIn: (boolean) => void
}
const UserSessionCheck: FunctionComponent<UserSessionCheckProps> = ({ setLoggedIn }) => {
const { user, loading } = useUserProfile();
useEffect(() => {
if (loading) return;
setLoggedIn(user && user.id !== '');
}, [user]);
return null;
};

View File

@ -0,0 +1,9 @@
import React, { FunctionComponent } from "react";
export const AppLoader:FunctionComponent = () => {
return (
<div className="app-loader">
<i className="fas fa-spinner fa-spin fa-5x"></i>
</div>
)
}

View File

@ -0,0 +1,142 @@
import React, { FunctionComponent, useEffect } from 'react';
import { Config } from '../../config';
import { useUserProfile } from '../../gql/queries/profile';
import { useConference } from '../../hooks/useConference';
import { Page } from '../Page';
import { Gravatar } from './Gravatar';
export interface ConferencePageProps {
}
const StatusHandRaised = 'hand-raised';
const StatusThumbsUp = 'thumbs-up';
const StatusThumbsDown = 'thumbs-down';
const StatusNoVote = 'no-vote';
export const ConferencePage:FunctionComponent<ConferencePageProps> = () => {
const { user } = useUserProfile();
const { uuid, data, setNickname, setEmail, ping, setStatus, forget } = useConference();
const currentStatus = data.statuses[uuid];
useEffect(() => {
if (!user.name && !user.email) return;
setNickname(user.name || user.email.split('@')[0]);
setEmail(user.email);
}, [user.name, user.email]);
useEffect(() => {
ping();
const intervalId = setInterval(() => ping(), Config.conferenceHeartbeatInterval + (Math.random() * Config.conferenceHeartbeatInterval/2));
return () => clearInterval(intervalId);
}, []);
const onStatusChange = (status: string) => {
setStatus(currentStatus === status ? '' : status);
};
return (
<Page title="Conference">
<div className="container is-fluid">
<section className="mt-5">
<h3 className="is-size-3">Mes actions</h3>
<div className="buttons has-addons">
<button
className={`button is-medium ${currentStatus === StatusHandRaised ? 'is-info is-selected' : ''}`}
onClick={onStatusChange.bind(null, StatusHandRaised)}>
<span className="icon">
<i className="fa fa-hand-paper"></i>
</span>
<span>Lever la main</span>
</button>
<button
className={`button is-medium ${currentStatus === StatusThumbsUp ? 'is-success is-selected' : ''}`}
onClick={onStatusChange.bind(null, StatusThumbsUp)}>
<span className="icon">
<i className="fa fa-thumbs-up"></i>
</span>
<span>Voter pour</span>
</button>
<button
className={`button is-medium ${currentStatus === StatusNoVote ? 'is-warning is-selected' : ''}`}
onClick={onStatusChange.bind(null, StatusNoVote)}>
<span className="icon">
<i className="fa fa-mitten"></i>
</span>
<span>Ne se prononce pas</span>
</button>
<button
className={`button is-medium ${currentStatus === StatusThumbsDown ? 'is-danger is-selected' : ''}`}
onClick={onStatusChange.bind(null, StatusThumbsDown)}>
<span className="icon">
<i className="fa fa-thumbs-down"></i>
</span>
<span>Voter contre</span>
</button>
</div>
<h3 className="is-size-3">Assemblée</h3>
<div className="columns mt-1 is-multiline">
<UserCard className="column is-narrow"
nickname={data.nicknames[uuid]}
status={currentStatus}
email={user.email} />
{
Object.keys(data.peers).map(p => {
const now = new Date();
const lastHeartBeat = new Date(data.peers[p]);
if (p === uuid) return null;
if (now.getTime() > lastHeartBeat.getTime() + Config.conferenceHeartbeatInterval*2) {
forget(p);
return null;
}
const nickname = data.nicknames[p] || '???';
const email = data.emails[p] || '';
return (
<UserCard key={`peer-${p}`} className="column is-narrow"
nickname={nickname}
status={data.statuses[p]}
email={email} />
)
})
}
</div>
</section>
</div>
</Page>
);
}
export default ConferencePage;
export interface UserCardProps {
nickname: string
email: string
className?: string
status: string
};
export const UserCard:FunctionComponent<UserCardProps> = ({ nickname, email, className, status }) => {
return (
<div className={className}>
<div className="box">
<div className="has-text-centered">
<div className="mb-1">
{ !status ? <span className="icon"><i className="far fa-2x fa-meh-blank"></i></span> : null }
{ status === StatusHandRaised ? <span className="icon has-text-info"><i className="fa fa-2x fa-hand-paper"></i></span> : null }
{ status === StatusThumbsUp ? <span className="icon has-text-success"><i className="fa fa-2x fa-thumbs-up"></i></span> : null }
{ status === StatusNoVote ? <span className="icon has-text-warning"><i className="fa fa-2x fa-mitten"></i></span> : null }
{ status === StatusThumbsDown ? <span className="icon has-text-danger"><i className="fa fa-2x fa-thumbs-down"></i></span> : null }
</div>
<figure className="image is-128x128 is-inline-block">
<Gravatar className="is-rounded" email={email} />
</figure>
<h4 className="is-size-4">{nickname}</h4>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,20 @@
import React, { FunctionComponent, useEffect, useState } from 'react';
import md5 from 'crypto-js/md5';
export interface GravatarProps {
className?: string
email: string
}
const defaultAvatarUrl = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&s=128';
export const Gravatar:FunctionComponent<GravatarProps> = ({ className, email }) => {
const [ avatarUrl, setAvatarUrl ] = useState(defaultAvatarUrl);
useEffect(() => {
const hash = md5(email.trim().toLowerCase());
setAvatarUrl(`https://www.gravatar.com/avatar/${hash}?d=mp&s=128`);
}, [email]);
return (
<img className={className} src={avatarUrl} />
);
}

View File

@ -1,27 +1,46 @@
import React from 'react';
import { WorkgroupsPanel } from './WorkgroupsPanel';
import { DecisionSupportFilePanel } from './DecisionSupportFilePanel';
import { Timeline } from '../Timeline';
import { useEvents } from '../../gql/queries/event';
const from = new Date();
from.setDate(from.getDate() - 7);
export function Dashboard() {
const { events } = useEvents({
variables: {
filter: {
from,
}
}
});
return (
<div className="columns">
<div className="column is-6">
<div className="column is-4">
<DecisionSupportFilePanel />
</div>
<div className="column is-3">
<div className="column is-4">
<WorkgroupsPanel />
</div>
<div className="column is-3">
<div className="box">
<div className="level">
<div className="column is-4">
<div className="panel is-info">
<div className="level panel-heading mb-0">
<div className="level-left">
<h3 className="is-size-3 subtitle level-item">Assemblées</h3>
<div className="level-item">
Ces 7 derniers jours
</div>
</div>
<div className="level-right">
<button disabled className="button is-primary level-item"><i className="fa fa-plus"></i></button>
<button disabled={true} className="button level-item is-outlined is-info is-inverted">
<i className="icon fa fa-sliders-h"></i>
</button>
</div>
</div>
<pre>TODO</pre>
<div className="panel-block">
<Timeline events={events} />
</div>
</div>
</div>
</div>

View File

@ -12,4 +12,6 @@ export function DashboardPage() {
</div>
</Page>
);
}
}
export default DashboardPage;

View File

@ -1,6 +1,6 @@
import React from 'react';
import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
import { ItemPanel, TabDefinition, Item } from './ItemPanel';
import { ItemPanel, TabDefinition, Item } from '../ItemPanel';
import { useUserProfile } from '../../gql/queries/profile';
import { inWorkgroup } from '../../types/workgroup';
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
@ -21,13 +21,20 @@ export function DecisionSupportFilePanel() {
label: 'Brouillons',
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Draft
},
{
label: 'À voter',
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Ready
},
{
label: 'Votés',
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Voted
},
{
label: 'Clos',
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Closed
},
];
return (
<ItemPanel
className='is-link'

View File

@ -1,11 +1,8 @@
import React, { useEffect, useState } from 'react';
import React, { } from 'react';
import { Workgroup, inWorkgroup } from '../../types/workgroup';
import { User } from '../../types/user';
import { Link } from 'react-router-dom';
import { useWorkgroupsQuery, useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfileQuery, useUserProfile } from '../../gql/queries/profile';
import { WithLoader } from '../WithLoader';
import { ItemPanel, Item } from './ItemPanel';
import { useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfile } from '../../gql/queries/profile';
import { ItemPanel, Item } from '../ItemPanel';
export function WorkgroupsPanel() {
const { workgroups } = useWorkgroups();

View File

@ -0,0 +1,25 @@
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { useWorkgroups } from "../gql/queries/workgroups";
import { useDecisionSupportFiles } from "../gql/queries/dsf";
export interface DecisioSupportFileLinkProps {
decisionSupportFileId: number|string
}
export const DecisionSupportFileLink: FunctionComponent<DecisioSupportFileLinkProps> = ({ decisionSupportFileId }) => {
const { decisionSupportFiles } = useDecisionSupportFiles({
fetchPolicy: "cache-first",
variables: {
filter: {
ids: [decisionSupportFileId]
}
}
});
const title = decisionSupportFiles.length > 0 ? decisionSupportFiles[0].title : `#${decisionSupportFileId}`;
return (
<Link to={`/decisions/${decisionSupportFileId}`}>{title}</Link>
);
};

View File

@ -7,7 +7,7 @@ export interface ClarificationSectionProps extends DecisionSupportFileUpdaterPro
const ClarificationSectionName = 'clarification';
export const ClarificationSection: FunctionComponent<ClarificationSectionProps> = ({ dsf, updateDSF }) => {
export const ClarificationSection: FunctionComponent<ClarificationSectionProps> = ({ dsf, updateDSF, readOnly }) => {
const [ state, setState ] = useState({
changed: false,
section: {
@ -49,82 +49,88 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
return (
<section>
<div className="box">
<div className="field">
<label className="label">Intitulé du dossier</label>
<div className="control">
<input className="input" type="text" value={dsf.title} onChange={onTitleChange} />
<div className="field">
<label className="label is-medium">Intitulé du dossier</label>
<div className="control">
<input className="input is-medium" type="text" readOnly={readOnly} value={dsf.title} onChange={onTitleChange} />
</div>
</div>
<div className="field">
<label className="label is-medium">Quelle décision devons nous prendre ?</label>
<div className="control">
<textarea className="textarea is-medium"
readOnly={readOnly}
value={state.section.objectives}
onChange={onSectionAttrChange.bind(null, 'objectives')}
placeholder="Décrire globalement les tenants et aboutissants de la décision à prendre."
rows={10}>
</textarea>
</div>
<p className="help is-info"><i className="fa fa-info-circle"></i> Ne pas essayer de rentrer trop dans les détails ici. Préférer l'utilisation des annexes et y faire référence.</p>
</div>
<div className="field">
<label className="label is-medium">Pourquoi devons nous prendre cette décision ?</label>
<div className="control">
<textarea className="textarea is-medium"
readOnly={readOnly}
value={state.section.motivations}
onChange={onSectionAttrChange.bind(null, 'motivations')}
placeholder="Décrire pourquoi il est important de prendre cette décision."
rows={10}>
</textarea>
</div>
<p className="help is-info"><i className="fa fa-info-circle"></i> Penser à indiquer si des obligations légales pèsent sur cette prise de décision.</p>
</div>
<div className="field">
<label className="label is-medium">Portée de la décision</label>
<div className="control">
<div className="select is-medium">
<select
disabled={readOnly}
onChange={onSectionAttrChange.bind(null, 'scope')}
value={state.section.scope}>
<option></option>
<option value="individual">Individuelle</option>
<option value="identified-group">Groupe identifié</option>
<option value="collective">Collective</option>
</select>
</div>
</div>
<div className="field">
<label className="label">Quelle décision devons nous prendre ?</label>
<div className="control">
<textarea className="textarea"
value={state.section.objectives}
onChange={onSectionAttrChange.bind(null, 'objectives')}
placeholder="Décrire globalement les tenants et aboutissants de la décision à prendre."
rows={10}>
</textarea>
</div>
<p className="help is-info"><i className="fa fa-info-circle"></i> Ne pas essayer de rentrer trop dans les détails ici. Préférer l'utilisation des annexes et y faire référence.</p>
</div>
<div className="field">
<label className="label">Pourquoi devons nous prendre cette décision ?</label>
<div className="control">
<textarea className="textarea"
value={state.section.motivations}
onChange={onSectionAttrChange.bind(null, 'motivations')}
placeholder="Décrire pourquoi il est important de prendre cette décision."
rows={10}>
</textarea>
</div>
<p className="help is-info"><i className="fa fa-info-circle"></i> Penser à indiquer si des obligations légales pèsent sur cette prise de décision.</p>
</div>
<div className="field">
<label className="label">Portée de la décision</label>
<div className="control">
<div className="select">
<select
onChange={onSectionAttrChange.bind(null, 'scope')}
value={state.section.scope}>
<option></option>
<option value="individual">Individuelle</option>
<option value="identified-group">Groupe identifié</option>
<option value="collective">Collective</option>
</select>
</div>
</div>
<div className="field">
<label className="label is-medium">Nature de la décision</label>
<div className="control">
<div className="select is-medium">
<select
disabled={readOnly}
onChange={onSectionAttrChange.bind(null, 'nature')}
value={state.section.nature}>
<option></option>
<option value="operational">Opérationnelle</option>
<option value="tactic">Tactique</option>
<option value="strategic">Stratégique</option>
</select>
</div>
</div>
<div className="field">
<label className="label">Nature de la décision</label>
<div className="control">
<div className="select">
<select onChange={onSectionAttrChange.bind(null, 'nature')}
value={state.section.nature}>
<option></option>
<option value="operational">Opérationnelle</option>
<option value="tactic">Tactique</option>
<option value="strategic">Stratégique</option>
</select>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<label className="checkbox">
<input type="checkbox"
onChange={onSectionAttrChange.bind(null, 'hasDeadline')}
checked={state.section.hasDeadline} />
<span className="ml-1">Existe t'il une échéance particulière pour cette décision ?</span>
</label>
<div className="field">
<div className="control">
<input disabled={!state.section.hasDeadline}
value={state.section.deadline ? asDate(state.section.deadline).toISOString().substr(0, 10) : ''}
onChange={onDeadlineChange}
type="date" className="input" />
</div>
</div>
<div className="columns">
<div className="column">
<label className="checkbox">
<input type="checkbox"
className="is-medium"
disabled={readOnly}
onChange={onSectionAttrChange.bind(null, 'hasDeadline')}
checked={state.section.hasDeadline} />
<span className="ml-1 has-text-weight-bold is-size-5">Existe t'il une échéance particulière pour cette décision ?</span>
</label>
<div className="field">
<div className="control">
<input disabled={!state.section.hasDeadline}
readOnly={readOnly}
value={state.section.deadline ? asDate(state.section.deadline).toISOString().substr(0, 10) : ''}
onChange={onDeadlineChange}
type="date" className="input is-medium" />
</div>
</div>
</div>

View File

@ -0,0 +1,49 @@
import React, { FunctionComponent, useState, ChangeEvent, useEffect } from 'react';
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
export interface DecisionReportSectionProps extends DecisionSupportFileUpdaterProps {};
const DecisionReportSectionName = 'decision-report';
export const DecisionReportSection: FunctionComponent<DecisionReportSectionProps> = ({ dsf, updateDSF, readOnly }) => {
const [ state, setState ] = useState({
changed: false,
section: {
report: "",
}
});
useEffect(() => {
if (!state.changed) return;
updateDSF({ ...dsf, sections: { ...dsf.sections, [DecisionReportSectionName]: { ...state.section }} })
setState(state => ({ ...state, changed: false }));
}, [state.changed]);
useEffect(() => {
if (!dsf.sections[DecisionReportSectionName]) return;
setState(state => ({ ...state, changed: false, section: {...state.section, ...dsf.sections[DecisionReportSectionName] }}));
}, [dsf.sections[DecisionReportSectionName]]);
const onSectionAttrChange = (attrName: string, evt: ChangeEvent<HTMLInputElement>) => {
const target = evt.currentTarget;
const value = target.hasOwnProperty('checked') ? target.checked : target.value;
setState(state => ({ ...state, changed: true, section: {...state.section, [attrName]: value }}));
};
return (
<section>
<div className="field">
<label className="label is-medium">Compte rendu du vote</label>
<div className="control">
<textarea className="textarea is-medium"
readOnly={readOnly}
value={state.section.report}
onChange={onSectionAttrChange.bind(null, 'report')}
rows={20}>
</textarea>
</div>
<p className="help is-info"><i className="fa fa-info-circle"></i> Penser à indiquer le résultat du vote et les éléments de contexte liés à la prise de décision.</p>
</div>
</section>
);
};

View File

@ -7,38 +7,66 @@ import { DecisionSupportFile, newDecisionSupportFile, DecisionSupportFileStatus
import { useParams, useHistory } from 'react-router';
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
import { useCreateDecisionSupportFileMutation, useUpdateDecisionSupportFileMutation } from '../../gql/mutations/dsf';
import { useDebounce } from '../../hooks/useDebounce';
import { OptionsSection } from './OptionsSection';
import { useIsAuthorized } from '../../gql/queries/authorization';
import { TimelinePanel } from './TimelinePanel';
import { DecisionReportSection } from './DecisionReportSection';
import { RoutedTabs } from '../RoutedTabs';
export interface DecisionSupportFilePageProps {
};
export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageProps> = () => {
const { id } = useParams();
const { id } = useParams<any>();
const history = useHistory();
const { decisionSupportFiles } = useDecisionSupportFiles({
variables:{
filter: {
ids: id !== 'new' ? [id] : undefined,
}
}
},
});
const [ state, setState ] = useState({
dsf: newDecisionSupportFile(),
saved: true,
selectedTabIndex: 0,
});
const { isAuthorized } = useIsAuthorized({
variables: {
action: 'update',
object: {
decisionSupportFileId: state.dsf.id,
}
}
}, id === 'new');
useEffect(() => {
const dsf = decisionSupportFiles.length > 0 && decisionSupportFiles[0].id === id ? decisionSupportFiles[0] : {};
setState(state => ({ ...state, dsf: { ...state.dsf, ...dsf }}))
}, [ decisionSupportFiles ]);
const selectTab = (tabIndex: number) => {
setState(state => ({ ...state, selectedTabIndex: tabIndex }));
};
const tabs = [
{
name: "Clarifier la proposition",
icon: "fas fa-pen",
route: '/info',
render: () => (<ClarificationSection readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />)
},
{
name: "Explorer les options",
icon: "fas fa-search",
route: '/options',
render: () => (<OptionsSection readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />)
},
{
name: "Prendre la décision",
icon: "fas fa-person-booth",
route: '/vote',
render: () => (<DecisionReportSection readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />)
}
];
const updateDSF = (dsf: DecisionSupportFile) => {
setState(state => {
@ -96,7 +124,7 @@ export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageP
</div> :
<div className="level-item">
<div>
<h2 className="is-size-3 title is-spaced">{state.dsf.title}</h2>
<h2 className="is-size-3 title is-spaced">{state.dsf.title} <span className={`ml-3 tag is-warning is-medium ${ isAuthorized ? 'is-hidden' : ''}`}>Lecture seule</span></h2>
<h3 className="is-size-5 subtitle">Dossier d'Aide à la Décision <span className="is-italic">{ isClosed ? '(clos)' : null }</span></h3>
</div>
</div>
@ -104,58 +132,33 @@ export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageP
</div>
<div className="level-right">
<div className="level-item buttons">
<button className="button is-medium is-success" disabled={!canSave} onClick={saveDSF}>
<span className="icon"><i className="fa fa-save"></i></span>
<span>Enregistrer</span>
</button>
{
isAuthorized ?
<button className="button is-medium is-success" disabled={!canSave} onClick={saveDSF}>
<span className="icon"><i className="fa fa-save"></i></span>
<span>Enregistrer</span>
</button> :
null
}
</div>
</div>
</div>
</section>
<div className="columns mt-3">
<div className="column is-9">
<div className="tabs is-medium is-toggle">
<ul>
<li className={`has-background-white ${state.selectedTabIndex === 0 ? 'is-active': ''}`}
onClick={selectTab.bind(null, 0)}>
<a>
<span className="icon is-small"><i className="fas fa-pen" aria-hidden="true"></i></span>
<span>Clarifier la proposition</span>
</a>
</li>
<li className={`has-background-white ${state.selectedTabIndex === 1 ? 'is-active': ''}`}
onClick={selectTab.bind(null, 1)}>
<a>
<span className="icon is-small"><i className="fas fa-search" aria-hidden="true"></i></span>
<span>Explorer les options</span>
</a>
</li>
<li className={`has-background-white ${state.selectedTabIndex === 2 ? 'is-active': ''}`}
onClick={selectTab.bind(null, 2)}>
<a>
<span className="icon is-small"><i className="fas fa-person-booth" aria-hidden="true"></i></span>
<span>Prendre la décision</span>
</a>
</li>
</ul>
<div className="column is-8">
<div className="box">
<RoutedTabs baseRoute={`/decisions/${id}`} tabs={tabs} />
</div>
{
state.selectedTabIndex === 0 ?
<ClarificationSection dsf={state.dsf} updateDSF={updateDSF} /> :
null
}
{
state.selectedTabIndex === 1 ?
<OptionsSection dsf={state.dsf} updateDSF={updateDSF} /> :
null
}
</div>
<div className="column is-3">
<MetadataPanel dsf={state.dsf} updateDSF={updateDSF} />
<AppendixPanel dsf={state.dsf} />
<div className="column is-4">
<MetadataPanel readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />
{/* <AppendixPanel dsf={state.dsf} /> */}
<TimelinePanel dsf={state.dsf} />
</div>
</div>
</div>
</Page>
);
};
};
export default DecisionSupportFilePage;

View File

@ -3,4 +3,5 @@ import { DecisionSupportFile } from "../../types/decision";
export interface DecisionSupportFileUpdaterProps {
dsf: DecisionSupportFile
updateDSF: (dsf: DecisionSupportFile) => void
readOnly: boolean
}

View File

@ -4,11 +4,12 @@ import { useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfile } from '../../gql/queries/profile';
import { inWorkgroup } from '../../types/workgroup';
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
import { asDate } from '../../util/date';
import { asDate, formatDate } from '../../util/date';
import { Link } from 'react-router-dom';
export interface MetadataPanelProps extends DecisionSupportFileUpdaterProps {};
export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf, updateDSF }) => {
export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf, updateDSF, readOnly }) => {
const { user } = useUserProfile();
const { workgroups } = useWorkgroups();
@ -39,26 +40,36 @@ export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf, upda
<div style={{width:'100%'}}>
<div className="field">
<div className="label">Groupe de travail</div>
<div className="control is-expanded">
<div className="select is-fullwidth">
<select onChange={onWorkgroupChanged} value={dsf.workgroup ? dsf.workgroup.id : ''}>
<option></option>
{
userOpenedWorkgroups.map(wg => {
return (
<option key={`wg-${wg.id}`} value={wg.id}>{wg.name}</option>
);
})
}
</select>
{
readOnly && dsf.workgroup !== null ?
<Link to={`/workgroups/${dsf.workgroup.id}`}>{dsf.workgroup.name}</Link> :
<div className="control is-expanded">
<div className="select is-fullwidth">
<select
disabled={readOnly}
onChange={onWorkgroupChanged}
value={dsf.workgroup ? dsf.workgroup.id : ''}>
<option></option>
{
userOpenedWorkgroups.map(wg => {
return (
<option key={`wg-${wg.id}`} value={wg.id}>{wg.name}</option>
);
})
}
</select>
</div>
</div>
</div>
}
</div>
<div className="field">
<div className="label">Statut</div>
<div className="control is-expanded">
<div className="select is-fullwidth">
<select onChange={onStatusChanged} value={dsf.status}>
<select
disabled={readOnly}
onChange={onStatusChanged}
value={dsf.status}>
<option value="draft">Brouillon</option>
<option value="ready">Prêt à voter</option>
<option value="voted">Voté</option>
@ -70,13 +81,13 @@ export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf, upda
<div className="field">
<div className="label">Créé le</div>
<div className="control">
<p>{asDate(dsf.createdAt).toISOString()}</p>
<p>{formatDate(dsf.createdAt)}</p>
</div>
</div>
<div className="field">
<div className="label">Voté le</div>
<div className="control">
<p>{dsf.votedAt ? dsf.votedAt : '--'}</p>
<p>{dsf.votedAt ? formatDate(dsf.votedAt) : '--'}</p>
</div>
</div>
</div>

View File

@ -6,7 +6,7 @@ export interface OptionsSectionProps extends DecisionSupportFileUpdaterProps {};
const OptionsSectionName = 'options';
export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, updateDSF }) => {
export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, updateDSF, readOnly }) => {
interface OptionsSectionState {
changed: boolean
section: OptionsSection
@ -75,8 +75,7 @@ export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, up
return (
<section>
<h4 id="options-section" className="is-size-4 title is-spaced"><a href="#options-section">Explorer les options</a></h4>
<div className="box">
<h4 id="options-section" className="is-size-4 title is-spaced">Explorer les options</h4>
<div className="table-container">
<table className={`table is-bordered is-striped is-hoverable is-fullwidth`}>
<thead>
@ -94,6 +93,7 @@ export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, up
<tr key={`option-${o.id}`}>
<td>
<button
disabled={readOnly}
onClick={onRemoveOptionClick.bind(null, index)}
className="button is-danger is-small is-outlined">
🗑
@ -101,6 +101,7 @@ export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, up
</td>
<td>
<textarea className="textarea"
readOnly={readOnly}
value={o.label}
onChange={onOptionChange.bind(null, index, 'label')}
placeholder="Décrire cette décision."
@ -110,6 +111,7 @@ export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, up
<td>
<textarea className="textarea is-success"
value={o.pros}
readOnly={readOnly}
onChange={onOptionChange.bind(null, index, 'pros')}
placeholder="Décrire les avantages de cette décision."
rows={10}>
@ -118,6 +120,7 @@ export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, up
<td>
<textarea className="textarea is-danger"
value={o.cons}
readOnly={readOnly}
onChange={onOptionChange.bind(null, index, 'cons')}
placeholder="Décrire les désavantages de cette décision."
rows={10}>
@ -139,15 +142,17 @@ export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, up
<tfoot>
<tr>
<td colSpan={5}>
<a className="button is-primary is-pulled-right" onClick={onAddOptionClick}>
<button
disabled={readOnly}
className="button is-primary is-pulled-right"
onClick={onAddOptionClick}>
Ajouter
</a>
</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,36 @@
import React, { FunctionComponent, useState } from 'react';
import { DecisionSupportFile } from '../../types/decision';
import { Timeline } from '../Timeline';
import { useEvents } from '../../gql/queries/event';
export interface TimelinePanelProps {
dsf: DecisionSupportFile,
};
export const TimelinePanel: FunctionComponent<TimelinePanelProps> = ({ dsf }) => {
const { events } = useEvents({
variables: {
filter: {
objectType: 'dsf',
objectId: dsf.id
}
}
});
return (
<div className="panel">
<div className="level panel-heading mb-0">
<div className="level-left">
<div className="level-item">
Suivi des opérations
</div>
</div>
<div className="level-right">
</div>
</div>
<div className="panel-block">
<Timeline events={events} />
</div>
</div>
);
};

View File

@ -3,18 +3,21 @@ import { Page } from '../Page';
import { WelcomeContent } from './WelcomeContent';
import { useUserProfile } from '../../gql/queries/profile';
import { useHistory } from 'react-router';
import { useLoggedIn } from '../../hooks/useLoggedIn';
export function HomePage() {
const { user } = useUserProfile();
const loggedIn = useLoggedIn();
const history = useHistory();
useEffect(() => {
if (user.id !== '') history.push('/dashboard');
}, [user.id])
if (loggedIn) history.push('/dashboard');
}, [loggedIn])
return (
<Page title="Accueil">
<WelcomeContent />
</Page>
);
}
}
export default HomePage;

View File

@ -1,6 +1,5 @@
import React, { FunctionComponent, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { WithLoader } from "../WithLoader";
export interface Item {
id: string
@ -11,8 +10,6 @@ export interface TabDefinition {
label: string
itemFilter?: (item: Item) => boolean
}
export interface ItemPanelProps {
className?: string
itemIconClassName?: string
@ -30,9 +27,12 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
const {
title, className, newItemUrl,
itemKey, itemLabel,
itemIconClassName, itemUrl
itemIconClassName, itemUrl,
} = props;
const items = props.items || [];
const tabs = props.tabs || [];
const [ state, setState ] = useState({ selectedTab: 0, filteredItems: [] });
const filterItemsForTab = (tab: TabDefinition, items: Item[]) => {
@ -42,7 +42,6 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
const selectTab = (tabIndex: number) => {
setState(state => {
const { tabs, items } = props;
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[tabIndex] : null;
return {
...state,
@ -61,7 +60,7 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
filteredItems: filterItemsForTab(newTab, items),
}
});
}, [props.items, props.tabs]);
}, [items, tabs]);
const itemElements = state.filteredItems.map((item: Item, i: number) => {
return (
@ -74,8 +73,6 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
);
});
const tabs = props.tabs || [];
return (
<nav className={`panel ${className}`}>
<div className="level is-mobile panel-heading mb-0">
@ -90,7 +87,7 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
</div>
<div className="panel-block">
<p className="control has-icons-left">
<input className="input" type="text" placeholder="Filtrer..." />
<input disabled={true} className="input" type="text" placeholder="Filtrer..." />
<span className="icon is-left">
<i className="fas fa-search" aria-hidden="true"></i>
</span>

View File

@ -0,0 +1,17 @@
import React, { FunctionComponent, useEffect } from "react";
import { saveLoggedIn } from "../hooks/useLoggedIn";
import { Config } from "../config";
export interface LogoutPageProps {
}
export const LogoutPage: FunctionComponent<LogoutPageProps> = () => {
useEffect(() => {
saveLoggedIn(false);
window.location.replace(Config.logoutURL);
}, []);
return null;
};
export default LogoutPage;

View File

@ -1,6 +1,5 @@
import React, { Fragment, useState } from 'react';
import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux';
import { Config } from '../config';
import { Link } from 'react-router-dom';
import { useLoggedIn } from '../hooks/useLoggedIn';
@ -32,6 +31,20 @@ export function Navbar() {
</a>
</div>
<div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
<div className="navbar-start">
{
loggedIn ?
<React.Fragment>
<Link to="/dashboard" className="navbar-item">
<i className="fa fa-columns"></i>&nbsp;Tableau de bord
</Link>
<Link to="/conference" className="navbar-item">
<i className="fa fa-users"></i>&nbsp;Conférence
</Link>
</React.Fragment> :
null
}
</div>
<div className="navbar-end">
<div className="navbar-item">
<div className="buttons">
@ -44,11 +57,12 @@ export function Navbar() {
</span>
<span>Mon profil</span>
</Link>
<a className="button is-warning" href={Config.logoutURL}>
<Link className="button is-warning is-small" to="/logout">
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span>
</a>
<span>Déconnexion</span>
</Link>
</Fragment> :
<a className="button is-primary" href={Config.loginURL}>
<span className="icon">

View File

@ -2,19 +2,16 @@ import React from 'react';
import { Page } from '../Page';
import { UserForm } from '../UserForm';
import { User } from '../../types/user';
import { useUserProfileQuery } from '../../gql/queries/profile';
import { useUserProfile } from '../../gql/queries/profile';
import { useUpdateUserProfileMutation } from '../../gql/mutations/profile';
import { WithLoader } from '../WithLoader';
export function ProfilePage() {
const userProfileQuery = useUserProfileQuery();
const { user, loading } = useUserProfile();
const [ updateProfile, updateUserProfileMutation ] = useUpdateUserProfileMutation();
const isLoading = updateUserProfileMutation.loading || userProfileQuery.loading;
const { userProfile } = (userProfileQuery.data || {});
const isLoading = updateUserProfileMutation.loading || loading;
const onUserChange = (user: User) => {
if (userProfile.name !== user.name) {
if (user.name !== user.name) {
updateProfile({ variables: {changes: { name: user.name }}});
}
};
@ -25,16 +22,16 @@ export function ProfilePage() {
<section className="section">
<div className="columns">
<div className="column is-6 is-offset-3">
<h2 className="is-size-2 subtitle">Mon profil</h2>
<WithLoader loading={isLoading || !userProfile}>
{
<UserForm onChange={onUserChange} user={userProfile} />
}
</WithLoader>
<div className="box">
<h2 className="is-size-2 subtitle">Mon profil</h2>
{ !isLoading ? <UserForm onChange={onUserChange} user={user} /> : null }
</div>
</div>
</div>
</section>
</div>
</Page>
);
}
}
export default ProfilePage;

View File

@ -0,0 +1,79 @@
import React, { FunctionComponent, ReactNode, useEffect, useState } from 'react';
import { useHistory, useLocation, useRouteMatch } from 'react-router';
import { Link } from 'react-router-dom';
export interface Tab {
route: string
name: string
icon ?: string
render: (tab: Tab) => ReactNode
}
export interface RoutedTabsProps {
tabs: Tab[]
baseRoute?: string
defaultTabIndex?: number
}
export const RoutedTabs: FunctionComponent<RoutedTabsProps> = ({ tabs, baseRoute, defaultTabIndex }) => {
const history = useHistory();
const location = useLocation();
const tabRoute = (route: string): string => {
return `${baseRoute}${route}`;
};
const [ selectedTabIndex, setSelectedTabIndex ] = useState(defaultTabIndex || 0);
const expectedTab = tabs[selectedTabIndex];
const expectedTabRoute = tabRoute(expectedTab.route);
let matchExpectedTabRoute = useRouteMatch(expectedTabRoute);
useEffect(() => {
if (matchExpectedTabRoute) return;
const newTabIndex = tabs.findIndex(t => location.pathname === tabRoute(t.route));
if (newTabIndex !== -1) {
selectTab(newTabIndex);
return;
}
history.push(expectedTabRoute);
}, [matchExpectedTabRoute]);
const selectTab = (tabIndex: number) => {
setSelectedTabIndex(tabIndex);
const newTab = tabs[tabIndex];
history.push(tabRoute(newTab.route));
};
return (
<React.Fragment>
<div className="tabs is-medium is-boxed">
<ul>
{
tabs.map((t: Tab, i: number) => {
return (
<li key={`tab-${i}`} className={`has-background-white ${selectedTabIndex === i ? 'is-active': ''}`}
onClick={selectTab.bind(null, i)}>
<a>
{
t.icon ?
<span className="icon is-small"><i className={t.icon} aria-hidden="true"></i></span> :
null
}
<span>{t.name}</span>
</a>
</li>
);
})
}
</ul>
</div>
{ expectedTab.render(expectedTab) }
</React.Fragment>
);
}

View File

@ -0,0 +1,232 @@
import React, { FunctionComponent } from "react";
import { formatDate } from "../util/date";
import { Event } from "../types/event";
import { Link } from "react-router-dom";
import { WorkgroupLink } from "./WorkgroupLink";
import { DecisionSupportFileLink } from "./DecisionSupportFileLink";
export interface TimelineProps {
events?: Event[]
}
export const Timeline: FunctionComponent<TimelineProps> = ({ events }) => {
events = debounceEvents(events) || [];
return (
<React.Fragment>
<div className="timeline" style={{width: '100%'}}>
{
events.map(evt => {
return (
<div key={evt.id} className="timeline-item">
{renderEventMarker(evt)}
<div className="timeline-content">
<p className="heading">{formatDate(evt.createdAt)}</p>
{renderEventContent(evt)}
</div>
</div>
);
})
}
{
events.length === 0 ?
<p className="has-text-centered is-italic mb-1 mt-1">Aucun évènement.</p> :
null
}
</div>
</React.Fragment>
);
}
function debounceEvents(events: Event[]): Event[] {
const debounced = [];
for(let evt: Event, i = 0; (evt = events[i]); ++i) {
const prev = i > 0 ? events[i-1] : null;
if (!prev) {
debounced.push(evt);
continue;
}
const isSame = evt.objectId === prev.objectId &&
evt.objectType === prev.objectType &&
evt.type === prev.type &&
evt.user.id === prev.user.id
;
if (isSame) continue;
debounced.push(evt);
}
return debounced;
}
const eventMarkerMap = {
"closed": (evt:Event) => (
<div className="timeline-marker is-icon is-danger">
<i className="fa fa-times"></i>
</div>
),
"created": (evt:Event) => (
<div className="timeline-marker is-icon is-success">
<i className="fa fa-plus"></i>
</div>
),
"updated": (evt:Event) => (
<div className="timeline-marker is-icon is-info">
<i className="fa fa-pen"></i>
</div>
),
"title-changed": (evt:Event) => (
<div className="timeline-marker is-icon is-info">
<i className="fa fa-pen"></i>
</div>
),
"status-changed": (evt:Event) => (
<div className="timeline-marker is-icon is-primary">
<i className="fa fa-star"></i>
</div>
),
"joined": (evt:Event) => (
<div className="timeline-marker is-icon is-info">
<i className="fa fa-users"></i>
</div>
),
"leaved": (evt:Event) => (
<div className="timeline-marker is-icon is-warning">
<i className="fas fa-users-slash"></i>
</div>
),
"voted": (evt:Event) => (
<div className="timeline-marker is-icon is-success">
<i className="fas fa-thumbs-up"></i>
</div>
),
}
function renderEventMarker(evt: Event) {
const render = eventMarkerMap[evt.type];
if (!render) return ( <div className="timeline-marker"></div> );
return render(evt);
}
const eventContentMap = {
"created": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a créé le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a créé le dossier d'aide à la décision `}</span>
"<DecisionSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
);
},
},
"title-changed": {
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a modifié le titre du dossier d'aide à la décision `}</span>
"<DecisionSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
)
}
},
"status-changed": {
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a modifié le statut du dossier d'aide à la décision `}</span>
"<DecisionSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
)
}
},
"joined": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a rejoint le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
},
"updated": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a mis à jour le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a modifié le dossier d'aide à la décision `}</span>
"<DecisionSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
);
},
},
"leaved": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a quitté le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
},
"closed": {
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a clos le dossier d'aide à la décision `}</span>
"<DecisionSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
);
},
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a clos le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
},
"voted": {
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`Le dossier d'aide à la décision `}</span>
"<DecisionSupportFileLink decisionSupportFileId={evt.objectId} />"
<span> a é voté.</span>
</React.Fragment>
);
},
},
};
function renderEventContent(evt: Event) {
const eventTypeMap = eventContentMap[evt.type];
const render = eventTypeMap && eventTypeMap[evt.objectType];
if (!eventTypeMap || !render) {
return (
<span className="is-italic">{`Type d'évènement "${evt.type}/${evt.objectType}" inconnu.`}</span>
);
}
return render(evt);
}

View File

@ -0,0 +1,39 @@
import React, { FunctionComponent } from 'react';
import { Config } from '../../config';
import { Page } from '../Page';
export interface UnauthorizedPageProps {
}
export const UnauthorizedPage:FunctionComponent<UnauthorizedPageProps> = () => {
return (
<Page title="Non autorisé">
<div className="container is-fluid">
<section className="section">
<div className="columns">
<div className="column is-6 is-offset-3">
<div className="message is-danger">
<div className="message-header">
<p><i className="fa fa-ban"></i> Non autorisé</p>
</div>
<div className="message-body">
<p>Vous n'êtes pas autorisé à accéder à cette page.</p>
<br />
<p>Votre compte est peut être désactivé, votre adresse courriel ne fait peut être
pas partie des domaines autorisés ou vous n'avez peut être pas les droits nécessaires pour effectuer cette opération.</p>
<div className="has-text-centered mt-5">
<a href={Config.logoutURL} className="is-warning button"><i className="fa fa-sign-out-alt"></i>&nbsp; Forcer la déconnexion</a>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</Page>
);
}
export default UnauthorizedPage;

View File

@ -1,5 +1,6 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { User } from '../types/user';
import { formatDate } from '../util/date';
export interface UserFormProps {
user: User
@ -62,13 +63,13 @@ export function UserForm({ user, onChange }: UserFormProps) {
<div className="field">
<label className="label">Date de dernière connexion</label>
<div className="control">
<p className="input is-static">{state.user.connectedAt}</p>
<p className="input is-static">{formatDate(state.user.connectedAt)}</p>
</div>
</div>
<div className="field">
<label className="label">Date de création</label>
<div className="control">
<p className="input is-static">{state.user.createdAt}</p>
<p className="input is-static">{formatDate(state.user.createdAt)}</p>
</div>
</div>
<div className="buttons is-right">

View File

@ -1,18 +0,0 @@
import React, { Fragment, PropsWithChildren, FunctionComponent } from 'react';
export interface WithLoaderProps {
loading?: boolean|boolean[]
}
export const WithLoader: FunctionComponent<WithLoaderProps> = ({ loading, children }) => {
const isLoading = Array.isArray(loading) ? loading.some(l => l) : loading;
return (
<Fragment>
{
isLoading ?
<div>Chargement</div> :
children
}
</Fragment>
)
}

View File

@ -0,0 +1,24 @@
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { useWorkgroups } from "../gql/queries/workgroups";
export interface WorkgroupLinkProps {
workgroupId: number
}
export const WorkgroupLink: FunctionComponent<WorkgroupLinkProps> = ({ workgroupId }) => {
const { workgroups } = useWorkgroups({
fetchPolicy: "cache-first",
variables: {
filter: {
ids: [workgroupId]
}
}
});
const workgroupName = workgroups.length > 0 ? workgroups[0].name : `#${workgroupId}`;
return (
<Link to={`/workgroups/${workgroupId}`}>{workgroupName}</Link>
);
};

View File

@ -0,0 +1,48 @@
import React, { FunctionComponent } from 'react';
import { Link } from 'react-router-dom';
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
import { DecisionSupportFile } from '../../types/decision';
import { User } from '../../types/user';
import { DecisionSupportFileLink } from '../DecisionSupportFileLink';
import { WorkgroupLink } from '../WorkgroupLink';
export interface DecisionSupportFilePanelProps {
workgroupId: string
}
export const DecisionSupportFilePanel: FunctionComponent<DecisionSupportFilePanelProps> = ({ workgroupId }) => {
const { decisionSupportFiles } = useDecisionSupportFiles({
variables: {
filter: {
workgroups: [workgroupId],
}
}
})
return (
<nav className="panel">
<p className="panel-heading">
Dossiers d'aide à la décision
</p>
{
decisionSupportFiles.map((dsf: DecisionSupportFile) => {
return (
<Link to={`/decisions/${dsf.id}`} key={`dsf-${dsf.id}`} className="panel-block">
<span className="panel-icon">
<i className="fas fa-file" aria-hidden="true"></i>
</span>
<span>{dsf.title}</span>
</Link>
);
})
}
{
decisionSupportFiles.length === 0 ?
<a className="panel-block has-text-centered is-block">
<p className="is-italic">Aucun dossier pour l'instant.</p>
</a> :
null
}
</nav>
);
}

View File

@ -1,12 +1,14 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { Workgroup } from '../../types/workgroup';
import { useIsAuthorized } from '../../gql/queries/authorization';
import { formatDate } from '../../util/date';
export interface InfoFormProps {
workgroup: Workgroup
onChange?: (workgroup: Workgroup) => void
}
export function InfoForm({ workgroup, onChange }: InfoFormProps) {
export function InfoForm({ workgroup, onChange }: InfoFormProps) {
const [ state, setState ] = useState({
changed: false,
workgroup: {
@ -17,6 +19,15 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
}
});
const { isAuthorized } = useIsAuthorized({
variables: {
action: 'update',
object: {
workgroupId: state.workgroup.id,
}
}
}, state.workgroup.id === '' ? true : false);
useEffect(() => {
setState({
changed: false,
@ -60,7 +71,8 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
<div className="field">
<label className="label">Nom du groupe</label>
<div className="control">
<input type="text" className="input" value={state.workgroup.name}
<input type="text" className="input" value={state.workgroup.name}
disabled={!isAuthorized}
onChange={onWorkgroupAttrChange.bind(null, "name")} />
</div>
</div>
@ -69,7 +81,7 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
<div className="field">
<label className="label">Date de création</label>
<div className="control">
<p className="input is-static">{state.workgroup.createdAt}</p>
<p className="input is-static">{formatDate(state.workgroup.createdAt)}</p>
</div>
</div>:
null
@ -79,13 +91,13 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
<div className="field">
<label className="label">Date de clôture</label>
<div className="control">
<p className="input is-static">{state.workgroup.closedAt}</p>
<p className="input is-static">{formatDate(state.workgroup.closedAt)}</p>
</div>
</div>:
null
}
<div className="buttons is-right">
<button disabled={!state.changed}
<button disabled={!state.changed || !isAuthorized}
className="button is-success" onClick={onSaveClick}>
<span>Enregistrer</span>
<span className="icon"><i className="fa fa-save"></i></span>

View File

@ -1,8 +1,6 @@
import React, { FunctionComponent } from 'react';
import { User } from '../../types/user';
import { Workgroup } from '../../types/workgroup';
import { InfoForm } from './InfoForm';
import { WithLoader } from '../WithLoader';
import { useUpdateWorkgroupMutation, useCreateWorkgroupMutation } from '../../gql/mutations/workgroups';
import { useHistory } from 'react-router';
@ -43,9 +41,7 @@ export const InfoPanel: FunctionComponent<InfoPanelProps> = ({ workgroup }) => {
Informations
</p>
<div className="panel-block">
<WithLoader loading={isLoading}>
<InfoForm workgroup={workgroup} onChange={onWorkgroupChange} />
</WithLoader>
{ !isLoading ? <InfoForm workgroup={workgroup} onChange={onWorkgroupChange} /> : null }
</div>
</nav>
);

View File

@ -0,0 +1,37 @@
import React, { FunctionComponent, useState } from 'react';
import { DecisionSupportFile } from '../../types/decision';
import { Timeline } from '../Timeline';
import { useEvents } from '../../gql/queries/event';
import { Workgroup } from '../../types/workgroup';
export interface TimelinePanelProps {
workgroup: Workgroup,
};
export const TimelinePanel: FunctionComponent<TimelinePanelProps> = ({ workgroup }) => {
const { events } = useEvents({
variables: {
filter: {
objectType: 'workgroup',
objectId: workgroup.id
}
}
});
return (
<div className="panel">
<div className="level panel-heading mb-0">
<div className="level-left">
<div className="level-item">
Suivi des opérations
</div>
</div>
<div className="level-right">
</div>
</div>
<div className="panel-block">
<Timeline events={events} />
</div>
</div>
);
};

View File

@ -1,28 +1,31 @@
import React, { useEffect, useState, Fragment } from 'react';
import { Page } from '../Page';
import { WithLoader } from '../WithLoader';
import { useParams } from 'react-router';
import { useWorkgroupsQuery } from '../../gql/queries/workgroups';
import { useUserProfileQuery } from '../../gql/queries/profile';
import { useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfile } from '../../gql/queries/profile';
import { MembersPanel } from './MembersPanel';
import { User } from '../../types/user';
import { InfoPanel } from './InfoPanel';
import { Workgroup } from '../../types/workgroup';
import { useJoinWorkgroupMutation, useLeaveWorkgroupMutation, useCloseWorkgroupMutation } from '../../gql/mutations/workgroups';
import { TimelinePanel } from './TimelinePanel';
import { DecisionSupportFilePanel } from './DecisionSupportFilePanel';
export function WorkgroupPage() {
const { id } = useParams();
const workgroupsQuery = useWorkgroupsQuery({
const { id } = useParams<any>();
const { workgroups } = useWorkgroups({
variables:{
filter: {
ids: [id],
}
}
});
const userProfileQuery = useUserProfileQuery();
const [ joinWorkgroup, joinWorkgroupMutation ] = useJoinWorkgroupMutation();
const [ leaveWorkgroup, leaveWorkgroupMutation ] = useLeaveWorkgroupMutation();
const [ closeWorkgroup, closeWorkgroupMutation ] = useCloseWorkgroupMutation();
const { user } = useUserProfile();
const [ joinWorkgroup ] = useJoinWorkgroupMutation();
const [ leaveWorkgroup ] = useLeaveWorkgroupMutation();
const [ closeWorkgroup ] = useCloseWorkgroupMutation();
const [ state, setState ] = useState({
userProfileId: '',
workgroup: {
@ -35,14 +38,12 @@ export function WorkgroupPage() {
});
useEffect(() => {
if (!workgroupsQuery.data) return;
setState(state => ({...state, workgroup:{ ...state.workgroup, ...workgroupsQuery.data.workgroups[0]}}));
}, [workgroupsQuery.data]);
setState(state => ({...state, workgroup:{ ...state.workgroup, ...workgroups[0]}}));
}, [workgroups]);
useEffect(() => {
if (!userProfileQuery.data) return;
setState(state => ({...state, userProfileId: userProfileQuery.data.userProfile.id }));
}, [userProfileQuery.data]);
setState(state => ({...state, userProfileId: user.id }));
}, [user]);
const onJoinWorkgroupClick = () => {
joinWorkgroup({
@ -54,6 +55,18 @@ export function WorkgroupPage() {
const onLeaveWorkgroupClick = () => {
leaveWorkgroup({
update: (cache, result) => {
cache.modify({
id: cache.identify(result.data.leaveWorkgroup),
fields: {
members(existingMembers, { readField }) {
return existingMembers.filter(
user => state.userProfileId !== readField('id', user)
);
},
},
});
},
variables: {
workgroupId: state.workgroup.id,
}
@ -121,18 +134,22 @@ export function WorkgroupPage() {
</div>
</div>
</div>
<WithLoader loading={[workgroupsQuery.loading, userProfileQuery.loading, joinWorkgroupMutation.loading, leaveWorkgroupMutation.loading]}>
<div className="columns">
<div className="column">
<InfoPanel workgroup={state.workgroup as Workgroup} />
</div>
<div className="column">
<MembersPanel users={state.workgroup.members as User[]} />
</div>
<div className="columns">
<div className="column is-4">
<InfoPanel workgroup={state.workgroup as Workgroup} />
</div>
</WithLoader>
<div className="column is-4">
<MembersPanel users={state.workgroup.members as User[]} />
<DecisionSupportFilePanel workgroupId={state.workgroup.id} />
</div>
<div className="column is-4">
<TimelinePanel workgroup={state.workgroup} />
</div>
</div>
</section>
</div>
</Page>
);
}
}
export default WorkgroupPage;

View File

@ -3,6 +3,8 @@ export const Config = {
logoutURL: get<string>("logoutURL", "http://localhost:8081/logout"),
graphQLEndpoint: get<string>("graphQLEndpoint", "http://localhost:8081/api/v1/graphql"),
subscriptionEndpoint: get<string>("subscriptionEndpoint", "ws://localhost:8081/api/v1/graphql"),
conferenceHeartbeatInterval: get<number>("conferenceHeartbeatInterval", 10000),
frontendBaseURL: get<string>("frontendBaseURL", window.location.protocol + '//' + window.location.host + '/'),
};
function get<T>(key: string, defaultValue: T):T {

View File

@ -1,56 +0,0 @@
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { Config } from '../config';
import { WebSocketLink } from "@apollo/client/link/ws";
import { RetryLink } from "@apollo/client/link/retry";
import { SubscriptionClient } from "subscriptions-transport-ws";
import { User } from '../types/user';
const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, {
reconnect: true,
});
const link = new RetryLink({attempts: {max: 2}}).split(
(operation) => operation.operationName === 'subscription',
new WebSocketLink(subscriptionClient),
new HttpLink({ uri: Config.graphQLEndpoint, credentials: 'include' })
);
const cache = new InMemoryCache({
typePolicies: {
Workgroup: {
fields: {
members: {
merge: mergeArrayByField<User>("id"),
}
}
}
}
});
export const client = new ApolloClient<any>({
cache: cache,
link: link,
});
function mergeArrayByField<T>(fieldName: string) {
return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => {
const merged: any[] = existing ? existing.slice(0) : [];
const objectFieldToIndex: Record<string, number> = Object.create(null);
if (existing) {
existing.forEach((obj, index) => {
objectFieldToIndex[readField(fieldName, obj)] = index;
});
}
incoming.forEach(obj => {
const field = readField(fieldName, obj);
const index = objectFieldToIndex[field];
if (typeof index === "number") {
merged[index] = mergeObjects(merged[index], obj);
} else {
objectFieldToIndex[name] = merged.length;
merged.push(obj);
}
});
return merged;
}
}

View File

@ -9,7 +9,16 @@ mutation createDecisionSupportFile($changes: DecisionSupportFileChanges!) {
status,
sections,
createdAt,
updatedAt
updatedAt,
workgroup {
id,
name,
members {
id,
email,
name
}
},
}
}`;
@ -27,7 +36,16 @@ mutation updateDecisionSupportFile($id: ID!, $changes: DecisionSupportFileChange
status,
sections,
createdAt,
updatedAt
updatedAt,
workgroup {
id,
name,
members {
id,
email,
name
}
},
}
}`;

View File

@ -1,5 +1,6 @@
import { gql, useQuery, useMutation } from '@apollo/client';
import { gql, useQuery, useMutation, FetchResult } from '@apollo/client';
import { QUERY_WORKGROUP } from '../queries/workgroups';
import { QUERY_IS_AUTHORIZED } from '../queries/authorization';
export const MUTATION_UPDATE_WORKGROUP = gql`
mutation updateWorkgroup($workgroupId: ID!, $changes: WorkgroupChanges!) {
@ -57,7 +58,19 @@ mutation joinWorkgroup($workgroupId: ID!) {
}`;
export function useJoinWorkgroupMutation() {
return useMutation(MUTATION_JOIN_WORKGROUP);
return useMutation(MUTATION_JOIN_WORKGROUP, {
refetchQueries: ({ data }: FetchResult) => {
return [{
query: QUERY_IS_AUTHORIZED,
variables: {
action: 'update',
object: {
workgroupId: data.joinWorkgroup.id,
}
}
}]
}
});
}
const MUTATION_LEAVE_WORKGROUP = gql`
@ -76,7 +89,27 @@ mutation leaveWorkgroup($workgroupId: ID!) {
}`;
export function useLeaveWorkgroupMutation() {
return useMutation(MUTATION_LEAVE_WORKGROUP);
return useMutation(MUTATION_LEAVE_WORKGROUP, {
refetchQueries: ({ data }: FetchResult) => {
return [{
query: QUERY_WORKGROUP,
variables: {
filter: {
ids: [data.leaveWorkgroup.id],
}
}
},
{
query: QUERY_IS_AUTHORIZED,
variables: {
action: 'update',
object: {
workgroupId: data.leaveWorkgroup.id,
}
}
}]
}
});
}
const MUTATION_CLOSE_WORKGROUP = gql`

View File

@ -0,0 +1,25 @@
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { useGraphQLData } from './helper';
export const QUERY_IS_AUTHORIZED = gql`
query isAuthorized($action: String!, $object: AuthorizationObject!) {
isAuthorized(action: $action, object: $object)
}
`;
export function useIsAuthorizedQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
options = Object.assign({
fetchPolicy: 'cache-and-network'
}, options);
return useQuery(QUERY_IS_AUTHORIZED, options);
}
export function useIsAuthorized<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}, defaultValue = false) {
options = Object.assign({
fetchPolicy: 'cache-and-network'
}, options);
const { data, loading, error } = useGraphQLData<boolean>(
QUERY_IS_AUTHORIZED, 'isAuthorized', defaultValue, options
);
return { isAuthorized: data, loading, error };
}

View File

@ -1,4 +1,4 @@
import { gql, useQuery } from '@apollo/client';
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { DecisionSupportFile } from '../../types/decision';
import { useGraphQLData } from './helper';
@ -16,18 +16,20 @@ export const QUERY_DECISION_SUPPORT_FILES = gql`
id,
name,
members {
id
id,
email,
name
}
},
}
}
`;
export function useDecisionSupportFilesQuery(options = {}) {
export function useDecisionSupportFilesQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
return useQuery(QUERY_DECISION_SUPPORT_FILES, options);
}
export function useDecisionSupportFiles(options = {}) {
export function useDecisionSupportFiles<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
const { data, loading, error } = useGraphQLData<DecisionSupportFile[]>(
QUERY_DECISION_SUPPORT_FILES, 'decisionSupportFiles', [], options
);

View File

@ -0,0 +1,31 @@
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { useGraphQLData } from './helper';
import { Event } from '../../types/event';
export const QUERY_EVENTS = gql`
query events($filter: EventFilter) {
events(filter: $filter) {
id
user {
id
name
email
}
type
objectType
objectId
createdAt
}
}
`;
export function useEventsQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
return useQuery(QUERY_EVENTS, options);
}
export function useEvents<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
const { data, loading, error } = useGraphQLData<Event[]>(
QUERY_EVENTS, 'events', [], options
);
return { events: data, loading, error };
}

View File

@ -1,7 +1,7 @@
import { useQuery, DocumentNode } from "@apollo/client";
import { useQuery, DocumentNode, QueryHookOptions } from "@apollo/client";
import { useState, useEffect } from "react";
export function useGraphQLData<T>(q: DocumentNode, key: string, defaultValue: T, options = {}) {
export function useGraphQLData<T, A = any, R = Record<string, any>>(q: DocumentNode, key: string, defaultValue: T, options: QueryHookOptions<A, R> = {}) {
const query = useQuery(q, options);
const [ data, setData ] = useState<T>(defaultValue);
useEffect(() => {

View File

@ -1,4 +1,4 @@
import { gql, useQuery } from '@apollo/client';
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { User } from '../../types/user';
import { useState, useEffect } from 'react';
import { useGraphQLData } from './helper';
@ -14,13 +14,13 @@ query userProfile {
}
}`;
export function useUserProfileQuery() {
return useQuery(QUERY_USER_PROFILE);
export function useUserProfileQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
return useQuery(QUERY_USER_PROFILE, options);
}
export function useUserProfile() {
export function useUserProfile<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
const { data, loading, error } = useGraphQLData<User>(
QUERY_USER_PROFILE, 'userProfile', {id: '', email: ''}
QUERY_USER_PROFILE, 'userProfile', {id: '', email: ''}, options
);
return { user: data, loading, error };
}

View File

@ -1,4 +1,4 @@
import { gql, useQuery } from '@apollo/client';
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { Workgroup } from '../../types/workgroup';
import { useGraphQLData } from './helper';
@ -18,11 +18,11 @@ export const QUERY_WORKGROUP = gql`
}
`;
export function useWorkgroupsQuery(options = {}) {
export function useWorkgroupsQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
return useQuery(QUERY_WORKGROUP, options);
}
export function useWorkgroups(options = {}) {
export function useWorkgroups<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
const { data, loading, error } = useGraphQLData<Workgroup[]>(
QUERY_WORKGROUP, 'workgroups', [],
options

View File

@ -0,0 +1,90 @@
import * as Y from 'yjs'
import { WebrtcProvider, } from 'y-webrtc'
import { useEffect, useRef, useState } from 'react'
import { uuidV4 } from '../util/uuid';
const UUIDKey = 'conference-uuid';
let uuid = localStorage.getItem(UUIDKey);
if (!uuid) {
uuid = uuidV4();
localStorage.setItem(UUIDKey, uuid);
}
export function useConference() {
const docRef = useRef(new Y.Doc());
const [ state, setState ] = useState({
data: {
emails: {},
nicknames: {},
statuses: {},
peers: {},
},
uuid,
});
const setData = (key: string, value: any) => {
setState(state => ({...state, data: { ...state.data, [key]: value }}));
}
useEffect(() => {
const doc = docRef.current;
const roomName = `${window.location.protocol}//${window.location.host}/daddy/conference`;
const provider = new WebrtcProvider(roomName, docRef.current);
const peers = doc.getMap('peers');
peers.observe(evt => setData('peers', evt.currentTarget.toJSON()));
const nicknames = doc.getMap('nicknames');
nicknames.observe(evt => setData('nicknames', evt.currentTarget.toJSON()));
const emails = doc.getMap('emails');
emails.observe(evt => setData('emails', evt.currentTarget.toJSON()));
const statuses = doc.getMap('statuses');
statuses.observe(evt => setData('statuses', evt.currentTarget.toJSON()));
return () => {
provider.destroy();
docRef.current.destroy();
};
}, []);
return {
data: state.data,
uuid: state.uuid,
setStatus: (status: string) => {
const doc = docRef.current;
const statuses = doc.getMap('statuses');
statuses.set(state.uuid, status);
},
ping: () => {
const doc = docRef.current;
const peers = doc.getMap('peers');
peers.set(state.uuid, (new Date()).toJSON());
},
setNickname: (nickname: string) => {
console.log('setNickname', nickname);
const doc = docRef.current;
const nicknames = doc.getMap('nicknames');
nicknames.set(state.uuid, nickname);
},
setEmail: (email: string) => {
console.log('setEmail', email);
const doc = docRef.current;
const emails = doc.getMap('emails');
emails.set(state.uuid, email);
},
forget: (uuid: string) => {
const doc = docRef.current;
const peers = doc.getMap('peers');
peers.delete(uuid);
},
};
}

View File

@ -1,7 +1,22 @@
import React, { useState, useContext } from "react";
import React, { useContext, useEffect } from "react";
export const LoggedInContext = React.createContext(false);
const LOGGED_IN_KEY = 'loggedIn';
export const LoggedInContext = React.createContext(getSavedLoggedIn());
export const useLoggedIn = () => {
return useContext(LoggedInContext);
};
};
export function saveLoggedIn(loggedIn: boolean) {
window.localStorage.setItem(LOGGED_IN_KEY, JSON.stringify(loggedIn));
}
export function getSavedLoggedIn(): boolean {
try {
const loggedIn = JSON.parse(window.localStorage.getItem(LOGGED_IN_KEY));
return !!loggedIn;
} catch(err) {
return false;
}
}

View File

@ -1,19 +1,21 @@
import { Config } from './config';
declare var __webpack_public_path__: string;
__webpack_public_path__ = Config.frontendBaseURL;
import './sass/_all.scss';
import React from 'react';
import ReactDOM from 'react-dom';
import { App } from './components/App';
import { client } from './gql/client';
import '@fortawesome/fontawesome-free/js/fontawesome'
import '@fortawesome/fontawesome-free/js/solid'
import '@fortawesome/fontawesome-free/js/regular'
import '@fortawesome/fontawesome-free/js/brands'
import './resources/favicon.png';
import { ApolloProvider } from '@apollo/client';
ReactDOM.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
<App />,
document.getElementById('app')
);

View File

@ -1,3 +1,5 @@
@import 'bulma/bulma.sass';
@import 'bulma-timeline/dist/css/bulma-timeline.sass';
@import '_bulma-timeline.scss';
@import '_base.scss';
@import '_loader.scss';

View File

@ -0,0 +1,12 @@
.timeline {
.timeline-item {
.timeline-marker {
&.is-icon {
> svg {
color: $white;
font-size: $timeline-icon-size !important;
}
}
}
}
}

View File

@ -1,44 +1,16 @@
.loader-container {
display: flex;
width: 100%;
justify-content: center;
height: 100%;
align-items: center;
}
.app-loader {
@extend body;
.lds-ripple {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
transform: scale(2);
}
.lds-ripple div {
position: absolute;
border: 4px solid $grey;
opacity: 1;
border-radius: 50%;
animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.lds-ripple div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes lds-ripple {
0% {
top: 36px;
left: 36px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 72px;
height: 72px;
opacity: 0;
}
}
display: flex;
position: absolute;
z-index: 10000;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 100vh;
width: 100vw;
align-items: center;
justify-content: center;
flex-direction: column;
}

11
client/src/types/event.ts Normal file
View File

@ -0,0 +1,11 @@
import { User } from "./user";
export interface Event {
id: string
createdAt: Date
updatedAt: Date
user: User
objectType: string
objectId: number
type: string
}

87
client/src/util/apollo.ts Normal file
View File

@ -0,0 +1,87 @@
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { Config } from '../config';
import { WebSocketLink } from "@apollo/client/link/ws";
import { RetryLink } from "@apollo/client/link/retry";
import { onError } from "@apollo/client/link/error";
import { SubscriptionClient } from "subscriptions-transport-ws";
import { User } from '../types/user';
export function createClient(setLoggedIn: (boolean) => void) {
const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, {
reconnect: true,
});
const errorLink = onError(({ operation }) => {
const { response } = operation.getContext();
if (response.status === 401) setLoggedIn(false);
});
const retryLink = new RetryLink({attempts: {max: 2}}).split(
(operation) => operation.operationName === 'subscription',
new WebSocketLink(subscriptionClient),
new HttpLink({
uri: Config.graphQLEndpoint,
credentials: 'include',
})
);
const cache = new InMemoryCache({
typePolicies: {
Workgroup: {
fields: {
members: {
merge: mergeArrayByField<User>("id"),
}
}
}
}
});
return new ApolloClient<any>({
cache: cache,
link: from([
errorLink,
retryLink
]),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
errorPolicy: 'ignore',
},
query: {
fetchPolicy: 'network-only',
errorPolicy: 'all',
},
mutate: {
errorPolicy: 'all',
},
}
});
}
export function mergeArrayByField<T>(fieldName: string) {
return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => {
const merged: any[] = existing ? existing.slice(0) : [];
const objectFieldToIndex: Record<string, number> = Object.create(null);
if (existing) {
existing.forEach((obj, index) => {
objectFieldToIndex[readField(fieldName, obj)] = index;
});
}
incoming.forEach(obj => {
const field = readField(fieldName, obj);
const index = objectFieldToIndex[field];
if (typeof index === "number") {
merged[index] = mergeObjects(merged[index], obj);
} else {
objectFieldToIndex[name] = merged.length;
merged.push(obj);
}
});
return merged;
}
}

View File

@ -1,4 +1,16 @@
export function asDate(d: string|Date): Date {
if (typeof d === 'string') return new Date(d);
return d;
}
const intl = Intl.DateTimeFormat(navigator.language, {
weekday: 'long',
month: 'short',
day: 'numeric',
hour: 'numeric', minute: 'numeric', second: 'numeric',
});
export function formatDate(d: Date|string): string {
d = asDate(d);
return intl.format(d);
}

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"module": "es6",
"module": "es2020",
"lib": ["dom", "es6"],
"moduleResolution": "node",
"jsx": "react",

View File

@ -11,7 +11,7 @@ const env = process.env;
module.exports = {
mode: `${env.NODE_ENV ? env.NODE_ENV : 'production'}`,
entry: './src/index.tsx',
devtool: 'inline-source-map',
devtool: env.NODE_ENV === 'production' ? 'source-map' : 'eval-source-map',
output: {
filename: '[name].[contenthash].js',
path: path.join(__dirname, 'dist')

View File

@ -5,6 +5,10 @@ import (
"net/http"
"time"
"forge.cadoles.com/Cadoles/daddy/internal/mail"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"forge.cadoles.com/Cadoles/daddy/internal/voter"
"github.com/wader/gormstore"
"forge.cadoles.com/Cadoles/daddy/internal/auth"
@ -75,8 +79,9 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con
// Define default cookie options
sessionStore.SessionOpts.Path = "/"
sessionStore.SessionOpts.HttpOnly = true
sessionStore.SessionOpts.Secure = conf.HTTP.CookieSecure
sessionStore.SessionOpts.MaxAge = conf.HTTP.CookieMaxAge
sessionStore.SessionOpts.SameSite = http.SameSiteStrictMode
sessionStore.SessionOpts.SameSite = http.SameSiteLaxMode
ctn.Provide(
session.ServiceName,
@ -99,5 +104,17 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con
ctn.Provide(auth.ServiceName, auth.ServiceProvider(conf.Auth.Rules))
ctn.Provide(voter.ServiceName, voter.ServiceProvider(
voter.StrategyUnanimous,
model.NewDecisionSupportFileVoter(),
model.NewWorkgroupVoter(),
))
ctn.Provide(mail.ServiceName, mail.ServiceProvider(
mail.WithServer(conf.SMTP.Host, conf.SMTP.Port),
mail.WithCredentials(conf.SMTP.User, conf.SMTP.Password),
mail.WithTLS(conf.SMTP.UseStartTLS, conf.SMTP.InsecureSkipVerify),
))
return ctn, nil
}

View File

@ -7,6 +7,7 @@ import (
"forge.cadoles.com/Cadoles/daddy/internal/config"
"forge.cadoles.com/Cadoles/daddy/internal/route"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"gitlab.com/wpetit/goweb/middleware/container"
@ -17,6 +18,7 @@ import (
"os"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
@ -129,6 +131,34 @@ func main() {
logger.Debug(ctx, "setting log level", logger.F("level", conf.Log.Level.String()))
logger.SetLevel(conf.Log.Level)
useSentry := conf.Sentry.DSN != ""
if useSentry {
var sentryEnv string
if conf.Sentry.Environment == "" {
sentryEnv, _ = os.Hostname()
} else {
sentryEnv = conf.Sentry.Environment
}
err := sentry.Init(sentry.ClientOptions{
Dsn: conf.Sentry.DSN,
Debug: conf.Debug,
SampleRate: conf.Sentry.ServerSampleRate,
Release: ProjectVersion + "-" + GitRef,
Environment: sentryEnv,
})
if err != nil {
logger.Fatal(
ctx,
"could initialize sentry",
logger.E(err),
)
}
defer sentry.Flush(conf.Sentry.ServerFlushTimeout)
}
// Create service container
ctn, err := getServiceContainer(ctx, conf)
if err != nil {
@ -153,12 +183,22 @@ func main() {
os.Exit(0)
}
go runTaskScheduler(ctx, conf)
r := chi.NewRouter()
// Define base middlewares
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
if useSentry {
sentryMiddleware := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
r.Use(sentryMiddleware.Handle)
}
// Expose service container on router
r.Use(container.ServiceContainer(ctn))

View File

@ -81,6 +81,7 @@ var initialModels = []interface{}{
&model.User{},
&model.Workgroup{},
&model.DecisionSupportFile{},
&model.Event{},
}
func m000initialSchema() orm.Migration {

85
cmd/server/scheduler.go Normal file
View File

@ -0,0 +1,85 @@
package main
import (
"context"
"fmt"
"gitlab.com/wpetit/goweb/logger"
"forge.cadoles.com/Cadoles/daddy/internal/config"
"forge.cadoles.com/Cadoles/daddy/internal/task"
"github.com/pkg/errors"
"github.com/robfig/cron/v3"
)
type cronLogger struct {
ctx context.Context
}
func (l *cronLogger) Info(msg string, keysAndValues ...interface{}) {
fields := l.createFields(keysAndValues)
logger.Info(l.ctx, msg, fields...)
}
func (l *cronLogger) Error(err error, msg string, keysAndValues ...interface{}) {
fields := l.createFields(keysAndValues)
fields = append(fields, logger.E(err))
logger.Error(l.ctx, msg, fields...)
}
func (l *cronLogger) createFields(keysAndValues ...interface{}) []logger.Field {
fields := make([]logger.Field, 0)
var key string
for _, v := range keysAndValues {
children, ok := v.([]interface{})
if !ok {
continue
}
for i, vv := range children {
if i%2 == 0 {
key = fmt.Sprintf("%v", vv)
continue
}
fields = append(fields, logger.F(key, vv))
}
}
return fields
}
func runTaskScheduler(ctx context.Context, conf *config.Config) {
c := cron.New(
cron.WithLogger(&cronLogger{ctx}),
)
tasks := map[string]task.Task{
conf.Task.Newsletter.CronSpec: task.NewNewsletter(
ctx,
conf.Task.Newsletter.TimeRange,
conf.Task.Newsletter.BaseURL,
conf.Task.Newsletter.ContentTemplate,
conf.Task.Newsletter.SubjectTemplate,
conf.SMTP.SenderAddress,
),
}
for spec, task := range tasks {
if _, err := c.AddFunc(spec, task.Run); err != nil {
logger.Fatal(
ctx,
"could not schedule task",
logger.F("task", task.Name()),
logger.E(errors.WithStack(err)),
)
return
}
}
c.Start()
}

21
go.mod
View File

@ -3,22 +3,37 @@ module forge.cadoles.com/Cadoles/daddy
go 1.14
require (
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20201013125038-8d8d1519a52d
forge.cadoles.com/wpetit/hydra-passwordless v0.0.0-20200908094025-38ac4422dddc // indirect
github.com/99designs/gqlgen v0.11.3
github.com/alecthomas/chroma v0.8.1 // indirect
github.com/antonmedv/expr v1.8.8
github.com/caarlos0/env/v6 v6.2.2
github.com/cortesi/modd v0.0.0-20200630120222-8983974e5450 // indirect
github.com/davecgh/go-spew v1.1.1
github.com/go-chi/chi v4.1.0+incompatible
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/getsentry/sentry-go v0.7.0
github.com/go-chi/chi v4.1.2+incompatible
github.com/gorilla/sessions v1.2.0
github.com/gorilla/websocket v1.2.0
github.com/gorilla/websocket v1.4.0
github.com/jackc/pgx v3.6.2+incompatible
github.com/jackc/pgx/v4 v4.7.1
github.com/jinzhu/gorm v1.9.14
github.com/lithammer/dedent v1.1.0
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/pkg/errors v0.9.1
github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e // indirect
github.com/robfig/cron v1.2.0
github.com/robfig/cron/v3 v3.0.1
github.com/rs/cors v1.7.0
github.com/vektah/gqlparser/v2 v2.0.1
github.com/wader/gormstore v0.0.0-20200328121358-65a111a20c23
gitlab.com/wpetit/goweb v0.0.0-20200707070104-985ce3eba3c2
go.opencensus.io v0.22.5 // indirect
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee // indirect
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb // indirect
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
golang.org/x/sys v0.0.0-20201013081832-0aaa2718063a // indirect
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v2 v2.2.8
)

356
go.sum
View File

@ -9,33 +9,72 @@ cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTj
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.49.0 h1:CH+lkubJzcPYB1Ggupcq0+k8Ni2ILdG2lYjDIgavDBQ=
cloud.google.com/go v0.49.0/go.mod h1:hGvAdzcWNbyuxS3nWhD7H2cIJxjRRTRLQVB0bdputVY=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032 h1:qTYaLPsLDlvqDkatONsvrisvfvpHaGe3lQqIaX7FFQQ=
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032/go.mod h1:gkfqGyk7fCj2Z0ngEOCJ3K0FVmqft/8dFV/OnYT1vec=
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20201013085949-5d5592098f13 h1:gZCo9pX3I3A0xVvXkbhdTDBbW44CypCxjiP5LtNV5bo=
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20201013085949-5d5592098f13/go.mod h1:phGAWHUGKNZj044478BvRg0jk049uK1IiX2Amh8krAk=
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20201013111944-d43b43b636ed h1:7dTCXOGxvAulu9vnOjpt2cTgsuxMHX4FH795/JJgo08=
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20201013111944-d43b43b636ed/go.mod h1:phGAWHUGKNZj044478BvRg0jk049uK1IiX2Amh8krAk=
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20201013125038-8d8d1519a52d h1:o+Ppy/MyT5UgbtUYI2J1YqS3iuThxOuNFenYoPgKZKk=
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20201013125038-8d8d1519a52d/go.mod h1:phGAWHUGKNZj044478BvRg0jk049uK1IiX2Amh8krAk=
forge.cadoles.com/wpetit/hydra-passwordless v0.0.0-20200908094025-38ac4422dddc h1:9gc/1qizPtK6/iMVlizknWUFii75ntl2xSUV/FSC92Y=
forge.cadoles.com/wpetit/hydra-passwordless v0.0.0-20200908094025-38ac4422dddc/go.mod h1:nANHORi270d5jDXjeJ7B3pMgK9R4J0/17p1IIc+rhOk=
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a/go.mod h1:EFZQ978U7x8IRnstaskI3IysnWY5Ao3QgZUKOXlsAdw=
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
github.com/Joker/jade v1.0.1-0.20190614124447-d475f43051e7/go.mod h1:6E6s8o2AE4KhCrqr6GRJjdC/gNfTdxkIXvuGZZda2VM=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0=
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw=
github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY=
github.com/alecthomas/chroma v0.8.1 h1:ym20sbvyC6RXz45u4qDglcgr8E313oPROshcuCHqiEE=
github.com/alecthomas/chroma v0.8.1/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
@ -48,17 +87,28 @@ github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9Pq
github.com/antonmedv/expr v1.8.8 h1:uVwIkIBNO2yn4vY2u2DQUqXTmv9jEEMCEcHa19G5weY=
github.com/antonmedv/expr v1.8.8/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/bmatcuk/doublestar v1.3.0/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/bmatcuk/doublestar v1.3.1 h1:rT8rxDPsavp9G+4ZULzqhhUSaI/OPsTZNG88Z3i0xvY=
github.com/bmatcuk/doublestar v1.3.1/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/caarlos0/env/v6 v6.2.2 h1:R0NIFXaB/LhwuGrjnsldzpnVNjFU/U+hTVHt+cq0yDY=
github.com/caarlos0/env/v6 v6.2.2/go.mod h1:3LpmfcAYCG6gCiSgDLaFR5Km1FRpPwFvBbRcjHar6Sw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cortesi/modd v0.0.0-20200630120222-8983974e5450 h1:3CQigZV4Vgu4XX34CGsQFHbO5re8boAbn0dqUza1LrQ=
@ -67,6 +117,8 @@ github.com/cortesi/moddwatch v0.0.0-20200427000745-d26468c93cf0 h1:7tjBO+RH4BoxJ
github.com/cortesi/moddwatch v0.0.0-20200427000745-d26468c93cf0/go.mod h1:QYGP4Q0SeEUNSC+dsNSKTmONSd1PpZVYUXIRAzxxpXo=
github.com/cortesi/termlog v0.0.0-20190809035425-7871d363854c h1:D5UylL3xKRrrqZKk/NhrOhoQVdCQwuEeyFgTfN9n9O4=
github.com/cortesi/termlog v0.0.0-20190809035425-7871d363854c/go.mod h1:gh6GQA3zOsGU4pz+X6ZHqW63KxI/V7KLmBCG9ODJ+l4=
github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
@ -82,31 +134,60 @@ github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4/go.mod h1:T9YF2M40nIgbVgp3rreNmTged+9HrbNTIQf1PsaIiTA=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
github.com/getsentry/sentry-go v0.7.0 h1:MR2yfR4vFfv/2+iBuSnkdQwVg7N9cJzihZ6KJu7srwQ=
github.com/getsentry/sentry-go v0.7.0/go.mod h1:pLFpD2Y5RHIKF9Bw3KH6/68DeN2K/XBJd8awjdPnUwg=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@ -117,32 +198,65 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE=
github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e h1:4WfjkTUTsO6siF8ghDQQk6t7x/FPsv3w6MXkc47do7Q=
github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
github.com/gorilla/csrf v1.6.2/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
@ -152,10 +266,21 @@ github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYb
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
github.com/iris-contrib/i18n v0.0.0-20171121225848-987a633949d0/go.mod h1:pMCz62A0xJL6I+umB2YTlFRwWXaDFA0jy+5HzGiJjqI=
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
@ -216,8 +341,22 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q=
github.com/juju/loggo v0.0.0-20180524022052-584905176618/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/kataras/golog v0.0.9/go.mod h1:12HJgwBIZFNGL0EJnMRhmvGA0PQGx8VFwrZtM4CqbAk=
github.com/kataras/iris/v12 v12.0.1/go.mod h1:udK4vLQKkdDqMGJJVd/msuMtN6hpYJhg/lSzuxjhO+U=
github.com/kataras/neffos v0.0.10/go.mod h1:ZYmJC07hQPW67eKuzlfY7SO3bC0mw83A3j6im82hfqw=
github.com/kataras/pio v0.0.0-20190103105442-ea782b38602d/go.mod h1:NV88laa9UiiDuX9AhMbDPkGYSPugBOV6yTZB1l2K9Z0=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
@ -228,6 +367,8 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -235,9 +376,12 @@ github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@ -249,6 +393,8 @@ github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+v
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
@ -264,15 +410,31 @@ github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/mediocregopher/mediocre-go-lib v0.0.0-20181029021733-cb65787f37ed/go.mod h1:dSsfyI2zABAdhcbvkXqgxOxrCsbYeHCPgrZkku60dSg=
github.com/mediocregopher/radix/v3 v3.3.0/go.mod h1:EmfVyvspXz1uZEyPBMyGK+kjWiKQGvsUt6O3Pj+LDCQ=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM=
github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -282,11 +444,17 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e h1:BLqxdwZ6j771IpSCRx7s/GJjXHUE00Hmu7/YegCGdzA=
github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e/go.mod h1:hoLfEwdY11HjRfKFH6KqnPsfxlo3BP6bJehpDv8t6sQ=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/tview v0.0.0-20200219210816-cd38d7432498/go.mod h1:6lkG1x+13OShEf0EaOCaTQYyB7d5nSbb181KtjlS+84=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1 h1:FLWDC+iIP9BWgYKvWKKtOUZux35LIQNAuIzp/63RQJU=
github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
@ -294,8 +462,11 @@ github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sanity-io/litter v1.2.0/go.mod h1:JF6pZUFgu2Q0sBZ+HSV35P8TVPI1TTzEwyu9FXAw2W4=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
@ -310,6 +481,14 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
@ -320,16 +499,35 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
github.com/wader/gormstore v0.0.0-20200328121358-65a111a20c23 h1:gtfR002LWpH9vQ1/GLbWBOTcS92cBi5PAR021lArKF8=
github.com/wader/gormstore v0.0.0-20200328121358-65a111a20c23/go.mod h1:2z7nYWeR0xUeFNCmlyH6Qt6qigF+Kl/k4LbQbj6Ksus=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
gitlab.com/wpetit/goweb v0.0.0-20200418152305-76dea96a46ce h1:B3inZUHFr/FpA3jb+ZeSSHk3FSpB0xkQ0TjePhRokxw=
gitlab.com/wpetit/goweb v0.0.0-20200418152305-76dea96a46ce/go.mod h1:Gfv7cBOw1T2XwXMsLm1d9kAjMAdNtLMjPv+yCzRO9qk=
@ -339,6 +537,10 @@ go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2 h1:75k/FF0Q2YM8QYo07VPddOLBslDt1MZOdEslOHvmzAs=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
@ -347,6 +549,7 @@ go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKY
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
@ -362,11 +565,18 @@ golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee h1:4yd7jl+vXjalO5ztz6Vc1VADv+S/80LGJmyl1ROJ2AI=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -376,45 +586,77 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb h1:mUVeFHoDKis5nxCAzoAi7E8Ghb86EXh/RK6wtvJIqRY=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -426,21 +668,41 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 h1:gSbV7h1NRL2G1xTg/owz62CST1oJBmxy4QpMMregXVQ=
golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201013081832-0aaa2718063a h1:bhXnJ7fn2SiL+C8iOWPfNBJKDTjUByftpPW7b9CX94U=
golang.org/x/sys v0.0.0-20201013081832-0aaa2718063a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
@ -449,13 +711,17 @@ golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@ -471,26 +737,67 @@ golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191202203127-2b6af5f9ace7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d h1:W07d4xkoAUSNOkOzdzXCdFGxT7o2rW4q8M34tB2i//k=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@ -499,39 +806,88 @@ google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRn
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1 h1:aQktFqmDE2yjveXJlVIfslDFmFnUXSqG0i6KRcJAeMc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1 h1:wdKvqQk7IttEw92GoRyKG2IDrUIpgpj6H6m81yfeMW0=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
mvdan.cc/sh v2.6.4+incompatible h1:eD6tDeh0pw+/TOTI1BBEryZ02rD2nMcFsgcvde7jffM=
mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=

View File

@ -5,6 +5,8 @@ import (
"io/ioutil"
"time"
"github.com/lithammer/dedent"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
@ -19,6 +21,9 @@ type Config struct {
OIDC OIDCConfig `yaml:"oidc"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
SMTP SMTPConfig `yaml:"smtp"`
Task TaskConfig `yaml:"task"`
Sentry SentryConfig `ymal:"sentry"`
}
// NewFromFile retrieves the configuration from the given file
@ -42,6 +47,7 @@ type HTTPConfig struct {
CookieAuthenticationKey string `yaml:"cookieAuthenticationKey" env:"HTTP_COOKIE_AUTHENTICATION_KEY"`
CookieEncryptionKey string `yaml:"cookieEncryptionKey" env:"HTTP_COOKIE_ENCRYPTION_KEY"`
CookieMaxAge int `yaml:"cookieMaxAge" env:"HTTP_COOKIE_MAX_AGE"`
CookieSecure bool `yaml:"cookieSecure" env:"HTTP_COOKIE_SECURE"`
TemplateDir string `yaml:"templateDir" env:"HTTP_TEMPLATE_DIR"`
PublicDir string `yaml:"publicDir" env:"HTTP_PUBLIC_DIR"`
FrontendURL string `yaml:"frontendURL" env:"HTTP_FRONTEND_URL"`
@ -74,6 +80,37 @@ type AuthConfig struct {
Rules []string `yaml:"rules" env:"AUTH_RULES"`
}
type SMTPConfig struct {
Host string `yaml:"host" env:"SMTP_HOST"`
Port int `yaml:"port" env:"SMTP_PORT"`
UseStartTLS bool `yaml:"useStartTLS" env:"SMTP_USE_START_TLS"`
User string `yaml:"user" env:"SMTP_USER"`
Password string `yaml:"password" env:"SMTP_PASSWORD"`
InsecureSkipVerify bool `yaml:"insecureSkipVerify" env:"SMTP_INSECURE_SKIP_VERIFY"`
SenderAddress string `yaml:"senderAddress" env:"SMTP_SENDER_ADDRESS"`
SenderName string `yaml:"senderName" env:"SMTP_SENDER_NAME"`
}
type TaskConfig struct {
Newsletter NewsletterTaskConfig `yaml:"newsletter"`
}
type NewsletterTaskConfig struct {
CronSpec string `yaml:"cronSpec" env:"TASK_NEWSLETTER_CRON_SPEC"`
TimeRange time.Duration `yaml:"timeRange" env:"TASK_NEWSLETTER_TIME_RANGE"`
BaseURL string `yaml:"baseUrl" env:"TASK_NEWSLETTER_BASE_URL"`
ContentTemplate string `yaml:"contentTemplate" env:"TASK_NEWSLETTER_CONTENT_TEMPLATE"`
SubjectTemplate string `yaml:"subjectTemplate" env:"TASK_NEWSLETTER_SUBJECT_TEMPLATE"`
}
type SentryConfig struct {
DSN string `yaml:"dsn" env:"SENTRY_DSN"`
// Server events sampling rate, see https://docs.sentry.io/platforms/go/configuration/options/
ServerSampleRate float64 `yaml:"serverSampleRate" env:"SENTRY_SERVER_SAMPLE_RATE"`
ServerFlushTimeout time.Duration `yaml:"serverFlushTimeout" env:"SENTRY_SERVER_FLUSH_TIMEOUT"`
Environment string `yaml:"environment" env:"SENTRY_ENVIRONMENT"`
}
func NewDumpDefault() *Config {
config := NewDefault()
return config
@ -90,7 +127,7 @@ func NewDefault() *Config {
Address: ":8081",
CookieAuthenticationKey: "",
CookieEncryptionKey: "",
CookieMaxAge: int((time.Hour * 1).Seconds()), // 1 hour
CookieMaxAge: int((time.Hour * 24).Seconds()), // 24 hours
TemplateDir: "template",
PublicDir: "public",
FrontendURL: "http://localhost:8080",
@ -112,6 +149,82 @@ func NewDefault() *Config {
"user.Email endsWith 'cadoles.com'",
},
},
SMTP: SMTPConfig{
Host: "localhost",
Port: 2525,
User: "",
Password: "",
SenderAddress: "noreply@localhost",
SenderName: "noreply",
},
Task: TaskConfig{
Newsletter: NewsletterTaskConfig{
CronSpec: "0 9 * * 1",
TimeRange: 24 * 7 * time.Hour,
BaseURL: "http://localhost:8080",
ContentTemplate: dedent.Dedent(`
{{- $root := . -}}
Bonjour{{if .User.Name}} {{ .User.Name }}{{end}},
{{ if not .HasEvents -}}
Aucun évènement notoire ces derniers jours.
{{ else -}}
Voici les évènements de ces derniers jours:
{{- end}}
{{- with .ReadyToVote }}
Dossiers récemment prêts à voter
--------------------------------
{{range . -}}
- "{{ .Title }}" - {{ $root.BaseURL }}/decisions/{{ .ID }} - créé le {{ .CreatedAt.Format "02/01/2006" }}
{{ end }}
{{- end}}
{{- with .Voted }}
Récemment votés
---------------
{{range . -}}
- "{{ .Title }}" - {{ $root.BaseURL }}/decisions/{{ .ID }} - voté le {{ .VotedAt.Format "02/01/2006" }}
{{ end }}
{{- end}}
{{- with .NewDecisionSupportFiles }}
Nouveaux dossiers d'aide à la décision
--------------------------------------
{{range . -}}
- "{{ .Title }}" - {{ $root.BaseURL }}/decisions/{{ .ID }} - créé le {{ .CreatedAt.Format "02/01/2006" }}
{{ end }}
{{- end}}
{{- with .NewWorkgroups}}
Nouveaux groupes de travail
---------------------------
{{range . -}}
- "{{ .Name }}" - {{ $root.BaseURL }}/workgroups/{{ .ID }} - créé le {{ .CreatedAt.Format "02/01/2006" }}
{{ end }}
{{- end}}
Bonne semaine,
Daddy
`),
SubjectTemplate: `[Daddy] Évènements du {{ .From.Format "02/01/2006" }} au {{ .To.Format "02/01/2006" }}`,
},
},
Sentry: SentryConfig{
DSN: "",
ServerSampleRate: 1,
ServerFlushTimeout: 2 * time.Second,
Environment: "",
},
}
}

View File

@ -0,0 +1,59 @@
package graph
import (
"context"
"forge.cadoles.com/Cadoles/daddy/internal/model"
errs "github.com/pkg/errors"
)
func handleIsAuthorized(ctx context.Context, action string, obj model.AuthorizationObject) (bool, error) {
db, err := getDB(ctx)
if err != nil {
return false, errs.WithStack(err)
}
var object interface{}
switch {
case obj.WorkgroupID != nil:
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.Find(ctx, *obj.WorkgroupID)
if err != nil {
return false, errs.WithStack(err)
}
object = workgroup
case obj.DecisionSupportFileID != nil:
repo := model.NewDSFRepository(db)
dsf, err := repo.Find(ctx, *obj.DecisionSupportFileID)
if err != nil {
return false, errs.WithStack(err)
}
object = dsf
case obj.UserID != nil:
repo := model.NewUserRepository(db)
user, err := repo.Find(ctx, *obj.UserID)
if err != nil {
return false, errs.WithStack(err)
}
object = user
default:
return false, errs.WithStack(ErrInvalidInput)
}
authorized, err := isAuthorized(ctx, object, model.Action(action))
if err != nil {
return false, errs.WithStack(err)
}
return authorized, nil
}

View File

@ -2,7 +2,7 @@ package graph
import (
"context"
"encoding/json"
"reflect"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"gitlab.com/wpetit/goweb/middleware/container"
@ -12,8 +12,19 @@ import (
)
func handleCreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
ctn := container.Must(ctx)
db := orm.Must(ctn).DB()
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errs.WithStack(err)
}
authorized, err := isAuthorized(ctx, &model.DecisionSupportFile{}, model.ActionCreate)
if err != nil {
return nil, errs.WithStack(err)
}
if !authorized {
return nil, errs.WithStack(ErrForbidden)
}
repo := model.NewDSFRepository(db)
@ -22,20 +33,73 @@ func handleCreateDecisionSupportFile(ctx context.Context, changes *model.Decisio
return nil, errs.WithStack(err)
}
eventRepo := model.NewEventRepository(db)
if _, err := eventRepo.Add(ctx, user, model.EventTypeCreated, dsf); err != nil {
return nil, errs.WithStack(err)
}
return dsf, nil
}
func handleUpdateDecisionSupportFile(ctx context.Context, id string, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
ctn := container.Must(ctx)
db := orm.Must(ctn).DB()
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errs.WithStack(err)
}
repo := model.NewDSFRepository(db)
prevDsf, err := repo.Find(ctx, id)
if err != nil {
return nil, errs.WithStack(err)
}
authorized, err := isAuthorized(ctx, prevDsf, model.ActionUpdate)
if err != nil {
return nil, errs.WithStack(err)
}
if !authorized {
return nil, errs.WithStack(ErrForbidden)
}
dsf, err := repo.Update(ctx, id, changes)
if err != nil {
return nil, errs.WithStack(err)
}
eventRepo := model.NewEventRepository(db)
if changes != nil && changes.Status != nil && prevDsf.Status != *changes.Status {
switch *changes.Status {
case model.StatusVoted:
if _, err := eventRepo.Add(ctx, user, model.EventTypeVoted, dsf); err != nil {
return nil, errs.WithStack(err)
}
case model.StatusClosed:
if _, err := eventRepo.Add(ctx, user, model.EventTypeClosed, dsf); err != nil {
return nil, errs.WithStack(err)
}
default:
if _, err := eventRepo.Add(ctx, user, model.EventTypeStatusChanged, dsf); err != nil {
return nil, errs.WithStack(err)
}
}
}
if changes != nil && changes.Title != nil && prevDsf.Title != *changes.Title {
if _, err := eventRepo.Add(ctx, user, model.EventTypeTitleChanged, dsf); err != nil {
return nil, errs.WithStack(err)
}
}
if changes != nil && !reflect.DeepEqual(prevDsf.Sections, dsf.Sections) {
if _, err := eventRepo.Add(ctx, user, model.EventTypeUpdated, dsf); err != nil {
return nil, errs.WithStack(err)
}
}
return dsf, nil
}
@ -45,15 +109,23 @@ func handleDecisionSupportFiles(ctx context.Context, filter *model.DecisionSuppo
repo := model.NewDSFRepository(db)
return repo.Search(ctx, filter)
}
func handleSections(ctx context.Context, dsf *model.DecisionSupportFile) (map[string]interface{}, error) {
sections := make(map[string]interface{})
if err := json.Unmarshal(dsf.Sections.RawMessage, &sections); err != nil {
found, err := repo.Search(ctx, filter)
if err != nil {
return nil, errs.WithStack(err)
}
return sections, nil
dsfs := make([]*model.DecisionSupportFile, 0)
for _, d := range found {
authorized, err := isAuthorized(ctx, d, model.ActionRead)
if err != nil {
return nil, errs.WithStack(err)
}
if authorized {
dsfs = append(dsfs, d)
}
}
return dsfs, nil
}

8
internal/graph/error.go Normal file
View File

@ -0,0 +1,8 @@
package graph
import "errors"
var (
ErrForbidden = errors.New("forbidden")
ErrInvalidInput = errors.New("invalid input")
)

View File

@ -0,0 +1,24 @@
package graph
import (
"context"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
errs "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
)
func handleEvents(ctx context.Context, filter *model.EventFilter) ([]*model.Event, error) {
ctn := container.Must(ctx)
db := orm.Must(ctn).DB()
repo := model.NewEventRepository(db)
events, err := repo.Search(ctx, filter)
if err != nil {
return nil, errs.WithStack(err)
}
return events, nil
}

View File

@ -3,6 +3,8 @@ package graph
import (
"context"
"forge.cadoles.com/Cadoles/daddy/internal/voter"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"forge.cadoles.com/Cadoles/daddy/internal/session"
@ -46,3 +48,31 @@ func getSessionUser(ctx context.Context) (*model.User, *gorm.DB, error) {
return user, db, nil
}
func isAuthorized(ctx context.Context, obj interface{}, action interface{}) (bool, error) {
user, _, err := getSessionUser(ctx)
if err != nil {
return false, errors.WithStack(err)
}
ctn, err := container.From(ctx)
if err != nil {
return false, errors.WithStack(err)
}
voterSrv, err := voter.From(ctn)
if err != nil {
return false, errors.WithStack(err)
}
decision, err := voterSrv.Authorized(ctx, user, obj, action)
if err != nil {
return false, errors.WithStack(err)
}
if decision == voter.Allow {
return true, nil
}
return false, nil
}

View File

@ -18,6 +18,16 @@ type Workgroup {
members: [User]!
}
type Event {
id: ID!
type: String!
createdAt: Time!
updatedAt: Time!
objectId: ID!
objectType: String!
user: User!
}
input WorkgroupsFilter {
ids: [ID]
}
@ -36,10 +46,28 @@ type DecisionSupportFile {
input DecisionSupportFileFilter {
ids: [ID]
workgroups: [ID]
}
input AuthorizationObject {
workgroupId: ID
userId: ID
decisionSupportFileId: ID
}
input EventFilter {
objectType: String
objectId: ID
userId: ID
type: String
from: Time
to: Time
}
type Query {
userProfile: User
workgroups(filter: WorkgroupsFilter): [Workgroup]!
decisionSupportFiles(filter: DecisionSupportFileFilter): [DecisionSupportFile]!
events(filter: EventFilter): [Event]!
isAuthorized(action: String!, object: AuthorizationObject!): Boolean!
}

View File

@ -15,8 +15,16 @@ func (r *decisionSupportFileResolver) ID(ctx context.Context, obj *model1.Decisi
return strconv.FormatUint(uint64(obj.ID), 10), nil
}
func (r *decisionSupportFileResolver) Sections(ctx context.Context, obj *model1.DecisionSupportFile) (map[string]interface{}, error) {
return handleSections(ctx, obj)
func (r *eventResolver) ID(ctx context.Context, obj *model1.Event) (string, error) {
return strconv.FormatUint(uint64(obj.ID), 10), nil
}
func (r *eventResolver) Type(ctx context.Context, obj *model1.Event) (string, error) {
return string(obj.Type), nil
}
func (r *eventResolver) ObjectID(ctx context.Context, obj *model1.Event) (string, error) {
return strconv.FormatUint(uint64(obj.ObjectID), 10), nil
}
func (r *queryResolver) UserProfile(ctx context.Context) (*model1.User, error) {
@ -31,6 +39,14 @@ func (r *queryResolver) DecisionSupportFiles(ctx context.Context, filter *model1
return handleDecisionSupportFiles(ctx, filter)
}
func (r *queryResolver) Events(ctx context.Context, filter *model1.EventFilter) ([]*model1.Event, error) {
return handleEvents(ctx, filter)
}
func (r *queryResolver) IsAuthorized(ctx context.Context, action string, object model1.AuthorizationObject) (bool, error) {
return handleIsAuthorized(ctx, action, object)
}
func (r *userResolver) ID(ctx context.Context, obj *model1.User) (string, error) {
return strconv.FormatUint(uint64(obj.ID), 10), nil
}
@ -44,6 +60,9 @@ func (r *Resolver) DecisionSupportFile() generated.DecisionSupportFileResolver {
return &decisionSupportFileResolver{r}
}
// Event returns generated.EventResolver implementation.
func (r *Resolver) Event() generated.EventResolver { return &eventResolver{r} }
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
@ -54,6 +73,17 @@ func (r *Resolver) User() generated.UserResolver { return &userResolver{r} }
func (r *Resolver) Workgroup() generated.WorkgroupResolver { return &workgroupResolver{r} }
type decisionSupportFileResolver struct{ *Resolver }
type eventResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type workgroupResolver struct{ *Resolver }
// !!! WARNING !!!
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
// one last chance to move it out of harms way if you want. There are two reasons this happens:
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
// it when you're done.
// - You have helper methods in this file. Move them out to keep these resolver files clean.
func (r *decisionSupportFileResolver) Sections(ctx context.Context, obj *model1.DecisionSupportFile) (map[string]interface{}, error) {
return obj.Sections, nil
}

View File

@ -2,10 +2,10 @@ package graph
import (
"context"
"strconv"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"github.com/pkg/errors"
errs "github.com/pkg/errors"
)
func handleWorkgroups(ctx context.Context, filter *model.WorkgroupsFilter) ([]*model.Workgroup, error) {
@ -24,20 +24,28 @@ func handleWorkgroups(ctx context.Context, filter *model.WorkgroupsFilter) ([]*m
}
}
workgroups, err := repo.FindWorkgroups(ctx, criteria...)
found, err := repo.FindWorkgroups(ctx, criteria...)
if err != nil {
return nil, errors.WithStack(err)
}
workgroups := make([]*model.Workgroup, 0)
for _, wg := range found {
authorized, err := isAuthorized(ctx, wg, model.ActionRead)
if err != nil {
return nil, errs.WithStack(err)
}
if authorized {
workgroups = append(workgroups, wg)
}
}
return workgroups, nil
}
func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
@ -45,20 +53,35 @@ func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wor
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.AddUserToWorkgroup(ctx, user.ID, workgroupID)
workgroup, err := repo.Find(ctx, rawWorkgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
authorized, err := isAuthorized(ctx, workgroup, model.ActionJoin)
if err != nil {
return nil, errs.WithStack(err)
}
if !authorized {
return nil, errs.WithStack(ErrForbidden)
}
workgroup, err = repo.AddUserToWorkgroup(ctx, user.ID, workgroup.ID)
if err != nil {
return nil, errors.WithStack(err)
}
eventRepo := model.NewEventRepository(db)
if _, err := eventRepo.Add(ctx, user, model.EventTypeJoined, workgroup); err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
func handleLeaveWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
@ -66,16 +89,45 @@ func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.RemoveUserFromWorkgroup(ctx, user.ID, workgroupID)
workgroup, err := repo.Find(ctx, workgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
authorized, err := isAuthorized(ctx, workgroup, model.ActionLeave)
if err != nil {
return nil, errs.WithStack(err)
}
if !authorized {
return nil, errs.WithStack(ErrForbidden)
}
workgroup, err = repo.RemoveUserFromWorkgroup(ctx, user.ID, workgroup.ID)
if err != nil {
return nil, errors.WithStack(err)
}
eventRepo := model.NewEventRepository(db)
if _, err := eventRepo.Add(ctx, user, model.EventTypeLeaved, workgroup); err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) (*model.Workgroup, error) {
db, err := getDB(ctx)
authorized, err := isAuthorized(ctx, &model.Workgroup{}, model.ActionCreate)
if err != nil {
return nil, errs.WithStack(err)
}
if !authorized {
return nil, errs.WithStack(ErrForbidden)
}
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
@ -87,56 +139,83 @@ func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges)
return nil, errors.WithStack(err)
}
return workgroup, nil
}
eventRepo := model.NewEventRepository(db)
func handleCloseWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
if err != nil {
if _, err := eventRepo.Add(ctx, user, model.EventTypeCreated, workgroup); err != nil {
return nil, errors.WithStack(err)
}
db, err := getDB(ctx)
return workgroup, nil
}
func handleCloseWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.CloseWorkgroup(ctx, workgroupID)
workgroup, err := repo.Find(ctx, workgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
authorized, err := isAuthorized(ctx, workgroup, model.ActionClose)
if err != nil {
return nil, errs.WithStack(err)
}
if !authorized {
return nil, errs.WithStack(ErrForbidden)
}
workgroup, err = repo.CloseWorkgroup(ctx, workgroup.ID)
if err != nil {
return nil, errors.WithStack(err)
}
eventRepo := model.NewEventRepository(db)
if _, err := eventRepo.Add(ctx, user, model.EventTypeClosed, workgroup); err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func handleUpdateWorkgroup(ctx context.Context, rawWorkgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) {
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
db, err := getDB(ctx)
func handleUpdateWorkgroup(ctx context.Context, workgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) {
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.UpdateWorkgroup(ctx, workgroupID, changes)
workgroup, err := repo.Find(ctx, workgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
authorized, err := isAuthorized(ctx, workgroup, model.ActionUpdate)
if err != nil {
return nil, errs.WithStack(err)
}
if !authorized {
return nil, errs.WithStack(ErrForbidden)
}
workgroup, err = repo.UpdateWorkgroup(ctx, workgroup.ID, changes)
if err != nil {
return nil, errors.WithStack(err)
}
eventRepo := model.NewEventRepository(db)
if _, err := eventRepo.Add(ctx, user, model.EventTypeUpdated, workgroup); err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func parseWorkgroupID(workgroupID string) (uint, error) {
workgroupID64, err := strconv.ParseUint(workgroupID, 10, 32)
if err != nil {
return 0, errors.WithStack(err)
}
return uint(workgroupID64), nil
}

52
internal/mail/mailer.go Normal file
View File

@ -0,0 +1,52 @@
package mail
const (
ContentTypeHTML = "text/html"
ContentTypeText = "text/plain"
)
type Option struct {
Host string
Port int
User string
Password string
InsecureSkipVerify bool
UseStartTLS bool
}
type OptionFunc func(*Option)
type Mailer struct {
opt *Option
}
func WithTLS(useStartTLS, insecureSkipVerify bool) OptionFunc {
return func(opt *Option) {
opt.UseStartTLS = useStartTLS
opt.InsecureSkipVerify = insecureSkipVerify
}
}
func WithServer(host string, port int) OptionFunc {
return func(opt *Option) {
opt.Host = host
opt.Port = port
}
}
func WithCredentials(user, password string) OptionFunc {
return func(opt *Option) {
opt.User = user
opt.Password = password
}
}
func NewMailer(funcs ...OptionFunc) *Mailer {
opt := &Option{}
for _, fn := range funcs {
fn(opt)
}
return &Mailer{opt}
}

11
internal/mail/provider.go Normal file
View File

@ -0,0 +1,11 @@
package mail
import "gitlab.com/wpetit/goweb/service"
func ServiceProvider(opts ...OptionFunc) service.Provider {
mailer := NewMailer(opts...)
return func(ctn *service.Container) (interface{}, error) {
return mailer, nil
}
}

207
internal/mail/send.go Normal file
View File

@ -0,0 +1,207 @@
package mail
import (
"crypto/tls"
"fmt"
"math/rand"
"net/mail"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
gomail "gopkg.in/mail.v2"
)
var (
ErrUnexpectedEmailAddressFormat = errors.New("unexpected email address format")
)
type SendFunc func(*SendOption)
type SendOption struct {
Charset string
AddressHeaders []AddressHeader
Headers []Header
Body Body
AlternativeBodies []Body
}
type AddressHeader struct {
Field string
Address string
Name string
}
type Header struct {
Field string
Values []string
}
type Body struct {
Type string
Content string
PartSetting gomail.PartSetting
}
func WithCharset(charset string) func(*SendOption) {
return func(opt *SendOption) {
opt.Charset = charset
}
}
func WithSender(address string, name string) func(*SendOption) {
return WithAddressHeader("From", address, name)
}
func WithSubject(subject string) func(*SendOption) {
return WithHeader("Subject", subject)
}
func WithAddressHeader(field, address, name string) func(*SendOption) {
return func(opt *SendOption) {
opt.AddressHeaders = append(opt.AddressHeaders, AddressHeader{field, address, name})
}
}
func WithHeader(field string, values ...string) func(*SendOption) {
return func(opt *SendOption) {
opt.Headers = append(opt.Headers, Header{field, values})
}
}
func WithRecipients(addresses ...string) func(*SendOption) {
return WithHeader("To", addresses...)
}
func WithCopies(addresses ...string) func(*SendOption) {
return WithHeader("Cc", addresses...)
}
func WithInvisibleCopies(addresses ...string) func(*SendOption) {
return WithHeader("Cci", addresses...)
}
func WithBody(contentType string, content string, setting gomail.PartSetting) func(*SendOption) {
return func(opt *SendOption) {
if setting == nil {
setting = gomail.SetPartEncoding(gomail.Unencoded)
}
opt.Body = Body{contentType, content, setting}
}
}
func WithAlternativeBody(contentType string, content string, setting gomail.PartSetting) func(*SendOption) {
return func(opt *SendOption) {
if setting == nil {
setting = gomail.SetPartEncoding(gomail.Unencoded)
}
opt.AlternativeBodies = append(opt.AlternativeBodies, Body{contentType, content, setting})
}
}
func (m *Mailer) Send(funcs ...SendFunc) error {
opt := &SendOption{
Charset: "UTF-8",
Body: Body{
Type: "text/plain",
Content: "",
PartSetting: gomail.SetPartEncoding(gomail.Unencoded),
},
AddressHeaders: make([]AddressHeader, 0),
Headers: make([]Header, 0),
AlternativeBodies: make([]Body, 0),
}
for _, f := range funcs {
f(opt)
}
conn, err := m.openConnection()
if err != nil {
return errors.Wrap(err, "could not open connection")
}
defer conn.Close()
message := gomail.NewMessage(gomail.SetCharset(opt.Charset))
for _, h := range opt.AddressHeaders {
message.SetAddressHeader(h.Field, h.Address, h.Name)
}
for _, h := range opt.Headers {
message.SetHeader(h.Field, h.Values...)
}
froms := message.GetHeader("From")
var sendDomain string
if len(froms) > 0 {
sendDomain, err = extractEmailDomain(froms[0])
if err != nil {
return err
}
}
messageID := generateMessageID(sendDomain)
message.SetHeader("Message-Id", messageID)
message.SetBody(opt.Body.Type, opt.Body.Content, opt.Body.PartSetting)
for _, b := range opt.AlternativeBodies {
message.AddAlternative(b.Type, b.Content, b.PartSetting)
}
if err := gomail.Send(conn, message); err != nil {
return errors.Wrap(err, "could not send message")
}
return nil
}
func (m *Mailer) openConnection() (gomail.SendCloser, error) {
dialer := gomail.NewDialer(
m.opt.Host,
m.opt.Port,
m.opt.User,
m.opt.Password,
)
if m.opt.InsecureSkipVerify {
dialer.TLSConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
conn, err := dialer.Dial()
if err != nil {
return nil, errors.Wrap(err, "could not dial smtp server")
}
return conn, nil
}
func extractEmailDomain(email string) (string, error) {
address, err := mail.ParseAddress(email)
if err != nil {
return "", errors.Wrapf(err, "could not parse email address '%s'", email)
}
addressParts := strings.SplitN(address.Address, "@", 2)
if len(addressParts) != 2 { // nolint: gomnd
return "", errors.WithStack(ErrUnexpectedEmailAddressFormat)
}
domain := addressParts[1]
return domain, nil
}
func generateMessageID(domain string) string {
// Based on https://www.jwz.org/doc/mid.html
timestamp := strconv.FormatInt(time.Now().UnixNano(), 36)
random := strconv.FormatInt(rand.Int63(), 36)
return fmt.Sprintf("<%s.%s@%s>", timestamp, random, domain)
}

33
internal/mail/service.go Normal file
View File

@ -0,0 +1,33 @@
package mail
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "mail"
// From retrieves the mail service in the given container
func From(container *service.Container) (*Mailer, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*Mailer)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the mail service in the given container or panic otherwise
func Must(container *service.Container) *Mailer {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}

13
internal/model/action.go Normal file
View File

@ -0,0 +1,13 @@
package model
type Action string
const (
ActionCreate Action = "create"
ActionRead Action = "read"
ActionUpdate Action = "update"
ActionDelete Action = "delete"
ActionJoin Action = "join"
ActionLeave Action = "leave"
ActionClose Action = "close"
)

View File

@ -1,19 +1,62 @@
package model
import (
"encoding/json"
"time"
"github.com/jinzhu/gorm"
"github.com/jinzhu/gorm/dialects/postgres"
errs "github.com/pkg/errors"
)
const ObjectTypeDecisionSupportFile = "dsf"
const (
StatusDraft = "draft"
StatusReady = "ready"
StatusVoted = "voted"
StatusClosed = "closed"
)
type DecisionSupportFile struct {
gorm.Model
Title string `json:"title"`
Sections postgres.Jsonb `json:"sections"`
Status string `json:"status"`
WorkgroupID uint `json:"-"`
Workgroup *Workgroup `json:"workgroup"`
VotedAt time.Time `json:"votedAt"`
ClosedAt time.Time `json:"closedAt"`
Title string `json:"title"`
SectionsJSON postgres.Jsonb `json:"-" gorm:"column:sections;"`
Sections map[string]interface{} `gorm:"-"`
Status string `json:"status"`
WorkgroupID uint `json:"-"`
Workgroup *Workgroup `json:"workgroup" gorm:"association_autoupdate:false"`
VotedAt time.Time `json:"votedAt"`
ClosedAt time.Time `json:"closedAt"`
}
func (f *DecisionSupportFile) ObjectID() uint {
return f.ID
}
func (f *DecisionSupportFile) ObjectType() string {
return ObjectTypeDecisionSupportFile
}
func (f *DecisionSupportFile) BeforeSave() error {
rawSections, err := json.Marshal(f.Sections)
if err != nil {
return errs.WithStack(err)
}
f.SectionsJSON = postgres.Jsonb{RawMessage: rawSections}
return nil
}
func (f *DecisionSupportFile) AfterFind() (err error) {
sections := make(map[string]interface{})
if err := json.Unmarshal(f.SectionsJSON.RawMessage, &sections); err != nil {
return errs.WithStack(err)
}
f.Sections = sections
return nil
}

View File

@ -2,11 +2,10 @@ package model
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jinzhu/gorm"
"github.com/jinzhu/gorm/dialects/postgres"
errs "github.com/pkg/errors"
)
@ -69,12 +68,7 @@ func (r *DSFRepository) updateFromChanges(dsf *DecisionSupportFile, changes *Dec
dsf.Workgroup = wg
if changes.Sections != nil {
rawSections, err := json.Marshal(changes.Sections)
if err != nil {
return errs.WithStack(err)
}
dsf.Sections = postgres.Jsonb{RawMessage: rawSections}
dsf.Sections = changes.Sections
}
if changes.Title != nil {
@ -82,12 +76,31 @@ func (r *DSFRepository) updateFromChanges(dsf *DecisionSupportFile, changes *Dec
}
if changes.Status != nil {
if dsf.Status != StatusVoted && *changes.Status == StatusVoted {
dsf.VotedAt = time.Now().UTC()
}
if *changes.Status == StatusClosed {
dsf.VotedAt = time.Time{}
}
dsf.Status = *changes.Status
}
return nil
}
func (r *DSFRepository) Find(ctx context.Context, id string) (*DecisionSupportFile, error) {
dsf := &DecisionSupportFile{}
query := r.db.Model(dsf).Preload("Workgroup").Where("id = ?", id)
if err := query.First(&dsf).Error; err != nil {
return nil, errs.WithStack(err)
}
return dsf, nil
}
func (r *DSFRepository) Search(ctx context.Context, filter *DecisionSupportFileFilter) ([]*DecisionSupportFile, error) {
query := r.db.Model(&DecisionSupportFile{}).Preload("Workgroup")
@ -95,6 +108,10 @@ func (r *DSFRepository) Search(ctx context.Context, filter *DecisionSupportFileF
if filter.Ids != nil {
query = query.Where("id in (?)", filter.Ids)
}
if filter.Workgroups != nil {
query = query.Where("workgroup_id in (?)", filter.Workgroups)
}
}
dsfs := make([]*DecisionSupportFile, 0)

View File

@ -0,0 +1,52 @@
package model
import (
"context"
"forge.cadoles.com/Cadoles/daddy/internal/voter"
)
type DecisionSupportFileVoter struct {
}
func (v *DecisionSupportFileVoter) Vote(ctx context.Context, subject interface{}, obj interface{}, act interface{}) (voter.Decision, error) {
user, ok := subject.(*User)
if !ok {
return voter.Abstain, nil
}
dsf, ok := obj.(*DecisionSupportFile)
if !ok {
return voter.Abstain, nil
}
action, ok := act.(Action)
if !ok {
return voter.Abstain, nil
}
switch action {
case ActionCreate:
return voter.Allow, nil
case ActionRead:
return voter.Allow, nil
case ActionUpdate:
if dsf.Status == StatusClosed || dsf.Status == StatusVoted {
return voter.Deny, nil
}
if inWorkgroup(user, dsf.Workgroup) {
return voter.Allow, nil
}
return voter.Deny, nil
case ActionDelete:
return voter.Deny, nil
}
return voter.Abstain, nil
}
func NewDecisionSupportFileVoter() *DecisionSupportFileVoter {
return &DecisionSupportFileVoter{}
}

32
internal/model/event.go Normal file
View File

@ -0,0 +1,32 @@
package model
import (
"github.com/jinzhu/gorm"
)
type EventType string
const (
EventTypeCreated EventType = "created"
EventTypeUpdated EventType = "updated"
EventTypeLeaved EventType = "leaved"
EventTypeJoined EventType = "joined"
EventTypeClosed EventType = "closed"
EventTypeStatusChanged EventType = "status-changed"
EventTypeTitleChanged EventType = "title-changed"
EventTypeVoted EventType = "voted"
)
type EventObject interface {
ObjectID() uint
ObjectType() string
}
type Event struct {
gorm.Model
UserID uint `json:"-"`
User *User `json:"user" gorm:"association_autoupdate:false"`
ObjectType string `json:"objectType"`
ObjectID uint `json:"objectId"`
Type EventType `json:"type"`
}

View File

@ -0,0 +1,73 @@
package model
import (
"context"
"github.com/jinzhu/gorm"
errs "github.com/pkg/errors"
)
type EventRepository struct {
db *gorm.DB
}
func (r *EventRepository) Add(ctx context.Context, user *User, eventType EventType, obj EventObject) (*Event, error) {
evt := &Event{
Type: eventType,
User: user,
ObjectID: obj.ObjectID(),
ObjectType: obj.ObjectType(),
}
if err := r.db.Save(&evt).Error; err != nil {
return nil, errs.WithStack(err)
}
return evt, nil
}
func (r *EventRepository) Search(ctx context.Context, filter *EventFilter) ([]*Event, error) {
query := r.db.Model(&Event{}).Preload("User")
if filter == nil {
filter = &EventFilter{}
}
if filter.ObjectID != nil {
query = query.Where("object_id = ?", filter.ObjectID)
}
if filter.ObjectType != nil {
query = query.Where("object_type = ?", filter.ObjectType)
}
if filter.UserID != nil {
query = query.Where("user_id = ?", filter.UserID)
}
if filter.Type != nil {
query = query.Where("type = ?", filter.Type)
}
if filter.From != nil {
query = query.Where("created_at >= ?", filter.From)
}
if filter.To != nil {
query = query.Where("created_at <= ?", filter.To)
}
query = query.Order("created_at DESC")
events := make([]*Event, 0)
if err := query.Find(&events).Error; err != nil {
return nil, errs.WithStack(err)
}
return events, nil
}
func NewEventRepository(db *gorm.DB) *EventRepository {
return &EventRepository{db}
}

15
internal/model/helper.go Normal file
View File

@ -0,0 +1,15 @@
package model
func inWorkgroup(user *User, workgroup *Workgroup) bool {
if workgroup == nil {
return false
}
for _, w := range user.Workgroups {
if w.ID == workgroup.ID {
return true
}
}
return false
}

View File

@ -11,7 +11,7 @@ type User struct {
Name *string `json:"name"`
Email string `json:"email" gorm:"unique;not null"`
ConnectedAt time.Time `json:"connectedAt"`
Workgroups []*Workgroup `gorm:"many2many:users_workgroups;"`
Workgroups []*Workgroup `gorm:"many2many:users_workgroups;association_autoupdate:false"`
}
type ProfileChanges struct {

View File

@ -7,6 +7,7 @@ import (
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
errs "github.com/pkg/errors"
)
type UserRepository struct {
@ -68,6 +69,28 @@ func (r *UserRepository) UpdateUserByEmail(ctx context.Context, email string, ch
return user, nil
}
func (r *UserRepository) Find(ctx context.Context, id string) (*User, error) {
user := &User{}
query := r.db.Model(user).Where("id = ?", id)
if err := query.First(&user).Error; err != nil {
return nil, errs.WithStack(err)
}
return user, nil
}
func (r *UserRepository) All(ctx context.Context) ([]*User, error) {
users := make([]*User, 0)
query := r.db.Model(&User{})
if err := query.Find(&users).Error; err != nil {
return nil, errs.WithStack(err)
}
return users, nil
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db}
}

View File

@ -6,11 +6,21 @@ import (
"github.com/jinzhu/gorm"
)
const ObjectTypeWorkgroup = "workgroup"
type Workgroup struct {
gorm.Model
Name *string `json:"name"`
ClosedAt time.Time `json:"closedAt"`
Members []*User `gorm:"many2many:users_workgroups;"`
Members []*User `gorm:"many2many:users_workgroups;association_autoupdate:false"`
}
func (w *Workgroup) ObjectID() uint {
return w.ID
}
func (w *Workgroup) ObjectType() string {
return ObjectTypeWorkgroup
}
type WorkgroupChanges struct {

View File

@ -6,6 +6,7 @@ import (
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
errs "github.com/pkg/errors"
)
type WorkgroupRepository struct {
@ -47,7 +48,7 @@ func (r *WorkgroupRepository) CreateWorkgroup(ctx context.Context, changes Workg
Name: changes.Name,
}
if err := r.db.Model(&Workgroup{}).Create(workgroup).Error; err != nil {
if err := r.db.Model(&Workgroup{}).Preload("Members").Create(workgroup).Error; err != nil {
return nil, errors.WithStack(err)
}
@ -105,29 +106,35 @@ func (r *WorkgroupRepository) AddUserToWorkgroup(ctx context.Context, userID, wo
}
func (r *WorkgroupRepository) RemoveUserFromWorkgroup(ctx context.Context, userID, workgroupID uint) (*Workgroup, error) {
user := &User{}
err := r.db.First(user, "id = ?", userID).Error
if err != nil {
return nil, errors.Wrap(err, "could not find user")
}
workgroup := &Workgroup{}
workgroup.ID = workgroupID
err = r.db.Model(user).
Association("Workgroups").
Delete(workgroup).
Error
err := r.db.Transaction(func(tx *gorm.DB) error {
user := &User{}
err := tx.First(user, "id = ?", userID).Error
if err != nil {
return errors.Wrap(err, "could not find user")
}
if err != nil {
return nil, errors.Wrap(err, "could not add user to workgroup")
}
err = tx.Model(user).
Association("Workgroups").
Delete(workgroup).
Error
err = r.db.Model(workgroup).
Preload("Members").
First(workgroup, "id = ?", workgroupID).
Error
if err != nil {
return errors.Wrap(err, "could not add user to workgroup")
}
err = tx.Model(workgroup).
Preload("Members").
First(workgroup, "id = ?", workgroupID).
Error
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
@ -135,6 +142,17 @@ func (r *WorkgroupRepository) RemoveUserFromWorkgroup(ctx context.Context, userI
return workgroup, nil
}
func (r *WorkgroupRepository) Find(ctx context.Context, id string) (*Workgroup, error) {
wg := &Workgroup{}
query := r.db.Model(wg).Preload("Members").Where("id = ?", id)
if err := query.First(&wg).Error; err != nil {
return nil, errs.WithStack(err)
}
return wg, nil
}
func NewWorkgroupRepository(db *gorm.DB) *WorkgroupRepository {
return &WorkgroupRepository{db}
}

View File

@ -0,0 +1,54 @@
package model
import (
"context"
"forge.cadoles.com/Cadoles/daddy/internal/voter"
)
type WorkgroupVoter struct {
}
func (v *WorkgroupVoter) Vote(ctx context.Context, subject interface{}, obj interface{}, act interface{}) (voter.Decision, error) {
user, ok := subject.(*User)
if !ok {
return voter.Abstain, nil
}
workgroup, ok := obj.(*Workgroup)
if !ok {
return voter.Abstain, nil
}
action, ok := act.(Action)
if !ok {
return voter.Abstain, nil
}
switch action {
case ActionCreate:
return voter.Allow, nil
case ActionRead:
return voter.Allow, nil
case ActionJoin:
return voter.Allow, nil
case ActionLeave:
fallthrough
case ActionUpdate:
fallthrough
case ActionClose:
if inWorkgroup(user, workgroup) {
return voter.Allow, nil
} else {
return voter.Deny, nil
}
case ActionDelete:
return voter.Deny, nil
}
return voter.Abstain, nil
}
func NewWorkgroupVoter() *WorkgroupVoter {
return &WorkgroupVoter{}
}

View File

@ -1,7 +1,6 @@
package route
import (
"fmt"
"net/http"
"forge.cadoles.com/Cadoles/daddy/internal/auth"
@ -80,11 +79,8 @@ func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
}
if !authorized {
message := fmt.Sprintf(
"You are not authorized to access this application. Disconnect by navigating to %s.",
"http://"+r.Host+"/logout",
)
http.Error(w, message, http.StatusForbidden)
redirectURL := conf.HTTP.FrontendURL + "/unauthorized"
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
return
}

View File

@ -1,10 +1,15 @@
package route
import (
"context"
"net/http"
"path"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/getsentry/sentry-go"
"github.com/pkg/errors"
"forge.cadoles.com/Cadoles/daddy/internal/config"
"forge.cadoles.com/Cadoles/daddy/internal/graph"
"forge.cadoles.com/Cadoles/daddy/internal/graph/generated"
@ -36,12 +41,27 @@ func Mount(r *chi.Mux, config *config.Config) error {
}).Handler)
r.Use(session.UserEmailMiddleware)
gqlConfig := generated.Config{
Resolvers: &graph.Resolver{},
}
gql := handler.New(
generated.NewExecutableSchema(generated.Config{
Resolvers: &graph.Resolver{},
}),
generated.NewExecutableSchema(gqlConfig),
)
gql.SetRecoverFunc(func(ctx context.Context, err interface{}) error {
// Dispatch error to Sentry
if realErr, ok := err.(error); ok {
sentry.CaptureException(realErr)
}
if strErr, ok := err.(string); ok {
sentry.CaptureException(errors.New(strErr))
}
return graphql.DefaultRecover(ctx, err)
})
gql.AddTransport(transport.POST{})
gql.AddTransport(&transport.Websocket{
KeepAlivePingInterval: 10 * time.Second,
@ -70,8 +90,18 @@ func Mount(r *chi.Mux, config *config.Config) error {
}
// List of paths handled directly by the client
r.Get("/workgroups/*", serveClientIndex)
r.Get("/profile", serveClientIndex)
clientRoutes := []string{
"/workgroups/*",
"/profile",
"/dashboard",
"/decisions/*",
"/unauthorized",
"/conference",
}
for _, cr := range clientRoutes {
r.Get(cr, serveClientIndex)
}
// Serve static files
notFoundHandler := r.NotFoundHandler()

219
internal/task/newsletter.go Normal file
View File

@ -0,0 +1,219 @@
package task
import (
"bytes"
"context"
"fmt"
"text/template"
"time"
"forge.cadoles.com/Cadoles/daddy/internal/mail"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"github.com/pkg/errors"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/logger"
)
type Newsletter struct {
ctx context.Context
timeRange time.Duration
baseURL string
contentTemplate string
subjectTemplate string
from string
}
func (t *Newsletter) Name() string {
return "newsletter"
}
func (t *Newsletter) Run() {
ctx := t.ctx
logger.Info(ctx, "preparing newsletter", logger.F("timeRange", t.timeRange))
contentTmpl, err := template.New("").Parse(t.contentTemplate)
if err != nil {
logger.Error(ctx, "could not parse newsletter content template", logger.E(errors.WithStack(err)))
return
}
subjectTmpl, err := template.New("").Parse(t.subjectTemplate)
if err != nil {
logger.Error(ctx, "could not parse newsletter subject template", logger.E(errors.WithStack(err)))
return
}
ctn := container.Must(ctx)
orm := orm.Must(ctn)
db := orm.DB()
mailSrv := mail.Must(ctn)
eventRepo := model.NewEventRepository(db)
to := time.Now()
from := to.Add(-t.timeRange)
events, err := eventRepo.Search(ctx, &model.EventFilter{
From: &from,
To: &to,
})
if err != nil {
logger.Error(ctx, "could not search events", logger.E(errors.WithStack(err)))
return
}
newWorkgroups := make([]*model.Workgroup, 0)
newDecisionSupportFiles := make([]*model.DecisionSupportFile, 0)
readyToVote := make([]*model.DecisionSupportFile, 0)
voted := make([]*model.DecisionSupportFile, 0)
workgroupRepo := model.NewWorkgroupRepository(db)
dsfRepo := model.NewDSFRepository(db)
for _, evt := range events {
switch {
case evt.Type == model.EventTypeCreated && evt.ObjectType == model.ObjectTypeWorkgroup:
workgroup, err := workgroupRepo.Find(ctx, fmt.Sprintf("%d", evt.ObjectID))
if err != nil {
logger.Error(
ctx, "could not find workgroup",
logger.E(errors.WithStack(err)),
logger.F("id", evt.ObjectID),
)
return
}
newWorkgroups = append(newWorkgroups, workgroup)
case evt.Type == model.EventTypeCreated && evt.ObjectType == model.ObjectTypeDecisionSupportFile:
dsf, err := dsfRepo.Find(ctx, fmt.Sprintf("%d", evt.ObjectID))
if err != nil {
logger.Error(
ctx, "could not find decision support file",
logger.E(errors.WithStack(err)),
logger.F("id", evt.ObjectID),
)
return
}
newDecisionSupportFiles = append(newDecisionSupportFiles, dsf)
case evt.Type == model.EventTypeStatusChanged && evt.ObjectType == model.ObjectTypeDecisionSupportFile:
dsf, err := dsfRepo.Find(ctx, fmt.Sprintf("%d", evt.ObjectID))
if err != nil {
logger.Error(
ctx, "could not find decision support file",
logger.E(errors.WithStack(err)),
logger.F("id", evt.ObjectID),
)
return
}
if dsf.Status == model.StatusReady {
readyToVote = append(readyToVote, dsf)
}
if dsf.Status == model.StatusVoted {
voted = append(voted, dsf)
}
}
}
hasEvents := len(newDecisionSupportFiles) > 0 || len(newWorkgroups) > 0
userRepo := model.NewUserRepository(db)
users, err := userRepo.All(ctx)
if err != nil {
logger.Error(ctx, "could not find users", logger.E(errors.WithStack(err)))
return
}
var (
contentBuff bytes.Buffer
subjectBuff bytes.Buffer
)
for _, u := range users {
tmplData := struct {
User *model.User
NewWorkgroups []*model.Workgroup
NewDecisionSupportFiles []*model.DecisionSupportFile
ReadyToVote []*model.DecisionSupportFile
Voted []*model.DecisionSupportFile
BaseURL string
From time.Time
To time.Time
HasEvents bool
}{
User: u,
BaseURL: t.baseURL,
NewWorkgroups: newWorkgroups,
NewDecisionSupportFiles: newDecisionSupportFiles,
ReadyToVote: readyToVote,
Voted: voted,
From: from.Local(),
To: to.Local(),
HasEvents: hasEvents,
}
err = contentTmpl.Execute(&contentBuff, tmplData)
if err != nil {
logger.Error(ctx, "could not execute newsletter content template", logger.E(errors.WithStack(err)))
return
}
err = subjectTmpl.Execute(&subjectBuff, tmplData)
if err != nil {
logger.Error(ctx, "could not execute newsletter subject template", logger.E(errors.WithStack(err)))
return
}
newsletterContent := contentBuff.String()
newsletterSubject := subjectBuff.String()
err := mailSrv.Send(
mail.WithRecipients(u.Email),
mail.WithSubject(newsletterSubject),
mail.WithSender(t.from, ""),
mail.WithBody(mail.ContentTypeText, newsletterContent, nil),
)
if err != nil {
logger.Error(
ctx, "could not send newsletter",
logger.E(errors.WithStack(err)),
logger.F("email", u.Email),
)
return
}
contentBuff.Reset()
subjectBuff.Reset()
}
}
func NewNewsletter(ctx context.Context, timeRange time.Duration, baseURL, contentTemplate, subjectTemplate, from string) *Newsletter {
return &Newsletter{
ctx: ctx,
timeRange: timeRange,
baseURL: baseURL,
contentTemplate: contentTemplate,
subjectTemplate: subjectTemplate,
from: from,
}
}

6
internal/task/task.go Normal file
View File

@ -0,0 +1,6 @@
package task
type Task interface {
Name() string
Run()
}

View File

@ -0,0 +1,22 @@
package voter
type Decision int
const (
Allow Decision = iota
Deny
Abstain
)
func AsString(d Decision) string {
switch d {
case Allow:
return "allow"
case Deny:
return "deny"
case Abstain:
return "abstain"
default:
return "unknown"
}
}

60
internal/voter/manager.go Normal file
View File

@ -0,0 +1,60 @@
package voter
import (
"context"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Voter interface {
Vote(ctx context.Context, subject interface{}, obj interface{}, action interface{}) (Decision, error)
}
type Strategy func(ctx context.Context, decisions []Decision) (Decision, error)
type Manager struct {
strategy Strategy
voters []Voter
}
func (m *Manager) Authorized(ctx context.Context, subject interface{}, obj interface{}, action interface{}) (Decision, error) {
decisions := make([]Decision, 0, len(m.voters))
logger.Debug(
ctx,
"checking authorization",
logger.F("subject", subject),
logger.F("object", obj),
logger.F("action", action),
)
for _, v := range m.voters {
dec, err := v.Vote(ctx, subject, obj, action)
if err != nil {
return Deny, errors.WithStack(err)
}
decisions = append(decisions, dec)
}
result, err := m.strategy(ctx, decisions)
if err != nil {
return Deny, errors.WithStack(err)
}
logger.Debug(
ctx,
"authorization checked",
logger.F("subject", subject),
logger.F("object", obj),
logger.F("action", action),
logger.F("result", AsString(result)),
)
return result, nil
}
func NewManager(strategy Strategy, voters ...Voter) *Manager {
return &Manager{strategy, voters}
}

View File

@ -0,0 +1,13 @@
package voter
import (
"gitlab.com/wpetit/goweb/service"
)
func ServiceProvider(strategy Strategy, voters ...Voter) service.Provider {
manager := NewManager(strategy, voters...)
return func(ctn *service.Container) (interface{}, error) {
return manager, nil
}
}

33
internal/voter/service.go Normal file
View File

@ -0,0 +1,33 @@
package voter
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "voter"
// From retrieves the voter service in the given container.
func From(container *service.Container) (*Manager, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*Manager)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the voter service in the given container or panic otherwise.
func Must(container *service.Container) *Manager {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}

View File

@ -0,0 +1,77 @@
package voter
import "context"
// StrategyUnanimous returns Allow if all voters allow the operations.
func StrategyUnanimous(ctx context.Context, decisions []Decision) (Decision, error) {
allAbstains := true
for _, d := range decisions {
if d == Deny {
return Deny, nil
}
if d != Abstain {
allAbstains = false
}
}
if allAbstains {
return Abstain, nil
}
return Allow, nil
}
// StrategyAffirmative returns Allow if at least one voter allow the operation.
func StrategyAffirmative(ctx context.Context, decisions []Decision) (Decision, error) {
allAbstains := true
for _, d := range decisions {
if d == Allow {
return Allow, nil
}
if allAbstains && d != Abstain {
allAbstains = false
}
}
if allAbstains {
return Abstain, nil
}
return Deny, nil
}
// StrategyConsensus returns Allow if the majority of voters allow the operation.
func StrategyConsensus(ctx context.Context, decisions []Decision) (Decision, error) {
deny := 0
allow := 0
abstain := 0
for _, d := range decisions {
switch {
case d == Allow:
allow++
case d == Deny:
deny++
case d == Abstain:
abstain++
}
}
if abstain > allow && abstain > deny {
return Abstain, nil
}
if allow > abstain && allow > deny {
return Allow, nil
}
if deny > allow && deny > abstain {
return Deny, nil
}
return Abstain, nil
}

View File

@ -0,0 +1,125 @@
package voter
import (
"context"
"testing"
)
func TestStrategyUnanimous(t *testing.T) {
testCases := []struct {
Decisions []Decision
Expect Decision
}{
{
Decisions: []Decision{Allow, Allow, Allow},
Expect: Allow,
},
{
Decisions: []Decision{Abstain, Abstain, Abstain},
Expect: Abstain,
},
{
Decisions: []Decision{Deny, Abstain, Abstain},
Expect: Deny,
},
{
Decisions: []Decision{Deny, Allow, Abstain},
Expect: Deny,
},
}
for _, tc := range testCases {
ctx := context.Background()
result, err := StrategyUnanimous(ctx, tc.Decisions)
if err != nil {
t.Error(err)
}
if e, g := tc.Expect, result; e != g {
t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g))
}
}
}
func TestStrategyAffirmative(t *testing.T) {
testCases := []struct {
Decisions []Decision
Expect Decision
}{
{
Decisions: []Decision{Allow, Allow, Allow},
Expect: Allow,
},
{
Decisions: []Decision{Abstain, Abstain, Abstain},
Expect: Abstain,
},
{
Decisions: []Decision{Deny, Abstain, Abstain},
Expect: Deny,
},
{
Decisions: []Decision{Deny, Allow, Abstain},
Expect: Allow,
},
}
for _, tc := range testCases {
ctx := context.Background()
result, err := StrategyAffirmative(ctx, tc.Decisions)
if err != nil {
t.Error(err)
}
if e, g := tc.Expect, result; e != g {
t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g))
}
}
}
func TestStrategyConsensus(t *testing.T) {
testCases := []struct {
Decisions []Decision
Expect Decision
}{
{
Decisions: []Decision{Allow, Allow, Allow},
Expect: Allow,
},
{
Decisions: []Decision{Abstain, Abstain, Abstain},
Expect: Abstain,
},
{
Decisions: []Decision{Deny, Allow, Abstain},
Expect: Abstain,
},
{
Decisions: []Decision{Deny, Deny, Allow},
Expect: Deny,
},
{
Decisions: []Decision{Deny, Deny, Allow, Allow},
Expect: Abstain,
},
{
Decisions: []Decision{Deny, Deny, Allow, Allow, Allow},
Expect: Allow,
},
}
for _, tc := range testCases {
ctx := context.Background()
result, err := StrategyConsensus(ctx, tc.Decisions)
if err != nil {
t.Error(err)
}
if e, g := tc.Expect, result; e != g {
t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g))
}
}
}

View File

@ -19,6 +19,7 @@ modd.conf {
prep: make test
}
client/webpack.config.js
{
daemon: cd client && NODE_ENV=development npm run server -- --display=minimal
}