Compare commits

...

63 Commits

Author SHA1 Message Date
aed0048d95 Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-09-10 19:28:31 +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
98334bafa0 Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-09-10 11:21:04 +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
8681776283 Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-09-08 10:18:51 +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
c026b33954 Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-08-31 16:13:33 +02:00
bc56c9dbae Ajout URLs manquantes directement gérées par le client 2020-08-31 16:13:10 +02:00
ac7fec9e01 Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-08-31 15:51:54 +02:00
c95fbf6915 Merge branch 'feature/options' of Cadoles/daddy into develop 2020-08-31 15:32:39 +02:00
952b1b6a8d Correction des variables en constantes 2020-08-31 15:29:38 +02:00
4d5251c724 Ajout bs58 2020-08-31 15:10:30 +02:00
44d4db079a Déplacement package-lock 2020-08-31 15:07:00 +02:00
089d91a84c Suppression des options OK + CSS 2020-08-31 13:18:39 +02:00
2d66888ed3 Modification des options OK 2020-08-31 12:55:33 +02:00
406202ddc4 Permettre de gérer les options proposées dans un DAD 2020-08-28 16:00:36 +02:00
9cb5a63cc9 Ignorer les variables de proxy pour la connexion au service Hydra 2020-08-27 10:22:10 +02:00
0fe6e1f07a Mise à jour version image bornholm/hydra-passwordless 2020-08-27 08:37:17 +02:00
bbc8f65a47 Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-08-26 14:54:34 +02:00
d6eae3a7d3 Merge branch 'feature/dad' of Cadoles/daddy into develop 2020-08-26 14:52:53 +02:00
39d266f701 Création/mise à jour basique d'un DAD 2020-08-26 14:51:53 +02:00
f03a0c96dc Ajout du Konami code réglementaire 2020-08-26 09:16:05 +02:00
32c19bace3 Ajout d'un filtre de connexion configurable pour l'utilisateur 2020-08-13 10:29:52 +02:00
5790c91d82 Routes privées avec page d'accueil publique 2020-08-13 10:16:00 +02:00
680614148c Base édition nouveau DAD 2020-08-05 17:53:52 +02:00
fc4912882a Base de la page de création/édition d'un DAD 2020-08-05 13:31:19 +02:00
ac41b301a9 Refactoring du tableau de bord et ajout du panel pour les DADs 2020-07-31 17:36:10 +02:00
9749ede28a Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-07-23 08:58:04 +02:00
c0ee95234d Prise en compte des routes gérées par le client côté serveur 2020-07-23 08:57:49 +02:00
bd133fa9d9 Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-07-23 08:29:17 +02:00
8d9d839acf Merge branch 'feature/workgroups' of Cadoles/daddy into develop 2020-07-23 08:28:38 +02:00
e990184a0b Clore un groupe de travail 2020-07-23 08:28:23 +02:00
4a340529da Créer/modifier/rejoindre/quitter un groupe de travail 2020-07-23 08:28:23 +02:00
bc9aa1721a Remplacement du Loader par WithLoader 2020-07-23 08:28:23 +02:00
c4373cce46 Remplacement de Redux/Saga par Apollo 2020-07-23 08:28:23 +02:00
8708e30020 Interface de gestion des groupes de travail
- Récupération et affichage des groupes existants
- Création d'un nouveau groupe
- Modification d'un groupe existant
- Rejoindre/quitter un groupe de travail
2020-07-23 08:28:23 +02:00
676ddf3bc8 Base d'API backend pour la manipulation des groupes de travail
Types:

type Workgroup {
  id: ID!
  name: String
  createdAt: Time!
  closedAt: Time
  members: [User]!
}

Mutations:

joinWorkgroup(workgroupId: ID!): Workgroup!
leaveWorkgroup(workgroupId: ID!): Workgroup!
createWorkgroup(changes: WorkgroupChanges!): Workgroup!
closeWorkgroup(workgroupId: ID!): Workgroup!
updateWorkgroup(workgroupId: ID!, changes: WorkgroupChanges!): Workgroup!

Queries:

workgroups: [Workgroup]!
2020-07-23 08:28:23 +02:00
7bf4c4f080 Base de tableau de bord 2020-07-23 08:28:23 +02:00
ab90365c9c Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-07-17 09:40:03 +02:00
303ea6b1d6 Stockage des sessions en base de données via GORM 2020-07-17 09:39:37 +02:00
ccf911322b Correction make up 2020-07-17 09:39:02 +02:00
0cb6c7c67e Merge branch 'feature/user-profile' of Cadoles/daddy into develop 2020-07-17 09:25:50 +02:00
ec6de8a217 Création du lien symbolique vers la configuration du client 2020-07-17 09:24:03 +02:00
17cd58d68f Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-07-16 22:51:53 +02:00
08bd11f4d9 Simplification Makefile 2020-07-16 22:51:26 +02:00
99fb4ac6d9 Correction recette packaging 2020-07-16 22:50:55 +02:00
2ceba1f219 Merge branch 'develop' into dist/ubuntu/bionic/develop 2020-07-16 22:35:27 +02:00
7122677351 Mise à jour règles packaging Debian 2020-07-16 22:33:51 +02:00
0d308acd5c Ajout script/commande de release 2020-07-16 22:31:02 +02:00
36c253d4d7 Correction nom projet client 2020-07-16 22:30:03 +02:00
ed219ddd11 Correction typo annotation 2020-07-16 22:29:33 +02:00
758c166f27 Simple page de modification de profil 2020-07-16 20:21:58 +02:00
05dd505d6b Bascule sur l'ORM GORM
- On n'utilise plus la pattern CQRS trop lourde pour le système
- Un système de models/repository "à la Symfony" est utilisé pour les
  requêtes
2020-07-16 14:30:16 +02:00
1d526a37d0 Empaquetage Debian basique 2020-06-17 18:58:27 +02:00
136 changed files with 4608 additions and 1220 deletions

View File

@ -4,4 +4,4 @@ OIDC_CLIENT_SECRET=daddycool
OIDC_POST_LOGOUT_REDIRECT_URL=http://localhost:8081/logout/redirect
HTTP_COOKIE_AUTHENTICATION_KEY=cL87ucJJSGt7XSjRuQe7GDb2qna8ijfQ
HTTP_COOKIE_ENCRYPTION_KEY=cL87ucJJSGt7XSjRuQe7GDb2qna8ijfQ
DATABASE_DSN="host=localhost user=daddy database=daddy password=daddy"
DATABASE_DSN="host=localhost user=daddy database=daddy password=daddy port=5432 sslmode=disable"

3
.gitignore vendored
View File

@ -1,4 +1,5 @@
/vendor
/data
/bin
/.env
/.env
/release

View File

@ -1,12 +1,12 @@
SHELL := /bin/bash
build: build-docker build-server
build: build-server
build-docker:
docker:
docker-compose build
generate:
cd internal && go run github.com/99designs/gqlgen generate
go generate ./...
build-server:
CGO_ENABLED=0 go build -v -o ./bin/server ./cmd/server
@ -15,7 +15,10 @@ deps: generate
cd client && npm install
go get ./...
up: build-docker
client-dist:
cd client && NODE_ENV=production npm run build
up: docker
docker-compose up
watch:
@ -45,7 +48,11 @@ test:
hydra-shell:
docker-compose exec hydra /bin/sh
clean: down
.PHONY: release
release:
./misc/script/release
clean:
rm -rf client/node_modules bin data .env internal/graph/generated internal/graph/server.go
rm -rf vendor
go clean -modcache

View File

@ -29,7 +29,7 @@ Les services suivants devraient être disponibles après démarrage de l'environ
|Service|Type|Accès|Description|
|-------|----|-----|-----------|
|Application React|HTTP (UI)|http://localhost:8080/|Page d'accueil de l'application React (serveur Webpack)|
|Interface Web GraphQL|HTTP (UI)|http://localhost:8081/api/v1/graphql (GET)|Interface Web de développement de l'API GraphQL (mode debug uniquement, nécessite d'être authentifié)|
|Interface Web GraphQL|HTTP (UI)|http://localhost:8081/api/v1/playground|Interface Web de développement de l'API GraphQL (mode debug uniquement, nécessite d'être authentifié)|
|Serveur GraphQL|HTTP (GraphQL)|http://localhost:8081/api/v1/graphql (POST)|Point d'entrée de l'API GraphQL|
|Serveur Hydra|HTTP (ReST)|http://localhost:4444|Point d'entrée pour l'API OAuth2 d'[Hydra](https://www.ory.sh/hydra/docs/)|
|Serveur Hydra Passwordless|HTTP|http://localhost:3000|Point d'entrée pour la ["Login/Consent App"](https://www.ory.sh/hydra/docs/implementing-consent) [hydra-passwordless](https://forge.cadoles.com/wpetit/hydra-passwordless)|

200
client/package-lock.json generated
View File

@ -1,9 +1,46 @@
{
"name": "dadd-",
"name": "daddy",
"version": "0.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@apollo/client": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.0.2.tgz",
"integrity": "sha512-4ighan5Anlj4tK/tdUHs4Mi1njqXZ7AxRCVolz/H702DjPphAJfm+FRkIadPTmwz+OLO+d+tX+6V1VBshf02rg==",
"requires": {
"@types/zen-observable": "^0.8.0",
"@wry/context": "^0.5.2",
"@wry/equality": "^0.1.9",
"fast-json-stable-stringify": "^2.0.0",
"graphql-tag": "^2.10.4",
"hoist-non-react-statics": "^3.3.2",
"optimism": "^0.12.1",
"prop-types": "^15.7.2",
"symbol-observable": "^1.2.0",
"ts-invariant": "^0.4.4",
"tslib": "^1.10.0",
"zen-observable": "^0.8.14"
},
"dependencies": {
"@wry/context": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.5.2.tgz",
"integrity": "sha512-B/JLuRZ/vbEKHRUiGj6xiMojST1kHhu4WcreLfNN7q9DqQFrb97cWgf/kiYsPSUCAMVN0HzfFc8XjJdzgZzfjw==",
"requires": {
"tslib": "^1.9.3"
}
},
"optimism": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.12.1.tgz",
"integrity": "sha512-t8I7HM1dw0SECitBYAqFOVHoBAHEQBTeKjIL9y9ImHzAVkdyPK4ifTgM4VJRDtTUY4r/u5Eqxs4XcGPHaoPkeQ==",
"requires": {
"@wry/context": "^0.5.2"
}
}
}
},
"@babel/code-frame": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz",
@ -1230,7 +1267,8 @@
"@types/node": {
"version": "13.13.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.12.tgz",
"integrity": "sha512-zWz/8NEPxoXNT9YyF2osqyA9WjssZukYpgI4UYZpOjcyqwIUqWGkcCionaEb9Ki+FULyPyvNFpg/329Kd2/pbw=="
"integrity": "sha512-zWz/8NEPxoXNT9YyF2osqyA9WjssZukYpgI4UYZpOjcyqwIUqWGkcCionaEb9Ki+FULyPyvNFpg/329Kd2/pbw==",
"dev": true
},
"@types/prop-types": {
"version": "15.7.3",
@ -1551,15 +1589,6 @@
"@xtuc/long": "4.2.2"
}
},
"@wry/context": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.4.4.tgz",
"integrity": "sha512-LrKVLove/zw6h2Md/KZyWxIkFM6AoyKp71OqpH9Hiip1csjPVoD3tPxlbQUNxEnHENks3UGgNpSBCAfq9KWuag==",
"requires": {
"@types/node": ">=6",
"tslib": "^1.9.3"
}
},
"@wry/equality": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz",
@ -1744,93 +1773,6 @@
}
}
},
"apollo-cache": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/apollo-cache/-/apollo-cache-1.3.5.tgz",
"integrity": "sha512-1XoDy8kJnyWY/i/+gLTEbYLnoiVtS8y7ikBr/IfmML4Qb+CM7dEEbIUOjnY716WqmZ/UpXIxTfJsY7rMcqiCXA==",
"requires": {
"apollo-utilities": "^1.3.4",
"tslib": "^1.10.0"
}
},
"apollo-cache-inmemory": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.6.tgz",
"integrity": "sha512-L8pToTW/+Xru2FFAhkZ1OA9q4V4nuvfoPecBM34DecAugUZEBhI2Hmpgnzq2hTKZ60LAMrlqiASm0aqAY6F8/A==",
"requires": {
"apollo-cache": "^1.3.5",
"apollo-utilities": "^1.3.4",
"optimism": "^0.10.0",
"ts-invariant": "^0.4.0",
"tslib": "^1.10.0"
}
},
"apollo-client": {
"version": "2.6.10",
"resolved": "https://registry.npmjs.org/apollo-client/-/apollo-client-2.6.10.tgz",
"integrity": "sha512-jiPlMTN6/5CjZpJOkGeUV0mb4zxx33uXWdj/xQCfAMkuNAC3HN7CvYDyMHHEzmcQ5GV12LszWoQ/VlxET24CtA==",
"requires": {
"@types/zen-observable": "^0.8.0",
"apollo-cache": "1.3.5",
"apollo-link": "^1.0.0",
"apollo-utilities": "1.3.4",
"symbol-observable": "^1.0.2",
"ts-invariant": "^0.4.0",
"tslib": "^1.10.0",
"zen-observable": "^0.8.0"
}
},
"apollo-link": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.14.tgz",
"integrity": "sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg==",
"requires": {
"apollo-utilities": "^1.3.0",
"ts-invariant": "^0.4.0",
"tslib": "^1.9.3",
"zen-observable-ts": "^0.8.21"
}
},
"apollo-link-http": {
"version": "1.5.17",
"resolved": "https://registry.npmjs.org/apollo-link-http/-/apollo-link-http-1.5.17.tgz",
"integrity": "sha512-uWcqAotbwDEU/9+Dm9e1/clO7hTB2kQ/94JYcGouBVLjoKmTeJTUPQKcJGpPwUjZcSqgYicbFqQSoJIW0yrFvg==",
"requires": {
"apollo-link": "^1.2.14",
"apollo-link-http-common": "^0.2.16",
"tslib": "^1.9.3"
}
},
"apollo-link-http-common": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.16.tgz",
"integrity": "sha512-2tIhOIrnaF4UbQHf7kjeQA/EmSorB7+HyJIIrUjJOKBgnXwuexi8aMecRlqTIDWcyVXCeqLhUnztMa6bOH/jTg==",
"requires": {
"apollo-link": "^1.2.14",
"ts-invariant": "^0.4.0",
"tslib": "^1.9.3"
}
},
"apollo-link-ws": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/apollo-link-ws/-/apollo-link-ws-1.0.20.tgz",
"integrity": "sha512-mjSFPlQxmoLArpHBeUb2Xj+2HDYeTaJqFGOqQ+I8NVJxgL9lJe84PDWcPah/yMLv3rB7QgBDSuZ0xoRFBPlySw==",
"requires": {
"apollo-link": "^1.2.14",
"tslib": "^1.9.3"
}
},
"apollo-utilities": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.4.tgz",
"integrity": "sha512-pk2hiWrCXMAy2fRPwEyhvka+mqwzeP60Jr1tRYi5xru+3ko94HI9o6lK0CT33/w4RDlxWchmdhDCrvdr+pHCig==",
"requires": {
"@wry/equality": "^0.1.2",
"fast-json-stable-stringify": "^2.0.0",
"ts-invariant": "^0.4.0",
"tslib": "^1.10.0"
}
},
"aproba": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
@ -2897,6 +2839,14 @@
}
}
},
"base-x": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.8.tgz",
"integrity": "sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA==",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
@ -3185,6 +3135,14 @@
"pkg-up": "^2.0.0"
}
},
"bs58": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
"integrity": "sha1-vhYedsNU9veIrkBx9j806MTwpCo=",
"requires": {
"base-x": "^3.0.2"
}
},
"btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
@ -3235,14 +3193,9 @@
"dev": true
},
"bulma": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.7.5.tgz",
"integrity": "sha512-cX98TIn0I6sKba/DhW0FBjtaDpxTelU166pf7ICXpCCuplHWyu6C9LYZmL5PEsnePIeJaiorsTEzzNk3Tsm1hw=="
},
"bulma-switch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bulma-switch/-/bulma-switch-2.0.0.tgz",
"integrity": "sha512-myD38zeUfjmdduq+pXabhJEe3x2hQP48l/OI+Y0fO3HdDynZUY/VJygucvEAJKRjr4HxD5DnEm4yx+oDOBXpAA=="
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz",
"integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ=="
},
"bytes": {
"version": "3.0.0",
@ -5615,11 +5568,6 @@
"resolved": "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz",
"integrity": "sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w=="
},
"graphql-request": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-2.0.0.tgz",
"integrity": "sha512-Ww3Ax+G3l2d+mPT8w7HC9LfrKjutnCKtnDq7ZZp2ghVk5IQDjwAk3/arRF1ix17Ky15rm0hrSKVKxRhIVlSuoQ=="
},
"graphql-tag": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.4.tgz",
@ -6507,11 +6455,6 @@
"verror": "1.10.0"
}
},
"jwt-decode": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz",
"integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk="
},
"killable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@ -7466,14 +7409,6 @@
"is-wsl": "^1.1.0"
}
},
"optimism": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.10.3.tgz",
"integrity": "sha512-9A5pqGoQk49H6Vhjb9kPgAeeECfUDF6aIICbMDL23kDLStBn1MWk3YvcZ4xWF9CsSf6XEgvRLkXy4xof/56vVw==",
"requires": {
"@wry/context": "^0.4.0"
}
},
"original": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz",
@ -8048,11 +7983,6 @@
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true
},
"qs": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
"integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ=="
},
"querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
@ -8717,8 +8647,7 @@
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"safe-regex": {
"version": "1.1.0",
@ -11006,15 +10935,6 @@
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
"integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ=="
},
"zen-observable-ts": {
"version": "0.8.21",
"resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz",
"integrity": "sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg==",
"requires": {
"tslib": "^1.9.3",
"zen-observable": "^0.8.0"
}
}
}
}

View File

@ -1,5 +1,5 @@
{
"name": "dadd-",
"name": "daddy",
"version": "0.0.0",
"description": "Daddy",
"main": "index.js",
@ -51,20 +51,11 @@
"webpack-dev-server": "^3.11.0"
},
"dependencies": {
"@apollo/client": "^3.0.2",
"@types/qs": "^6.9.3",
"apollo-cache-inmemory": "^1.6.6",
"apollo-client": "^2.6.10",
"apollo-link": "^1.2.14",
"apollo-link-http": "^1.5.17",
"apollo-link-ws": "^1.0.20",
"apollo-utilities": "^1.3.4",
"bulma": "^0.7.2",
"bulma-switch": "^2.0.0",
"bs58": "^4.0.1",
"bulma": "^0.9.0",
"graphql": "^15.3.0",
"graphql-request": "^2.0.0",
"graphql-tag": "^2.10.4",
"jwt-decode": "^2.2.0",
"qs": "^6.9.4",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.1.3",

View File

@ -1,20 +1,81 @@
import React from 'react';
import React, { FunctionComponent, useState, useEffect } from 'react';
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
import { HomePage } from './HomePage/HomePage';
import { store } from '../store/store';
import { Provider } from 'react-redux';
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, 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 { LogoutPage } from './LogoutPage';
export class App extends React.Component {
render() {
return (
<Provider store={store}>
export interface AppProps {
}
export const App: FunctionComponent<AppProps> = () => {
const [ loggedIn, setLoggedIn ] = useState(getSavedLoggedIn());
const client = createClient((loggedIn) => {
setLoggedIn(loggedIn);
});
useEffect(() => {
saveLoggedIn(loggedIn);
}, [loggedIn]);
const [ showBoneyM, setShowBoneyM ] = useState(false);
useKonamiCode(() => setShowBoneyM(true));
return (
<ApolloProvider client={client}>
<LoggedInContext.Provider value={loggedIn}>
<UserSessionCheck setLoggedIn={setLoggedIn} />
<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} />
<PrivateRoute path="/logout" exact component={LogoutPage} />
<Route component={() => <Redirect to="/" />} />
</Switch>
</BrowserRouter>
</Provider>
);
}
}
{
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>
);
}
interface UserSessionCheckProps {
setLoggedIn: (boolean) => void
}
const UserSessionCheck: FunctionComponent<UserSessionCheckProps> = ({ setLoggedIn }) => {
const { user, loading } = useUserProfile();
useEffect(() => {
if (loading) return;
setLoggedIn(user.id !== '');
}, [user]);
return null;
};

View File

@ -0,0 +1,29 @@
import React from 'react';
import { WorkgroupsPanel } from './WorkgroupsPanel';
import { DecisionSupportFilePanel } from './DecisionSupportFilePanel';
export function Dashboard() {
return (
<div className="columns">
<div className="column is-6">
<DecisionSupportFilePanel />
</div>
<div className="column is-3">
<WorkgroupsPanel />
</div>
<div className="column is-3">
<div className="box">
<div className="level">
<div className="level-left">
<h3 className="is-size-3 subtitle level-item">Assemblées</h3>
</div>
<div className="level-right">
<button disabled className="button is-primary level-item"><i className="fa fa-plus"></i></button>
</div>
</div>
<pre>TODO</pre>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import { Page } from '../Page';
import { Dashboard } from './Dashboard';
export function DashboardPage() {
return (
<Page title={'Tableau de bord'}>
<div className="container is-fluid">
<section className="mt-5">
<Dashboard />
</section>
</div>
</Page>
);
}

View File

@ -0,0 +1,51 @@
import React from 'react';
import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
import { ItemPanel, TabDefinition, Item } from './ItemPanel';
import { useUserProfile } from '../../gql/queries/profile';
import { inWorkgroup } from '../../types/workgroup';
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
export function DecisionSupportFilePanel() {
const { user } = useUserProfile();
const { decisionSupportFiles } = useDecisionSupportFiles();
const tabs: TabDefinition[] = [
{
label: 'Mes dossiers en cours',
itemFilter: (item: Item) => {
const dsf = item as DecisionSupportFile;
return (dsf.status === DecisionSupportFileStatus.Draft || dsf.status === DecisionSupportFileStatus.Ready) && inWorkgroup(user, dsf.workgroup);
}
},
{
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'
title="Dossiers"
newItemUrl="/decisions/new"
items={decisionSupportFiles}
tabs={tabs}
itemIconClassName='fas fa-folder'
itemKey={item => item.id}
itemLabel={item => item.title}
itemUrl={item => `/decisions/${item.id}`}
/>
);
}

View File

@ -0,0 +1,119 @@
import React, { FunctionComponent, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { WithLoader } from "../WithLoader";
export interface Item {
id: string
[propName: string]: any;
}
export interface TabDefinition {
label: string
itemFilter?: (item: Item) => boolean
}
export interface ItemPanelProps {
className?: string
itemIconClassName?: string
title?: string
newItemUrl: string
isLoading?: boolean
items: Item[]
tabs?: TabDefinition[],
itemKey: (item: Item, index: number) => string
itemLabel: (item: Item, index: number) => string
itemUrl: (item: Item, index: number) => string
}
export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
const {
title, className, newItemUrl,
itemKey, itemLabel,
itemIconClassName, itemUrl,
} = props;
const items = props.items || [];
const tabs = props.tabs || [];
const [ state, setState ] = useState({ selectedTab: 0, filteredItems: [] });
const filterItemsForTab = (tab: TabDefinition, items: Item[]) => {
const itemFilter = tab && typeof tab.itemFilter === 'function' ? tab.itemFilter : () => true;
return items.filter(itemFilter);
};
const selectTab = (tabIndex: number) => {
setState(state => {
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[tabIndex] : null;
return {
...state,
selectedTab: tabIndex,
filteredItems: filterItemsForTab(newTab, items)
};
});
};
useEffect(() => {
setState(state => {
const { tabs, items } = props;
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[state.selectedTab] : null;
return {
...state,
filteredItems: filterItemsForTab(newTab, items),
}
});
}, [items, tabs]);
const itemElements = state.filteredItems.map((item: Item, i: number) => {
return (
<Link to={itemUrl(item, i)} key={`item-${itemKey(item, i)}`} className="panel-block">
<span className="panel-icon">
<i className={itemIconClassName} aria-hidden="true"></i>
</span>
{itemLabel(item, i)}
</Link>
);
});
return (
<nav className={`panel ${className}`}>
<div className="level is-mobile panel-heading mb-0">
<div className="level-left">
<p className="level-item">{title}</p>
</div>
<div className="level-right">
<Link to={newItemUrl} className="button level-item is-outlined is-info is-inverted">
<i className="icon fa fa-plus"></i>
</Link>
</div>
</div>
<div className="panel-block">
<p className="control has-icons-left">
<input className="input" type="text" placeholder="Filtrer..." />
<span className="icon is-left">
<i className="fas fa-search" aria-hidden="true"></i>
</span>
</p>
</div>
<p className="panel-tabs">
{
tabs.map((tab, i) => {
return (
<a key={`workgroup-tab-${i}`}
onClick={selectTab.bind(null, i)}
className={i === state.selectedTab ? 'is-active' : ''}>
{tab.label}
</a>
)
})
}
</p>
{
itemElements.length > 0 ?
itemElements :
<a className="panel-block has-text-centered is-block">
<em>Aucun élément pour l'instant.</em>
</a>
}
</nav>
)
};

View File

@ -0,0 +1,42 @@
import React, { } from 'react';
import { Workgroup, inWorkgroup } from '../../types/workgroup';
import { useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfile } from '../../gql/queries/profile';
import { ItemPanel, Item } from './ItemPanel';
export function WorkgroupsPanel() {
const { workgroups } = useWorkgroups();
const { user } = useUserProfile();
const tabs = [
{
label: "Mes groupes en cours",
itemFilter: (item: Item) => {
const wg = item as Workgroup;
return wg.closedAt === null && inWorkgroup(user, wg);
}
},
{
label: "Ouverts",
itemFilter: (item: Item) => !(item as Workgroup).closedAt
},
{
label: "Clos",
itemFilter: (item: Item) => !!(item as Workgroup).closedAt
}
];
return (
<ItemPanel
className='is-info'
title="Groupes de travail"
newItemUrl="/workgroups/new"
items={workgroups}
tabs={tabs}
itemIconClassName='fas fa-users'
itemKey={item => item.id}
itemLabel={item => item.name}
itemUrl={item => `/workgroups/${item.id}`}
/>
);
}

View File

@ -0,0 +1,18 @@
import React, { FunctionComponent, useState } from 'react';
import { DecisionSupportFile } from '../../types/decision';
export interface AppendixPanelProps {
dsf: DecisionSupportFile,
};
export const AppendixPanel: FunctionComponent<AppendixPanelProps> = ({ dsf }) => {
return (
<nav className="panel">
<p className="panel-heading">
Annexes
</p>
<div className="panel-block">
</div>
</nav>
);
};

View File

@ -0,0 +1,142 @@
import React, { FunctionComponent, useState, ChangeEvent, useEffect } from 'react';
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
import { useDebounce } from '../../hooks/useDebounce';
import { asDate } from '../../util/date';
export interface ClarificationSectionProps extends DecisionSupportFileUpdaterProps {};
const ClarificationSectionName = 'clarification';
export const ClarificationSection: FunctionComponent<ClarificationSectionProps> = ({ dsf, updateDSF, readOnly }) => {
const [ state, setState ] = useState({
changed: false,
section: {
objectives: '',
motivations: '',
scope: '',
nature: '',
deadline: undefined,
hasDeadline: false,
}
});
useEffect(() => {
if (!state.changed) return;
updateDSF({ ...dsf, sections: { ...dsf.sections, [ClarificationSectionName]: { ...state.section }} })
setState(state => ({ ...state, changed: false }));
}, [state.changed]);
useEffect(() => {
if (!dsf.sections[ClarificationSectionName]) return;
setState(state => ({ ...state, changed: false, section: {...state.section, ...dsf.sections[ClarificationSectionName] }}));
}, [dsf.sections[ClarificationSectionName]]);
const onTitleChange = (evt: ChangeEvent<HTMLInputElement>) => {
const title = (evt.currentTarget).value;
updateDSF({ ...dsf, title });
};
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 }}));
};
const onDeadlineChange = (evt: ChangeEvent<HTMLInputElement>) => {
const deadline = evt.currentTarget.valueAsDate;
setState(state => ({ ...state, changed: true, section: { ...state.section, deadline }}));
};
return (
<section>
<div className="box">
<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>
<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>
<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>
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,175 @@
import React, { FunctionComponent, useState, useEffect } from 'react';
import { Page } from '../Page';
import { ClarificationSection } from './ClarificationSection';
import { MetadataPanel } from './MetadataPanel';
import { AppendixPanel } from './AppendixPanel';
import { DecisionSupportFile, newDecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
import { useParams, useHistory } from 'react-router';
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
import { useCreateDecisionSupportFileMutation, useUpdateDecisionSupportFileMutation } from '../../gql/mutations/dsf';
import { OptionsSection } from './OptionsSection';
import { useIsAuthorized } from '../../gql/queries/authorization';
export interface DecisionSupportFilePageProps {
};
export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageProps> = () => {
const { id } = useParams();
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 updateDSF = (dsf: DecisionSupportFile) => {
setState(state => {
return { ...state, saved: false, dsf: { ...state.dsf, ...dsf } };
});
};
const [ createDecisionSupportFile ] = useCreateDecisionSupportFileMutation();
const [ updateDecisionSupportFile ] = useUpdateDecisionSupportFileMutation();
const saveDSF = () => {
const changes = {
title: state.dsf.title !== '' ? state.dsf.title : undefined,
status: state.dsf.status,
workgroupId: state.dsf.workgroup ? state.dsf.workgroup.id : undefined,
sections: state.dsf.sections,
};
if (!changes.workgroupId) return;
if (state.dsf.id === '') {
createDecisionSupportFile({
variables: { changes },
}).then(({ data }) => {
history.push(`/decisions/${data.createDecisionSupportFile.id}`);
});
} else {
updateDecisionSupportFile({
variables: { changes, id: state.dsf.id },
}).then(({ data }) => {
setState(state => {
return { ...state, saved: true, dsf: { ...state.dsf, ...data.updateDecisionSupportFile } };
});
});
}
};
const canSave = !!state.dsf.workgroup && !state.saved;
const isNew = state.dsf.id === '';
const isClosed = state.dsf.status === DecisionSupportFileStatus.Closed;
return (
<Page title="Dossier d'Aide à la Décision">
<div className="container is-fluid">
<section className="mt-5">
<div className="level">
<div className="level-left">
{
isNew ?
<div className="level-item">
<div>
<h2 className="is-size-3 title is-spaced">Nouveau</h2>
<h3 className="is-size-5 subtitle">Dossier d'Aide à la Décision</h3>
</div>
</div> :
<div className="level-item">
<div>
<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>
}
</div>
<div className="level-right">
<div className="level-item buttons">
{
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>
{
state.selectedTabIndex === 0 ?
<ClarificationSection readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} /> :
null
}
{
state.selectedTabIndex === 1 ?
<OptionsSection readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} /> :
null
}
</div>
<div className="column is-3">
<MetadataPanel readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />
<AppendixPanel dsf={state.dsf} />
</div>
</div>
</div>
</Page>
);
};

View File

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

View File

@ -0,0 +1,97 @@
import React, { FunctionComponent, useState, useEffect, ChangeEvent } from 'react';
import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
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 { Link } from 'react-router-dom';
export interface MetadataPanelProps extends DecisionSupportFileUpdaterProps {};
export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf, updateDSF, readOnly }) => {
const { user } = useUserProfile();
const { workgroups } = useWorkgroups();
const [ userOpenedWorkgroups, setUserOpenedWorkgroups ] = useState([]);
useEffect(() => {
const filtered = workgroups.filter(wg => !wg.closedAt && inWorkgroup(user, wg));
setUserOpenedWorkgroups(filtered);
}, [workgroups, user])
const onStatusChanged = (evt: ChangeEvent<HTMLSelectElement>) => {
const status = evt.currentTarget.value as DecisionSupportFileStatus;
updateDSF({ ...dsf, status });
};
const onWorkgroupChanged = (evt: ChangeEvent<HTMLSelectElement>) => {
const workgroupId = evt.currentTarget.value;
const workgroup = workgroups.find(wg => wg.id === workgroupId);
updateDSF({ ...dsf, workgroup });
};
return (
<nav className="panel">
<p className="panel-heading">
Métadonnées
</p>
<div className="panel-block">
<div style={{width:'100%'}}>
<div className="field">
<div className="label">Groupe de travail</div>
{
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 className="field">
<div className="label">Statut</div>
<div className="control is-expanded">
<div className="select is-fullwidth">
<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>
<option value="closed">Clôs</option>
</select>
</div>
</div>
</div>
<div className="field">
<div className="label">Créé le</div>
<div className="control">
<p>{asDate(dsf.createdAt).toISOString()}</p>
</div>
</div>
<div className="field">
<div className="label">Voté le</div>
<div className="control">
<p>{dsf.votedAt ? dsf.votedAt : '--'}</p>
</div>
</div>
</div>
</div>
</nav>
);
};

View File

@ -0,0 +1,160 @@
import React, { FunctionComponent, useState, useEffect, ChangeEvent, MouseEvent } from 'react';
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
import { base58UUID } from "../../util/uuid";
export interface OptionsSectionProps extends DecisionSupportFileUpdaterProps {};
const OptionsSectionName = 'options';
export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf, updateDSF, readOnly }) => {
interface OptionsSectionState {
changed: boolean
section: OptionsSection
}
interface OptionsSection {
options: Option[]
}
interface Option {
id: string
label: string
pros: string
cons: string
}
const [ state, setState ] = useState<OptionsSectionState>({
changed: false,
section: {
options: [],
}
});
useEffect(() => {
if (!state.changed) return;
updateDSF({ ...dsf, sections: { ...dsf.sections, [OptionsSectionName]: { ...state.section }} })
setState(state => ({ ...state, changed: false }));
}, [state.changed]);
useEffect(() => {
if (!dsf.sections[OptionsSectionName]) return;
setState(state => ({ ...state, changed: false, section: {...state.section, ...dsf.sections[OptionsSectionName] }}));
}, [dsf.sections[OptionsSectionName]]);
function newOption(label: string, pros: string, cons: string): Option {
return {
id: base58UUID(),
label,
pros,
cons
};
}
const onAddOptionClick = (evt: MouseEvent) => {
const options = JSON.parse(JSON.stringify(state.section.options))
const option = newOption("Décision", "", "");
options.push(option);
setState(state => ({ ...state, changed: true, section: { ...state.section, options }}));
};
const onOptionChange = (id: number, attrName: string, evt: ChangeEvent<HTMLInputElement>) => {
const target = evt.currentTarget;
const value = target.hasOwnProperty('checked') ? target.checked : target.value;
const options = JSON.parse(JSON.stringify(state.section.options))
options[id][attrName] = value;
setState(state => ({ ...state, changed: true, section: { ...state.section, options }}));
};
const onRemoveOptionClick = (id: number, evt: MouseEvent) => {
if(confirm('Voulez-vous supprimer cette option ?')){
const options = JSON.parse(JSON.stringify(state.section.options))
options.splice(id, 1);
setState(state => ({ ...state, changed: true, section: { ...state.section, options }}));
}
};
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">
<div className="table-container">
<table className={`table is-bordered is-striped is-hoverable is-fullwidth`}>
<thead>
<tr>
<th></th>
<th>Décision</th>
<th>Pours</th>
<th>Contres</th>
</tr>
</thead>
<tbody>
{
state.section.options.map((o, index) => {
return (
<tr key={`option-${o.id}`}>
<td>
<button
disabled={readOnly}
onClick={onRemoveOptionClick.bind(null, index)}
className="button is-danger is-small is-outlined">
🗑
</button>
</td>
<td>
<textarea className="textarea"
readOnly={readOnly}
value={o.label}
onChange={onOptionChange.bind(null, index, 'label')}
placeholder="Décrire cette décision."
rows={10}>
</textarea>
</td>
<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}>
</textarea>
</td>
<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}>
</textarea>
</td>
</tr>
)
})
}
{
state.section.options.length === 0 ?
<tr>
<td></td>
<td colSpan={4}>Aucune option pour l'instant.</td>
</tr> :
null
}
</tbody>
<tfoot>
<tr>
<td colSpan={5}>
<button
disabled={readOnly}
className="button is-primary is-pulled-right"
onClick={onAddOptionClick}>
Ajouter
</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</section>
);
};

View File

@ -1,28 +1,21 @@
import React, { useEffect } from 'react';
import { Page } from '../Page';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../store/reducers/root';
import { WelcomeContent } from './WelcomeContent';
import { useUserProfile } from '../../gql/queries/profile';
import { useHistory } from 'react-router';
import { useLoggedIn } from '../../hooks/useLoggedIn';
export function HomePage() {
const currentUser = useSelector((state: RootState) => state.auth.currentUser);
const loggedIn = useLoggedIn();
const history = useHistory();
useEffect(() => {
if (loggedIn) history.push('/dashboard');
}, [loggedIn])
return (
<Page title="Daddy - Accueil">
<div className="container is-fluid">
<section className="section">
<div className="columns">
<div className="column is-4 is-offset-4">
<div className="box">
{
currentUser && currentUser.email ?
<p>Bonjour <span className="has-text-weight-bold">{currentUser.email}</span> !</p> :
<p>Veuillez vous authentifier.</p>
}
</div>
</div>
</div>
</section>
</div>
<Page title="Accueil">
<WelcomeContent />
</Page>
);
}

View File

@ -0,0 +1,75 @@
import React, { FunctionComponent, Fragment } from "react";
export interface WelcomeContentProps {
}
export const WelcomeContent: FunctionComponent<WelcomeContentProps> = () => {
return (
<Fragment>
<section className="hero is-normal is-light is-bold">
<div className="hero-body has-text-centered">
<div className="container">
<h1 className="title">
Bienvenue sur Daddy !
</h1>
<h2 className="subtitle">
L'outil de suivi de la vie d'entreprise démocratique.
</h2>
</div>
</div>
</section>
<div className="box cta">
<p className="has-text-centered">
<span className="tag is-info">Attention</span> Le service est actuellement en alpha. L'accès est restreint aux adresses autorisées.
</p>
</div>
<section className="container mt-5">
<div className="columns">
<div className="column is-4">
<div className="card is-shady">
<div className="card-image has-text-centered">
<i className="fa fa-at mt-5" style={{fontSize: '8rem'}}></i>
</div>
<div className="card-content">
<div className="content">
<h4>Une adresse courriel et c'est parti !</h4>
<p>Pas de création de compte, pas de mot de passe à retenir. Entrez votre adresse courriel et commencez directement à travailler !</p>
{/* <p><a href="#">En savoir plus</a></p> */}
</div>
</div>
</div>
</div>
<div className="column is-4">
<div className="card is-shady">
<div className="card-image has-text-centered">
<i className="fa fa-edit mt-5" style={{fontSize: '8rem'}}></i>
</div>
<div className="card-content">
<div className="content">
<h4>Préparer vos dossiers d'aide à la décision</h4>
<p>Une décision à prendre ? Un nouveau projet à lancer ? Crééz votre groupe de travail et rédigez un dossier pour faciliter la prise de décision collective !</p>
{/* <p><a href="#">En savoir plus</a></p> */}
</div>
</div>
</div>
</div>
<div className="column is-4">
<div className="card is-shady">
<div className="card-image has-text-centered">
<i className="fa fa-users mt-5" style={{fontSize: '8rem'}}></i>
</div>
<div className="card-content">
<div className="content">
<h4>Travaillez collaborativement</h4>
<p>Éditez à plusieurs vos dossiers d'aide à la décision, suivi l'avancée des débats et retrouvez simplement les décisions prises dans l'entreprise.</p>
{/* <p><a href="#">En savoir plus</a></p> */}
</div>
</div>
</div>
</div>
</div>
</section>
</Fragment>
);
};

View File

@ -1,14 +0,0 @@
import React from 'react';
export class Loader extends React.Component {
render() {
return (
<div className="loader-container">
<div className="lds-ripple">
<div></div>
<div></div>
</div>
</div>
)
}
}

View File

@ -0,0 +1,15 @@
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;
};

View File

@ -1,44 +1,64 @@
import React from 'react';
import React, { Fragment, useState } from 'react';
import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux';
import { RootState } from '../store/reducers/root';
import { Config } from '../config';
import { Link } from 'react-router-dom';
import { useLoggedIn } from '../hooks/useLoggedIn';
export function Navbar() {
const isAuthenticated = useSelector<RootState>(state => state.auth.isAuthenticated);
const loggedIn = useLoggedIn();
const [ isActive, setActive ] = useState(false);
const toggleMenu = () => {
setActive(active => !active);
};
return (
<nav className="navbar" role="navigation" aria-label="main navigation">
<nav className="navbar is-fixed-top" role="navigation" aria-label="main navigation">
<div className="container is-fluid">
<div className="navbar-brand">
<a className="navbar-item" href="#/">
<Link className="navbar-item" to="/">
<img src={logo} style={{marginRight:'5px',width:'28px',height:'28px'}} />
<h1 className="is-size-4">Daddy</h1>
</a>
<a role="button" className="navbar-burger" aria-label="menu" aria-expanded="false">
</Link>
<a role="button"
className={`navbar-burger ${isActive ? 'is-active' : ''}`}
onClick={toggleMenu}
aria-label="menu"
aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div className="navbar-menu">
<div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
<div className="navbar-end">
<div className="navbar-item">
{
isAuthenticated ?
<a className="button is-small" href={Config.logoutURL}>
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span>
<span>Se déconnecter</span>
</a> :
<a className="button is-small" href={Config.loginURL}>
<span className="icon">
<i className="fas fa-sign-in-alt"></i>
</span>
<span>Se connecter</span>
</a>
}
<div className="buttons">
{
loggedIn ?
<Fragment>
<Link to="/profile" className="button">
<span className="icon">
<i className="fas fa-user"></i>
</span>
<span>Mon profil</span>
</Link>
<Link className="button is-warning is-small" to="/logout">
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span>
<span>Déconnexion</span>
</Link>
</Fragment> :
<a className="button is-primary" href={Config.loginURL}>
<span className="icon">
<i className="fas fa-sign-in-alt"></i>
</span>
<span>S'identifier</span>
</a>
}
</div>
</div>
</div>
</div>

View File

@ -25,6 +25,6 @@ export class Page extends React.PureComponent<PageProps> {
updateTitle() {
const { title } = this.props;
if (title !== undefined) window.document.title = title;
if (title !== undefined) window.document.title = title + ' - Daddy';
}
}

View File

@ -0,0 +1,18 @@
import React, { FunctionComponent, Component, ReactType } from "react"
import { Route, Redirect, RouteProps } from "react-router"
import { useLoggedIn } from "../hooks/useLoggedIn";
export interface PrivateRouteProps extends RouteProps {
}
export const PrivateRoute: FunctionComponent<PrivateRouteProps> = ({component: Component, ...rest}) => {
const loggedIn = useLoggedIn();
return (
<Route
{...rest}
render={(props) => loggedIn === true
? <Component {...props} />
: <Redirect to={{pathname: '/', state: {from: props.location}}} />}
/>
)
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Page } from '../Page';
import { UserForm } from '../UserForm';
import { User } from '../../types/user';
import { useUserProfileQuery } from '../../gql/queries/profile';
import { useUpdateUserProfileMutation } from '../../gql/mutations/profile';
import { WithLoader } from '../WithLoader';
export function ProfilePage() {
const userProfileQuery = useUserProfileQuery();
const [ updateProfile, updateUserProfileMutation ] = useUpdateUserProfileMutation();
const isLoading = updateUserProfileMutation.loading || userProfileQuery.loading;
const { userProfile } = (userProfileQuery.data || {});
const onUserChange = (user: User) => {
if (userProfile.name !== user.name) {
updateProfile({ variables: {changes: { name: user.name }}});
}
};
return (
<Page title="Mon profil">
<div className="container is-fluid">
<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>
</div>
</section>
</div>
</Page>
);
}

View File

@ -0,0 +1,80 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { User } from '../types/user';
export interface UserFormProps {
user: User
onChange?: (user: User) => void
}
export function UserForm({ user, onChange }: UserFormProps) {
const [ state, setState ] = useState({
changed: false,
user: {
id: user && user.id ? user.id : '',
name: user && user.name ? user.name : '',
email: user && user.email ? user.email : '',
createdAt: user && user.createdAt ? user.createdAt : null,
connectedAt: user && user.connectedAt ? user.connectedAt : null,
}
});
const onSaveClick = () => {
if (!state.changed) return;
if (typeof onChange !== 'function') return;
onChange(state.user);
setState(state => {
return {
...state,
changed: false,
};
})
};
const onUserAttrChange = function(attrName: string, evt: ChangeEvent<HTMLInputElement>) {
const value = evt.currentTarget.value;
setState(state => {
return {
...state,
changed: true,
user: {
...state.user,
[attrName]: value,
}
};
});
};
return (
<div className="form">
<div className="field">
<label className="label">Nom d'utilisateur</label>
<div className="control">
<input type="text" className="input" value={state.user.name}
onChange={onUserAttrChange.bind(null, "name")} />
</div>
</div>
<div className="field">
<label className="label">Adresse courriel</label>
<div className="control">
<p className="input is-static">{state.user.email}</p>
</div>
</div>
<div className="field">
<label className="label">Date de dernière connexion</label>
<div className="control">
<p className="input is-static">{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>
</div>
</div>
<div className="buttons is-right">
<button disabled={!state.changed}
className="button is-primary" onClick={onSaveClick}>Enregistrer</button>
</div>
</div>
);
}

View File

@ -0,0 +1,18 @@
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,107 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { Workgroup } from '../../types/workgroup';
import { useIsAuthorized } from '../../gql/queries/authorization';
export interface InfoFormProps {
workgroup: Workgroup
onChange?: (workgroup: Workgroup) => void
}
export function InfoForm({ workgroup, onChange }: InfoFormProps) {
const [ state, setState ] = useState({
changed: false,
workgroup: {
id: workgroup && workgroup.id ? workgroup.id : '',
name: workgroup && workgroup.name ? workgroup.name : '',
createdAt: workgroup && workgroup.createdAt ? workgroup.createdAt : null,
closedAt: workgroup && workgroup.closedAt ? workgroup.closedAt : null,
}
});
const { isAuthorized } = useIsAuthorized({
variables: {
action: 'update',
object: {
workgroupId: state.workgroup.id,
}
}
}, state.workgroup.id === '' ? true : false);
useEffect(() => {
setState({
changed: false,
workgroup: {
id: workgroup && workgroup.id ? workgroup.id : '',
name: workgroup && workgroup.name ? workgroup.name : '',
createdAt: workgroup && workgroup.createdAt ? workgroup.createdAt : null,
closedAt: workgroup && workgroup.closedAt ? workgroup.closedAt : null,
}
});
}, [workgroup]);
const onSaveClick = () => {
if (!state.changed) return;
if (typeof onChange !== 'function') return;
onChange(state.workgroup as Workgroup);
setState(state => {
return {
...state,
changed: false,
};
})
};
const onWorkgroupAttrChange = function(attrName: string, evt: ChangeEvent<HTMLInputElement>) {
const value = evt.currentTarget.value;
setState(state => {
return {
...state,
changed: true,
workgroup: {
...state.workgroup,
[attrName]: value,
}
};
});
};
return (
<div className="form" style={{width: '100%'}}>
<div className="field">
<label className="label">Nom du groupe</label>
<div className="control">
<input type="text" className="input" value={state.workgroup.name}
disabled={!isAuthorized}
onChange={onWorkgroupAttrChange.bind(null, "name")} />
</div>
</div>
{
state.workgroup.createdAt ?
<div className="field">
<label className="label">Date de création</label>
<div className="control">
<p className="input is-static">{state.workgroup.createdAt}</p>
</div>
</div>:
null
}
{
state.workgroup.closedAt ?
<div className="field">
<label className="label">Date de clôture</label>
<div className="control">
<p className="input is-static">{state.workgroup.closedAt}</p>
</div>
</div>:
null
}
<div className="buttons is-right">
<button disabled={!state.changed || !isAuthorized}
className="button is-success" onClick={onSaveClick}>
<span>Enregistrer</span>
<span className="icon"><i className="fa fa-save"></i></span>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
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';
export interface InfoPanelProps {
workgroup: Workgroup
}
export const InfoPanel: FunctionComponent<InfoPanelProps> = ({ workgroup }) => {
const [ updateWorkgroup, updateWorkgroupMutation ] = useUpdateWorkgroupMutation();
const [ createWorkgroup, createWorkgroupMutation ] = useCreateWorkgroupMutation();
const history = useHistory();
const isLoading = updateWorkgroupMutation.loading || createWorkgroupMutation.loading;
const onWorkgroupChange = (formWorkgroup: Workgroup) => {
const variables: any = { changes: {} };
if (workgroup.name !== formWorkgroup.name) {
variables.changes.name = formWorkgroup.name;
}
if (Object.keys(variables.changes).length === 0) return;
const isCreation = workgroup.id === '';
if (isCreation) {
createWorkgroup({variables})
.then(({ data: { createWorkgroup } }) => {
history.push(`/workgroups/${createWorkgroup.id}`);
});
} else {
variables.workgroupId = workgroup.id;
updateWorkgroup({variables});
}
};
return (
<nav className="panel">
<p className="panel-heading">
Informations
</p>
<div className="panel-block">
<WithLoader loading={isLoading}>
<InfoForm workgroup={workgroup} onChange={onWorkgroupChange} />
</WithLoader>
</div>
</nav>
);
}

View File

@ -0,0 +1,35 @@
import React, { FunctionComponent } from 'react';
import { User } from '../../types/user';
export interface MembersPanelProps {
users: User[]
}
export const MembersPanel: FunctionComponent<MembersPanelProps> = ({ users }) => {
return (
<nav className="panel">
<p className="panel-heading">
Membres
</p>
{
users.map(u => {
return (
<div key={`user-${u.id}`} className="panel-block">
<span className="panel-icon">
<i className="fas fa-user" aria-hidden="true"></i>
</span>
<span>{`${ u.name ? (u.name + ' - ') : '' }`}</span><span className="is-italic">{`${u.email}`}</span>
</div>
);
})
}
{
users.length === 0 ?
<a className="panel-block has-text-centered is-block">
<p className="is-italic">Aucun membre pour l'instant.</p>
</a> :
null
}
</nav>
);
}

View File

@ -0,0 +1,148 @@
import React, { useEffect, useState, Fragment } from 'react';
import { Page } from '../Page';
import { WithLoader } from '../WithLoader';
import { useParams } from 'react-router';
import { useWorkgroupsQuery, useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfileQuery, 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';
export function WorkgroupPage() {
const { id } = useParams();
const { workgroups } = useWorkgroups({
variables:{
filter: {
ids: [id],
}
}
});
const { user } = useUserProfile();
const [ joinWorkgroup ] = useJoinWorkgroupMutation();
const [ leaveWorkgroup ] = useLeaveWorkgroupMutation();
const [ closeWorkgroup ] = useCloseWorkgroupMutation();
const [ state, setState ] = useState({
userProfileId: '',
workgroup: {
id: '',
name: '',
closedAt: null,
createdAt: null,
members: [],
}
});
useEffect(() => {
setState(state => ({...state, workgroup:{ ...state.workgroup, ...workgroups[0]}}));
}, [workgroups]);
useEffect(() => {
setState(state => ({...state, userProfileId: user.id }));
}, [user]);
const onJoinWorkgroupClick = () => {
joinWorkgroup({
variables: {
workgroupId: state.workgroup.id,
}
});
}
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,
}
});
}
const onCloseWorkgroupClick = () => {
closeWorkgroup({
variables: {
workgroupId: state.workgroup.id,
}
});
}
const isNew = state.workgroup.id === '';
const isWorkgroupMember = state.workgroup.members.some(u => u.id === state.userProfileId);
const isClosed = state.workgroup.closedAt !== null;
return (
<Page title="Groupe de travail">
<div className="container is-fluid">
<section className="mt-5">
<div className="level">
<div className="level-left">
{
isNew ?
<div className="level-item">
<div>
<h2 className="is-size-3 title is-spaced">Nouveau</h2>
<h3 className="is-size-5 subtitle">Groupe de travail</h3>
</div>
</div> :
<div className="level-item">
<div>
<h2 className="is-size-3 title is-spaced">{state.workgroup.name}</h2>
<h3 className="is-size-5 subtitle">Groupe de travail <span className="is-italic">{ isClosed ? '(clos)' : null }</span></h3>
</div>
</div>
}
</div>
<div className="level-right">
<div className="buttons is-right level-item">
{
isNew || isClosed ? null :
<Fragment>
{
isWorkgroupMember ?
<Fragment>
<button onClick={onLeaveWorkgroupClick} className="button is-info is-warning is-medium">
<span>Quitter</span>
<span className="icon"><i className="fas fa-sign-out-alt"></i></span>
</button>
<button onClick={onCloseWorkgroupClick} className="button is-danger is-medium">
<span>Clore</span>
<span className="icon"><i className="far fa-times-circle"></i></span>
</button>
</Fragment> :
<button onClick={onJoinWorkgroupClick} className="button is-info is-medium">
<span>Rejoindre</span>
<span className="icon"><i className="fas fa-user-plus"></i></span>
</button>
}
</Fragment>
}
</div>
</div>
</div>
<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>
</section>
</div>
</Page>
);
}

View File

@ -0,0 +1,58 @@
import { gql, useQuery, useMutation } from '@apollo/client';
import { QUERY_DECISION_SUPPORT_FILES } from '../queries/dsf';
export const MUTATION_CREATE_DECISION_SUPPORT_FILE = gql`
mutation createDecisionSupportFile($changes: DecisionSupportFileChanges!) {
createDecisionSupportFile(changes: $changes) {
id,
title,
status,
sections,
createdAt,
updatedAt,
workgroup {
id,
name,
members {
id,
email,
name
}
},
}
}`;
export function useCreateDecisionSupportFileMutation() {
return useMutation(MUTATION_CREATE_DECISION_SUPPORT_FILE, {
refetchQueries: [{query: QUERY_DECISION_SUPPORT_FILES}],
});
}
export const MUTATION_UPDATE_DECISION_SUPPORT_FILE = gql`
mutation updateDecisionSupportFile($id: ID!, $changes: DecisionSupportFileChanges!) {
updateDecisionSupportFile(id: $id, changes: $changes) {
id,
title,
status,
sections,
createdAt,
updatedAt,
workgroup {
id,
name,
members {
id,
email,
name
}
},
}
}`;
export function useUpdateDecisionSupportFileMutation() {
return useMutation(MUTATION_UPDATE_DECISION_SUPPORT_FILE, {
refetchQueries: [{
query: QUERY_DECISION_SUPPORT_FILES,
}],
});
}

View File

@ -0,0 +1,15 @@
import { gql, useQuery, useMutation } from '@apollo/client';
export const MUTATION_UPDATE_USER_PROFILE = gql`
mutation updateUserProfile($changes: ProfileChanges!) {
updateProfile(changes: $changes) {
id,
name,
createdAt,
connectedAt,
}
}`;
export function useUpdateUserProfileMutation() {
return useMutation(MUTATION_UPDATE_USER_PROFILE);
}

View File

@ -0,0 +1,132 @@
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!) {
updateWorkgroup(workgroupId: $workgroupId, changes: $changes) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useUpdateWorkgroupMutation() {
return useMutation(MUTATION_UPDATE_WORKGROUP);
}
export const MUTATION_CREATE_WORKGROUP = gql`
mutation createWorkgroup($changes: WorkgroupChanges!) {
createWorkgroup(changes: $changes) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useCreateWorkgroupMutation() {
return useMutation(MUTATION_CREATE_WORKGROUP, {
refetchQueries: [{query: QUERY_WORKGROUP}],
});
}
export const MUTATION_JOIN_WORKGROUP = gql`
mutation joinWorkgroup($workgroupId: ID!) {
joinWorkgroup(workgroupId: $workgroupId) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useJoinWorkgroupMutation() {
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`
mutation leaveWorkgroup($workgroupId: ID!) {
leaveWorkgroup(workgroupId: $workgroupId) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useLeaveWorkgroupMutation() {
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`
mutation closeWorkgroup($workgroupId: ID!) {
closeWorkgroup(workgroupId: $workgroupId) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useCloseWorkgroupMutation() {
return useMutation(MUTATION_CLOSE_WORKGROUP);
}

View File

@ -0,0 +1,19 @@
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> = {}) {
return useQuery(QUERY_IS_AUTHORIZED, options);
}
export function useIsAuthorized<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}, defaultValue = false) {
const { data, loading, error } = useGraphQLData<boolean>(
QUERY_IS_AUTHORIZED, 'isAuthorized', defaultValue, options
);
return { isAuthorized: data, loading, error };
}

View File

@ -0,0 +1,37 @@
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { DecisionSupportFile } from '../../types/decision';
import { useGraphQLData } from './helper';
export const QUERY_DECISION_SUPPORT_FILES = gql`
query decisionSupportFiles($filter: DecisionSupportFileFilter) {
decisionSupportFiles(filter: $filter) {
id,
title,
sections,
createdAt,
closedAt,
votedAt,
status,
workgroup {
id,
name,
members {
id,
email,
name
}
},
}
}
`;
export function useDecisionSupportFilesQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
return useQuery(QUERY_DECISION_SUPPORT_FILES, 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
);
return { decisionSupportFiles: data, loading, error };
}

View File

@ -0,0 +1,11 @@
import { useQuery, DocumentNode, QueryHookOptions } from "@apollo/client";
import { useState, useEffect } from "react";
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(() => {
setData(query.data ? query.data[key] as T : defaultValue);
}, [query.loading, query.data]);
return { data, loading: query.loading, error: query.error };
}

View File

@ -0,0 +1,26 @@
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { User } from '../../types/user';
import { useState, useEffect } from 'react';
import { useGraphQLData } from './helper';
export const QUERY_USER_PROFILE = gql`
query userProfile {
userProfile {
id,
name,
email,
createdAt,
connectedAt
}
}`;
export function useUserProfileQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
return useQuery(QUERY_USER_PROFILE, options);
}
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: ''}, options
);
return { user: data, loading, error };
}

View File

@ -0,0 +1,31 @@
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { Workgroup } from '../../types/workgroup';
import { useGraphQLData } from './helper';
export const QUERY_WORKGROUP = gql`
query workgroups($filter: WorkgroupsFilter) {
workgroups(filter: $filter) {
id,
name,
createdAt,
closedAt,
members {
id,
email,
name
}
}
}
`;
export function useWorkgroupsQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
return useQuery(QUERY_WORKGROUP, options);
}
export function useWorkgroups<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
const { data, loading, error } = useGraphQLData<Workgroup[]>(
QUERY_WORKGROUP, 'workgroups', [],
options
);
return { workgroups: data, loading, error };
}

View File

@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[value, delay]
);
return debouncedValue;
}

View File

@ -0,0 +1,20 @@
import { useEffect } from "react";
export function useKonamiCode(cb: Function) {
const KONAMI_CODE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
let cursor = 0;
useEffect(()=> {
const onKeyDown = (e) => {
cursor = (e.keyCode == KONAMI_CODE[cursor]) ? cursor + 1 : 0;
if (cursor == KONAMI_CODE.length) {
cb();
cursor = 0;
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
}

View File

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

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="has-navbar-fixed-top">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

View File

@ -2,13 +2,13 @@ import './sass/_all.scss';
import React from 'react';
import ReactDOM from 'react-dom';
import { App } from './components/App';
import { Config } from './config';
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(
<App />,

View File

@ -1,4 +1,3 @@
@import 'bulma/bulma.sass';
@import 'bulma-switch/dist/css/bulma-switch.sass';
@import '_base.scss';
@import '_loader.scss';

View File

@ -1,6 +1,8 @@
html, body {
height: 100%;
background-color: #f7f7f7;
background-color: #ffffff;
// Generated with https://www.svgbackgrounds.com/
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='351' height='292.5' viewBox='0 0 1080 900'%3E%3Cg fill-opacity='0.04'%3E%3Cpolygon fill='%23444' points='90 150 0 300 180 300'/%3E%3Cpolygon points='90 150 180 0 0 0'/%3E%3Cpolygon fill='%23AAA' points='270 150 360 0 180 0'/%3E%3Cpolygon fill='%23DDD' points='450 150 360 300 540 300'/%3E%3Cpolygon fill='%23999' points='450 150 540 0 360 0'/%3E%3Cpolygon points='630 150 540 300 720 300'/%3E%3Cpolygon fill='%23DDD' points='630 150 720 0 540 0'/%3E%3Cpolygon fill='%23444' points='810 150 720 300 900 300'/%3E%3Cpolygon fill='%23FFF' points='810 150 900 0 720 0'/%3E%3Cpolygon fill='%23DDD' points='990 150 900 300 1080 300'/%3E%3Cpolygon fill='%23444' points='990 150 1080 0 900 0'/%3E%3Cpolygon fill='%23DDD' points='90 450 0 600 180 600'/%3E%3Cpolygon points='90 450 180 300 0 300'/%3E%3Cpolygon fill='%23666' points='270 450 180 600 360 600'/%3E%3Cpolygon fill='%23AAA' points='270 450 360 300 180 300'/%3E%3Cpolygon fill='%23DDD' points='450 450 360 600 540 600'/%3E%3Cpolygon fill='%23999' points='450 450 540 300 360 300'/%3E%3Cpolygon fill='%23999' points='630 450 540 600 720 600'/%3E%3Cpolygon fill='%23FFF' points='630 450 720 300 540 300'/%3E%3Cpolygon points='810 450 720 600 900 600'/%3E%3Cpolygon fill='%23DDD' points='810 450 900 300 720 300'/%3E%3Cpolygon fill='%23AAA' points='990 450 900 600 1080 600'/%3E%3Cpolygon fill='%23444' points='990 450 1080 300 900 300'/%3E%3Cpolygon fill='%23222' points='90 750 0 900 180 900'/%3E%3Cpolygon points='270 750 180 900 360 900'/%3E%3Cpolygon fill='%23DDD' points='270 750 360 600 180 600'/%3E%3Cpolygon points='450 750 540 600 360 600'/%3E%3Cpolygon points='630 750 540 900 720 900'/%3E%3Cpolygon fill='%23444' points='630 750 720 600 540 600'/%3E%3Cpolygon fill='%23AAA' points='810 750 720 900 900 900'/%3E%3Cpolygon fill='%23666' points='810 750 900 600 720 600'/%3E%3Cpolygon fill='%23999' points='990 750 900 900 1080 900'/%3E%3Cpolygon fill='%23999' points='180 0 90 150 270 150'/%3E%3Cpolygon fill='%23444' points='360 0 270 150 450 150'/%3E%3Cpolygon fill='%23FFF' points='540 0 450 150 630 150'/%3E%3Cpolygon points='900 0 810 150 990 150'/%3E%3Cpolygon fill='%23222' points='0 300 -90 450 90 450'/%3E%3Cpolygon fill='%23FFF' points='0 300 90 150 -90 150'/%3E%3Cpolygon fill='%23FFF' points='180 300 90 450 270 450'/%3E%3Cpolygon fill='%23666' points='180 300 270 150 90 150'/%3E%3Cpolygon fill='%23222' points='360 300 270 450 450 450'/%3E%3Cpolygon fill='%23FFF' points='360 300 450 150 270 150'/%3E%3Cpolygon fill='%23444' points='540 300 450 450 630 450'/%3E%3Cpolygon fill='%23222' points='540 300 630 150 450 150'/%3E%3Cpolygon fill='%23AAA' points='720 300 630 450 810 450'/%3E%3Cpolygon fill='%23666' points='720 300 810 150 630 150'/%3E%3Cpolygon fill='%23FFF' points='900 300 810 450 990 450'/%3E%3Cpolygon fill='%23999' points='900 300 990 150 810 150'/%3E%3Cpolygon points='0 600 -90 750 90 750'/%3E%3Cpolygon fill='%23666' points='0 600 90 450 -90 450'/%3E%3Cpolygon fill='%23AAA' points='180 600 90 750 270 750'/%3E%3Cpolygon fill='%23444' points='180 600 270 450 90 450'/%3E%3Cpolygon fill='%23444' points='360 600 270 750 450 750'/%3E%3Cpolygon fill='%23999' points='360 600 450 450 270 450'/%3E%3Cpolygon fill='%23666' points='540 600 630 450 450 450'/%3E%3Cpolygon fill='%23222' points='720 600 630 750 810 750'/%3E%3Cpolygon fill='%23FFF' points='900 600 810 750 990 750'/%3E%3Cpolygon fill='%23222' points='900 600 990 450 810 450'/%3E%3Cpolygon fill='%23DDD' points='0 900 90 750 -90 750'/%3E%3Cpolygon fill='%23444' points='180 900 270 750 90 750'/%3E%3Cpolygon fill='%23FFF' points='360 900 450 750 270 750'/%3E%3Cpolygon fill='%23AAA' points='540 900 630 750 450 750'/%3E%3Cpolygon fill='%23FFF' points='720 900 810 750 630 750'/%3E%3Cpolygon fill='%23222' points='900 900 990 750 810 750'/%3E%3Cpolygon fill='%23222' points='1080 300 990 450 1170 450'/%3E%3Cpolygon fill='%23FFF' points='1080 300 1170 150 990 150'/%3E%3Cpolygon points='1080 600 990 750 1170 750'/%3E%3Cpolygon fill='%23666' points='1080 600 1170 450 990 450'/%3E%3Cpolygon fill='%23DDD' points='1080 900 1170 750 990 750'/%3E%3C/g%3E%3C/svg%3E");
}
.is-fullheight {
@ -18,4 +20,8 @@ html, body {
#app {
display: flex;
flex-direction: column;
}
.panel {
background-color: #ffffff;
}

View File

@ -1,11 +0,0 @@
import { Action } from "redux";
export const SET_CURRENT_USER = 'SET_CURRENT_USER';
export interface setCurrentUserAction extends Action {
email: string
}
export function setCurrentUser(email: string): setCurrentUserAction {
return { type: SET_CURRENT_USER, email };
}

View File

@ -1,19 +0,0 @@
import { Action } from "redux";
import { User } from "../../types/user";
export const FETCH_PROFILE_REQUEST = 'FETCH_PROFILE_REQUEST';
export const FETCH_PROFILE_SUCCESS = 'FETCH_PROFILE_SUCCESS';
export const FETCH_PROFILE_FAILURE = 'FETCH_PROFILE_FAILURE';
export interface fetchProfileRequestAction extends Action {
}
export interface fetchProfileSuccessAction extends Action {
profile: User
}
export function fetchProfile(): fetchProfileRequestAction {
return { type: FETCH_PROFILE_REQUEST }
}

View File

@ -1,47 +0,0 @@
import { Action } from "redux";
import { User } from "../../types/user";
import { SET_CURRENT_USER, setCurrentUserAction } from "../actions/auth";
import { FETCH_PROFILE_SUCCESS, fetchProfileSuccessAction } from "../actions/profile";
export interface AuthState {
isAuthenticated: boolean
currentUser: User
}
const defaultState = {
isAuthenticated: false,
currentUser: null,
};
export function authReducer(state = defaultState, action: Action): AuthState {
switch (action.type) {
case SET_CURRENT_USER:
return handleSetCurrentUser(state, action as setCurrentUserAction);
case FETCH_PROFILE_SUCCESS:
return handleFetchProfileSuccess(state, action as fetchProfileSuccessAction);
}
return state;
}
function handleSetCurrentUser(state: AuthState, { email }: setCurrentUserAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
email
}
};
};
function handleFetchProfileSuccess(state: AuthState, { profile }: fetchProfileSuccessAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
email: profile.email,
connectedAt: profile.connectedAt,
createdAt: profile.createdAt,
}
};
};

View File

@ -1,32 +0,0 @@
import { Action } from "redux";
export interface FlagsState {
actions: { [actionName: string]: ActionState }
}
export interface ActionState {
isLoading: boolean
}
const defaultState = {
actions: {}
};
export function flagsReducer(state = defaultState, action: Action): FlagsState {
const matches = (/^(.*)_((SUCCESS)|(FAILURE)|(REQUEST))$/).exec(action.type);
if(!matches) return state;
const actionPrefix = matches[1];
return {
...state,
actions: {
...state.actions,
[actionPrefix]: {
isLoading: matches[2] === 'REQUEST'
}
}
};
}

View File

@ -1,13 +0,0 @@
import { combineReducers } from 'redux';
import { flagsReducer, FlagsState } from './flags';
import { authReducer, AuthState } from './auth';
export interface RootState {
auth: AuthState,
flags: FlagsState,
}
export const rootReducer = combineReducers({
flags: flagsReducer,
auth: authReducer,
});

View File

@ -1,21 +0,0 @@
import { UnauthorizedError } from "../../util/daddy";
import { all, takeEvery } from 'redux-saga/effects';
export function* failureRootSaga() {
yield all([
takeEvery(patternFromRegExp(/^.*_FAILURE/), failuresSaga),
]);
}
export function* failuresSaga(action) {
if (action.error instanceof UnauthorizedError) {
// TODO Implements better authorization error handling
window.location.reload();
}
}
export function patternFromRegExp(re: any) {
return (action: any) => {
return re.test(action.type);
};
}

View File

@ -1,12 +0,0 @@
import { all, put } from "redux-saga/effects";
import { fetchProfile } from "../actions/profile";
export function* initRootSaga() {
yield all([
fetchUserProfileSaga(),
]);
}
export function* fetchUserProfileSaga() {
yield put(fetchProfile());
}

View File

@ -1,12 +0,0 @@
import { all } from 'redux-saga/effects';
import { failureRootSaga } from './failure';
import { initRootSaga } from './init';
import { usersRootSaga } from './users';
export function* rootSaga() {
yield all([
initRootSaga(),
failureRootSaga(),
usersRootSaga(),
]);
}

View File

@ -1,33 +0,0 @@
import { DaddyClient, getClient } from "../../util/daddy";
import { Config } from "../../config";
import { all, takeLatest, put, select } from "redux-saga/effects";
import { FETCH_PROFILE_REQUEST, fetchProfile, FETCH_PROFILE_FAILURE, FETCH_PROFILE_SUCCESS } from "../actions/profile";
import { SET_CURRENT_USER } from "../actions/auth";
import { RootState } from "../reducers/root";
import { User } from "../../types/user";
export function* usersRootSaga() {
yield all([
takeLatest(SET_CURRENT_USER, onCurrentUserChangeSaga),
takeLatest(FETCH_PROFILE_REQUEST, fetchProfileSaga),
]);
}
export function* onCurrentUserChangeSaga() {
yield put(fetchProfile());
}
export function* fetchProfileSaga() {
const client = getClient(Config.graphQLEndpoint, Config.subscriptionEndpoint);
let profile: User;
try {
profile = yield client.fetchProfile().then(result => result.userProfile);
console.log(profile);
} catch(err) {
yield put({ type: FETCH_PROFILE_FAILURE, err });
return;
}
yield put({type: FETCH_PROFILE_SUCCESS, profile });
}

View File

@ -1,7 +0,0 @@
export function selectFlagsIsLoading(state: any, ...actionPrefixes: any[]) {
const { actions } = state.flags;
return actionPrefixes.reduce((isLoading, prefix) => {
if (!(prefix in actions)) return isLoading;
return isLoading || actions[prefix].isLoading;
}, false);
};

View File

@ -1,30 +0,0 @@
import { createStore, applyMiddleware, compose } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { rootReducer } from './reducers/root'
import { rootSaga } from './sagas/root'
let reduxMiddlewares = [];
if (process.env.NODE_ENV !== 'production') {
const createLogger = require('redux-logger').createLogger;
const loggerMiddleware = createLogger({
collapsed: true,
diff: true
});
reduxMiddlewares.push(loggerMiddleware);
}
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
// create the saga middleware
const sagaMiddleware = createSagaMiddleware()
reduxMiddlewares.push(sagaMiddleware);
// mount it on the Store
export const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(...reduxMiddlewares)),
)
// then run the saga
sagaMiddleware.run(rootSaga);

View File

@ -0,0 +1,35 @@
import { Workgroup } from "./workgroup";
export enum DecisionSupportFileStatus {
Draft = "draft",
Ready = "ready",
Voted = "voted",
Closed = "closed",
}
export interface DecisionSupportFileSection {
name: string
}
// aka Dossier d'aide à la décision
export interface DecisionSupportFile {
id: string
title: string
sections: {[name: string]: any}
status: DecisionSupportFileStatus
workgroup?: Workgroup,
createdAt: Date
votedAt?: Date
closedAt?: Date
}
export function newDecisionSupportFile(): DecisionSupportFile {
return {
id: '',
title: '',
sections: {},
status: DecisionSupportFileStatus.Draft,
workgroup: null,
createdAt: new Date(),
};
}

View File

@ -1,5 +1,7 @@
export interface User {
id: string
email: string
name?: string
connectedAt?: Date
createdAt?: Date
}

View File

@ -0,0 +1,18 @@
import { User } from "./user";
export interface Workgroup {
id: string
name: string
createdAt: Date
closedAt: Date
members: User[]
}
export function inWorkgroup(u: User, wg: Workgroup): boolean {
for (let m, i = 0; (m = wg.members[i]); i++) {
if(m.id === u.id) {
return true;
}
}
return false;
}

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,83 +0,0 @@
import ApolloClient from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { split } from 'apollo-link';
import { HttpLink } from 'apollo-link-http';
import { WebSocketLink } from 'apollo-link-ws';
import { getMainDefinition } from 'apollo-utilities';
import gql from 'graphql-tag';
export class UnauthorizedError extends Error {
constructor(...args: any[]) {
super(...args)
Object.setPrototypeOf(this, UnauthorizedError.prototype);
}
}
let client: DaddyClient
export function getClient(graphQLEndpoint: string, subscriptionEndpoint: string): DaddyClient {
if (!client) {
client = new DaddyClient(graphQLEndpoint, subscriptionEndpoint);
}
return client;
}
export class DaddyClient {
gql: ApolloClient<InMemoryCache>
constructor(graphQLEndpoint: string, subscriptionEndpoint: string) {
const wsLink = new WebSocketLink({
uri: subscriptionEndpoint,
options: {
reconnect: true
}
});
const httpLink = new HttpLink({
uri: graphQLEndpoint,
fetchOptions: {
mode: 'cors',
credentials: 'include',
}
});
const link = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
this.gql = new ApolloClient<any>({
link: link,
cache: new InMemoryCache(),
});
}
fetchProfile() {
return this.gql.query({
query: gql`
query {
userProfile {
email,
createdAt,
connectedAt
}
}`
})
.then(this.assertAuthorization)
}
assertAuthorization({ status, data }: any) {
if (status === 401) return Promise.reject(new UnauthorizedError());
return data;
}
}

4
client/src/util/date.ts Normal file
View File

@ -0,0 +1,4 @@
export function asDate(d: string|Date): Date {
if (typeof d === 'string') return new Date(d);
return d;
}

53
client/src/util/uuid.ts Normal file
View File

@ -0,0 +1,53 @@
import bs58 from 'bs58';
const hex: string[] = [];
for (var i = 0; i < 256; i++) {
hex[i] = (i < 16 ? '0' : '') + (i).toString(16);
}
export function uuidV4(): string {
const r = crypto.getRandomValues(new Uint8Array(16));
r[6] = r[6] & 0x0f | 0x40;
r[8] = r[8] & 0x3f | 0x80;
return (
hex[r[0]] +
hex[r[1]] +
hex[r[2]] +
hex[r[3]] +
"-" +
hex[r[4]] +
hex[r[5]] +
"-" +
hex[r[6]] +
hex[r[7]] +
"-" +
hex[r[8]] +
hex[r[9]] +
"-" +
hex[r[10]] +
hex[r[11]] +
hex[r[12]] +
hex[r[13]] +
hex[r[14]] +
hex[r[15]]
);
}
export function toUTF8Bytes(str: string): number[] {
var utf8 = unescape(encodeURIComponent(str));
var arr: number[] = [];
for (var i = 0; i < utf8.length; i++) {
arr.push(utf8.charCodeAt(i));
}
return arr
}
export function base58UUID(): string {
const uuid = uuidV4();
return bs58.encode(toUTF8Bytes(uuid));
}

View File

@ -3,17 +3,20 @@ package main
import (
"context"
"net/http"
"time"
"forge.cadoles.com/Cadoles/daddy/internal/migration"
"gitlab.com/wpetit/goweb/cqrs"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"forge.cadoles.com/Cadoles/daddy/internal/voter"
"forge.cadoles.com/Cadoles/daddy/internal/database"
"github.com/wader/gormstore"
"forge.cadoles.com/Cadoles/daddy/internal/auth"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"gitlab.com/wpetit/goweb/logger"
"forge.cadoles.com/Cadoles/daddy/internal/config"
oidc "forge.cadoles.com/wpetit/goweb-oidc"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
"gitlab.com/wpetit/goweb/service/build"
@ -51,23 +54,36 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con
conf.HTTP.CookieEncryptionKey = string(cookieEncryptionKey)
}
ctn.Provide(orm.ServiceName, orm.ServiceProvider("postgres", conf.Database.DSN, conf.Debug))
orm, err := orm.From(ctn)
if err != nil {
return nil, errors.WithStack(err)
}
// Create and initialize HTTP session service provider
cookieStore := sessions.NewCookieStore(
sessionStore := gormstore.NewOptions(
orm.DB(),
gormstore.Options{
TableName: "sessions",
SkipCreateTable: false,
},
[]byte(conf.HTTP.CookieAuthenticationKey),
[]byte(conf.HTTP.CookieEncryptionKey),
)
quit := make(chan struct{})
go sessionStore.PeriodicCleanup(1*time.Hour, quit)
// Define default cookie options
cookieStore.Options = &sessions.Options{
Path: "/",
HttpOnly: true,
MaxAge: conf.HTTP.CookieMaxAge,
SameSite: http.SameSiteStrictMode,
}
sessionStore.SessionOpts.Path = "/"
sessionStore.SessionOpts.HttpOnly = true
sessionStore.SessionOpts.MaxAge = conf.HTTP.CookieMaxAge
sessionStore.SessionOpts.SameSite = http.SameSiteStrictMode
ctn.Provide(
session.ServiceName,
gorilla.ServiceProvider("daddy", cookieStore),
gorilla.ServiceProvider("daddy", sessionStore),
)
// Create and expose config service provider
@ -84,21 +100,13 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con
oidc.WithScopes("email", "openid"),
))
ctn.Provide(database.ServiceName, database.ServiceProvider(conf.Database.DSN))
ctn.Provide(auth.ServiceName, auth.ServiceProvider(conf.Auth.Rules))
dbpool, err := database.From(ctn)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve database service")
}
versionResolver := database.NewVersionResolver(dbpool)
if err := versionResolver.Init(ctx); err != nil {
return nil, errors.Wrap(err, "could not initialize database version resolver")
}
ctn.Provide(migration.ServiceName, migration.ServiceProvider(versionResolver))
ctn.Provide(cqrs.ServiceName, cqrs.ServiceProvider())
ctn.Provide(voter.ServiceName, voter.ServiceProvider(
voter.StrategyUnanimous,
model.NewDecisionSupportFileVoter(),
model.NewWorkgroupVoter(),
))
return ctn, nil
}

View File

@ -1,37 +0,0 @@
package main
import (
"forge.cadoles.com/Cadoles/daddy/internal/command"
"forge.cadoles.com/Cadoles/daddy/internal/query"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/service"
)
func initCommands(ctn *service.Container) error {
dispatcher, err := cqrs.From(ctn)
if err != nil {
return errors.WithStack(err)
}
dispatcher.RegisterCommand(
cqrs.MatchCommandRequest(&command.CreateUserCommandRequest{}),
cqrs.CommandHandlerFunc(command.HandleCreateUserCommand),
)
return nil
}
func initQueries(ctn *service.Container) error {
dispatcher, err := cqrs.From(ctn)
if err != nil {
return errors.WithStack(err)
}
dispatcher.RegisterQuery(
cqrs.MatchQueryRequest(&query.FindUserQueryRequest{}),
cqrs.QueryHandlerFunc(query.HandleFindUserQuery),
)
return nil
}

View File

@ -153,23 +153,6 @@ func main() {
os.Exit(0)
}
// Init commands and queries
if err := initCommands(ctn); err != nil {
logger.Fatal(
ctx,
"could not init commands",
logger.E(err),
)
}
if err := initQueries(ctn); err != nil {
logger.Fatal(
ctx,
"could not init queries",
logger.E(err),
)
}
r := chi.NewRouter()
// Define base middlewares

View File

@ -3,9 +3,9 @@ package main
import (
"context"
"forge.cadoles.com/Cadoles/daddy/internal/database"
"forge.cadoles.com/Cadoles/daddy/internal/migration"
"github.com/jackc/pgx/v4"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
"gitlab.com/wpetit/goweb/service"
@ -18,11 +18,13 @@ const (
)
func applyMigration(ctx context.Context, ctn *service.Container) error {
migr, err := migration.From(ctn)
orm, err := orm.From(ctn)
if err != nil {
return err
}
migr := orm.Migration()
// Register available migrations
migr.Register(
m000initialSchema(),
@ -74,29 +76,33 @@ func applyMigration(ctx context.Context, ctn *service.Container) error {
return nil
}
func m000initialSchema() migration.Migration {
return database.NewMigration(
// nolint: gochecknoglobals
var initialModels = []interface{}{
&model.User{},
&model.Workgroup{},
&model.DecisionSupportFile{},
}
func m000initialSchema() orm.Migration {
return orm.NewDBMigration(
"00_initial_schema",
func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
connected_at TIMESTAMPTZ,
CONSTRAINT unique_email unique(email)
);
`)
func(ctx context.Context, tx *gorm.DB) error {
for _, m := range initialModels {
if err := tx.AutoMigrate(m).Error; err != nil {
return errors.WithStack(err)
}
}
return err
return nil
},
func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
DROP TABLE users;
`)
func(ctx context.Context, tx *gorm.DB) error {
for i := len(initialModels) - 1; i >= 0; i-- {
if err := tx.DropTableIfExists(initialModels[i]).Error; err != nil {
return errors.WithStack(err)
}
}
return err
return nil
},
)
}

1
debian/compat vendored Normal file
View File

@ -0,0 +1 @@
9

14
debian/control vendored Normal file
View File

@ -0,0 +1,14 @@
Source: daddy
Section: unknown
Priority: optional
Maintainer: Cadoles <contact@cadoles.com>
Build-Depends: debhelper (>= 8.0.0), wget, ca-certificates, tar
Standards-Version: 3.9.4
Homepage: http://forge.cadoles.com/Cadoles/daddy
Vcs-Git: http://forge.cadoles.com/Cadoles/daddy.git
Vcs-Browser: http://forge.cadoles.com/Cadoles/daddy
Package: daddy
Architecture: amd64
Depends: ${shlibs:Depends}, ${misc:Depends}
Description: Daddy app

1
debian/daddy.links vendored Normal file
View File

@ -0,0 +1 @@
/etc/daddy/client-config.js /usr/share/daddy/public/config.js

11
debian/daddy.service vendored Normal file
View File

@ -0,0 +1,11 @@
[Unit]
Description=Daddy app
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/daddy -workdir /usr/share/daddy -config /etc/daddy/config.yml
Restart=on-failure
[Install]
WantedBy=multi-user.target

56
debian/rules vendored Normal file
View File

@ -0,0 +1,56 @@
#!/usr/bin/make -f
# -*- makefile -*-
# Uncomment this to turn on verbose mode.
export DH_VERBOSE=1
GO_VERSION := 1.13.5
OS := linux
ARCH := amd64
GOPATH=$(HOME)/go
ifeq (, $(shell which go 2>/dev/null))
override_dh_auto_build: install-go
override_dh_auto_clean: install-go
endif
ifeq (, $(shell which node 2>/dev/null))
override_dh_auto_build: install-nodejs
endif
%:
dh $@ --with systemd
override_dh_auto_build: $(GOPATH)
GOPATH=$(GOPATH) PATH="$(PATH):/usr/local/go/bin:$(GOPATH)/bin" make deps
GOPATH=$(GOPATH) PATH="$(PATH):/usr/local/go/bin:$(GOPATH)/bin" ARCH_TARGETS=$(ARCH) make release
$(GOPATH):
mkdir -p $(GOPATH)
install-go:
wget -nc https://dl.google.com/go/go$(GO_VERSION).$(OS)-$(ARCH).tar.gz
tar -C /usr/local -xzf go$(GO_VERSION).$(OS)-$(ARCH).tar.gz
install-nodejs:
wget -O- https://deb.nodesource.com/setup_12.x | bash -
apt-get install -y nodejs
override_dh_auto_install:
mkdir -p debian/daddy/usr/share/daddy
mkdir -p debian/daddy/etc/daddy
mkdir -p debian/daddy/usr/bin
cp -r release/server-$(OS)-$(ARCH)/* debian/daddy/usr/share/daddy/
mv debian/daddy/usr/share/daddy/bin/server debian/daddy/usr/bin/daddy
mv debian/daddy/usr/share/daddy/server.conf debian/daddy/etc/daddy/config.yml
mv debian/daddy/usr/share/daddy/public/config.js debian/daddy/etc/daddy/client-config.js
install -d debian/daddy
override_dh_strip:
override_dh_auto_test:

1
debian/source/format vendored Normal file
View File

@ -0,0 +1 @@
3.0 (native)

View File

@ -32,7 +32,7 @@ services:
command: hydra serve all --dangerous-force-http
hydra-passwordless:
image: bornholm/hydra-passwordless:latest
image: bornholm/hydra-passwordless:latest@sha256:e6b335e3677dc937c62978890b42312a7486e4fe10208aa2670b1917489ec492
ports:
- 3000:3000
environment:
@ -48,6 +48,7 @@ services:
- SMTP_INSECURE_SKIP_VERIFY=true
- HYDRA_BASE_URL=http://hydra:4445
- HYDRA_FAKE_SSL_TERMINATION=false
- NO_PROXY=hydra
smtp:
image: bornholm/fake-smtp

4
go.mod
View File

@ -5,16 +5,20 @@ go 1.14
require (
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032
github.com/99designs/gqlgen v0.11.3
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/gorilla/sessions v1.2.0
github.com/gorilla/websocket v1.2.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/pkg/errors v0.9.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
gopkg.in/yaml.v2 v2.2.8
)

53
go.sum
View File

@ -20,8 +20,10 @@ github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2b
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
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/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/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
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=
@ -42,6 +44,9 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
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/bmatcuk/doublestar v1.3.0/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/bmatcuk/doublestar v1.3.1 h1:rT8rxDPsavp9G+4ZULzqhhUSaI/OPsTZNG88Z3i0xvY=
@ -50,6 +55,7 @@ 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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
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/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
@ -68,21 +74,28 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
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/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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
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/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/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/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=
@ -90,9 +103,15 @@ github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxm
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
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/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=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -121,6 +140,8 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
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/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/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=
@ -188,6 +209,13 @@ github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv
github.com/jackc/puddle v1.1.1 h1:PJAw7H/9hoWC4Kf3J8iNmL1SwA6E8vfsLqBiL+F6CtI=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs=
github.com/jinzhu/gorm v1.9.14 h1:Kg3ShyTPcM6nzVo148fRrcMO6MNKuqtOUwnzqMgVniM=
github.com/jinzhu/gorm v1.9.14/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
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/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -203,9 +231,13 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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=
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/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/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=
@ -226,6 +258,12 @@ github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGe
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
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/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=
@ -239,11 +277,14 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@ -255,11 +296,13 @@ github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OK
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
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/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=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
@ -270,6 +313,7 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
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=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@ -284,6 +328,8 @@ github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWp
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/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=
@ -302,6 +348,7 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E
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-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=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -309,6 +356,7 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 h1:ULYEB3JvPRE/IfO+9uO7vKV/xzVTO7XPAwm8xbf4w2g=
golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@ -333,6 +381,7 @@ golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCc
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/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-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -347,6 +396,8 @@ golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/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-20200202094626-16171245cfb2/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-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -374,6 +425,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-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=
@ -382,6 +434,7 @@ golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 h1:gSbV7h1NRL2G1xTg/owz62CST
golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/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-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-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

3
internal/.gitignore vendored
View File

@ -1,2 +1,3 @@
/server.go
/graph/generated
/graph/generated
/model/models_gen.go

71
internal/auth/auth.go Normal file
View File

@ -0,0 +1,71 @@
package auth
import (
"sync"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"github.com/antonmedv/expr"
"github.com/antonmedv/expr/vm"
"github.com/pkg/errors"
)
var (
ErrUnexpectedRuleResult = errors.New("unexpected rule result")
)
type Service struct {
rules []*vm.Program
mutex sync.RWMutex
}
func (s *Service) LoadRules(rawRules ...string) error {
rules := make([]*vm.Program, 0, len(rawRules))
for _, rr := range rawRules {
r, err := expr.Compile(rr)
if err != nil {
return errors.WithStack(err)
}
rules = append(rules, r)
}
s.mutex.Lock()
s.rules = rules
s.mutex.Unlock()
return nil
}
func (s *Service) Authorize(user *model.User) (bool, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
env := map[string]interface{}{
"user": user,
}
for _, r := range s.rules {
result, err := expr.Run(r, env)
if err != nil {
return false, errors.WithStack(err)
}
authorized, ok := result.(bool)
if !ok {
return false, errors.WithStack(ErrUnexpectedRuleResult)
}
if !authorized {
return false, nil
}
}
return true, nil
}
func NewService() *Service {
return &Service{
rules: make([]*vm.Program, 0),
}
}

20
internal/auth/provider.go Normal file
View File

@ -0,0 +1,20 @@
package auth
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
func ServiceProvider(rules []string) service.Provider {
srv := NewService()
err := srv.LoadRules(rules...)
return func(ctn *service.Container) (interface{}, error) {
if err != nil {
return nil, errors.WithStack(err)
}
return srv, nil
}
}

View File

@ -1,21 +1,20 @@
package database
package auth
import (
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "database"
const ServiceName service.Name = "auth"
// From retrieves the database pool service in the given container.
func From(container *service.Container) (*pgxpool.Pool, error) {
// From retrieves the auth service in the given container.
func From(container *service.Container) (*Service, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*pgxpool.Pool)
srv, ok := service.(*Service)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
@ -23,8 +22,8 @@ func From(container *service.Container) (*pgxpool.Pool, error) {
return srv, nil
}
// Must retrieves the database pool service in the given container or panic otherwise.
func Must(container *service.Container) *pgxpool.Pool {
// Must retrieves the auth service in the given container or panic otherwise.
func Must(container *service.Container) *Service {
srv, err := From(container)
if err != nil {
panic(err)

View File

@ -1,99 +0,0 @@
package command
import (
"context"
"github.com/jackc/pgx/v4/pgxpool"
"forge.cadoles.com/Cadoles/daddy/internal/database"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
const (
createConnectedUserStatement = `
INSERT INTO users (email, connected_at) VALUES ($1, now())
ON CONFLICT ON CONSTRAINT unique_email
DO UPDATE SET connected_at = now();
`
createUserStatement = `
INSERT INTO users (email) VALUES ($1)
ON CONFLICT ON CONSTRAINT unique_email
DO NOTHING;
`
)
type CreateUserCommandRequest struct {
Email string
Connected bool
}
func HandleCreateUserCommand(ctx context.Context, cmd cqrs.Command) error {
req, ok := cmd.Request().(*CreateUserCommandRequest)
if !ok {
return errors.WithStack(cqrs.ErrUnexpectedRequest)
}
ctn, err := container.From(ctx)
if err != nil {
return errors.WithStack(err)
}
pool, err := database.From(ctn)
if err != nil {
return errors.WithStack(err)
}
conn, err := pool.Acquire(ctx)
if err != nil {
return errors.WithStack(err)
}
defer conn.Release()
if req.Connected {
if err := createConnectedUser(ctx, conn, req.Email); err != nil {
return errors.WithStack(err)
}
} else {
if err := createUser(ctx, conn, req.Email); err != nil {
return errors.WithStack(err)
}
}
return nil
}
func createConnectedUser(ctx context.Context, conn *pgxpool.Conn, email string) error {
_, err := conn.Conn().Prepare(
ctx, "create_connected_user",
createConnectedUserStatement,
)
if err != nil {
return errors.WithStack(err)
}
if _, err := conn.Exec(ctx, "create_connected_user", email); err != nil {
return errors.WithStack(err)
}
return nil
}
func createUser(ctx context.Context, conn *pgxpool.Conn, email string) error {
_, err := conn.Conn().Prepare(
ctx, "create_user",
createUserStatement,
)
if err != nil {
return errors.WithStack(err)
}
if _, err := conn.Exec(ctx, "create_user", email); err != nil {
return errors.WithStack(err)
}
return nil
}

View File

@ -18,6 +18,7 @@ type Config struct {
HTTP HTTPConfig `yaml:"http"`
OIDC OIDCConfig `yaml:"oidc"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
}
// NewFromFile retrieves the configuration from the given file
@ -55,7 +56,7 @@ type CORSConfig struct {
type OIDCConfig struct {
ClientID string `yaml:"clientId" env:"OIDC_CLIENT_ID"`
ClientSecret string `yaml:"clientSecret" env:"OIDC_CLIENT_SECRET"`
IssuerURL string `ymal:"issuerUrl" env:"OIDC_ISSUER_URL"`
IssuerURL string `yaml:"issuerUrl" env:"OIDC_ISSUER_URL"`
RedirectURL string `yaml:"redirectUrl" env:"OIDC_REDIRECT_URL"`
PostLogoutRedirectURL string `yaml:"postLogoutRedirectURL" env:"OIDC_POST_LOGOUT_REDIRECT_URL"`
}
@ -69,6 +70,10 @@ type DatabaseConfig struct {
DSN string `yaml:"dsn" env:"DATABASE_DSN"`
}
type AuthConfig struct {
Rules []string `yaml:"rules" env:"AUTH_RULES"`
}
func NewDumpDefault() *Config {
config := NewDefault()
return config
@ -85,7 +90,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",
@ -102,6 +107,11 @@ func NewDefault() *Config {
Database: DatabaseConfig{
DSN: "host=localhost database=daddy",
},
Auth: AuthConfig{
Rules: []string{
"user.Email endsWith 'cadoles.com'",
},
},
}
}

View File

@ -1,79 +0,0 @@
package database
import (
"context"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
)
type MigrationFunc func(ctx context.Context, tx pgx.Tx) error
type Migration struct {
version string
up MigrationFunc
down MigrationFunc
}
func (m *Migration) Version() string {
return m.version
}
func (m *Migration) Up(ctx context.Context) error {
pool, err := m.getDatabaseService(ctx)
if err != nil {
return err
}
err = WithTx(ctx, pool, func(ctx context.Context, tx pgx.Tx) error {
return m.up(ctx, tx)
})
if err != nil {
return errors.Wrap(err, "could not apply up migration")
}
return nil
}
func (m *Migration) Down(ctx context.Context) error {
pool, err := m.getDatabaseService(ctx)
if err != nil {
return err
}
err = WithTx(ctx, pool, func(ctx context.Context, tx pgx.Tx) error {
return m.down(ctx, tx)
})
if err != nil {
return errors.Wrap(err, "could not apply down migration")
}
return nil
}
func (m *Migration) getDatabaseService(ctx context.Context) (*pgxpool.Pool, error) {
ctn, err := container.From(ctx)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve service container")
}
pool, err := From(ctn)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve database service")
}
return pool, nil
}
func NewMigration(version string, up, down MigrationFunc) *Migration {
return &Migration{
version: version,
up: up,
down: down,
}
}

View File

@ -1,24 +0,0 @@
package database
import (
"context"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
func ServiceProvider(dsn string) service.Provider {
pool, err := pgxpool.Connect(context.Background(), dsn)
if err != nil {
err = errors.Wrap(err, "could not connect to database")
}
return func(ctn *service.Container) (interface{}, error) {
if err != nil {
return nil, err
}
return pool, nil
}
}

View File

@ -1,38 +0,0 @@
package database
import (
"context"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
)
func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(context.Context, pgx.Tx) error) error {
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return errors.Wrap(err, "could not begin transaction")
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
panic(errors.Wrap(err, "could not rollback transaction"))
}
}()
if err := fn(ctx, tx); err != nil {
err := errors.Wrap(err, "could not apply down migration")
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil {
return errors.Wrap(err, rollbackErr.Error())
}
return err
}
if err := tx.Commit(ctx); err != nil {
return errors.Wrap(err, "could not commit transaction")
}
return nil
}

View File

@ -1,94 +0,0 @@
package database
import (
"context"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
)
type VersionResolver struct {
pool *pgxpool.Pool
}
func (r *VersionResolver) Current(ctx context.Context) (string, error) {
var version string
err := WithTx(ctx, r.pool, func(ctx context.Context, tx pgx.Tx) error {
err := tx.QueryRow(ctx, `SELECT version FROM database_schema WHERE is_current = true;`).
Scan(&version)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
})
if err != nil {
return "", errors.Wrap(err, "could execute version resolver init transaction")
}
return version, nil
}
func (r *VersionResolver) Set(ctx context.Context, version string) error {
err := WithTx(ctx, r.pool, func(ctx context.Context, tx pgx.Tx) error {
if version != "" {
_, err := tx.Exec(ctx, `
INSERT INTO database_schema (version, is_current, migrated_at)
VALUES
(
$1,
true,
now()
)
ON CONFLICT ON CONSTRAINT unique_version
DO UPDATE SET migrated_at = now(), is_current = true;
`, version)
if err != nil {
return err
}
}
_, err := tx.Exec(ctx, `
UPDATE database_schema SET is_current = false, migrated_at = null WHERE version <> $1;
`, version)
return err
})
if err != nil {
return errors.Wrap(err, "could not update schema version")
}
return nil
}
func (r *VersionResolver) Init(ctx context.Context) error {
err := WithTx(ctx, r.pool, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
CREATE TABLE IF NOT EXISTS database_schema(
version TEXT NOT NULL,
migrated_at TIME,
is_current BOOLEAN,
CONSTRAINT unique_version UNIQUE(version)
);`)
return err
})
if err != nil {
return errors.Wrap(err, "could execute version resolver init transaction")
}
return nil
}
func NewVersionResolver(pool *pgxpool.Pool) *VersionResolver {
return &VersionResolver{
pool: pool,
}
}

View File

@ -1,6 +1,6 @@
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema:
- graph/*.graphqls
- graph/*.graphql
# Where should the generated server code go?
exec:
@ -14,7 +14,7 @@ exec:
# Where should any generated models go?
model:
filename: graph/model/models_gen.go
filename: model/models_gen.go
package: model
# Where should the resolver implementations go?
@ -35,7 +35,7 @@ resolver:
# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
- "forge.cadoles.com/Cadoles/daddy/internal/graph/model"
- "forge.cadoles.com/Cadoles/daddy/internal/model"
# This section declares type mapping between the GraphQL and go type systems
#

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

@ -0,0 +1,100 @@
package graph
import (
"context"
"encoding/json"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"gitlab.com/wpetit/goweb/middleware/container"
"forge.cadoles.com/Cadoles/daddy/internal/model"
errs "github.com/pkg/errors"
)
func handleCreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
authorized, err := isAuthorized(ctx, &model.DecisionSupportFile{}, model.ActionCreate)
if err != nil {
return nil, errs.WithStack(err)
}
if !authorized {
return nil, errs.WithStack(ErrForbidden)
}
ctn := container.Must(ctx)
db := orm.Must(ctn).DB()
repo := model.NewDSFRepository(db)
dsf, err := repo.Create(ctx, changes)
if 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()
repo := model.NewDSFRepository(db)
dsf, err := repo.Find(ctx, id)
if err != nil {
return nil, errs.WithStack(err)
}
authorized, err := isAuthorized(ctx, dsf, 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)
}
return dsf, nil
}
func handleDecisionSupportFiles(ctx context.Context, filter *model.DecisionSupportFileFilter) ([]*model.DecisionSupportFile, error) {
ctn := container.Must(ctx)
db := orm.Must(ctn).DB()
repo := model.NewDSFRepository(db)
found, err := repo.Search(ctx, filter)
if err != nil {
return nil, errs.WithStack(err)
}
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
}
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 {
return nil, errs.WithStack(err)
}
return sections, 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")
)

78
internal/graph/helper.go Normal file
View File

@ -0,0 +1,78 @@
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"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
)
func getDB(ctx context.Context) (*gorm.DB, error) {
ctn, err := container.From(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
orm, err := orm.From(ctn)
if err != nil {
return nil, errors.WithStack(err)
}
return orm.DB(), nil
}
func getSessionUser(ctx context.Context) (*model.User, *gorm.DB, error) {
db, err := getDB(ctx)
if err != nil {
return nil, nil, errors.WithStack(err)
}
userEmail, err := session.UserEmail(ctx)
if err != nil {
return nil, nil, errors.WithStack(err)
}
repo := model.NewUserRepository(db)
user, err := repo.FindUserByEmail(ctx, userEmail)
if err != nil {
return nil, nil, errors.WithStack(err)
}
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

@ -1,14 +0,0 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package model
import (
"time"
)
type User struct {
Name *string `json:"name"`
Email string `json:"email"`
ConnectedAt time.Time `json:"connectedAt"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@ -0,0 +1,29 @@
input ProfileChanges {
name: String
}
input WorkgroupChanges {
name: String
}
input DecisionSupportFileChanges {
title: String
sections: Map
status: String
workgroupId: ID
votedAt: Time
closedAt: Time
}
type Mutation {
createDecisionSupportFile(changes: DecisionSupportFileChanges): DecisionSupportFile!
updateDecisionSupportFile(id: ID!, changes: DecisionSupportFileChanges): DecisionSupportFile!
updateProfile(changes: ProfileChanges!): User!
joinWorkgroup(workgroupId: ID!): Workgroup!
leaveWorkgroup(workgroupId: ID!): Workgroup!
createWorkgroup(changes: WorkgroupChanges!): Workgroup!
closeWorkgroup(workgroupId: ID!): Workgroup!
updateWorkgroup(workgroupId: ID!, changes: WorkgroupChanges!): Workgroup!
}

View File

@ -0,0 +1,48 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"forge.cadoles.com/Cadoles/daddy/internal/graph/generated"
"forge.cadoles.com/Cadoles/daddy/internal/model"
)
func (r *mutationResolver) CreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
return handleCreateDecisionSupportFile(ctx, changes)
}
func (r *mutationResolver) UpdateDecisionSupportFile(ctx context.Context, id string, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
return handleUpdateDecisionSupportFile(ctx, id, changes)
}
func (r *mutationResolver) UpdateProfile(ctx context.Context, changes model.ProfileChanges) (*model.User, error) {
return handleUpdateUserProfile(ctx, changes)
}
func (r *mutationResolver) JoinWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
return handleJoinWorkgroup(ctx, workgroupID)
}
func (r *mutationResolver) LeaveWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
return handleLeaveWorkgroup(ctx, workgroupID)
}
func (r *mutationResolver) CreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) (*model.Workgroup, error) {
return handleCreateWorkgroup(ctx, changes)
}
func (r *mutationResolver) CloseWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
return handleCloseWorkgroup(ctx, workgroupID)
}
func (r *mutationResolver) UpdateWorkgroup(ctx context.Context, workgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) {
return handleUpdateWorkgroup(ctx, workgroupID, changes)
}
// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }
type mutationResolver struct{ *Resolver }

View File

@ -0,0 +1,52 @@
scalar Time
scalar Map
type User {
id: ID!
name: String
email: String!
connectedAt: Time!
createdAt: Time!
workgroups:[Workgroup]!
}
type Workgroup {
id: ID!
name: String
createdAt: Time!
closedAt: Time
members: [User]!
}
input WorkgroupsFilter {
ids: [ID]
}
type DecisionSupportFile {
id: ID!
title: String
sections: Map
status: String
workgroup: Workgroup
createdAt: Time
updatedAt: Time
votedAt: Time
closedAt: Time
}
input DecisionSupportFileFilter {
ids: [ID]
}
input AuthorizationObject {
workgroupId: ID
userId: ID
decisionSupportFileId: ID
}
type Query {
userProfile: User
workgroups(filter: WorkgroupsFilter): [Workgroup]!
decisionSupportFiles(filter: DecisionSupportFileFilter): [DecisionSupportFile]!
isAuthorized(action: String!, object: AuthorizationObject!): Boolean!
}

View File

@ -0,0 +1,63 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"strconv"
"forge.cadoles.com/Cadoles/daddy/internal/graph/generated"
model1 "forge.cadoles.com/Cadoles/daddy/internal/model"
)
func (r *decisionSupportFileResolver) ID(ctx context.Context, obj *model1.DecisionSupportFile) (string, error) {
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 *queryResolver) UserProfile(ctx context.Context) (*model1.User, error) {
return handleUserProfile(ctx)
}
func (r *queryResolver) Workgroups(ctx context.Context, filter *model1.WorkgroupsFilter) ([]*model1.Workgroup, error) {
return handleWorkgroups(ctx, filter)
}
func (r *queryResolver) DecisionSupportFiles(ctx context.Context, filter *model1.DecisionSupportFileFilter) ([]*model1.DecisionSupportFile, error) {
return handleDecisionSupportFiles(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
}
func (r *workgroupResolver) ID(ctx context.Context, obj *model1.Workgroup) (string, error) {
return strconv.FormatUint(uint64(obj.ID), 10), nil
}
// DecisionSupportFile returns generated.DecisionSupportFileResolver implementation.
func (r *Resolver) DecisionSupportFile() generated.DecisionSupportFileResolver {
return &decisionSupportFileResolver{r}
}
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
// User returns generated.UserResolver implementation.
func (r *Resolver) User() generated.UserResolver { return &userResolver{r} }
// Workgroup returns generated.WorkgroupResolver implementation.
func (r *Resolver) Workgroup() generated.WorkgroupResolver { return &workgroupResolver{r} }
type decisionSupportFileResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type workgroupResolver struct{ *Resolver }

View File

@ -4,4 +4,6 @@ package graph
//
// It serves as dependency injection for your app, add any dependencies you require here.
//go:generate go run github.com/99designs/gqlgen
type Resolver struct{}

View File

@ -1,16 +0,0 @@
# GraphQL schema example
#
# https://gqlgen.com/getting-started/
scalar Time
type User {
name: String
email: String!
connectedAt: Time!
createdAt: Time!
}
type Query {
userProfile: User
}

Some files were not shown because too many files have changed in this diff Show More