Compare commits

...

94 Commits

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

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

- Les créations de groupes de travail
- Les créations de dossiers d'aide à la décision
- Les dossiers dont le statut à été modifié et prêt à voté
2020-10-05 14:16:25 +02:00
wpetit 6cdbea92d1 Merge branch 'feature/events' of Cadoles/daddy into develop 2020-10-02 16:39:19 +02:00
wpetit f169169bc7 Enregistrement et affichage d'un flux d'évènements
- Ajout d'une nouvelle entité "Event"
- Affichage d'une "timeline" sur le tableau de bord
- Création semi-automatique des évènements lors des modifications par
  les utilisateurs
2020-10-02 16:37:24 +02:00
wpetit 61eacefd6c Formatage des dates dans l'interface 2020-10-01 11:44:29 +02:00
wpetit 11f54ab66e Utilisation de la stratégie de cache 'cache puis réseau' 2020-09-10 19:28:08 +02:00
wpetit 772b09381c Ajout label 'Déconnexion' 2020-09-10 19:27:43 +02:00
wpetit 978cc65c41 Dissimulation du bouton enregistrer en mode lecture seule 2020-09-10 19:27:17 +02:00
wpetit 596108b4f4 Panel DADs: ajout onglets 'à voter' et 'votés' 2020-09-10 19:26:41 +02:00
wpetit 6845e1ce50 Ajout types génériques pour autocomplétion 2020-09-10 19:25:52 +02:00
wpetit 04b32772fc Merge branch 'feature/dsf-front-acl' of Cadoles/daddy into develop 2020-09-10 11:20:16 +02:00
wpetit 17747e998d Passage en lecture seule du DAD lorsqu'il appartient à un groupe
différent
2020-09-10 11:12:58 +02:00
wpetit 12151ff613 Merge branch 'feature/authorization' of Cadoles/daddy into develop 2020-09-08 10:18:06 +02:00
wpetit 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
wpetit 7dad33b6e4 Correction récupération/fusion des Workgroups 2020-09-04 12:28:38 +02:00
wpetit 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
wpetit 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
wpetit bc56c9dbae Ajout URLs manquantes directement gérées par le client 2020-08-31 16:13:10 +02:00
tcornaut c95fbf6915 Merge branch 'feature/options' of Cadoles/daddy into develop 2020-08-31 15:32:39 +02:00
Teddy Cornaut 952b1b6a8d Correction des variables en constantes 2020-08-31 15:29:38 +02:00
Teddy Cornaut 4d5251c724 Ajout bs58 2020-08-31 15:10:30 +02:00
Teddy Cornaut 44d4db079a Déplacement package-lock 2020-08-31 15:07:00 +02:00
Teddy Cornaut 089d91a84c Suppression des options OK + CSS 2020-08-31 13:18:39 +02:00
Teddy Cornaut 2d66888ed3 Modification des options OK 2020-08-31 12:55:33 +02:00
Teddy Cornaut 406202ddc4 Permettre de gérer les options proposées dans un DAD 2020-08-28 16:00:36 +02:00
wpetit 9cb5a63cc9 Ignorer les variables de proxy pour la connexion au service Hydra 2020-08-27 10:22:10 +02:00
wpetit 0fe6e1f07a Mise à jour version image bornholm/hydra-passwordless 2020-08-27 08:37:17 +02:00
wpetit d6eae3a7d3 Merge branch 'feature/dad' of Cadoles/daddy into develop 2020-08-26 14:52:53 +02:00
wpetit 39d266f701 Création/mise à jour basique d'un DAD 2020-08-26 14:51:53 +02:00
wpetit f03a0c96dc Ajout du Konami code réglementaire 2020-08-26 09:16:05 +02:00
wpetit 32c19bace3 Ajout d'un filtre de connexion configurable pour l'utilisateur 2020-08-13 10:29:52 +02:00
wpetit 5790c91d82 Routes privées avec page d'accueil publique 2020-08-13 10:16:00 +02:00
wpetit 680614148c Base édition nouveau DAD 2020-08-05 17:53:52 +02:00
wpetit fc4912882a Base de la page de création/édition d'un DAD 2020-08-05 13:31:19 +02:00
wpetit ac41b301a9 Refactoring du tableau de bord et ajout du panel pour les DADs 2020-07-31 17:36:10 +02:00
wpetit c0ee95234d Prise en compte des routes gérées par le client côté serveur 2020-07-23 08:57:49 +02:00
wpetit 8d9d839acf Merge branch 'feature/workgroups' of Cadoles/daddy into develop 2020-07-23 08:28:38 +02:00
wpetit e990184a0b Clore un groupe de travail 2020-07-23 08:28:23 +02:00
wpetit 4a340529da Créer/modifier/rejoindre/quitter un groupe de travail 2020-07-23 08:28:23 +02:00
wpetit bc9aa1721a Remplacement du Loader par WithLoader 2020-07-23 08:28:23 +02:00
wpetit c4373cce46 Remplacement de Redux/Saga par Apollo 2020-07-23 08:28:23 +02:00
wpetit 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
wpetit 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
wpetit 7bf4c4f080 Base de tableau de bord 2020-07-23 08:28:23 +02:00
wpetit 303ea6b1d6 Stockage des sessions en base de données via GORM 2020-07-17 09:39:37 +02:00
wpetit ccf911322b Correction make up 2020-07-17 09:39:02 +02:00
wpetit 0cb6c7c67e Merge branch 'feature/user-profile' of Cadoles/daddy into develop 2020-07-17 09:25:50 +02:00
wpetit 08bd11f4d9 Simplification Makefile 2020-07-16 22:51:26 +02:00
wpetit 0d308acd5c Ajout script/commande de release 2020-07-16 22:31:02 +02:00
wpetit 36c253d4d7 Correction nom projet client 2020-07-16 22:30:03 +02:00
wpetit ed219ddd11 Correction typo annotation 2020-07-16 22:29:33 +02:00
wpetit 758c166f27 Simple page de modification de profil 2020-07-16 20:21:58 +02:00
wpetit 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
wpetit 8b8f322630 Récupération automatique du profil au lancement de l'application 2020-07-13 18:49:44 +02:00
wpetit 3bcebdfcd1 Merge branch 'feature/go-server' of Cadoles/daddy into develop 2020-07-13 15:50:20 +02:00
wpetit d0228b6c11 Exécuter make generate avant le make deps 2020-07-13 15:07:55 +02:00
wpetit 00e331b985 Correction génération resolvers GraphQL 2020-07-13 14:55:32 +02:00
wpetit 3fd8bf7e69 Auto-création du compte utilisateur à la première connexion
- Sauvegarde de l'adresse courriel de l'utilisateur en session
- Implémentation d'une première Query GraphQL pour récupérer le profil
  de l'utilisateur connecté
- Utilisation de la pattern CQRS pour les commandes/requêtes sur la base
  de données
2020-07-13 14:44:05 +02:00
wpetit a096b506e2 Correction procédure de démarrage et mise à jour du README 2020-07-13 12:01:20 +02:00
wpetit 591112a800 Intégration d'un point d'entrée GraphQL et d'un connecteur pour
PostgreSQL

- Possibilité de migrer le schéma de la base de données via drapeau
- Génération du code GraphQL avec https://gqlgen.com/
2020-07-13 09:20:14 +02:00
wpetit 1120474ad9 Utilisation d'un serveur Go custom pour le backend au lieu de
super-graph

Malheureusement, super-graph n'a pas tenu les promesses qu'il semblait
annoncer.

Je propose donc de basculer sur un serveur Go classique (via goweb).
L'authentification OpenID Connect étant gérée côté backend et non plus
côté frontend.
2020-07-12 19:14:46 +02:00
tcornaut ff70a6d570 Merge branch 'feature/super-graph-auth' of Cadoles/daddy into develop 2020-06-22 21:28:40 +02:00
173 changed files with 8863 additions and 1349 deletions

7
.env.dist Normal file
View File

@ -0,0 +1,7 @@
DEBUG=true
OIDC_CLIENT_ID=daddy
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 port=5432 sslmode=disable"

5
.gitignore vendored Normal file
View File

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

View File

@ -1,17 +1,28 @@
build:
SHELL := /bin/bash
build: build-server
docker:
docker-compose build
deps:
cd frontend && npm install
generate:
go generate ./...
up: build
( cd frontend && NODE_ENV=development npm run server ) & USER_ID=$(shell id -u) docker-compose up && wait
build-server:
CGO_ENABLED=0 go build -v -o ./bin/server ./cmd/server
sg:
docker-compose exec -u $(shell id -u) super-graph sh
deps: generate
cd client && npm install
go get ./...
sgr:
docker-compose run -u $(shell id -u) super-graph sh
client-dist:
cd client && NODE_ENV=production npm run build
up: docker
docker-compose up
watch:
go run github.com/cortesi/modd/cmd/modd
down:
docker-compose down -v --remove-orphans
@ -19,5 +30,29 @@ down:
db-shell:
docker-compose exec postgres psql -Udaddy
migrate: build-server
( set -o allexport && source .env && set +o allexport && bin/server -workdir "./cmd/server" -config ../../data/config.yml -migrate $(MIGRATE) )
migrate-latest:
$(MAKE) MIGRATE=latest migrate
migrate-up:
$(MAKE) MIGRATE=up migrate
migrate-down:
$(MAKE) MIGRATE=down migrate
test:
go test -v ./...
hydra-shell:
docker-compose exec hydra /bin/sh
docker-compose exec hydra /bin/sh
.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

@ -17,39 +17,45 @@ Application de gestion des Dossiers d'Aide à la Décision (D.A.D.) à Cadoles.
```bash
git clone https://forge.cadoles.com/Cadoles/daddy.git # Cloner le projet
cd daddy # Se placer dans le répertoire
make deps # Installer les dépendances NPM
make up # Démarrer l'environnement de développement
make clean # On s'assure d'avoir un environnement propre
make deps # Installer les dépendances
make up # Démarrer l'environnement docker-compose (hydra, hydra-passwordless et fake-smtp)
# Dans un second terminal
make watch # Suivre les modifications et compiler à la volée le backend et frontend
```
Les services suivants devraient être disponibles après démarrage de l'environnement:
|Service|Type|Accès|Description|
|-------|----|-----|-----------|
|Application React|HTTP (UI)|http://localhost:8081/|Page d'accueil de l'application React (serveur Webpack)|
|Interface Web GraphQL|HTTP (UI)|http://localhost:8080/|Interface Web de développement de l'API GraphQL **\***|
|Serveur GraphQL|HTTP (GraphQL)|http://localhost:8080/api/v1/graphql|Point d'entrée de l'API GraphQL|
|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/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)|
|Serveur FakeSMTP|HTTP|http://localhost:8082|Interface web du serveur [FakeSMTP](https://forge.cadoles.com/wpetit/fake-smtp)
|Serveur PostgreSQL|TCP/IP (PostgreSQL)|`127.0.0.1:5432`|Port de connexion à la base de données PostgreSQL de développement|
**\*** Pensez à passer l'attribut `auth_fail_block: false` dans le fichier `backend/config/dev.yml` si vous voulez pouvoir utiliser cette interface sans avoir à définir l'entête `Authorization`.
#### Fichiers/répertoires notables
|Chemin|Description|
|------------------|-----------|
|`docker-compose.yml`|Configuration de l'environnement Docker Compose|
|`frontend/src`|Sources du frontend ([React](https://reactjs.org))|
|`backend/config/migrations`|Migrations SQL pour le backend, voir [la documentation de SuperGraph à ce sujet](https://supergraph.dev/docs/start#migrations)|
|`client/src`|Sources du frontend ([React](https://reactjs.org))|
#### Commandes utiles
|Commande|Description|
|--------|-----------|
|`make up`|Démarrer l'environnement de développement, `Ctrl+C` pour le stopper.|
|`make down`|Stopper et supprimer l'environnement de développement.|
|`make up`|Démarrer l'environnement Docker Compose, `Ctrl+C` pour le stopper.|
|`make down`|Stopper et supprimer l'environnement Docker Compose.|
|`make watch`|Suerveiller les sources et recompiler à la volée le client/server.|
|`make db-shell`|Ouvrir une console `psql` sur la base de données de développement.|
|`make hydra-shell`|Ouvrir un shell interactif dans le conteneur Hydra. (`hydra --help` pour voir les commandes disponibles pour l'administration)|
|`make migrate-latest`|Migrer la base de données à la dernière version disponible du schéma.|
|`make migrate-down`|Migrer la base de données à la version précédente du schéma.|
|`make migrate-up`|Migrer la base de données à la version suivante du schéma.|
|`make clean`|Nettoyer l'environnement.|
#### Ressources

View File

@ -1,18 +0,0 @@
/* fetchUser */
variables {
"email": ""
}
query fetchUser {
user(where: {email: {eq: $email}}) {
id
created_at
updated_at
email,
full_name
}
}

View File

@ -1,225 +0,0 @@
app_name: "Daddy Dev"
host_port: 0.0.0.0:8080
web_ui: true
# debug, error, warn, info
log_level: debug
# enable or disable http compression (uses gzip)
http_compress: true
# When production mode is 'true' only queries
# from the allow list are permitted.
# When it's 'false' all queries are saved to the
# the allow list in ./config/allow.list
production: false
# Throw a 401 on auth failure for queries that need auth
auth_fail_block: true
# Latency tracing for database queries and remote joins
# the resulting latency information is returned with the
# response
enable_tracing: true
# Watch the config folder and reload Super Graph
# with the new configs when a change is detected
reload_on_config_change: true
# File that points to the database seeding script
# seed_file: seed.js
# Path pointing to where the migrations can be found
# this must be a relative path under the config path
migrations_path: ./migrations
# Secret key for general encryption operations like
# encrypting the cursor data
secret_key: supercalifajalistics
# CORS: A list of origins a cross-domain request can be executed from.
# If the special * value is present in the list, all origins will be allowed.
# An origin may contain a wildcard (*) to replace 0 or more
# characters (i.e.: http://*.domain.com).
cors_allowed_origins: ["*"]
cors_allowed_headers: ["Authorization", "Content-Type", "Mode"]
cors_allowed_methods: ["POST"]
# Debug Cross Origin Resource Sharing requests
cors_debug: false
# Postgres related environment Variables
# SG_DATABASE_HOST
# SG_DATABASE_PORT
# SG_DATABASE_USER
# SG_DATABASE_PASSWORD
# Auth related environment Variables
# SG_AUTH_RAILS_COOKIE_SECRET_KEY_BASE
# SG_AUTH_RAILS_REDIS_URL
# SG_AUTH_RAILS_REDIS_PASSWORD
# SG_AUTH_JWT_PUBLIC_KEY_FILE
# inflections:
# person: people
# sheep: sheep
auth:
# Can be 'rails', 'jwt' or 'header'
type: jwt
#cookie: _supergraph_session
# Comment this out if you want to disable setting
# the user_id via a header for testing.
# Disable in production
#creds_in_header: false
jwt:
provider: hydra
jwks_url: http://hydra:4444/.well-known/jwks.json
# header:
# name: dnt
# exists: true
# value: localhost:8080
# You can add additional named auths to use with actions
# In this example actions using this auth can only be
# called from the Google Appengine Cron service that
# sets a special header to all it's requests
# auths:
# - name: from_taskqueue
# type: header
# header:
# name: X-Appengine-Cron
# exists: true
database:
type: postgres
host: localhost
port: 5432
dbname: daddy
user: daddy
password: daddy
#schema: "public"
#pool_size: 10
#max_retries: 0
log_level: "debug"
# Set session variable "user.id" to the user id
# Enable this if you need the user id in triggers, etc
set_user_id: true
# database ping timeout is used for db health checking
ping_timeout: 1m
# Define additional variables here to be used with filters
variables:
# admin_account_id: "5"
# admin_account_id: "sql:select id from users where admin = true limit 1"
# Field and table names that you wish to block
blocklist:
- ar_internal_metadata
- schema_migrations
- secret
- password
- encrypted
- token
# Create custom actions with their own api endpoints
# For example the below action will be available at /api/v1/actions/refresh_leaderboard_users
# A request to this url will execute the configured SQL query
# which in this case refreshes a materialized view in the database.
# The auth_name is from one of the configured auths
actions:
# - name: refresh_leaderboard_users
# sql: REFRESH MATERIALIZED VIEW CONCURRENTLY "leaderboard_users"
# auth_name: from_taskqueue
tables:
# - name: customers
# remotes:
# - name: payments
# id: stripe_id
# url: http://rails_app:3000/stripe/$id
# path: data
# # debug: true
# pass_headers:
# - cookie
# set_headers:
# - name: Host
# value: 0.0.0.0
# - name: Authorization
# value: Bearer <stripe_api_key>
# - # You can create new fields that have a
# # real db table backing them
# name: me
# table: users
roles_query: "select * from users where users.email = $user_id"
roles:
# Rôle par défaut si l'utilisateur n'existe pas dans la table `users`
- name: anon
tables:
- name: users
# insert:
# block: true
# query:
# block: true
# update:
# block: true
# delete:
# block: true
# Rôle par défaut si l'utilisateur existe dans la table `users`
# mais que la valeur de la colonne `role` n'est pas définie
- name: user
tables:
- name: users
insert:
block: true
query:
filters: ["{ email: { _eq: $user_id } }"]
update:
columns:
- full_name
filters: ["{ email: { _eq: $user_id } }"]
delete:
block: true
- name: admin
match: role = 'admin'
tables:
- name: users
# - name: products
# query:
# limit: 50
# filters: ["{ user_id: { eq: $user_id } }"]
# disable_functions: false
# insert:
# filters: ["{ user_id: { eq: $user_id } }"]
# presets:
# - user_id: "$user_id"
# - created_at: "now"
# update:
# filters: ["{ user_id: { eq: $user_id } }"]
# presets:
# - updated_at: "now"
# delete:
# block: true
# - name: admin
# match: id = 1000
# tables:
# - name: users
# filters: []

View File

@ -1,17 +0,0 @@
-- Write your migrate up statements here
CREATE TABLE public.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
full_name text,
email text UNIQUE NOT NULL CHECK (length(email) < 255),
created_at timestamptz NOT NULL NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL NOT NULL DEFAULT NOW(),
role varchar(64)
);
---- create above / drop below ----
-- Write your down migrate statements here. If this migration is irreversible
-- then delete the separator line above.
DROP TABLE public.users

View File

@ -1,67 +0,0 @@
# Inherit config from this other config file
# so I only need to overwrite some values
inherits: dev
app_name: "Backend Production"
host_port: 0.0.0.0:8080
web_ui: false
# debug, info, warn, error, fatal, panic, disable
log_level: "warn"
# enable or disable http compression (uses gzip)
http_compress: true
# When production mode is 'true' only queries
# from the allow list are permitted.
# When it's 'false' all queries are saved to the
# the allow list in ./config/allow.list
production: true
# Throw a 401 on auth failure for queries that need auth
auth_fail_block: true
# Latency tracing for database queries and remote joins
# the resulting latency information is returned with the
# response
enable_tracing: true
# File that points to the database seeding script
# seed_file: seed.js
# Path pointing to where the migrations can be found
# migrations_path: migrations
# Secret key for general encryption operations like
# encrypting the cursor data
# secret_key: supercalifajalistics
# Postgres related environment Variables
# SG_DATABASE_HOST
# SG_DATABASE_PORT
# SG_DATABASE_USER
# SG_DATABASE_PASSWORD
# Auth related environment Variables
# SG_AUTH_RAILS_COOKIE_SECRET_KEY_BASE
# SG_AUTH_RAILS_REDIS_URL
# SG_AUTH_RAILS_REDIS_PASSWORD
# SG_AUTH_JWT_PUBLIC_KEY_FILE
database:
type: postgres
host: db
port: 5432
dbname: backend_development
user: postgres
password: postgres
#pool_size: 10
#max_retries: 0
#log_level: "debug"
# Set session variable "user.id" to the user id
# Enable this if you need the user id in triggers, etc
set_user_id: false
# database ping timeout is used for db health checking
ping_timeout: 5m

View File

@ -1,33 +0,0 @@
// Voir https://supergraph.dev/docs/seed
var users = [
{
full_name: 'Admin',
email: 'admin@cadoles.com',
role: 'admin',
},
{
full_name: 'User 1',
email: 'user1@cadoles.com',
role: 'user',
},
{
full_name: 'User 2',
email: 'user2@cadoles.com',
role: 'user',
},
{
full_name: 'User 3',
email: 'user3@cadoles.com',
role: 'user',
}
];
for (var user, i = 0; (user = users[i]); i++) {
var res = graphql(" \
mutation { \
user(insert: $data) { \
id \
} \
}", { data: user });
}

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",
@ -1088,9 +1125,9 @@
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
},
"@fortawesome/fontawesome-free": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz",
"integrity": "sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg==",
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.0.tgz",
"integrity": "sha512-wXetjQBNMTP59MAYNR1tdahMDOLx3FYj3PKdso7PLFLDpTvmAIqhSSEqnSTmWKahRjD+Sh5I5635+5qaoib5lw==",
"dev": true
},
"@nodelib/fs.scandir": {
@ -1372,6 +1409,11 @@
}
}
},
"@types/zen-observable": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz",
"integrity": "sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg=="
},
"@webassemblyjs/ast": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
@ -1547,6 +1589,14 @@
"@xtuc/long": "4.2.2"
}
},
"@wry/equality": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz",
"integrity": "sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA==",
"requires": {
"tslib": "^1.9.3"
}
},
"@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@ -1875,8 +1925,7 @@
"async-limiter": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
"dev": true
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
},
"asynckit": {
"version": "0.4.0",
@ -2724,6 +2773,11 @@
"integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==",
"dev": true
},
"backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
"integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@ -2785,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",
@ -3073,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",
@ -3123,14 +3193,14 @@
"dev": true
},
"bulma": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.7.5.tgz",
"integrity": "sha512-cX98TIn0I6sKba/DhW0FBjtaDpxTelU166pf7ICXpCCuplHWyu6C9LYZmL5PEsnePIeJaiorsTEzzNk3Tsm1hw=="
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz",
"integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ=="
},
"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=="
"bulma-timeline": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/bulma-timeline/-/bulma-timeline-3.0.4.tgz",
"integrity": "sha512-gCUOcSUuzHoeVMkCpLF49j5Z5yl78XQ+KgJcT+1ju5WIGgBgVytRUob/dw5NHAxPLO2rmcvwYNbCJFp7w4WT4Q=="
},
"bytes": {
"version": "3.0.0",
@ -3965,6 +4035,11 @@
"randomfill": "^1.0.3"
}
},
"crypto-js": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz",
"integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg=="
},
"css": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
@ -4419,9 +4494,9 @@
"dev": true
},
"elliptic": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
"dev": true,
"requires": {
"bn.js": "^4.4.0",
@ -5029,8 +5104,7 @@
"fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"fastparse": {
"version": "1.1.2",
@ -5350,6 +5424,11 @@
"integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==",
"dev": true
},
"get-browser-rtc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.0.2.tgz",
"integrity": "sha1-u81AyEUaftTvXDc7gWmkCd0dEdk="
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -5499,10 +5578,15 @@
"integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
"dev": true
},
"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": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz",
"integrity": "sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w=="
},
"graphql-tag": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.4.tgz",
"integrity": "sha512-O7vG5BT3w6Sotc26ybcvLKNTdfr4GfsIVMD+LdYqXCeJIYPRyp8BIsDOUtxw7S1PYvRw5vH3278J2EDezR6mfA=="
},
"handle-thing": {
"version": "2.0.1",
@ -5981,8 +6065,7 @@
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ini": {
"version": "1.3.5",
@ -6302,12 +6385,22 @@
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
"dev": true
},
"isomorphic.js": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.1.5.tgz",
"integrity": "sha512-MkX5lLQApx/8IAIU31PKvpAZosnu2Jqcj1rM8TzxyA4CR96tv3SgMKQNTCxL58G7696Q57zd7ubHV/hTg+5fNA=="
},
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
"dev": true
},
"iterall": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz",
"integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg=="
},
"js-base64": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.2.tgz",
@ -6381,11 +6474,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",
@ -6422,6 +6510,14 @@
"leven": "^3.1.0"
}
},
"lib0": {
"version": "0.2.34",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.34.tgz",
"integrity": "sha512-cqsVIMPgFlDtgQcpkt7HOY6W3sbYPIe3qxMnbRSwHTgiQancgm+TRDPx28mC6GUZ6lG6Nr0bIWf4Nog6dWUNUg==",
"requires": {
"isomorphic.js": "^0.1.3"
}
},
"load-json-file": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
@ -6482,9 +6578,9 @@
}
},
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
},
"loglevel": {
"version": "1.6.8",
@ -6983,9 +7079,9 @@
}
},
"node-forge": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz",
"integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz",
"integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==",
"dev": true
},
"node-gyp": {
@ -7914,11 +8010,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",
@ -7937,11 +8028,15 @@
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==",
"dev": true
},
"queue-microtask": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.1.4.tgz",
"integrity": "sha512-eY/4Obve9cE5FK8YvC1cJsm5cr7XvAurul8UtBDJ2PR1p5NmAwHtvAt5ftcLtwYRCUKNhxCneZZlxmUDFoSeKA=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"requires": {
"safe-buffer": "^5.1.0"
}
@ -8583,8 +8678,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",
@ -8682,12 +8776,12 @@
"dev": true
},
"selfsigned": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.7.tgz",
"integrity": "sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==",
"version": "1.10.8",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.8.tgz",
"integrity": "sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==",
"dev": true,
"requires": {
"node-forge": "0.9.0"
"node-forge": "^0.10.0"
}
},
"semver": {
@ -8900,6 +8994,30 @@
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
"dev": true
},
"simple-peer": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.7.2.tgz",
"integrity": "sha512-xeMyxa9B4V0eA6mf17fVr8nm2QhAYFu+ZZv8zkSFFTjJETGF227CshwobrIYZuspJglMD63egcevQXGOrTIsuA==",
"requires": {
"debug": "^4.0.1",
"get-browser-rtc": "^1.0.0",
"queue-microtask": "^1.1.0",
"randombytes": "^2.0.3",
"readable-stream": "^3.4.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
}
}
},
"slash": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
@ -9329,7 +9447,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.0"
}
@ -9420,6 +9537,33 @@
"resolved": "https://registry.npmjs.org/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz",
"integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw=="
},
"subscriptions-transport-ws": {
"version": "0.9.17",
"resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.17.tgz",
"integrity": "sha512-hNHi2N80PBz4T0V0QhnnsMGvG3XDFDS9mS6BhZ3R12T6EBywC8d/uJscsga0cVO4DKtXCkCRrWm2sOYrbOdhEA==",
"requires": {
"backo2": "^1.0.2",
"eventemitter3": "^3.1.0",
"iterall": "^1.2.1",
"symbol-observable": "^1.0.4",
"ws": "^5.2.0"
},
"dependencies": {
"eventemitter3": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
},
"ws": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz",
"integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==",
"requires": {
"async-limiter": "~1.0.0"
}
}
}
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@ -9658,6 +9802,14 @@
"glob": "^7.1.2"
}
},
"ts-invariant": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz",
"integrity": "sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA==",
"requires": {
"tslib": "^1.9.3"
}
},
"ts-loader": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-7.0.5.tgz",
@ -9725,8 +9877,7 @@
"tslib": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz",
"integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==",
"dev": true
"integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q=="
},
"tty-browserify": {
"version": "0.0.0",
@ -10012,8 +10163,7 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"dev": true
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
},
"util.promisify": {
"version": "1.0.0",
@ -10709,6 +10859,33 @@
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true
},
"y-protocols": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.1.tgz",
"integrity": "sha512-QP3fCM7c2gGfUi2nqf8gspyO4VW23zv3kNqPNdD3wNxMbuNQenMyoDVZYEo12jzR4RQ3aaDfPK62Sf31SVOmfg==",
"requires": {
"lib0": "^0.2.28"
}
},
"y-webrtc": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.1.6.tgz",
"integrity": "sha512-b3pTIv9LcPuMb4nbDT3/kkgmcuQoTrBmaPbBqPH1LJMzI8HwYnMK8p5r0fBQJBI0YRor+i8BT15Evv1nQBP0zg==",
"requires": {
"lib0": "^0.2.32",
"simple-peer": "^9.7.2",
"ws": "^7.2.0",
"y-protocols": "^1.0.0"
},
"dependencies": {
"ws": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz",
"integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==",
"optional": true
}
}
},
"y18n": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
@ -10833,6 +11010,19 @@
"dev": true
}
}
},
"yjs": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.4.1.tgz",
"integrity": "sha512-kIh0sprCTzIm2qyr1VsovkvjKzD2GR4WcU/McJpLAEvImCJHA78Q3S6uSLnhZX0i7FQdrLPCRT8DtTPEH73jnw==",
"requires": {
"lib0": "^0.2.33"
}
},
"zen-observable": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
"integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ=="
}
}
}

View File

@ -1,5 +1,5 @@
{
"name": "dadd-",
"name": "daddy",
"version": "0.0.0",
"description": "Daddy",
"main": "index.js",
@ -25,7 +25,7 @@
"@babel/plugin-transform-runtime": "^7.7.4",
"@babel/preset-env": "^7.7.1",
"@babel/preset-react": "^7.7.4",
"@fortawesome/fontawesome-free": "^5.11.2",
"@fortawesome/fontawesome-free": "^5.14.0",
"@types/node": "^13.13.4",
"@types/react-dom": "^16.9.7",
"@types/react-redux": "^7.1.7",
@ -51,12 +51,13 @@
"webpack-dev-server": "^3.11.0"
},
"dependencies": {
"@apollo/client": "^3.0.2",
"@types/qs": "^6.9.3",
"bulma": "^0.7.2",
"bulma-switch": "^2.0.0",
"graphql-request": "^2.0.0",
"jwt-decode": "^2.2.0",
"qs": "^6.9.4",
"bs58": "^4.0.1",
"bulma": "^0.9.0",
"bulma-timeline": "^3.0.4",
"crypto-js": "^4.0.0",
"graphql": "^15.3.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.1.3",
@ -65,6 +66,9 @@
"redux": "^4.0.4",
"redux-saga": "^1.1.3",
"styled-components": "^4.4.1",
"typescript": "^3.8.3"
"subscriptions-transport-ws": "^0.9.17",
"typescript": "^3.8.3",
"y-webrtc": "^10.1.6",
"yjs": "^13.4.1"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,17 @@
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>
);
}
export default DashboardPage;

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

View File

@ -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,140 @@
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="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>
</section>
);
};

View File

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

View File

@ -0,0 +1,164 @@
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';
import { TimelinePanel } from './TimelinePanel';
import { DecisionReportSection } from './DecisionReportSection';
import { RoutedTabs } from '../RoutedTabs';
export interface DecisionSupportFilePageProps {
};
export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageProps> = () => {
const { id } = useParams<any>();
const history = useHistory();
const { decisionSupportFiles } = useDecisionSupportFiles({
variables:{
filter: {
ids: id !== 'new' ? [id] : undefined,
}
},
});
const [ state, setState ] = useState({
dsf: newDecisionSupportFile(),
saved: true,
});
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 tabs = [
{
name: "Clarifier la proposition",
icon: "fas fa-pen",
route: '/info',
render: () => (<ClarificationSection readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />)
},
{
name: "Explorer les options",
icon: "fas fa-search",
route: '/options',
render: () => (<OptionsSection readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />)
},
{
name: "Prendre la décision",
icon: "fas fa-person-booth",
route: '/vote',
render: () => (<DecisionReportSection readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />)
}
];
const updateDSF = (dsf: DecisionSupportFile) => {
setState(state => {
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-8">
<div className="box">
<RoutedTabs baseRoute={`/decisions/${id}`} tabs={tabs} />
</div>
</div>
<div className="column is-4">
<MetadataPanel readOnly={!isAuthorized} dsf={state.dsf} updateDSF={updateDSF} />
{/* <AppendixPanel dsf={state.dsf} /> */}
<TimelinePanel dsf={state.dsf} />
</div>
</div>
</div>
</Page>
);
};
export default DecisionSupportFilePage;

View File

@ -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, formatDate } 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>{formatDate(dsf.createdAt)}</p>
</div>
</div>
<div className="field">
<div className="label">Voté le</div>
<div className="control">
<p>{dsf.votedAt ? formatDate(dsf.votedAt) : '--'}</p>
</div>
</div>
</div>
</div>
</nav>
);
};

View File

@ -0,0 +1,158 @@
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">Explorer les options</h4>
<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>
</section>
);
};

View File

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

View File

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

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

@ -0,0 +1,118 @@
import React, { FunctionComponent, useState, useEffect } from "react";
import { Link } from "react-router-dom";
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 disabled={true} 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,17 @@
import React, { FunctionComponent, useEffect } from "react";
import { saveLoggedIn } from "../hooks/useLoggedIn";
import { Config } from "../config";
export interface LogoutPageProps {
}
export const LogoutPage: FunctionComponent<LogoutPageProps> = () => {
useEffect(() => {
saveLoggedIn(false);
window.location.replace(Config.logoutURL);
}, []);
return null;
};
export default LogoutPage;

View File

@ -0,0 +1,81 @@
import React, { Fragment, useState } from 'react';
import logo from '../resources/logo.svg';
import { Config } from '../config';
import { Link } from 'react-router-dom';
import { useLoggedIn } from '../hooks/useLoggedIn';
export function Navbar() {
const loggedIn = useLoggedIn();
const [ isActive, setActive ] = useState(false);
const toggleMenu = () => {
setActive(active => !active);
};
return (
<nav className="navbar is-fixed-top" role="navigation" aria-label="main navigation">
<div className="container is-fluid">
<div className="navbar-brand">
<Link className="navbar-item" to="/">
<img src={logo} style={{marginRight:'5px',width:'28px',height:'28px'}} />
<h1 className="is-size-4">Daddy</h1>
</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 ${isActive ? 'is-active' : ''}`}>
<div className="navbar-start">
{
loggedIn ?
<React.Fragment>
<Link to="/dashboard" className="navbar-item">
<i className="fa fa-columns"></i>&nbsp;Tableau de bord
</Link>
<Link to="/conference" className="navbar-item">
<i className="fa fa-users"></i>&nbsp;Conférence
</Link>
</React.Fragment> :
null
}
</div>
<div className="navbar-end">
<div className="navbar-item">
<div className="buttons">
{
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>
</div>
</nav>
);
};

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,37 @@
import React from 'react';
import { Page } from '../Page';
import { UserForm } from '../UserForm';
import { User } from '../../types/user';
import { useUserProfile } from '../../gql/queries/profile';
import { useUpdateUserProfileMutation } from '../../gql/mutations/profile';
export function ProfilePage() {
const { user, loading } = useUserProfile();
const [ updateProfile, updateUserProfileMutation ] = useUpdateUserProfileMutation();
const isLoading = updateUserProfileMutation.loading || loading;
const onUserChange = (user: User) => {
if (user.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">
<div className="box">
<h2 className="is-size-2 subtitle">Mon profil</h2>
{ !isLoading ? <UserForm onChange={onUserChange} user={user} /> : null }
</div>
</div>
</div>
</section>
</div>
</Page>
);
}
export default ProfilePage;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,81 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { User } from '../types/user';
import { formatDate } from '../util/date';
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">{formatDate(state.user.connectedAt)}</p>
</div>
</div>
<div className="field">
<label className="label">Date de création</label>
<div className="control">
<p className="input is-static">{formatDate(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,24 @@
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { useWorkgroups } from "../gql/queries/workgroups";
export interface WorkgroupLinkProps {
workgroupId: number
}
export const WorkgroupLink: FunctionComponent<WorkgroupLinkProps> = ({ workgroupId }) => {
const { workgroups } = useWorkgroups({
fetchPolicy: "cache-first",
variables: {
filter: {
ids: [workgroupId]
}
}
});
const workgroupName = workgroups.length > 0 ? workgroups[0].name : `#${workgroupId}`;
return (
<Link to={`/workgroups/${workgroupId}`}>{workgroupName}</Link>
);
};

View File

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

View File

@ -0,0 +1,108 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { Workgroup } from '../../types/workgroup';
import { useIsAuthorized } from '../../gql/queries/authorization';
import { formatDate } from '../../util/date';
export interface InfoFormProps {
workgroup: Workgroup
onChange?: (workgroup: Workgroup) => void
}
export function InfoForm({ workgroup, onChange }: InfoFormProps) {
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">{formatDate(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">{formatDate(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,48 @@
import React, { FunctionComponent } from 'react';
import { Workgroup } from '../../types/workgroup';
import { InfoForm } from './InfoForm';
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">
{ !isLoading ? <InfoForm workgroup={workgroup} onChange={onWorkgroupChange} /> : null }
</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,37 @@
import React, { FunctionComponent, useState } from 'react';
import { DecisionSupportFile } from '../../types/decision';
import { Timeline } from '../Timeline';
import { useEvents } from '../../gql/queries/event';
import { Workgroup } from '../../types/workgroup';
export interface TimelinePanelProps {
workgroup: Workgroup,
};
export const TimelinePanel: FunctionComponent<TimelinePanelProps> = ({ workgroup }) => {
const { events } = useEvents({
variables: {
filter: {
objectType: 'workgroup',
objectId: workgroup.id
}
}
});
return (
<div className="panel">
<div className="level panel-heading mb-0">
<div className="level-left">
<div className="level-item">
Suivi des opérations
</div>
</div>
<div className="level-right">
</div>
</div>
<div className="panel-block">
<Timeline events={events} />
</div>
</div>
);
};

View File

@ -0,0 +1,155 @@
import React, { useEffect, useState, Fragment } from 'react';
import { Page } from '../Page';
import { useParams } from 'react-router';
import { useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfile } from '../../gql/queries/profile';
import { MembersPanel } from './MembersPanel';
import { User } from '../../types/user';
import { InfoPanel } from './InfoPanel';
import { Workgroup } from '../../types/workgroup';
import { useJoinWorkgroupMutation, useLeaveWorkgroupMutation, useCloseWorkgroupMutation } from '../../gql/mutations/workgroups';
import { TimelinePanel } from './TimelinePanel';
import { DecisionSupportFilePanel } from './DecisionSupportFilePanel';
export function WorkgroupPage() {
const { id } = useParams<any>();
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 is-4">
<InfoPanel workgroup={state.workgroup as Workgroup} />
</div>
<div className="column is-4">
<MembersPanel users={state.workgroup.members as User[]} />
<DecisionSupportFilePanel workgroupId={state.workgroup.id} />
</div>
<div className="column is-4">
<TimelinePanel workgroup={state.workgroup} />
</div>
</div>
</section>
</div>
</Page>
);
}
export default WorkgroupPage;

17
client/src/config.ts Normal file
View File

@ -0,0 +1,17 @@
export const Config = {
loginURL: get<string>("loginURL", "http://localhost:8081/login"),
logoutURL: get<string>("logoutURL", "http://localhost:8081/logout"),
graphQLEndpoint: get<string>("graphQLEndpoint", "http://localhost:8081/api/v1/graphql"),
subscriptionEndpoint: get<string>("subscriptionEndpoint", "ws://localhost:8081/api/v1/graphql"),
conferenceHeartbeatInterval: get<number>("conferenceHeartbeatInterval", 10000),
frontendBaseURL: get<string>("frontendBaseURL", window.location.protocol + '//' + window.location.host + '/'),
};
function get<T>(key: string, defaultValue: T):T {
const config = window['__CONFIG__'] || {};
if (config && config.hasOwnProperty(key)) {
return config[key] as T;
} else {
return defaultValue;
}
}

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

View File

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

View File

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

View File

@ -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,22 @@
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) {
window.localStorage.setItem(LOGGED_IN_KEY, JSON.stringify(loggedIn));
}
export function getSavedLoggedIn(): boolean {
try {
const loggedIn = JSON.parse(window.localStorage.getItem(LOGGED_IN_KEY));
return !!loggedIn;
} catch(err) {
return false;
}
}

View File

@ -1,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

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

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

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

View File

@ -0,0 +1,27 @@
html, body {
height: 100%;
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 {
height: 100%;
}
.has-margin-top-normal {
margin-top: $size-normal;
}
.has-padding-small {
padding: 1rem;
}
#app {
display: flex;
flex-direction: column;
}
.panel {
background-color: #ffffff;
}

View File

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

View File

@ -0,0 +1,16 @@
.app-loader {
@extend body;
display: flex;
position: absolute;
z-index: 10000;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 100vh;
width: 100vw;
align-items: center;
justify-content: center;
flex-direction: column;
}

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(),
};
}

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

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

7
client/src/types/user.ts Normal file
View File

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

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

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

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

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

View File

@ -11,7 +11,7 @@ const env = process.env;
module.exports = {
mode: `${env.NODE_ENV ? env.NODE_ENV : 'production'}`,
entry: './src/index.tsx',
devtool: 'inline-source-map',
devtool: env.NODE_ENV === 'production' ? 'source-map' : 'eval-source-map',
output: {
filename: '[name].[contenthash].js',
path: path.join(__dirname, 'dist')
@ -22,7 +22,8 @@ module.exports = {
devServer: {
contentBase: path.join(__dirname, 'dist'),
compress: true,
port: 8081,
host: '0.0.0.0',
port: 8080,
historyApiFallback: true,
writeToDisk: true,
},

120
cmd/server/container.go Normal file
View File

@ -0,0 +1,120 @@
package main
import (
"context"
"net/http"
"time"
"forge.cadoles.com/Cadoles/daddy/internal/mail"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"forge.cadoles.com/Cadoles/daddy/internal/voter"
"github.com/wader/gormstore"
"forge.cadoles.com/Cadoles/daddy/internal/auth"
"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/pkg/errors"
"gitlab.com/wpetit/goweb/service"
"gitlab.com/wpetit/goweb/service/build"
"gitlab.com/wpetit/goweb/service/session"
"gitlab.com/wpetit/goweb/session/gorilla"
)
func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Container, error) {
// Initialize and configure service container
ctn := service.NewContainer()
ctn.Provide(build.ServiceName, build.ServiceProvider(ProjectVersion, GitRef, BuildDate))
// Generate random cookie authentication key if none is set
if conf.HTTP.CookieAuthenticationKey == "" {
logger.Info(ctx, "could not find cookie authentication key. generating one...")
cookieAuthenticationKey, err := gorilla.GenerateRandomBytes(64)
if err != nil {
return nil, errors.Wrap(err, "could not generate cookie authentication key")
}
conf.HTTP.CookieAuthenticationKey = string(cookieAuthenticationKey)
}
// Generate random cookie encryption key if none is set
if conf.HTTP.CookieEncryptionKey == "" {
logger.Info(ctx, "could not find cookie encryption key. generating one...")
cookieEncryptionKey, err := gorilla.GenerateRandomBytes(32)
if err != nil {
return nil, errors.Wrap(err, "could not generate cookie encryption key")
}
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
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
sessionStore.SessionOpts.Path = "/"
sessionStore.SessionOpts.HttpOnly = true
sessionStore.SessionOpts.Secure = conf.HTTP.CookieSecure
sessionStore.SessionOpts.MaxAge = conf.HTTP.CookieMaxAge
sessionStore.SessionOpts.SameSite = http.SameSiteLaxMode
ctn.Provide(
session.ServiceName,
gorilla.ServiceProvider("daddy", sessionStore),
)
// Create and expose config service provider
ctn.Provide(config.ServiceName, config.ServiceProvider(conf))
provider, err := oidc.NewProvider(ctx, conf.OIDC.IssuerURL)
if err != nil {
return nil, errors.Wrap(err, "could not create oidc provider")
}
ctn.Provide(oidc.ServiceName, oidc.ServiceProvider(
oidc.WithCredentials(conf.OIDC.ClientID, conf.OIDC.ClientSecret),
oidc.WithProvider(provider),
oidc.WithScopes("email", "openid"),
))
ctn.Provide(auth.ServiceName, auth.ServiceProvider(conf.Auth.Rules))
ctn.Provide(voter.ServiceName, voter.ServiceProvider(
voter.StrategyUnanimous,
model.NewDecisionSupportFileVoter(),
model.NewWorkgroupVoter(),
))
ctn.Provide(mail.ServiceName, mail.ServiceProvider(
mail.WithServer(conf.SMTP.Host, conf.SMTP.Port),
mail.WithCredentials(conf.SMTP.User, conf.SMTP.Password),
mail.WithTLS(conf.SMTP.UseStartTLS, conf.SMTP.InsecureSkipVerify),
))
return ctn, nil
}

223
cmd/server/main.go Normal file
View File

@ -0,0 +1,223 @@
package main
import (
"context"
"net/http"
"forge.cadoles.com/Cadoles/daddy/internal/config"
"forge.cadoles.com/Cadoles/daddy/internal/route"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"gitlab.com/wpetit/goweb/middleware/container"
"flag"
"fmt"
"log"
"os"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
//nolint: gochecknoglobals
var (
configFile = ""
workdir = ""
dumpConfig = false
version = false
migrate = ""
)
// nolint: gochecknoglobals
var (
GitRef = "unknown"
ProjectVersion = "unknown"
BuildDate = "unknown"
)
//nolint: gochecknoinits
func init() {
flag.StringVar(&configFile, "config", configFile, "configuration file")
flag.StringVar(&workdir, "workdir", workdir, "working directory")
flag.BoolVar(&dumpConfig, "dump-config", dumpConfig, "dump configuration and exit")
flag.BoolVar(&version, "version", version, "show version and exit")
flag.StringVar(&migrate, "migrate", migrate, "migrate data schema version and exit, possible values: latest, down, up")
}
func main() {
ctx := context.Background()
flag.Parse()
if version {
fmt.Printf("%s (%s) - %s\n", ProjectVersion, GitRef, BuildDate)
os.Exit(0)
}
// Switch to new working directory if defined
if workdir != "" {
if err := os.Chdir(workdir); err != nil {
logger.Fatal(
ctx,
"could not change working directory",
logger.E(err),
logger.F("workdir", workdir),
)
}
}
// Load configuration file if defined, use default configuration otherwise
var conf *config.Config
var err error
if configFile != "" {
conf, err = config.NewFromFile(configFile)
if err != nil {
log.Fatalf("%+v", errors.Wrapf(err, " '%s'", configFile))
logger.Fatal(
ctx,
"could not load config file",
logger.E(err),
logger.F("configFile", configFile),
)
}
} else {
if dumpConfig {
conf = config.NewDumpDefault()
} else {
conf = config.NewDefault()
}
}
// Dump configuration if asked
if dumpConfig {
if err := config.Dump(conf, os.Stdout); err != nil {
logger.Fatal(
ctx,
"could not dump config",
logger.E(err),
)
}
os.Exit(0)
}
if err := config.WithEnvironment(conf); err != nil {
logger.Fatal(
ctx,
"could not override config with environment",
logger.E(err),
)
}
logger.Info(
ctx,
"starting",
logger.F("gitRef", GitRef),
logger.F("projectVersion", ProjectVersion),
logger.F("buildDate", BuildDate),
)
logger.Debug(ctx, "setting log format", logger.F("format", conf.Log.Format))
logger.SetFormat(conf.Log.Format)
logger.Debug(ctx, "setting log level", logger.F("level", conf.Log.Level.String()))
logger.SetLevel(conf.Log.Level)
useSentry := conf.Sentry.DSN != ""
if useSentry {
var sentryEnv string
if conf.Sentry.Environment == "" {
sentryEnv, _ = os.Hostname()
} else {
sentryEnv = conf.Sentry.Environment
}
err := sentry.Init(sentry.ClientOptions{
Dsn: conf.Sentry.DSN,
Debug: conf.Debug,
SampleRate: conf.Sentry.ServerSampleRate,
Release: ProjectVersion + "-" + GitRef,
Environment: sentryEnv,
})
if err != nil {
logger.Fatal(
ctx,
"could initialize sentry",
logger.E(err),
)
}
defer sentry.Flush(conf.Sentry.ServerFlushTimeout)
}
// Create service container
ctn, err := getServiceContainer(ctx, conf)
if err != nil {
logger.Fatal(
ctx,
"could not create service container",
logger.E(err),
)
}
ctx = container.WithContainer(ctx, ctn)
if migrate != "" {
if err := applyMigration(ctx, ctn); err != nil {
logger.Fatal(
ctx,
"could not apply migration",
logger.E(err),
)
}
os.Exit(0)
}
go runTaskScheduler(ctx, conf)
r := chi.NewRouter()
// Define base middlewares
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
if useSentry {
sentryMiddleware := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
r.Use(sentryMiddleware.Handle)
}
// Expose service container on router
r.Use(container.ServiceContainer(ctn))
// Define routes
if err := route.Mount(r, conf); err != nil {
logger.Fatal(
ctx,
"could not mount http routes",
logger.E(err),
)
}
logger.Info(ctx, "listening", logger.F("address", conf.HTTP.Address))
if err := http.ListenAndServe(conf.HTTP.Address, r); err != nil {
logger.Fatal(
ctx,
"could not listen",
logger.E(err),
logger.F("address", conf.HTTP.Address),
)
}
}

109
cmd/server/migration.go Normal file
View File

@ -0,0 +1,109 @@
package main
import (
"context"
"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"
)
const (
migrateUp = "up"
migrateLatest = "latest"
migrateDown = "down"
)
func applyMigration(ctx context.Context, ctn *service.Container) error {
orm, err := orm.From(ctn)
if err != nil {
return err
}
migr := orm.Migration()
// Register available migrations
migr.Register(
m000initialSchema(),
)
currentVersion, err := migr.CurrentVersion(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve current data schema version")
}
switch migrate {
case migrateUp:
if err := migr.Up(ctx); err != nil {
return errors.Wrap(err, "could not apply up migration")
}
case migrateLatest:
latestVersion, err := migr.LatestVersion()
if err != nil {
return errors.Wrap(err, "could not retrieve latest data schema version")
}
logger.Info(
ctx,
"migrating data schema to latest version",
logger.F("currentVersion", currentVersion),
logger.F("latestVersion", latestVersion),
)
// Execute migration to latest available version
if err := migr.Latest(ctx); err != nil {
return errors.Wrap(err, "could not migrate to latest data schema")
}
case migrateDown:
if err := migr.Down(ctx); err != nil {
return errors.Wrap(err, "could not apply down migration")
}
default:
return errors.Errorf("unknown migration command: '%s'", migrate)
}
logger.Info(
ctx,
"migration completed",
)
return nil
}
// nolint: gochecknoglobals
var initialModels = []interface{}{
&model.User{},
&model.Workgroup{},
&model.DecisionSupportFile{},
&model.Event{},
}
func m000initialSchema() orm.Migration {
return orm.NewDBMigration(
"00_initial_schema",
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 nil
},
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 nil
},
)
}

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

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

View File

@ -1,26 +1,5 @@
version: '2.4'
services:
super-graph:
build:
context: ./misc/containers/super-graph
args:
- HTTP_PROXY=${HTTP_PROXY}
- HTTPS_PROXY=${HTTPS_PROXY}
- http_proxy=${http_proxy}
- https_proxy=${https_proxy}
environment:
- SG_DATABASE_HOST=postgres
- SG_DATABASE_USER=daddy
- SG_DATABASE_PASSWORD=daddy
- USER_ID=${USER_ID}
- GO_ENV=dev
volumes:
- ./backend:/app
links:
- postgres
ports:
- 8080:8080
postgres:
build:
context: ./misc/containers/postgres
@ -48,15 +27,12 @@ services:
SUPPORTED_CLAIMS: email,email_verified
SECRETS_SYSTEM: fAAya66yXNib52lbXpo16bxy1jD4NZrX
HYDRA_ADMIN_URL: http://localhost:4445
SERVE_PUBLIC_CORS_ENABLED: "true"
SERVE_PUBLIC_CORS_ALLOWED_ORIGINS: http://localhost:8081
WEBFINGER_JWKS_BROADCAST_KEYS: hydra.openid.id-token,hydra.jwt.access-token
ports:
- 4444:4444
command: hydra serve all --dangerous-force-http
hydra-passwordless:
image: bornholm/hydra-passwordless
image: bornholm/hydra-passwordless:latest@sha256:e6b335e3677dc937c62978890b42312a7486e4fe10208aa2670b1917489ec492
ports:
- 3000:3000
environment:
@ -72,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

View File

@ -1,22 +0,0 @@
import React 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 { OAuth2Page } from './OAuth2Page/OAuth2Page';
export class App extends React.Component {
render() {
return (
<Provider store={store}>
<BrowserRouter>
<Switch>
<Route path="/" exact component={HomePage} />
<Route path="/oauth2/:action" exact component={OAuth2Page} />
<Route component={() => <Redirect to="/" />} />
</Switch>
</BrowserRouter>
</Provider>
);
}
}

View File

@ -1,28 +0,0 @@
import React, { useEffect } from 'react';
import { Page } from '../Page';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../store/reducers/root';
export function HomePage() {
const currentUser = useSelector((state: RootState) => state.auth.currentUser);
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.full_name ?
<p>Bonjour <span className="has-text-weight-bold">{currentUser.full_name}</span> !</p> :
<p>Veuillez vous authentifier.</p>
}
</div>
</div>
</div>
</section>
</div>
</Page>
);
}

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

@ -1,48 +0,0 @@
import React from 'react';
import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux';
import { RootState } from '../store/reducers/root';
import { Link } from 'react-router-dom';
export function Navbar() {
const isAuthenticated = useSelector<RootState>(state => state.auth.isAuthenticated);
return (
<nav className="navbar" role="navigation" aria-label="main navigation">
<div className="container is-fluid">
<div className="navbar-brand">
<a className="navbar-item" href="#/">
<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">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div className="navbar-menu">
<div className="navbar-end">
<div className="navbar-item">
{
isAuthenticated ?
<Link className="button is-small" to="/oauth2/logout">
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span>
<span>Se déconnecter</span>
</Link> :
<Link className="button is-small" to="/oauth2/login">
<span className="icon">
<i className="fas fa-sign-in-alt"></i>
</span>
<span>Se connecter</span>
</Link>
}
</div>
</div>
</div>
</div>
</nav>
);
};

View File

@ -1,31 +0,0 @@
import React, { useEffect } from 'react';
import { Page } from '../Page';
import { useDispatch } from 'react-redux';
import { logout, login, handleOAuth2Callback } from '../../store/actions/auth';
export function OAuth2Page({ match, location, history }) {
const dispatch = useDispatch();
const { action } = match.params;
useEffect(() => {
switch(action) {
case 'logout':
dispatch(logout());
history.push("/");
break;
case 'login':
dispatch(login());
break;
case 'callback':
dispatch(handleOAuth2Callback(location.search));
history.push("/");
break;
}
}, [action]);
return (
<Page title="Daddy - OAuth2">
</Page>
);
}

View File

@ -1,21 +0,0 @@
export const Config = {
// The OpenID Connect client_id
oauth2ClientId: get<string>("oauth2ClientId", "daddy"),
oauth2Scope: get<string>("oauth2Scope", "email email_verified openid offline_access"),
oauth2RedirectURI: get<string>("oauth2RedirectURI", "http://localhost:8081/oauth2/callback"),
oauth2Audience: get<string>("oauth2Audience", ""),
oauth2AuthorizeURL: get<string>("oauth2AuthorizeURL", "http://localhost:4444/oauth2/auth"),
oauth2TokenURL: get<string>("oauth2TokenURL", "http://localhost:4444/oauth2/token"),
oauth2LogoutURL: get<string>("oauth2LogoutURL", "http://localhost:4444/oauth2/sessions/logout"),
oauth2PostLogoutRedirectURI: get<string>("oauth2PostLogoutRedirectURI", "http://localhost:8081"),
graphQLEndpoint: get<string>("graphQLEndpoint", "http://localhost:8080/api/v1/graphql")
};
function get<T>(key: string, defaultValue: T):T {
const config = window['__CONFIG__'] || {};
if (config && config.hasOwnProperty(key)) {
return config[key] as T;
} else {
return defaultValue;
}
}

View File

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

View File

@ -1,21 +0,0 @@
html, body {
height: 100%;
background-color: #f7f7f7;
}
.is-fullheight {
height: 100%;
}
.has-margin-top-normal {
margin-top: $size-normal;
}
.has-padding-small {
padding: 1rem;
}
#app {
display: flex;
flex-direction: column;
}

View File

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

View File

@ -1,69 +0,0 @@
import { Action } from "redux";
import { AccessGrant } from "../../util/auth";
import { IdToken } from "../../types/idToken";
export const LOGOUT = "LOGOUT_REQUEST";
export function logout() {
return { type: LOGOUT };
};
export const LOGIN_REQUEST = "LOGIN_REQUEST";
export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
export const LOGIN_FAILURE = "LOGIN_FAILURE";
export function login() {
return { type: LOGIN_REQUEST };
};
export const HANDLE_OAUTH2_CALLBACK_REQUEST = "HANDLE_OAUTH2_CALLBACK_REQUEST";
export const HANDLE_OAUTH2_CALLBACK_SUCCESS = "HANDLE_OAUTH2_CALLBACK_SUCCESS";
export const HANDLE_OAUTH2_CALLBACK_FAILURE = "HANDLE_OAUTH2_CALLBACK_FAILURE";
export interface handleOAuth2CallbackAction extends Action {
search: string
}
export function handleOAuth2Callback(search: string): handleOAuth2CallbackAction {
return { type: HANDLE_OAUTH2_CALLBACK_REQUEST, search };
};
export interface handleOAuth2CallbackSuccessAction extends Action {
grant: AccessGrant
}
export function handleOAuth2CallbackSuccess(grant: AccessGrant): handleOAuth2CallbackSuccessAction {
return { type: HANDLE_OAUTH2_CALLBACK_SUCCESS, grant };
};
export const PARSE_ID_TOKEN_REQUEST = "PARSE_ID_TOKEN_REQUEST";
export const PARSE_ID_TOKEN_SUCCESS = "PARSE_ID_TOKEN_SUCCESS";
export const PARSE_ID_TOKEN_FAILURE = "PARSE_ID_TOKEN_FAILURE";
export interface parseIdTokenAction extends Action {
rawIdToken: string
};
export function parseIdToken(rawIdToken: string): parseIdTokenAction {
return { type: PARSE_ID_TOKEN_REQUEST, rawIdToken };
};
export interface parseIdTokenSuccessAction extends Action {
idToken: IdToken
}
export function parseIdTokenSuccess(idToken: IdToken): parseIdTokenSuccessAction {
return { type: PARSE_ID_TOKEN_SUCCESS, idToken };
};
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,55 +0,0 @@
import { Action } from "redux";
import { User } from "../../types/user";
import { SET_CURRENT_USER, setCurrentUserAction, LOGOUT } 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 LOGOUT:
return handleLogout(state);
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 handleLogout(state: AuthState): AuthState {
return {
...state,
isAuthenticated: false,
currentUser: null,
};
};
function handleFetchProfileSuccess(state: AuthState, { profile }: fetchProfileSuccessAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
...profile,
}
};
};

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'
}
}
};
}

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