diff --git a/docker-compose.yml b/docker-compose.yml index e177cbca..73d22d9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -218,6 +218,34 @@ services: retries: 3 start_period: 60s + ihm_client: + build: + context: ./ihm_client + dockerfile: Dockerfile + container_name: 4nk-ihm-client + ports: + - "8080:80" + environment: + - SDK_RELAY_WS_URL=ws://sdk_relay_1:8090 + - SDK_RELAY_HTTP_URL=http://sdk_relay_1:8091 + - BITCOIN_RPC_URL=http://bitcoin:18443 + - BLINDBIT_URL=http://blindbit:8000 + volumes: + - ihm_client_logs:/var/log/nginx + networks: + - btcnet + depends_on: + - sdk_relay_1 + - sdk_relay_2 + - sdk_relay_3 + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--timeout=5", "--spider", "http://localhost"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + volumes: bitcoin_data: name: 4nk_node_bitcoin_data @@ -229,6 +257,8 @@ volumes: name: 4nk_node_sdk_relay_2_data sdk_relay_3_data: name: 4nk_node_sdk_relay_3_data + ihm_client_logs: + driver: local networks: btcnet: diff --git a/ihm_client/Dockerfile b/ihm_client/Dockerfile new file mode 100644 index 00000000..e3e37402 --- /dev/null +++ b/ihm_client/Dockerfile @@ -0,0 +1,53 @@ +# Dockerfile optimisé pour l'intégration dans 4NK_node +FROM node:20-alpine AS builder + +WORKDIR /app + +# Installation des dépendances système +RUN apk update && apk add --no-cache \ + git \ + build-base \ + python3 \ + make \ + g++ + +# Copie des fichiers de dépendances +COPY package*.json ./ + +# Installation des dépendances +RUN npm ci --only=production + +# Copie du code source +COPY . . + +# Build de l'application +RUN npm run build + +# Image de production +FROM nginx:alpine + +# Installation de Node.js pour les scripts de démarrage +RUN apk update && apk add --no-cache nodejs npm + +# Copie des fichiers buildés +COPY --from=builder /app/dist /usr/share/nginx/html +COPY --from=builder /app/package*.json /app/ + +# Copie de la configuration nginx optimisée pour 4NK_node +COPY nginx.4nk-node.conf /etc/nginx/conf.d/default.conf + +# Script de démarrage +COPY start-4nk-node.sh /start-4nk-node.sh +RUN chmod +x /start-4nk-node.sh + +# Exposition des ports +EXPOSE 80 3003 + +# Variables d'environnement pour 4NK_node +ENV SDK_RELAY_WS_URL=ws://sdk_relay_1:8090 +ENV SDK_RELAY_HTTP_URL=http://sdk_relay_1:8091 +ENV BITCOIN_RPC_URL=http://bitcoin:18443 +ENV BLINDBIT_URL=http://blindbit:8000 + +# Point d'entrée +CMD ["/start-4nk-node.sh"] diff --git a/ihm_client/index.html b/ihm_client/index.html new file mode 100755 index 00000000..f7f60fb4 --- /dev/null +++ b/ihm_client/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + 4NK Application + + +
+
+ +
+ + + + \ No newline at end of file diff --git a/ihm_client/nginx.conf b/ihm_client/nginx.conf new file mode 100644 index 00000000..318b3e4a --- /dev/null +++ b/ihm_client/nginx.conf @@ -0,0 +1,96 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gestion des fichiers statiques + location / { + try_files $uri $uri/ /index.html; + + # Headers de sécurité + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } + + # Proxy vers sdk_relay WebSocket + location /ws/ { + proxy_pass http://sdk_relay_1:8090; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } + + # Proxy vers sdk_relay HTTP API + location /api/ { + proxy_pass http://sdk_relay_1:8091/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # CORS headers + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE" always; + add_header Access-Control-Allow-Headers "Authorization,Content-Type,Accept,X-Requested-With" always; + + # Gestion des requêtes OPTIONS + if ($request_method = 'OPTIONS') { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; + add_header Access-Control-Allow-Headers "Authorization,Content-Type,Accept,X-Requested-With"; + add_header Content-Length 0; + add_header Content-Type text/plain; + return 204; + } + } + + # Proxy vers Bitcoin Core RPC (si nécessaire) + location /bitcoin/ { + proxy_pass http://bitcoin:18443/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Authentification basique pour Bitcoin RPC + auth_basic "Bitcoin RPC"; + auth_basic_user_file /etc/nginx/.htpasswd; + } + + # Proxy vers Blindbit (si nécessaire) + location /blindbit/ { + proxy_pass http://blindbit:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Cache pour les assets statiques + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Gestion des erreurs + error_page 404 /index.html; + error_page 500 502 503 504 /50x.html; + + location = /50x.html { + root /usr/share/nginx/html; + } + + # Logs + access_log /var/log/nginx/ihm_client_access.log; + error_log /var/log/nginx/ihm_client_error.log; +} diff --git a/ihm_client/package.json b/ihm_client/package.json new file mode 100755 index 00000000..77528b6b --- /dev/null +++ b/ihm_client/package.json @@ -0,0 +1,51 @@ +{ + "name": "sdk_client", + "version": "1.0.0", + "description": "", + "main": "dist/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build_wasm": "./scripts/setup-remote-deps.sh", + "cleanup_deps": "./scripts/cleanup-deps.sh", + "start": "vite --host 0.0.0.0", + "build": "tsc && vite build", + "deploy": "sudo cp -r dist/* /var/www/html/", + "prettify": "prettier --config ./.prettierrc --write \"src/**/*{.ts,.html,.css,.js}\"", + "build:dist": "tsc -p tsconfig.build.json" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@rollup/plugin-typescript": "^12.1.1", + "copy-webpack-plugin": "^12.0.2", + "html-webpack-plugin": "^5.6.0", + "prettier": "^3.3.3", + "rimraf": "^6.0.1", + "ts-loader": "^9.5.1", + "typescript": "^5.3.3", + "vite": "^5.4.11", + "vite-plugin-static-copy": "^1.0.6", + "webpack": "^5.90.3", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.0.2" + }, + "dependencies": { + "@angular/elements": "^19.0.1", + "@types/jsonwebtoken": "^9.0.9", + "@types/qrcode": "^1.5.5", + "@vitejs/plugin-react": "^4.3.1", + "@vitejs/plugin-vue": "^5.0.5", + "axios": "^1.11.0", + "html5-qrcode": "^2.3.8", + "jose": "^6.0.13", + "jsonwebtoken": "^9.0.2", + "pdf-lib": "^1.17.1", + "qr-scanner": "^1.4.2", + "qrcode": "^1.5.4", + "sweetalert2": "^11.22.4", + "vite-plugin-copy": "^0.1.6", + "vite-plugin-html": "^3.2.2", + "vite-plugin-wasm": "^3.3.0" + } +} diff --git a/ihm_client/public/assets/4nk_image.png b/ihm_client/public/assets/4nk_image.png new file mode 100755 index 00000000..d58693f1 Binary files /dev/null and b/ihm_client/public/assets/4nk_image.png differ diff --git a/ihm_client/public/assets/4nk_revoke.jpg b/ihm_client/public/assets/4nk_revoke.jpg new file mode 100755 index 00000000..9fba6de5 Binary files /dev/null and b/ihm_client/public/assets/4nk_revoke.jpg differ diff --git a/ihm_client/public/assets/bgd.webp b/ihm_client/public/assets/bgd.webp new file mode 100755 index 00000000..aa457afa Binary files /dev/null and b/ihm_client/public/assets/bgd.webp differ diff --git a/ihm_client/public/assets/camera.jpg b/ihm_client/public/assets/camera.jpg new file mode 100755 index 00000000..adde7dd8 Binary files /dev/null and b/ihm_client/public/assets/camera.jpg differ diff --git a/ihm_client/public/assets/home.js b/ihm_client/public/assets/home.js new file mode 100755 index 00000000..2a54d5d5 --- /dev/null +++ b/ihm_client/public/assets/home.js @@ -0,0 +1,34 @@ +document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); + document.getElementById(tab.getAttribute('data-tab')).classList.add('active'); + }); + }); + function toggleMenu() { + var menu = document.getElementById('menu'); + if (menu.style.display === 'block') { + menu.style.display = 'none'; + } else { + menu.style.display = 'block'; + } + } + + //// Modal + function openModal() { + document.getElementById('modal').style.display = 'flex'; + } + + function closeModal() { + document.getElementById('modal').style.display = 'none'; + } + + // Close modal when clicking outside of it + window.onclick = function(event) { + const modal = document.getElementById('modal'); + if (event.target === modal) { + closeModal(); + } + } \ No newline at end of file diff --git a/ihm_client/public/assets/qr_code.png b/ihm_client/public/assets/qr_code.png new file mode 100755 index 00000000..defa4107 Binary files /dev/null and b/ihm_client/public/assets/qr_code.png differ diff --git a/ihm_client/public/style/4nk.css b/ihm_client/public/style/4nk.css new file mode 100755 index 00000000..02b45334 --- /dev/null +++ b/ihm_client/public/style/4nk.css @@ -0,0 +1,877 @@ +:root { + --primary-color + : #3A506B; + /* Bleu métallique */ + --secondary-color + : #B0BEC5; + /* Gris acier */ + --accent-color + : #D68C45; + /* Cuivre */ +} +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: #f4f4f4; + background-image: url(../assets/bgd.webp); + background-repeat:no-repeat; + background-size: cover; + background-blend-mode :soft-light; + height: 100vh; + } + .message { + margin: 30px 0; + font-size: 14px; + overflow-wrap: anywhere; + } + + .message strong{ + font-family: "Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji", sans-serif; + font-size: 20px; + } + + /** Modal Css */ + .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 3; + } + + .modal-content { + width: 55%; + height: 30%; + background-color: white; + border-radius: 4px; + padding: 20px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + } + + .modal-title { + margin: 0; + padding-bottom: 8px; + width: 100%; + font-size: 0.9em; + border-bottom: 1px solid #ccc; + } + + .confirmation-box { + /* margin-top: 20px; */ + align-content: center; + width: 70%; + height: 20%; + /* padding: 20px; */ + font-size: 1.5em; + color: #333333; + top: 5%; + position: relative; + } + + /* Confirmation Modal Styles */ + #confirmation-modal { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 1000; + } + + .modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + } + + .modal-content { + background: white; + padding: 20px; + border-radius: 8px; + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + + .modal-confirmation { + text-align: left; + padding: 10px; + } + + .modal-confirmation h3 { + margin-bottom: 15px; + color: var(--primary-color); + font-size: 1.1em; + } + + .modal-confirmation p { + margin: 8px 0; + font-size: 0.9em; + line-height: 1.4; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid #eee; + } + + .modal-footer button { + padding: 8px 16px; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 0.9em; + } + + .btn-primary { + background: var(--primary-color); + color: white; + } + + .btn-secondary { + background: var(--secondary-color); + color: white; + } + + /* Responsive adjustments */ + @media only screen and (max-width: 600px) { + .modal-content { + width: 95%; + margin: 10px; + padding: 15px; + } + + .modal-confirmation h3 { + font-size: 1em; + } + + .modal-confirmation p { + font-size: 0.85em; + } + } + + .nav-wrapper { + position: fixed; + z-index: 2; + background: radial-gradient(circle, white, var(--primary-color)); + /* background-color: #CFD8DC; */ + display: flex; + justify-content: flex-end; + align-items: center; + color: #37474F; + height: 9vh; + width: 100vw; + left: 0; + top: 0; + box-shadow: 0px 8px 10px -5px rgba(0, 0, 0, .2), 0px 16px 24px 2px rgba(0, 0, 0, .14), 0px 6px 30px 5px rgba(0, 0, 0, .12); + + .nav-right-icons { + display: flex; + .notification-container { + position: relative; + display: inline-block; + } + .notification-bell, .burger-menu { + z-index: 3; + height: 20px; + width: 20px; + margin-right: 1rem; + } + .notification-badge { + position: absolute; + top: -.7rem; + left: -.8rem; + background-color: red; + color: white; + border-radius: 50%; + padding: 2.5px 6px; + font-size: 0.8em; + font-weight: bold; + } + } + .notification-board { + position: absolute; + width: 20rem; + min-height: 8rem; + background-color: white; + right: 0.5rem; + display: none; + border-radius: 4px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + display: none; + + .notification-element { + padding: .8rem 0; + width: 100%; + &:hover { + background-color: rgba(26, 28, 24, .08); + } + } + .notification-element:not(:last-child) { + border-bottom: 1px solid; + } + } + } + + .brand-logo { + height: 100%; + width: 100vw; + align-content: center; + position: relative; + display: flex; + position: absolute; + align-items: center; + justify-content: center; + text-align: center; + font-size: 1.5em; + font-weight: bold; + } + + .container { + text-align: center; + display: grid; + height: 100vh; + grid-template-columns: repeat(7, 1fr); + gap: 10px; + grid-auto-rows: 10vh 15vh 1fr; + } + .title-container { + grid-column: 2 / 7; + grid-row: 2; + } + .page-container { + grid-column: 2 / 7; + grid-row: 3 ; + justify-content: center; + display: flex; + padding: 1rem; + box-sizing: border-box; + max-height: 60vh; + } + + h1 { + font-size: 2em; + margin: 20px 0; + } + @media only screen and (min-width: 600px) { + .tab-container { + display: none; + } + .page-container { + display: flex; + align-items: center; + } + .process-container { + grid-column: 3 / 6; + grid-row: 3 ; + + .card { + min-width: 40vw; + } + } + .separator { + width: 2px; + background-color: #78909C; + height: 80%; + margin: 0 0.5em; + } + .tab-content { + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: center; + height: 80%; + } + } + + @media only screen and (max-width: 600px) { + .process-container { + grid-column: 2 / 7; + grid-row: 3 ; + } + .container { + grid-auto-rows: 10vh 15vh 15vh 1fr; + } + .tab-container { + grid-column: 1 / 8; + grid-row: 3; + } + .page-container { + grid-column: 2 / 7; + grid-row: 4 ; + } + .separator { + display: none; + } + .tabs { + display: flex; + flex-grow: 1; + overflow: hidden; + z-index: 1; + border-bottom-style: solid; + border-bottom-width: 1px; + border-bottom-color: #E0E4D6; + } + + .tab { + flex: 1; + text-align: center; + padding: 10px 0; + cursor: pointer; + font-size: 1em; + color: #6200ea; + &:hover { + background-color: rgba(26, 28, 24, .08); + } + } + .tab.active { + border-bottom: 2px solid #6200ea; + font-weight: bold; + } + + .card.tab-content { + display: none; + } + + .tab-content.active { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 80%; + } + .modal-content { + width: 80%; + height: 20%; + } + } + + .qr-code { + display: flex; + justify-content: center; + align-items: center; + height: 200px; + } + + .emoji-display { + font-family: "Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji", sans-serif; + font-size: 20px; + + } + + #emoji-display-2{ + margin-top: 30px; + font-family: "Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji", sans-serif; + font-size: 20px; + } + + #okButton{ + margin-bottom: 2em; + cursor: pointer; + background-color: #D0D0D7; + color: white; + border-style: none; + border-radius: 5px; + color: #000; + padding: 2px; + margin-top: 10px; + } + + .pairing-request { + font-family: "Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji", sans-serif; + font-size: 14px; + margin-top: 0px; + } + + .sp-address-btn { + margin-bottom: 2em; + cursor: pointer; + background-color: #D0D0D7; + color: white; + border-style: none; + border-radius: 5px; + color: #000; + padding: 2px; + + } + + .camera-card { + display: flex; + justify-content: center; + align-items: center; + /* height: 200px; */ + } + + .btn { + display: inline-block; + padding: 10px 20px; + background-color: var(--primary-color); + color: white; + text-align: center; + border-radius: 5px; + cursor: pointer; + text-decoration: none; + } + + .btn:hover { + background-color: #3700b3; + } + + + .card { + min-width: 300px; + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + height: 60vh; + justify-content: flex-start; + padding: 1rem; + overflow-y: auto; + + } + + .card-content { + flex-grow: 1; + flex-direction: column; + display: flex; + justify-content: flex-start; + align-items: center; + text-align: left; + font-size: .8em; + position: relative; + left: 2vw; + width: 90%; + .process-title { + font-weight: bold; + padding: 1rem 0; + } + .process-element { + padding: .4rem 0; + &:hover { + background-color: rgba(26, 28, 24, .08); + } + &.selected { + background-color: rgba(26, 28, 24, .08); + } + } + } + + .card-description { + padding: 20px; + font-size: 1em; + color: #333; + width: 90%; + height: 50px; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 0px; + } + + + .card-action { + width: 100%; + } + + .menu-content { + display: none; + position: absolute; + top: 3.4rem; + right: 1rem; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 5px; + overflow: hidden; + } + + .menu-content a { + display: block; + padding: 10px 20px; + text-decoration: none; + color: #333; + border-bottom: 1px solid #e0e0e0; + &:hover { + background-color: rgba(26, 28, 24, .08); + } + } + + .menu-content a:last-child { + border-bottom: none; + } + + .qr-code-scanner { + display: none; + } + + + /* QR READER */ + #qr-reader div { + position: inherit; + } + + #qr-reader div img{ + top: 15px ; + right: 25px; + margin-top: 5px; + } + + + /* INPUT CSS **/ + .input-container { + position: relative; + width: 100%; + background-color: #ECEFF1; + } + + .input-field { + width: 36vw; + padding: 10px 0; + font-size: 1em; + border: none; + border-bottom: 1px solid #ccc; + outline: none; + background: transparent; + transition: border-color 0.3s; + } + + .input-field:focus { + border-bottom: 2px solid #6200ea; + } + + .input-label { + position: absolute; + margin-top: -0.5em; + top: 0; + left: 0; + padding: 10px 0; + font-size: 1em; + color: #999; + pointer-events: none; + transition: transform 0.3s, color 0.3s, font-size 0.3s; + } + + .input-field:focus + .input-label, + .input-field:not(:placeholder-shown) + .input-label { + transform: translateY(-20px); + font-size: 0.8em; + color: #6200ea; + } + + .input-underline { + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 2px; + background-color: #6200ea; + transition: width 0.3s, left 0.3s; + } + + .input-field:focus ~ .input-underline { + width: 100%; + left: 0; + } + + .dropdown-content { + position: absolute; + flex-direction: column; + top: 100%; + left: 0; + width: 100%; + max-height: 150px; + overflow-y: auto; + border: 1px solid #ccc; + border-radius: 4px; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + display: none; + z-index: 1; + } + + .dropdown-content span { + padding: 10px; + cursor: pointer; + list-style: none; + } + + .dropdown-content span:hover { + background-color: #f0f0f0; + } + + + + + /** AUTOCOMPLETE **/ + +select[data-multi-select-plugin] { + display: none !important; +} + +.multi-select-component { + width: 36vw; + padding: 5px 0; + font-size: 1em; + border: none; + border-bottom: 1px solid #ccc; + outline: none; + background: transparent; + display: flex; + flex-direction: row; + height: auto; + width: 100%; + -o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; + transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; +} + +.autocomplete-list { + border-radius: 4px 0px 0px 4px; +} + +.multi-select-component:focus-within { + box-shadow: inset 0px 0px 0px 2px #78ABFE; +} + +.multi-select-component .btn-group { + display: none !important; +} + +.multiselect-native-select .multiselect-container { + width: 100%; +} + +.selected-processes { + background-color: white; + padding: 0.4em; +} + +.selected-wrapper { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + display: inline-block; + border: 1px solid #d9d9d9; + background-color: #ededed; + white-space: nowrap; + margin: 1px 5px 5px 0; + height: 22px; + vertical-align: top; + cursor: default; +} + +.selected-wrapper .selected-label { + max-width: 514px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 4px; + vertical-align: top; +} + +.selected-wrapper .selected-close { + display: inline-block; + text-decoration: none; + font-size: 14px; + line-height: 1.49em; + margin-left: 5px; + padding-bottom: 10px; + height: 100%; + vertical-align: top; + padding-right: 4px; + opacity: 0.2; + color: #000; + text-shadow: 0 1px 0 #fff; + font-weight: 700; +} + +.search-container { + display: flex; + flex-direction: row; +} + +.search-container .selected-input { + background: none; + border: 0; + height: 20px; + width: 60px; + padding: 0; + margin-bottom: 6px; + -webkit-box-shadow: none; + box-shadow: none; +} + +.search-container .selected-input:focus { + outline: none; +} + +.dropdown-icon.active { + transform: rotateX(180deg) +} + +.search-container .dropdown-icon { + display: inline-block; + padding: 10px 5px; + position: absolute; + top: 5px; + right: 5px; + width: 10px; + height: 10px; + border: 0 !important; + /* needed */ + -webkit-appearance: none; + -moz-appearance: none; + /* SVG background image */ + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%3E%3Ctitle%3Edown-arrow%3C%2Ftitle%3E%3Cg%20fill%3D%22%23818181%22%3E%3Cpath%20d%3D%22M10.293%2C3.293%2C6%2C7.586%2C1.707%2C3.293A1%2C1%2C0%2C0%2C0%2C.293%2C4.707l5%2C5a1%2C1%2C0%2C0%2C0%2C1.414%2C0l5-5a1%2C1%2C0%2C1%2C0-1.414-1.414Z%22%20fill%3D%22%23818181%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E"); + background-position: center; + background-size: 10px; + background-repeat: no-repeat; +} + +.search-container ul { + position: absolute; + list-style: none; + padding: 0; + z-index: 3; + margin-top: 29px; + width: 100%; + right: 0px; + background: #fff; + border: 1px solid #ccc; + border-top: none; + border-bottom: none; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); +} + +.search-container ul :focus { + outline: none; +} + +.search-container ul li { + display: block; + text-align: left; + padding: 8px 29px 2px 12px; + border-bottom: 1px solid #ccc; + font-size: 14px; + min-height: 31px; +} + +.search-container ul li:first-child { + border-top: 1px solid #ccc; + border-radius: 4px 0px 0 0; +} + +.search-container ul li:last-child { + border-radius: 4px 0px 0 0; +} + + +.search-container ul li:hover.not-cursor { + cursor: default; +} + +.search-container ul li:hover { + color: #333; + background-color: #f0f0f0; + ; + border-color: #adadad; + cursor: pointer; +} + +/* Adding scrool to select options */ +.autocomplete-list { + max-height: 130px; + overflow-y: auto; +} + + + +/**************************************** Process page card ******************************************************/ +.process-card { + min-width: 300px; + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + min-height: 40vh; + max-height: 60vh; + justify-content: space-between; + padding: 1rem; + overflow-y: auto; + +} + +.process-card-content { + text-align: left; + font-size: .8em; + position: relative; + left: 2vw; + width: 90%; + .process-title { + font-weight: bold; + padding: 1rem 0; + } + .process-element { + padding: .4rem 0; + &:hover { + background-color: rgba(26, 28, 24, .08); + } + &.selected { + background-color: rgba(26, 28, 24, .08); + } + } + .selected-process-zone { + background-color: rgba(26, 28, 24, .08); + } +} + +.process-card-description { + padding: 20px; + font-size: 1em; + color: #333; + width: 90%; +} + + +.process-card-action { + width: 100%; +} \ No newline at end of file diff --git a/ihm_client/public/style/account.css b/ihm_client/public/style/account.css new file mode 100755 index 00000000..09fe0c6a --- /dev/null +++ b/ihm_client/public/style/account.css @@ -0,0 +1,1507 @@ +/* Styles de base */ +:root { + --primary-color: #3A506B; + /* Bleu métallique */ + --secondary-color: #B0BEC5; + /* Gris acier */ + --accent-color: #D68C45; + /* Cuivre */ +} + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + display: flex; + height: 100vh; + background-color: #e9edf1; + flex-direction: column; +} + +/*-------------------------------------- Avatar--------------------------------------*/ + +.avatar-section { + position: relative; + height: 60px; + width: 260px; + margin-left: 10px; + overflow: hidden; + border-radius: 10px; +} + +.user-info { + display: flex; + flex-direction: column; + color: white; +} + +.user-name, .user-lastname { + font-size: 0.9rem; + font-weight: 700; +} + + +.user-name:hover, .user-lastname:hover { + color: var(--accent-color); + cursor: pointer; +} + +.avatar-container { + width: 45px; + height: 45px; + flex-shrink: 0; +} + +.avatar-container { + width: 80px; /* Taille réduite */ + height: 80px; + margin: 0 auto; +} + +.avatar { + height: 100%; + border-radius: 50%; + object-fit: cover; + border: 2px solid white; +} + +.avatar img { + width: 100%; + height: 100%; + border-radius: 50%; +} + + +/*-------------------------------------- BANNER--------------------------------------*/ + +/* Styles pour la bannière avec image */ +.banner-image-container { + position: relative; + width: 100%; + height: 200px; + overflow: hidden; + border-radius: 10px; + margin-bottom: 15px; +} + +.banner-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 1; +} + +.banner-content { + position: relative; + z-index: 2; + display: flex; + align-items: center; + gap: 15px; + height: 100%; + padding: 0 15px; + background: rgba(0, 0, 0, 0.3); + overflow: visible; +} + +.banner-content .avatar-container { + width: 45px; + height: 45px; + overflow: hidden; + border-radius: 50%; + border: 2px solid white; + transition: transform 0.3s ease; +} + +.banner-content .avatar { + width: 100%; + height: 100%; + object-fit: cover; + border: none; +} + +.banner-content .avatar-container:hover { + transform: scale(1.15); + cursor: pointer; +} + +/* Style pour le bouton de changement de bannière */ +.banner-upload-label { + display: block; + width: auto; + padding: 12px 20px; + background-color: var(--accent-color); + color: white; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.3s; + text-align: center; + font-size: 16px; + margin: 20px auto; + max-width: 250px; +} + +.banner-upload-label:hover { + background-color: #b06935; +} + +.banner-controls { + margin-top: 15px; + display: flex; + justify-content: center; + width: 100%; +} + +.banner-preview { + margin: 10px 0; +} + +.banner-preview h3 { + margin: 0 0 10px 0; + font-size: 18px; +} + +.banner-image-container { + height: 150px; + margin-bottom: 10px; +} + + +.nav-wrapper { + position: fixed; + background: white; + display: flex; + justify-content: space-between; + align-items: center; + height: 9vh; + width: 100vw; + left: 0; + top: 0; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 1000; +} +/* Mise à jour des styles de la navbar pour inclure l'image de bannière */ +.nav-wrapper .avatar-section { + position: relative; + background: none; +} + +.nav-wrapper .banner-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: -1; + filter: brightness(0.7); +} + + + +/*-------------------------------------- Popup--------------------------------------*/ +/* Styles pour la popup */ +.popup { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.popup-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + padding: 20px; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + width: 400px; + max-height: 80vh; + overflow-y: auto; +} +.popup-content h2 { + margin: 0 0 15px 0; + font-size: 24px; +} + +.close-popup { + position: absolute; + right: 15px; + top: 10px; + font-size: 24px; + cursor: pointer; + color: #666; +} + +.close-popup:hover { + color: #000; +} + +.popup-avatar { + text-align: center; + margin: 20px 0; + position: relative; +} + +.avatar-upload-label { + position: relative; + display: inline-block; + cursor: pointer; + width: 0%; + margin-left: -20%; +} + +.avatar-overlay { + position: absolute; + top: 0; + left: 50px; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.3s; +} + +.avatar-overlay span { + color: white; + font-size: 14px; + text-align: center; +} + +.avatar-upload-label:hover .avatar-overlay { + opacity: 1; +} + +.popup-avatar img { + width: 100px; + height: 100px; + border-radius: 50%; + border: 3px solid var(--accent-color); + object-fit: cover; +} + +.popup-info { + margin: 15px 0; +} + +.info-row { + margin: 8px 0; + display: flex; + align-items: center; + gap: 10px; +} + +.popup-info strong { + min-width: 100px; /* Largeur fixe pour l'alignement */ +} + +/* Editable Name and Lastname */ +.editable { + cursor: pointer; + display: inline-block; + min-width: 100px; + padding: 2px 5px; + transition: background-color 0.3s; +} + +.editable:hover { + background-color: #f0f0f0; +} + +.editable.editing { + background-color: white; + border: 1px solid var(--accent-color); + outline: none; +} + +.edit-input { + border: 1px solid var(--accent-color); + border-radius: 3px; + padding: 2px 5px; + font-size: inherit; + font-family: inherit; + outline: none; + width: 100%; + min-width: 100px; + margin: 0; + box-sizing: border-box; +} + +/* Boutons */ + + +.popup-button-container { + display: flex +; + flex-direction: column; + margin-top: 20px; + gap: 15px; +} + +.action-buttons-row { + display: flex +; + justify-content: space-between; + gap: 15px; +} +.banner-upload-label, +.export-btn, +.delete-account-btn { + padding: 8px 15px; + margin: 10px 0; + font-size: 14px; +} + +.delete-account-btn { + background-color: #dc3545; +} + + +.export-btn, +.delete-account-btn { + flex: 1; /* Pour qu'ils prennent la même largeur */ + padding: 12px 20px; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; + color: white; + text-align: center; +} + + + +/* Séparateurs */ +.popup-info, +.export-section, +.delete-account-section { + padding-top: 10px; + margin-top: 10px; + border-top: 1px solid #eee; +} + +.logout-btn { + background-color: rgb(108, 117, 125); + font-size: 16px; + cursor: pointer; + color: white; + text-align: center; + flex: 1 1 0%; + padding: 12px 20px; + border-width: initial; + border-style: none; + border-color: initial; + border-image: initial; + border-radius: 8px; + transition: background-color 0.3s; +} + +/*-------------------------------------- Delete Account--------------------------------------*/ +.delete-account-section { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #ddd; + text-align: center; +} + +.delete-account-btn { + background-color: #dc3545; +} + +.delete-account-btn:hover { + background-color: #c82333; +} + +/* Style pour la modal de confirmation */ +.confirm-delete-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.confirm-delete-content { + background-color: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + max-width: 400px; + width: 90%; + text-align: center; +} + +.confirm-delete-content h3 { + margin-top: 0; + color: #333; +} + +.confirm-delete-content p { + margin: 15px 0; + color: #666; +} + +.confirm-delete-buttons { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 20px; +} + +.confirm-delete-buttons button { + padding: 8px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.3s; +} + +.confirm-delete-buttons .confirm-btn { + background-color: #dc3545; + color: white; +} + +.confirm-delete-buttons .confirm-btn:hover { + background-color: #c82333; +} + +.confirm-delete-buttons .cancel-btn { + background-color: #6c757d; + color: white; +} + +.confirm-delete-buttons .cancel-btn:hover { + background-color: #5a6268; +} + +/*-------------------------------------- Export--------------------------------------*/ +.export-section { + margin: 20px 0; + text-align: center; + padding: 15px 0; + border-top: 1px solid #ddd; +} + +.export-btn { + background-color: var(--accent-color); +} + +.export-btn:hover { + background-color: #b06935; +} + +.export-section, +.delete-account-section { + width: 100%; + display: flex; + justify-content: center; + margin: 15px 0; +} + +.export-btn, +.delete-account-btn { + width: 80%; + padding: 12px 20px; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; + color: white; + text-align: center; +} + +/*-------------------------------------- NAVBAR--------------------------------------*/ + +.brand-logo { + font-size: 1.5rem; + font-weight: bold; +} + +.nav-wrapper { + position: fixed; + background: radial-gradient(circle, white, var(--primary-color)); + display: flex; + justify-content: space-between; + align-items: center; + color: #37474F; + height: 9vh; + width: 100vw; + left: 0; + top: 0; + box-shadow: 0px 8px 10px -5px rgba(0, 0, 0, .2), 0px 16px 24px 2px rgba(0, 0, 0, .14), 0px 6px 30px 5px rgba(0, 0, 0, .12); +} + +/* Icônes de la barre de navigation */ +.nav-right-icons { + margin-right: 20px; +} + +.burger-menu { + height: 20px; + width: 20px; + margin-right: 1rem; + cursor: pointer; +} + +/* Par défaut, le menu est masqué */ +#menu { + display: none; + /* Menu caché par défaut */ + transition: display 0.3s ease-in-out; +} + + +.burger-menu { + width: 24px; + height: 24px; + cursor: pointer; +} + +.burger-menu-icon { + width: 100%; + height: 100%; +} +/* Icône burger */ +#burger-icon { + cursor: pointer; +} + +/* .menu-content { + display: none; + position: absolute; + top: 3.4rem; + right: 1rem; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 5px; + overflow: hidden; +} + +.menu-content a { + display: block; + padding: 10px 20px; + text-decoration: none; + color: #333; + border-bottom: 1px solid #e0e0e0; + + &:hover { + background-color: rgba(26, 28, 24, .08); + } +} + +.menu-content a:last-child { + border-bottom: none; +} */ + +/* Ajustement pour la barre de navigation fixe */ +.container { + display: flex; + flex: 1; + height: 90vh; + margin-top: 9vh; + margin-left: -1%; + text-align: left; + width: 100vw; +} + +/* Liste des information sur l'account */ + +.parameter-list { + width: 24.5%; + background-color: #1f2c3d; + color: white; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; + border-right: 2px solid #2c3e50; + flex-shrink: 0; + padding-right: 10px; + height: 91vh; +} + +.parameter-list ul { + padding: 15px; + margin-left: 10px; + border-radius: 8px; + background-color: #273646; + cursor: pointer; + transition: background-color 0.3s, box-shadow 0.3s; +} + + +.parameter-list ul:hover { + background-color: #34495e; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + + +/* Zone des info des parametre */ + +.parameter-area { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + background-color: #ffffff; + border-radius: 10px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + margin: 0px; + margin-top: 20px; + margin-left: 1%; + margin-bottom: -7px; +} + +/* En-tête du parametre */ +.parameter-header { + background-color: #34495e; + color: white; + padding: 15px; + font-size: 20px; + font-weight: bold; + border-radius: 10px 10px 0 0; + text-align: center; +} + +/* Style du tableau dans parameter-area */ +.parameter-table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; + table-layout: fixed; +} + +.parameter-table th, .parameter-table td { + border: 1px solid #ddd; + padding: 8px; + white-space: nowrap; + overflow: hidden; + text-align: center; +} + +.parameter-table th { + background-color: var(--secondary-color); + color: white; + font-weight: bold; +} + +.parameter-table tr:nth-child(even) { + background-color: #f2f2f2; +} + + + +.parameter-table tr:hover { + background-color: #ddd; +} + +/* Conteneur pour les boutons */ +.button-container { + display: flex; + justify-content: center; + gap: 15px; + margin: 15px 0; +} + +/* Boutons "Ajouter une ligne" et "Confirmer" */ +.add-row-button, .confirm-all-button, .delete-row-button { + background-color: var(--accent-color); + color: white; + border: none; + padding: 8px 15px; + cursor: pointer; + border-radius: 5px; + font-size: 0.9em; + margin-right: 5px; +} + +.add-row-button:hover, .confirm-all-button:hover, .delete-row-button:hover { + background-color: #b06935; +} + + +.button-style { + background-color: var(--accent-color); + color: white; + border: none; + border-radius: 5px; + padding: 10px 20px; + cursor: pointer; + transition: background-color 0.3s; +} + +.button-style:hover { + background-color: darkorange; +} + +.content-container { + width: 100%; +} + +#pairing-content, +#wallet-content { + width: 100%; +} + +.editable-cell { + cursor: pointer; +} + +.editable-cell:hover { + background-color: #f5f5f5; +} + +.edit-input { + width: 100%; + padding: 5px; + box-sizing: border-box; + border: 1px solid #ddd; + border-radius: 4px; +} + +/*-------------------------------------- Notification--------------------------------------*/ +.notification-cell { + position: relative; +} + +.notification-bell { + position: relative; + display: inline-block; +} + +.notification-badge { + position: absolute; + top: -8px; + right: -8px; + background-color: red; + color: white; + border-radius: 50%; + padding: 2px 6px; + font-size: 12px; + min-width: 15px; + text-align: center; +} + +.fa-bell { + color: #666; + font-size: 20px; +} + + +/* Media Queries pour Mobile */ +@media screen and (max-width: 768px) { + /* Navbar */ + .nav-wrapper { + height: 9vh; + padding: 0; + display: flex; + justify-content: space-between; + align-items: center; + } + + /* Section avatar (gauche) */ + .avatar-section { + width: 200px; /* Largeur réduite */ + margin-left: 10px; + order: 0; /* Garde à gauche */ + } + + .avatar-container { + width: 35px; + height: 35px; + } + + .user-info span { + font-size: 0.8rem; + } + + /* Logo (centre) */ + .brand-logo { + order: 0; + flex: 0 0 auto; + padding: 0; + font-size: 1.2rem; + } + + /* Menu burger (droite) */ + .nav-right-icons { + order: 0; + width: auto; + padding-right: 15px; + } + + .burger-menu { + width: 24px; + height: 24px; + } + + /* Ajustements pour la bannière */ + .banner-image-container { + height: 100%; + } + + .banner-content { + padding: 0 10px; + } + + /* Style des boutons dans la popup */ + .button-container { + display: flex; + gap: 10px; + margin: 15px 0; + width: 100%; + } + + .export-btn, + .delete-account-btn { + flex: 1; + padding: 12px 15px; + border: none; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.3s; + color: white; + } + + .export-btn { + background-color: var(--accent-color); + } + + .delete-account-btn { + background-color: var(--danger-color); + } +} + +/* Media Queries pour très petits écrans */ +@media screen and (max-width: 380px) { + .avatar-section { + width: 150px; + } + + .user-info span { + font-size: 0.7rem; + } +} + +/* Style des boutons */ +.button-container { + display: flex; + gap: 15px; + margin: 15px 0; + width: 100%; +} + +.export-btn, +.delete-account-btn { + flex: 1; + padding: 12px 20px; + border: none; + border-radius: 8px; + color: white; + cursor: pointer; + transition: background-color 0.3s; + font-size: 14px; + display: block; +} + +.export-btn { + background-color: var(--accent-color); +} + +.delete-account-btn { + background-color: rgb(219, 17, 17); + display: block; + visibility: visible; +} + +.export-btn:hover { + background-color: #b06935; +} + +.delete-account-btn:hover { + background-color: #b60718; +} + +@media screen and (max-width: 768px) { + .button-container { + gap: 10px; + } + + .export-btn, + .delete-account-btn { + padding: 12px 15px; + font-size: 14px; + } +} + +/* Style pour les boutons de la popup */ +.popup-buttons { + display: flex; + gap: 15px; + margin: 15px 0; + width: 100%; +} + +/* Style pour les boutons d'action des tableaux */ +.button-container { + display: flex; + gap: 15px; + margin: 15px 0; + width: 100%; +} + +/* Style pour le header mobile */ +.mobile-nav { + display: none; + width: 100%; + padding: 10px; + background-color: #34495e; + overflow-x: auto; + white-space: nowrap; +} + +.mobile-nav ul { + display: flex; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +/* Media Query pour mobile */ +@media screen and (max-width: 768px) { + .parameter-list { + display: flex; + width: 100%; + min-width: 100%; + height: auto; + overflow-x: auto; + background-color: rgb(31, 44, 61); + padding: 10px; + border-right: none; + border-bottom: 2px solid rgb(44, 62, 80); + } + + .mobile-nav { + display: flex; /* Affiche la navigation mobile */ + } + + .parameter-header { + display: flex; + flex-direction: column; + } + + .parameter-list-ul { + text-align: center; + flex: 1 1 0%; + margin: 0px 5px; + padding: 10px; + white-space: nowrap; + margin-bottom: none; + } + + .parameter-list-ul:hover { + background-color: #34495e; + } + + .container { + flex-direction: column; + } + + .parameter-area { + margin: -5px; + } +} + +/* Style pour le header et la navigation mobile */ +.parameter-header { + background-color: #34495e; + padding: 20px 0; + margin: 0; + width: 100%; +} + +.mobile-nav { + display: none; /* Par défaut caché */ + width: 100%; + padding: 10px; + background-color: #34495e; + overflow-x: auto; +} + +.mobile-nav ul { + display: flex; + gap: 10px; + margin: 0; + padding: 10px; + list-style: none; +} + +.mobile-nav li { + flex: 0 0 auto; + white-space: nowrap; +} + +/* Ajoutez ces styles pour la bannière dans la popup */ +.banner-container { + width: 100%; + margin-bottom: 20px; +} + +.banner-wrapper { + width: 100%; + height: 120px; + overflow: hidden; + position: relative; + border-radius: 10px; +} + +.banner-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; +} + +/* Mise à jour des styles existants */ +.popup-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + padding: 20px; + border-radius: 10px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; +} + +/* Style pour le conteneur de la bannière */ +.banner-upload-label { + display: block; + width: auto; + padding: 12px 20px; + background-color: var(--accent-color); + color: white; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.3s; + text-align: center; + font-size: 16px; + margin: 10px auto; + max-width: 200px; +} + +/* ---------------------Style pour la popup de contrat--------------------- */ + +.contract-popup-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex +; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.contract-popup-content { + background: white; + padding: 30px; + border-radius: 8px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + position: relative; +} + +.close-contract-popup { + position: absolute; + top: 15px; + right: 15px; + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; +} + +/* Style pour la popup d'alerte */ +.alert-popup { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background-color: #f44336; + color: white; + padding: 15px 25px; + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + z-index: 1000; + display: none; + animation: slideDown 0.3s ease-out; +} + +/* ---------------------Style pour la popup notification--------------------- */ + + +.notifications-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.notifications-content { + position: relative; + background: white; + border-radius: 8px; + padding: 24px; + width: 90%; + max-width: 500px; +} + +.close-button { + position: absolute; + top: 15px; + right: 20px; + font-size: 24px; + color: #666; + cursor: pointer; + transition: color 0.2s; +} + +.close-button:hover { + color: #000; +} + +.notifications-title { + padding-right: 30px; /* Pour éviter que le titre ne chevauche le bouton de fermeture */ +} + +.notifications-title { + font-size: 24px; + color: #445B6E; + margin-bottom: 20px; + font-weight: 500; +} + +.notifications-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.notification-item { + display: flex; + align-items: flex-start; + padding: 12px 0; + border-bottom: 1px solid #eee; + cursor: pointer; +} + +.notification-status { + margin-right: 16px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.dot-icon, .check-icon { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.notification-content { + flex: 1; +} + +.notification-message { + font-size: 16px; + color: #333; + margin-bottom: 4px; +} + +.notification-date { + font-size: 14px; + color: #666; +} + +.notification-item:hover { + background-color: #f8f9fa; +} + +.notification-item.read { + opacity: 0.7; +} + +.notification-item.unread { + background-color: #fff; +} + +.close-notifications { + position: absolute; + top: 15px; + right: 15px; + border: none; + background: none; + font-size: 24px; + color: #666; + cursor: pointer; +} + +/*-------------------------------------- STYLE ACTION BUTTON ---------------------*/ + +.action-buttons-wrapper { + display: flex; + flex-direction: row; + gap: 10px; + justify-content: center; + margin: 20px 0; +} + +.action-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 8px; + border: none; + font-size: 16px; + cursor: pointer; + transition: all 0.3s ease; + color: white; + height: 40px; + width: 40px; +} + +.confirm-button { + background-color: #4CAF50; +} + +.confirm-button:hover { + background-color: #3d8b40; + transform: translateY(-2px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.cancel-button { + background-color: rgb(244, 67, 54); +} + +.cancel-button:hover { + background-color: #d32f2f; + transform: translateY(-2px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.banner-image.clickable { + cursor: pointer; + transition: opacity 0.3s ease; +} + +.banner-image.clickable:hover { + opacity: 0.8; +} + +.parameter-list-ul.profile { + position: relative; + overflow: hidden; + max-height: 200px; + margin-bottom: 20px; +} + + +.profile-preview { + position: relative; + width: 100%; + height: 100%; +} + +.preview-banner { + position: relative; + width: 100%; + height: 120px; + overflow: hidden; +} + +.preview-banner-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.preview-info { + position: relative; + display: flex; + align-items: center; + padding: 10px; + gap: 10px; + background: rgba(0, 0, 0, 0.3); +} + +.preview-avatar { + width: 45px; + height: 45px; + border-radius: 50%; + border: 2px solid white; +} + +/* ---------------------Style pour le QR code--------------------- */ + +.qr-code { + width: 50px; + height: 50px; + cursor: pointer; + transition: transform 0.2s ease; +} + +.qr-code:hover { + transform: scale(1.5); +} + +.qr-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.qr-modal-content { + background-color: white; + padding: 20px; + border-radius: 8px; + position: relative; + text-align: center; +} + +.close-qr-modal { + position: absolute; + right: 10px; + top: 5px; + font-size: 24px; + cursor: pointer; + color: #666; +} + +.close-qr-modal:hover { + color: #000; +} + +.qr-code-large { + max-width: 300px; + margin: 10px 0; +} + +.qr-address { + margin-top: 10px; + word-break: break-all; + font-size: 12px; + color: #666; +} + +.pairing-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.pairing-modal-content { + background-color: white; + padding: 2rem; + border-radius: 8px; + width: 90%; + max-width: 500px; +} + +.pairing-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-weight: bold; +} + +.button-group { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; +} + +.button-group button { + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; +} + +.confirm-button { + background-color: var(--accent-color); + color: white; + border: none; +} + +.cancel-button { + background-color: #ccc; + border: none; +} diff --git a/ihm_client/public/style/chat.css b/ihm_client/public/style/chat.css new file mode 100755 index 00000000..ad2f97f5 --- /dev/null +++ b/ihm_client/public/style/chat.css @@ -0,0 +1,597 @@ +/* Styles de base */ +:root { + --primary-color: #3A506B; + /* Bleu métallique */ + --secondary-color: #B0BEC5; + /* Gris acier */ + --accent-color: #D68C45; + /* Cuivre */ +} + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; +} + + +/* 4NK NAVBAR */ + +.brand-logo { + text-align: center; + font-size: 1.5em; + font-weight: bold; +} + +.nav-wrapper { + position: fixed; + background: radial-gradient(circle, white, var(--primary-color)); + display: flex; + justify-content: space-between; + align-items: center; + color: #37474F; + height: 9vh; + width: 100vw; + left: 0; + top: 0; + box-shadow: 0px 8px 10px -5px rgba(0, 0, 0, .2), 0px 16px 24px 2px rgba(0, 0, 0, .14), 0px 6px 30px 5px rgba(0, 0, 0, .12); +} + +/* Icônes de la barre de navigation */ +.nav-right-icons { + display: flex; +} + +.notification-bell, +.burger-menu { + height: 20px; + width: 20px; + margin-right: 1rem; + cursor: pointer; +} + +.notification-container { + position: relative; + /* Conserve la position pour le notification-board */ + display: inline-flex; + align-items: center; +} + +.notification-board { + position: absolute; + /* Position absolue pour le placer par rapport au container */ + top: 40px; + right: 0; + background-color: white; + border: 1px solid #ccc; + padding: 10px; + width: 200px; + max-height: 300px; + overflow-y: auto; + /* Scroll si les notifications dépassent la taille */ + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 10; + /* Définit la priorité d'affichage au-dessus des autres éléments */ + display: none; + /* Par défaut, la notification est masquée */ +} + +.notification-item{ + cursor: pointer; +} + +.notification-badge { + position: absolute; + top: -18px; + right: 35px; + background-color: red; + color: white; + border-radius: 50%; + padding: 4px 8px; + font-size: 12px; + display: none; + /* S'affiche seulement lorsqu'il y a des notifications */ + z-index: 10; +} + +/* Par défaut, le menu est masqué */ +#menu { + display: none; + /* Menu caché par défaut */ + transition: display 0.3s ease-in-out; +} + +.burger-menu { + cursor: pointer; +} + +/* Icône burger */ +#burger-icon { + cursor: pointer; +} + +.menu-content { + display: none; + position: absolute; + top: 3.4rem; + right: 1rem; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 5px; + overflow: hidden; +} + +.menu-content a { + display: block; + padding: 10px 20px; + text-decoration: none; + color: #333; + border-bottom: 1px solid #e0e0e0; + + &:hover { + background-color: rgba(26, 28, 24, .08); + } +} + +.menu-content a:last-child { + border-bottom: none; +} + +/* Ajustement pour la barre de navigation fixe */ +.container { + display: flex; + flex: 1; + height: 90vh; + margin-top: 9vh; + margin-left: -1%; + text-align: left; + width: 100vw; +} + + +/* Liste des groupes */ + +.group-list { + width: 25%; + background-color: #1f2c3d; + color: white; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; + border-right: 2px solid #2c3e50; + flex-shrink: 0; + padding-right: 10px; + height: 91vh; +} +.group-list ul { + cursor: pointer; + list-style: none; + padding: 0; + padding-right: 10px; + margin-left: 20px; +} + +.group-list li { + margin-bottom: 20px; + padding: 15px; + border-radius: 8px; + background-color: #273646; + cursor: pointer; + transition: background-color 0.3s, box-shadow 0.3s; +} + +.group-list li:hover { + background-color: #34495e; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + + +.group-list .member-container { + position: relative; +} + +.group-list .member-container button { + margin-left: 40px; + padding: 5px; + cursor: pointer; + background: var(--primary-color); + color: white; + border: 0px solid var(--primary-color); + border-radius: 50px; + position: absolute; + top: -25px; + right: -25px; +} + +.group-list .member-container button:hover { + background: var(--accent-color) +} + + +/* Zone de chat */ +.chat-area { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + background-color:#f1f1f1; + border-radius: 10px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + margin: 1% 0% 0.5% 1%; +} + +/* En-tête du chat */ +.chat-header { + background-color: #34495e; + color: white; + padding: 15px; + font-size: 20px; + font-weight: bold; + border-radius: 10px 10px 0 0; + text-align: center; +} + +/* Messages */ +.messages { + flex: 1; + padding: 20px; + overflow-y: auto; + background-color: #f1f1f1; + border-top: 1px solid #ddd; +} + +.message-container { + display: flex; + margin: 8px; +} +.message-container .message { + align-self: flex-start; +} + +.message-container .message.user { + align-self: flex-end; + margin-left: auto; + color: white; +} + +.message { + max-width: 70%; + padding: 10px; + border-radius: 12px; + background:var(--secondary-color); + margin: 2px 0; +} + +/* Messages de l'utilisateur */ +.message.user { + background: #2196f3; + color: white; +} + +.message-time { + font-size: 0.7em; + opacity: 0.7; + margin-left: 0px; + margin-top: 5px; +} + + +/* Amélioration de l'esthétique des messages */ +/* .message.user:before { + content: ''; + position: absolute; + top: 10px; + right: -10px; + border: 10px solid transparent; + border-left-color: #3498db; +} */ + +/* Zone de saisie */ +.input-area { + padding: 10px; + background-color: #bdc3c7; + display: flex; + align-items: center; + border-radius: 10px; + margin: 1%; + /* Alignement vertical */ +} + +.input-area input[type="text"] { + flex: 1; + /* Prend l'espace restant */ + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; +} + +.input-area .attachment-icon { + margin: 0 10px; + cursor: pointer; + display: flex; + align-items: center; +} + +.input-area button { + padding: 10px; + margin-left: 10px; + background-color: #2980b9; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; +} + +.input-area button:hover { + background-color: #1f608d; +} + +.tabs { + display: flex; + margin: 20px 0px; + gap: 10px; +} + +.tabs button { + padding: 10px 20px; + cursor: pointer; + background: var(--primary-color); + color: white; + border: 0px solid var(--primary-color); + margin-right: 5px; + border-radius: 10px; +} + +.tabs button:hover { + background: var(--secondary-color); + color: var(--primary-color); +} + +/* Signature */ +.signature-area { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + background-color:#f1f1f1; + border-radius: 10px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + margin: 1% 0% 0.5% 1%; + transition: all 1s ease 0.1s; + visibility: visible; +} + +.signature-area.hidden { + opacity: 0; + visibility: hidden; + display: none; + pointer-events: none; +} + +.signature-header { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--primary-color); + color: white; + border-radius: 10px 10px 0 0; + padding-left: 4%; +} + +.signature-content { + padding: 10px; + background-color: var(--secondary-color); + color: var(--primary-color); + height: 100%; + border-radius: 10px; + margin: 1%; + display: flex; + flex-direction: column; + align-items: center; +} + +.signature-description { + height: 20%; + width: 100%; + margin: 0% 10% 0% 10%; + overflow: auto; + display: flex; +} + +.signature-description li { + margin: 1% 0% 1% 0%; + list-style: none; + padding: 2%; + border-radius: 10px; + background-color: var(--primary-color); + color: var(--secondary-color); + width: 20%; + text-align: center; + cursor: pointer; + font-weight: bold; + margin-right: 2%; + overflow: auto; +} + +.signature-description li .member-list { + margin-left: -30%; +} + +.signature-description li .member-list li { + width: 100%; +} + +.signature-description li .member-list li:hover { + background-color: var(--secondary-color); + color: var(--primary-color); +} + +.signature-documents { + height: 80%; + width: 100%; + margin: 0% 10% 0% 10%; + overflow: auto; + display: flex; +} + +.signature-documents-header { + display: flex; + width: 100%; + height: 15%; + align-items: center; +} + +#request-document-button { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 10px; + padding: 8px; + cursor: pointer; + margin-left: 5%; + font-weight: bold; +} + +#request-document-button:hover { + background-color: var(--accent-color); + font-weight: bold; +} + +#close-signature { + cursor: pointer; + align-items: center; + margin-left: auto; + margin-right: 2%; + border-radius: 50%; + background-color: var(--primary-color); + color: white; + border: none; + padding: -3%; + margin-top: -5%; + font-size: 1em; + font-weight: bold; + } + + #close-signature:hover { + background-color: var(--secondary-color); + color: var(--primary-color); + } + + /* REQUEST MODAL */ + .request-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background-color: var(--secondary-color); + padding: 20px; + border-radius: 8px; + position: relative; + min-width: 300px; +} + +.close-modal { + position: absolute; + top: 10px; + right: 10px; + border: none; + background: none; + font-size: 1.5em; + cursor: pointer; + font-weight: bold; +} + +.close-modal:hover { + color: var(--accent-color); +} + +.modal-members { + display: flex; + justify-content: space-between; +} + +.modal-members ul li{ + list-style: none; +} + +.file-upload-container { + margin: 10px 0; +} + +.file-list { + margin-top: 10px; +} + +.file-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px; + margin: 5px 0; + background: var(--background-color-secondary); + border-radius: 4px; +} + +.remove-file { + background: none; + border: none; + color: var(--text-color); + cursor: pointer; + padding: 0 5px; +} + +.remove-file:hover { + color: var(--error-color); +} + +#message-input { + width: 100%; + height: 50px; + resize: none; + padding: 10px; + box-sizing: border-box; + overflow: auto; + max-width: 100%; + border-radius: 10px; +} + +/* Responsive */ +@media screen and (max-width: 768px) { + .group-list { + display: none; + /* Masquer la liste des groupes sur les petits écrans */ + } + + .chat-area { + margin: 0; + } +} + + +::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +::-webkit-scrollbar-track { + background: var(--primary-color); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb { + background: var(--secondary-color); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent-color); +} \ No newline at end of file diff --git a/ihm_client/public/style/signature.css b/ihm_client/public/style/signature.css new file mode 100755 index 00000000..2c8cf285 --- /dev/null +++ b/ihm_client/public/style/signature.css @@ -0,0 +1,1664 @@ +/* Styles de base */ +:root { + --primary-color: #3A506B; + /* Bleu métallique */ + --secondary-color: #B0BEC5; + /* Gris acier */ + --accent-color: #D68C45; + /* Cuivre */ +} + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + display: flex; + height: 100vh; + background-color: #e9edf1; + flex-direction: column; +} + + + +/* 4NK NAVBAR */ + +.brand-logo { + text-align: center; + font-size: 1.5em; + font-weight: bold; +} + +.nav-wrapper { + position: fixed; + background: radial-gradient(circle, white, var(--primary-color)); + display: flex; + justify-content: space-between; + align-items: center; + color: #37474F; + height: 9vh; + width: 100vw; + left: 0; + top: 0; + box-shadow: 0px 8px 10px -5px rgba(0, 0, 0, .2), 0px 16px 24px 2px rgba(0, 0, 0, .14), 0px 6px 30px 5px rgba(0, 0, 0, .12); +} + +/* Icônes de la barre de navigation */ +.nav-right-icons { + display: flex; +} + +.notification-bell, +.burger-menu { + height: 20px; + width: 20px; + margin-right: 1rem; + cursor: pointer; +} + +.notification-container { + position: relative; + /* Conserve la position pour le notification-board */ + display: inline-flex; + align-items: center; +} + +.notification-board { + position: absolute; + /* Position absolue pour le placer par rapport au container */ + top: 40px; + right: 0; + background-color: white; + border: 1px solid #ccc; + padding: 10px; + width: 200px; + max-height: 300px; + overflow-y: auto; + /* Scroll si les notifications dépassent la taille */ + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 10; + /* Définit la priorité d'affichage au-dessus des autres éléments */ + display: none; + /* Par défaut, la notification est masquée */ +} + +.notification-item{ + cursor: pointer; +} + +.notification-badge { + position: absolute; + top: -18px; + right: 35px; + background-color: red; + color: white; + border-radius: 50%; + padding: 4px 8px; + font-size: 12px; + display: none; + /* S'affiche seulement lorsqu'il y a des notifications */ + z-index: 10; +} + +/* Par défaut, le menu est masqué */ +#menu { + display: none; + /* Menu caché par défaut */ + transition: display 0.3s ease-in-out; +} + +.burger-menu { + cursor: pointer; +} + +/* Icône burger */ +#burger-icon { + cursor: pointer; +} + +.menu-content { + display: none; + position: absolute; + top: 3.4rem; + right: 1rem; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 5px; + overflow: hidden; +} + +.menu-content a { + display: block; + padding: 10px 20px; + text-decoration: none; + color: #333; + border-bottom: 1px solid #e0e0e0; + + &:hover { + background-color: rgba(26, 28, 24, .08); + } +} + +.menu-content a:last-child { + border-bottom: none; +} + +/* Ajustement pour la barre de navigation fixe */ +.container { + display: flex; + flex: 1; + height: 90vh; + margin-top: 9vh; + margin-left: -1%; + text-align: left; + width: 209vh; +} + + +/* Liste des groupes */ + +.group-list { + width: 25%; + background-color: #1f2c3d; + color: white; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; + border-right: 2px solid #2c3e50; + flex-shrink: 0; + padding-right: 10px; + height: 91vh; +} + +.group-list ul { + cursor: pointer; + list-style: none; + padding: 0; + padding-right: 10px; + margin-left: 20px; +} + +.group-list li { + margin-bottom: 10px; + padding: 15px; + border-radius: 8px; + background-color: #273646; + cursor: pointer; + transition: background-color 0.3s, box-shadow 0.3s; +} + +.group-list li:hover { + background-color: #34495e; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + + +/* Zone de chat */ + +.chat-area { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + background-color: #ffffff; + border-radius: 10px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + margin: 0px; + margin-top: 20px; + margin-left: 1%; + margin-bottom: -7px; +} + +/* En-tête du chat */ +.chat-header { + background-color: #34495e; + color: white; + padding: 15px; + font-size: 20px; + font-weight: bold; + border-radius: 10px 10px 0 0; + text-align: center; +} + +/* Messages */ +.messages { + flex: 1; + padding: 20px; + overflow-y: auto; + background-color: #f1f1f1; + border-top: 1px solid #ddd; +} + +.message-container { + max-width: 100%; + border-radius: 5px; + overflow-wrap: break-word; + word-wrap: break-word; + background-color: #f1f1f1; + display: flex; + flex-direction: column; +} + +.message-container .message { + align-self: flex-start; +} + +.message-container .message.user { + align-self: flex-end; + margin-left: auto; + color: white; +} + +.message { + padding: 12px 18px; + background-color: #e1e1e1; + border-radius: 15px; + max-width: 70%; + font-size: 16px; + line-height: 1.4; + margin-bottom: 0%; + white-space: pre-wrap; + word-wrap: break-word; + position: relative; + display: inline-block; +} + +/* Messages de l'utilisateur */ +.message.user { + background-color: #3498db; + color: white; + align-self: flex-end; + text-align: right; +} + +/* Amélioration de l'esthétique des messages */ +/* .message.user:before { + content: ''; + position: absolute; + top: 10px; + right: -10px; + border: 10px solid transparent; + border-left-color: #3498db; +} */ + +/* Zone de saisie */ +.input-area { + padding: 10px; + background-color: #bdc3c7; + display: flex; + align-items: center; + /* Alignement vertical */ +} + +.input-area input[type="text"] { + flex: 1; + /* Prend l'espace restant */ + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; +} + +.input-area .attachment-icon { + margin: 0 10px; + cursor: pointer; + display: flex; + align-items: center; +} + +.input-area button { + padding: 10px; + margin-left: 10px; + background-color: #2980b9; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; +} + +.input-area button:hover { + background-color: #1f608d; +} + +#message-input { + width: 100%; + height: 50px; + resize: none; + padding: 10px; + box-sizing: border-box; + overflow: auto; + max-width: 100%; + border-radius: 10px; +} + +/* Responsive */ +@media screen and (max-width: 768px) { + .group-list { + display: none; + /* Masquer la liste des groupes sur les petits écrans */ + } + + .chat-area { + margin: 0; + } +} + +#process-details { + flex: 1; + background: white; + border-radius: 8px; + margin: 10px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + display: none; + overflow: hidden; +} + +.process-details-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + background: #f8f9fa; + border-bottom: 1px solid #eee; + border-radius: 8px 8px 0 0; +} + +.process-details-header h2 { + margin: 0; + color: #333; +} + +.close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; +} + +.close-btn:hover { + color: #333; +} + +.process-details-content { + padding: 20px; + overflow-y: auto; + height: calc(100% - 70px); +} + +.details-section { + margin-bottom: 30px; +} + +.details-section h3 { + color: #444; + margin-bottom: 15px; + padding-bottom: 8px; + border-bottom: 2px solid #f0f0f0; +} + +.documents-list { + list-style: none; + padding: 0; +} + +.documents-list li { + padding: 8px 0; + border-bottom: 1px solid #eee; +} + +.roles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 20px; +} + +.role-block { + background: #f8f9fa; + border-radius: 8px; + padding: 15px; + border: 1px solid #eee; +} + +.role-block h4 { + color: #555; + margin: 0 0 10px 0; + padding-bottom: 8px; + border-bottom: 1px solid #eee; +} + +.members-list { + list-style: none; + padding: 0; +} + +.members-list li { + padding: 8px 12px; + margin: 4px 0; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s; +} + +.members-list li:hover { + background-color: #e9ecef; +} + +.group-list-item { + padding: 8px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.group-item-container { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.process-name { + flex-grow: 1; + cursor: pointer; +} + +.settings-icon { + cursor: pointer; + padding: 5px 8px; + margin-left: 10px; + border-radius: 4px; +} + +.settings-icon:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.process-details { + position: fixed; + top: 9vh; + right: 0; + bottom: 0; + left: 23.5%; + background-color: white; + box-sizing: border-box; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border-radius: 8px; + margin: 10px; + z-index: 990; +} + +.process-details-content { + height: calc(100% - 60px); /* Ajusté pour tenir compte du header */ + overflow-y: auto; + padding: 20px; +} + +.documents-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 15px; +} + +.document-card { + background: white; + border-radius: 8px; + padding: 15px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border: 1px solid #eee; +} + +.document-card.public { + border-left: 4px solid #4CAF50; +} + +.document-card.private { + border-left: 4px solid #FFC107; +} + +.document-card.confidential { + border-left: 4px solid #F44336; +} + +.document-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + +.document-header h4 { + margin: 0; + color: #333; +} + +.document-visibility { + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; +} + +.public .document-visibility { + background-color: #E8F5E9; + color: #2E7D32; +} + +.private .document-visibility { + background-color: #FFF3E0; + color: #F57C00; +} + +.confidential .document-visibility { + background-color: #FFEBEE; + color: #C62828; +} + +.document-info { + margin: 10px 0; + font-size: 14px; + color: #666; +} + +.document-info p { + margin: 5px 0; +} + +.signatures-list { + margin-top: 10px; +} + +.signature-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + border-radius: 4px; + margin: 5px 0; + background-color: #f8f9fa; +} + +.signature-item.signed { + background-color: #E8F5E9; +} + +.signature-item.pending { + background-color: #FFF3E0; +} + +.signer-name { + font-weight: 500; +} + +.signature-status { + font-size: 12px; +} + +.signed .signature-status { + color: #2E7D32; +} + +.pending .signature-status { + color: #F57C00; +} + +.user-selector { + position: relative; + margin-right: 20px; +} + +#userSwitchBtn { + background: none; + border: none; + cursor: pointer; + padding: 8px 12px; + border-radius: 4px; + display: flex; + align-items: center; + color: var(--primary-color); +} + +#userSwitchBtn:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.current-user-info { + display: flex; + align-items: center; + gap: 8px; +} + +.user-avatar { + background-color: var(--primary-color); + color: white; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +} + +.user-list { + position: absolute; + top: 100%; + right: 0; + background: white; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + display: none; + z-index: 1000; + max-height: 400px; + overflow-y: auto; + width: 250px; +} + +.user-list.show { + display: block; +} + +.user-list-item { + padding: 8px 16px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.user-list-item:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.user-list-item .user-avatar { + width: 24px; + height: 24px; + font-size: 12px; +} + +.user-list-item .user-email { + font-size: 12px; + color: #666; + display: block; +} + +.document-card { + margin-bottom: 20px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; +} + +.progress-bar { + background-color: #f3f3f3; + border-radius: 5px; + height: 10px; + width: 100%; + margin-top: 5px; +} + +.progress { + background-color: #4caf50; /* Couleur de la barre de progression */ + height: 100%; + border-radius: 5px; +} + +.new-request-btn { + background-color: #4caf50; + color: white; + border: none; + border-radius: 5px; + padding: 10px 15px; + cursor: pointer; + margin-left: 10px; +} + +.new-request-btn:hover { + background-color: #45a049; +} + +.header-buttons { + display: flex; + align-items: center; + gap: 10px; +} + +.close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; +} + +.close-btn:hover { + color: #333; +} + +.new-request-view { + padding: 20px; + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin: 20px; +} + +.upload-area { + border: 2px dashed #ccc; + padding: 20px; + text-align: center; + margin: 20px 0; +} + +.upload-icon { + font-size: 50px; + margin: 10px 0; +} + +/* New Request View */ +.new-request-view { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: white; + z-index: 1000; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; +} + +.upload-area { + border: 2px dashed #ccc; + padding: 40px; + text-align: center; + margin: 20px auto; + max-width: 600px; + background-color: #f9f9f9; + border-radius: 8px; + cursor: pointer; +} + +.upload-icon { + font-size: 50px; + margin: 20px 0; +} + +.new-request-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 60px; + padding: 0 20px; +} + +.upload-area { + border: 2px dashed #ccc; + padding: 40px; + text-align: center; + margin: 20px auto; + max-width: 600px; + background-color: #f9f9f9; + border-radius: 8px; + cursor: pointer; +} + +.details-header { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #f8f9fa; + padding: 5px 20px; + border-radius:20px; +} + +.header-buttons { + display: flex; + align-items: center; + gap: 10px; + flex-direction: row; +} + +.close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; + padding: 5px; +} + +.close-btn:hover { + color: #333; +} + +.new-request-btn { + background-color: #4caf50; + color: white; + border: none; + border-radius: 5px; + padding: 8px 16px; + cursor: pointer; + /* margin-left: 10px; <- Supprimez cette ligne si elle existe */ +} + +.new-request-btn:hover { + background-color: #45a049; +} + +/* Ajoutez ces styles à votre fichier CSS existant */ +.document-card.vierge { + background-color: #fff3cd; + border: 2px solid #ffeeba; +} + +.document-card.vierge .document-header { + background-color: #fff3cd; +} + +.document-card.vierge h4 { + color: #856404; +} + +.document-card.vierge .document-info { + color: #856404; +} + +/* Style pour l'emoji d'avertissement */ +.document-card.vierge h4::before { + margin-right: 8px; +} + +.vierge-documents-container { + padding: 20px; + overflow-y: auto; + max-height: calc(100vh - 150px); +} + +.document-form { + padding: 15px; + background-color: #fff; + border-radius: 0 0 5px 5px; + display: flex; + gap: 20px; +} + +.form-left { + flex: 1; + display: flex; + flex-direction: column; + gap: 15px; +} + +.form-right { + display: flex; + flex-direction: column; + align-items: flex-end; /* Aligner le bouton à droite */ +} + +.form-group { + flex: 2; + display: flex; + flex-direction: column; + margin-bottom: 15px; +} + +.form-group-members { + flex: 2; + display: flex; + flex-direction: column; + margin-bottom: 15px; + font-weight: bold; +} + +.submit-btn { + background-color: #4caf50; + color: white; + border: none; + border-radius: 5px; + padding: 10px 15px; + cursor: pointer; + margin-left: 47%; + margin-top: 20px; +} + +.submit-btn:hover { + background-color: #45a049; +} + +.upload-format { + font-size: 12px; + color: #666; + margin: 5px 0; +} + +.browse-btn { + background-color: #4caf50; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + margin-top: 10px; +} + +.browse-btn:hover { + background-color: #45a049; +} + +.document-selector { + padding: 20px; + margin-bottom: 20px; +} + +.document-selector label { + display: block; + margin-bottom: 10px; + font-weight: bold; + color: #666; +} + +.document-selector select { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 16px; + background-color: white; +} + +#selected-document-form { + padding: 0 20px; + overflow: auto; + height: 65%; +} + +/* Style pour l'option avec l'emoji */ +.document-selector select option { + padding: 10px; + font-size: 14px; +} + +.members-selection { + max-height: 200px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 4px; + padding: 10px; + background-color: white; +} + +.member-checkbox { + display: flex; + align-items: center; + margin-bottom: 8px; + padding: 5px; +} + +.member-checkbox:hover { + background-color: #f5f5f5; +} + +.member-checkbox input[type="checkbox"] { + margin-right: 10px; +} + +.member-checkbox label { + cursor: pointer; + flex: 1; +} + +/* Style pour le conteneur des membres */ +.members-selection-container { + display: flex; + flex-direction: column; + align-items: center; /* Centrer la liste des membres */ + width: 100%; +} + +#members-list { + width: 60%; + height: 100%; + margin-bottom: -40px; + margin-top: 10px; +} + +/* Style pour le label des membres */ +.members-selection-container label { + font-weight: bold; /* Mettre le texte en gras */ + margin-bottom: 10px; /* Espacement en bas */ + display: block; /* Affichage en bloc */ +} + +/* Style pour les cases à cocher des membres */ +.member-checkbox { + display: flex; + align-items: center; + margin-bottom: 8px; + padding: 5px; + border-radius: 4px; /* Coins arrondis */ + transition: background-color 0.2s; /* Transition pour l'effet de survol */ +} + +.member-checkbox:hover { + background-color: #e9ecef; /* Couleur de fond au survol */ +} + +/* Style pour les labels */ +.form-group label { + font-weight: bold; + margin-bottom: 5px; +} + +/* Style pour les champs de saisie */ +.form-group input, +.form-group select { + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 16px; + width: 80%; +} + +.add-members-btn { + background-color: #4caf50; + color: white; + border: none; + border-radius: 5px; + padding: 10px 15px; + cursor: pointer; + margin-top: 10px; /* Espacement au-dessus du bouton */ +} + +.add-members-btn:hover { + background-color: #45a049; +} + +/* Styles pour l'overlay et la modale */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 999; +} + +.modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + z-index: 1000; + width: 25%; + max-width: 500px; +} + +.modal-content { + max-height: 70vh; + overflow-y: auto; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; + padding-top: 10px; + border-top: 1px solid #eee; +} + +.confirm-btn { + background-color: #4caf50; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; +} + +.confirm-btn:hover { + background-color: #45a049; +} + +.selected-member { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #f5f5f5; + padding: 8px 12px; + margin: 4px 0; + border-radius: 4px; +} + +.cancel-btn { + background-color: #df2020; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; +} + +.cancel-btn:hover { + background-color: #c62828; / +} + +.remove-member-btn { + background-color: #dc3545; + color: white; + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + padding: 0; + margin-left: 8px; +} + +.remove-member-btn:hover { + background-color: #c82333; +} + +#description { + height: 100px; + width: 100%; + border-radius: 20px; + padding: 10px 0; + padding-left: 10px; + resize: none; + +} + +.role-item-container { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 5px 0; +} + +.role-item-container span:last-child { + margin-left: 10px; +} + +.document-card .new-request-btn { + margin-top: 60%; + margin-left: 65%; + padding: 6px 12px; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; +} + +.document-card .new-request-btn:hover { + background-color: #45a049; +} + +/* Styles pour la modale de nouveau document */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + + +.modal-content-document { + background: white; + padding: 20px; + border-radius: 8px; + width: 97%; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + +.modal-header h2 { + margin: 0; + color: #333; +} + +.modal-body { + margin-bottom: 20px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.modal-document { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: bold; + color: #555; +} + +.form-control { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-row { + display: flex; + gap: 15px; + margin-bottom: 15px; +} + +.form-group.half { + flex: 1; + margin-bottom: 0; /* Annule la marge du form-group standard */ +} + +.selected-signatories { + margin: 10px 0; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + min-height: 50px; +} + +.signatory-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px; + margin: 5px 0; + background: #f5f5f5; + border-radius: 4px; +} + +.remove-btn { + background: none; + border: none; + color: #dc3545; + cursor: pointer; + font-size: 18px; + padding: 0 5px; +} + +.remove-btn:hover { + color: #bd2130; +} + +.btn-primary { + background: #4CAF50; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.btn-primary:hover { + background: #45a049; +} + +.btn-secondary { + background: #6c757d; + color: white; + padding: 8px 15px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.btn-secondary:hover { + background: #5a6268; +} + +.signatories-list { + max-height: 300px; + overflow-y: auto; +} + +.signatory-option { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border-bottom: 1px solid #eee; +} + +.role-select { + padding: 4px; + border: 1px solid #ddd; + border-radius: 4px; + margin-left: auto; +} + +.role-section { + margin-bottom: 20px; + padding: 10px; + background: #f8f9fa; + border-radius: 4px; +} + +.role-section h4 { + margin: 0 0 10px 0; + color: #495057; +} + +.members-selection { + max-height: 300px; + overflow-y: auto; + padding: 10px; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +input[type="file"] { + padding: 10px; + border: 1px dashed #ccc; + border-radius: 4px; + width: 100%; + margin-top: 5px; +} + +.file-upload-container { + border: 2px dashed #ccc; + padding: 20px; + text-align: center; + margin: 10px 0; + border-radius: 5px; + cursor: pointer; +} + +.file-upload-container:hover { + background-color: #f5f5f5; + border-color: #666; +} + +.file-upload-container.dragover { + background-color: #f0f0f0; + border-color: #4CAF50; +} + +.file-list { + margin-top: 10px; +} + +.file-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + margin: 5px 0; + background: #f5f5f5; + border-radius: 4px; +} + +.file-info { + display: flex; + gap: 10px; + align-items: center; +} + +.remove-file { + background: none; + border: none; + color: #ff4444; + cursor: pointer; + font-size: 18px; +} + +#fileInput { + display: none; +} + +.required-signatories { + margin: 10px 0; +} + +.signatory-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + margin: 5px 0; + background: #f5f5f5; + border-radius: 4px; +} + +.signatory-item.locked { + background: #e8e8e8; + cursor: not-allowed; +} + +.member-name { + font-weight: 500; +} + +.role-info { + color: #666; + font-size: 0.9em; +} + +.lock-icon { + margin-left: auto; + opacity: 0.6; +} + +/* Style pour la popup d'alerte */ +.alert-popup { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background-color: #f44336; + color: white; + padding: 15px 25px; + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + z-index: 1000; + display: none; + animation: slideDown 0.3s ease-out; +} + +.sign-button { + background-color: #4CAF50; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + margin-top: 10px; +} + +.sign-button:hover { + background-color: #45a049; +} + +.sign-button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +.modal-document { + background: white; + border-radius: 8px; + max-width: 800px; + width: 90%; + max-height: 95vh; + overflow-y: auto; + position: relative; +} + +.document-details { + padding: 20px; +} + +.info-section { + margin: 20px 0; + background: #f8f9fa; + padding: 15px; + border-radius: 6px; +} + +.info-row { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.label { + font-weight: bold; + color: #666; +} + +.description-section { + margin: 20px 0; +} + +.signatures-section { + margin: 20px 0; +} + +.signature-item { + display: flex; + justify-content: space-between; + padding: 8px; + border-bottom: 1px solid #eee; +} + +.signature-item.signed { + background-color: #e8f5e9; +} + +.signature-item.pending { + background-color: #fff3e0; +} + +.files-section { + margin: 20px 0; +} + +.file-item { + display: flex; + align-items: center; + padding: 8px; + background: #f8f9fa; + margin-bottom: 5px; + border-radius: 4px; +} + +.file-icon { + margin-right: 10px; +} + +.download-link { + margin-left: auto; +} + +.confirmation-section { + margin-top: 30px; + text-align: center; +} + +.warning-text { + color: #f44336; + margin-bottom: 15px; +} + +.sign-confirm-btn { + background-color: #4CAF50; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.sign-confirm-btn:hover { + background-color: #45a049; +} + +.signature-slider-container { + margin: 20px 20%; + padding: 10px; +} + +.slider-track { + position: relative; + background: #e0e0e0; + height: 40px; + border-radius: 20px; + display: flex; + align-items: center; + overflow: hidden; +} + +.slider-handle { + position: absolute; + left: 0; + width: 40px; + height: 40px; + background: #4CAF50; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + touch-action: none; + z-index: 1; +} + +.slider-arrow { + color: white; + font-size: 20px; +} + +.slider-text { + width: 100%; + text-align: center; + color: #666; + user-select: none; +} \ No newline at end of file diff --git a/ihm_client/src/4nk.css b/ihm_client/src/4nk.css new file mode 100644 index 00000000..180690f6 --- /dev/null +++ b/ihm_client/src/4nk.css @@ -0,0 +1,818 @@ +:host { + --primary-color: #3a506b; + /* Bleu métallique */ + --secondary-color: #b0bec5; + /* Gris acier */ + --accent-color: #d68c45; + /* Cuivre */ + font-family: Arial, sans-serif; + height: 100vh; + font-size: 16px; +} +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: #f4f4f4; + background-image: url(../assets/bgd.webp); + background-repeat: no-repeat; + background-size: cover; + background-blend-mode: soft-light; + height: 100vh; +} +.message { + margin: 30px 0; + font-size: 14px; + overflow-wrap: anywhere; +} + +.message strong { + font-family: 'Segoe UI Emoji', 'Noto Color Emoji', 'Apple Color Emoji', sans-serif; + font-size: 20px; +} + +/** Modal Css */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 3; +} + +.modal-content { + width: 55%; + height: 30%; + background-color: white; + border-radius: 4px; + padding: 20px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.modal-title { + margin: 0; + padding-bottom: 8px; + width: 100%; + font-size: 0.9rem; + border-bottom: 1px solid #ccc; +} + +.confirmation-box { + /* margin-top: 20px; */ + align-content: center; + width: 70%; + height: 20%; + /* padding: 20px; */ + font-size: 1.5em; + color: #333333; + top: 5%; + position: relative; +} + +.nav-wrapper { + position: fixed; + background: radial-gradient(circle, white, var(--primary-color)); + /* background-color: #CFD8DC; */ + display: flex; + justify-content: flex-end; + align-items: center; + color: #37474f; + height: 9vh; + width: 100vw; + left: 0; + top: 0; + box-shadow: + 0px 8px 10px -5px rgba(0, 0, 0, 0.2), + 0px 16px 24px 2px rgba(0, 0, 0, 0.14), + 0px 6px 30px 5px rgba(0, 0, 0, 0.12); + + .nav-right-icons { + display: flex; + .notification-container { + position: relative; + display: inline-block; + } + .notification-bell, + .burger-menu { + z-index: 3; + height: 20px; + width: 20px; + margin-right: 1rem; + } + .notification-badge { + position: absolute; + top: -0.7rem; + left: -0.8rem; + background-color: red; + color: white; + border-radius: 50%; + padding: 2.5px 6px; + font-size: 0.8rem; + font-weight: bold; + } + } + .notification-board { + position: absolute; + width: 20rem; + min-height: 8rem; + background-color: white; + right: 0.5rem; + display: none; + border-radius: 4px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + display: none; + + .notification-element { + padding: 0.8rem 0; + width: 100%; + &:hover { + background-color: rgba(26, 28, 24, 0.08); + } + } + .notification-element:not(:last-child) { + border-bottom: 1px solid; + } + } +} + +.brand-logo { + height: 100%; + width: 100vw; + align-content: center; + position: relative; + display: flex; + position: absolute; + align-items: center; + justify-content: center; + text-align: center; + font-size: 1.5em; + font-weight: bold; +} + +.container { + text-align: center; + display: grid; + height: 100vh; + grid-template-columns: repeat(7, 1fr); + gap: 10px; + grid-auto-rows: 10vh 15vh 1fr; +} +.title-container { + grid-column: 2 / 7; + grid-row: 2; +} +.page-container { + grid-column: 2 / 7; + grid-row: 3; + justify-content: center; + display: flex; + padding: 1rem; + box-sizing: border-box; + max-height: 60vh; +} + +h1 { + font-size: 2em; + margin: 20px 0; +} +@media only screen and (min-width: 600px) { + .tab-container { + display: none; + } + .page-container { + display: flex; + align-items: center; + } + .process-container { + grid-column: 3 / 6; + grid-row: 3; + + .card { + min-width: 40vw; + } + } + .separator { + width: 2px; + background-color: #78909c; + height: 80%; + margin: 0 0.5em; + } + .tab-content { + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: center; + height: 80%; + } +} + +@media only screen and (max-width: 600px) { + .process-container { + grid-column: 2 / 7; + grid-row: 3; + } + .container { + grid-auto-rows: 10vh 15vh 15vh 1fr; + } + .tab-container { + grid-column: 1 / 8; + grid-row: 3; + } + .page-container { + grid-column: 2 / 7; + grid-row: 4; + } + .separator { + display: none; + } + .tabs { + display: flex; + flex-grow: 1; + overflow: hidden; + z-index: 1; + border-bottom-style: solid; + border-bottom-width: 1px; + border-bottom-color: #e0e4d6; + } + + .tab { + flex: 1; + text-align: center; + padding: 10px 0; + cursor: pointer; + font-size: 1rem; + color: #6200ea; + &:hover { + background-color: rgba(26, 28, 24, 0.08); + } + } + .tab.active { + border-bottom: 2px solid #6200ea; + font-weight: bold; + } + + .card.tab-content { + display: none; + } + + .tab-content.active { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 80%; + } + .modal-content { + width: 80%; + height: 20%; + } +} + +.qr-code { + display: flex; + justify-content: center; + align-items: center; + height: 200px; +} + +.emoji-display { + font-family: 'Segoe UI Emoji', 'Noto Color Emoji', 'Apple Color Emoji', sans-serif; + font-size: 20px; +} + +#emoji-display-2 { + margin-top: 30px; + font-family: 'Segoe UI Emoji', 'Noto Color Emoji', 'Apple Color Emoji', sans-serif; + font-size: 20px; +} + +#okButton { + margin-bottom: 2em; + cursor: pointer; + background-color: #d0d0d7; + color: white; + border-style: none; + border-radius: 5px; + color: #000; + padding: 2px; + margin-top: 10px; +} + +.pairing-request { + font-family: 'Segoe UI Emoji', 'Noto Color Emoji', 'Apple Color Emoji', sans-serif; + font-size: 14px; + margin-top: 0px; +} + +.create-btn { + margin-bottom: 2em; + cursor: pointer; + background-color: #d0d0d7; + color: white; + border-style: none; + border-radius: 5px; + color: #000; + padding: 2px; +} + +.camera-card { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + /* height: 200px; */ +} + +.btn { + display: inline-block; + padding: 10px 20px; + background-color: var(--primary-color); + color: white; + text-align: center; + border-radius: 5px; + cursor: pointer; + text-decoration: none; +} + +.btn:hover { + background-color: #3700b3; +} + +.card { + min-width: 300px; + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + height: 60vh; + justify-content: flex-start; + padding: 1rem; + overflow-y: auto; +} + +.card-content { + flex-grow: 1; + flex-direction: column; + display: flex; + justify-content: flex-start; + align-items: center; + text-align: left; + font-size: 0.8em; + position: relative; + left: 2vw; + width: 90%; + .process-title { + font-weight: bold; + padding: 1rem 0; + } + .process-element { + padding: 0.4rem 0; + &:hover { + background-color: rgba(26, 28, 24, 0.08); + } + &.selected { + background-color: rgba(26, 28, 24, 0.08); + } + } +} + +.card-description { + padding: 20px; + font-size: 1rem; + color: #333; + width: 90%; + height: 50px; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 0px; +} + +.card-action { + width: 100%; +} + +.menu-content { + display: none; + position: absolute; + top: 3.4rem; + right: 1rem; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 5px; + overflow: hidden; +} + +.menu-content a { + display: block; + padding: 10px 20px; + text-decoration: none; + color: #333; + border-bottom: 1px solid #e0e0e0; + &:hover { + background-color: rgba(26, 28, 24, 0.08); + } +} + +.menu-content a:last-child { + border-bottom: none; +} + +.qr-code-scanner { + display: none; +} + +/* QR READER */ +#qr-reader div { + position: inherit; +} + +#qr-reader div img { + top: 15px; + right: 25px; + margin-top: 5px; +} + +/* INPUT CSS **/ +.input-container { + position: relative; + width: 100%; + background-color: #eceff1; +} + +.input-field { + width: 36vw; + padding: 10px 0; + font-size: 1rem; + border: none; + border-bottom: 1px solid #ccc; + outline: none; + background: transparent; + transition: border-color 0.3s; +} + +.input-field:focus { + border-bottom: 2px solid #6200ea; +} + +.input-label { + position: absolute; + margin-top: -0.5em; + top: 0; + left: 0; + padding: 10px 0; + font-size: 1rem; + color: #999; + pointer-events: none; + transition: + transform 0.3s, + color 0.3s, + font-size 0.3s; +} + +.input-field:focus + .input-label, +.input-field:not(:placeholder-shown) + .input-label { + transform: translateY(-20px); + font-size: 0.8em; + color: #6200ea; +} + +.input-underline { + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 2px; + background-color: #6200ea; + transition: + width 0.3s, + left 0.3s; +} + +.input-field:focus ~ .input-underline { + width: 100%; + left: 0; +} + +.dropdown-content { + position: absolute; + flex-direction: column; + top: 100%; + left: 0; + width: 100%; + max-height: 150px; + overflow-y: auto; + border: 1px solid #ccc; + border-radius: 4px; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + display: none; + z-index: 1; +} + +.dropdown-content span { + padding: 10px; + cursor: pointer; + list-style: none; +} + +.dropdown-content span:hover { + background-color: #f0f0f0; +} + +/** AUTOCOMPLETE **/ + +select[data-multi-select-plugin] { + display: none !important; +} + +.multi-select-component { + width: 36vw; + padding: 5px 0; + font-size: 1rem; + border: none; + border-bottom: 1px solid #ccc; + outline: none; + background: transparent; + display: flex; + flex-direction: row; + height: auto; + width: 100%; + -o-transition: + border-color ease-in-out 0.15s, + box-shadow ease-in-out 0.15s; + transition: + border-color ease-in-out 0.15s, + box-shadow ease-in-out 0.15s; +} + +.autocomplete-list { + border-radius: 4px 0px 0px 4px; +} + +.multi-select-component:focus-within { + box-shadow: inset 0px 0px 0px 2px #78abfe; +} + +.multi-select-component .btn-group { + display: none !important; +} + +.multiselect-native-select .multiselect-container { + width: 100%; +} + +.selected-processes { + background-color: white; + padding: 0.4em; +} + +.selected-wrapper { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + display: inline-block; + border: 1px solid #d9d9d9; + background-color: #ededed; + white-space: nowrap; + margin: 1px 5px 5px 0; + height: 22px; + vertical-align: top; + cursor: default; +} + +.selected-wrapper .selected-label { + max-width: 514px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 4px; + vertical-align: top; +} + +.selected-wrapper .selected-close { + display: inline-block; + text-decoration: none; + font-size: 14px; + line-height: 1.49rem; + margin-left: 5px; + padding-bottom: 10px; + height: 100%; + vertical-align: top; + padding-right: 4px; + opacity: 0.2; + color: #000; + text-shadow: 0 1px 0 #fff; + font-weight: 700; +} + +.search-container { + display: flex; + flex-direction: row; +} + +.search-container .selected-input { + background: none; + border: 0; + height: 20px; + width: 60px; + padding: 0; + margin-bottom: 6px; + -webkit-box-shadow: none; + box-shadow: none; +} + +.search-container .selected-input:focus { + outline: none; +} + +.dropdown-icon.active { + transform: rotateX(180deg); +} + +.search-container .dropdown-icon { + display: inline-block; + padding: 10px 5px; + position: absolute; + top: 5px; + right: 5px; + width: 10px; + height: 10px; + border: 0 !important; + /* needed */ + -webkit-appearance: none; + -moz-appearance: none; + /* SVG background image */ + background-image: url('data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%3E%3Ctitle%3Edown-arrow%3C%2Ftitle%3E%3Cg%20fill%3D%22%23818181%22%3E%3Cpath%20d%3D%22M10.293%2C3.293%2C6%2C7.586%2C1.707%2C3.293A1%2C1%2C0%2C0%2C0%2C.293%2C4.707l5%2C5a1%2C1%2C0%2C0%2C0%2C1.414%2C0l5-5a1%2C1%2C0%2C1%2C0-1.414-1.414Z%22%20fill%3D%22%23818181%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E'); + background-position: center; + background-size: 10px; + background-repeat: no-repeat; +} + +.search-container ul { + position: absolute; + list-style: none; + padding: 0; + z-index: 3; + margin-top: 29px; + width: 100%; + right: 0px; + background: #fff; + border: 1px solid #ccc; + border-top: none; + border-bottom: none; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); +} + +.search-container ul :focus { + outline: none; +} + +.search-container ul li { + display: block; + text-align: left; + padding: 8px 29px 2px 12px; + border-bottom: 1px solid #ccc; + font-size: 14px; + min-height: 31px; +} + +.search-container ul li:first-child { + border-top: 1px solid #ccc; + border-radius: 4px 0px 0 0; +} + +.search-container ul li:last-child { + border-radius: 4px 0px 0 0; +} + +.search-container ul li:hover.not-cursor { + cursor: default; +} + +.search-container ul li:hover { + color: #333; + background-color: #f0f0f0; + border-color: #adadad; + cursor: pointer; +} + +/* Adding scrool to select options */ +.autocomplete-list { + max-height: 130px; + overflow-y: auto; +} + +/**************************************** Process page card ******************************************************/ +.process-card { + min-width: 300px; + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + min-height: 40vh; + max-height: 60vh; + justify-content: space-between; + padding: 1rem; + overflow-y: auto; +} + +.process-card-content { + text-align: left; + font-size: 0.8em; + position: relative; + left: 2vw; + width: 90%; + .process-title { + font-weight: bold; + padding: 1rem 0; + } + .process-element { + padding: 0.4rem 0; + &:hover { + background-color: rgba(26, 28, 24, 0.08); + } + &.selected { + background-color: rgba(26, 28, 24, 0.08); + } + } + .selected-process-zone { + background-color: rgba(26, 28, 24, 0.08); + } +} + +.process-card-description { + padding: 20px; + font-size: 1rem; + color: #333; + width: 90%; +} + +.process-card-action { + width: 100%; +} + +/**************************************** Select Member Home Page ******************************************************/ +.custom-select { + width: 100%; + max-height: 150px; + overflow-y: auto; + direction: ltr; + background-color: white; + border: 1px solid #ccc; + border-radius: 4px; + margin: 10px 0; +} + +.custom-select option { + padding: 8px 12px; + cursor: pointer; +} + +.custom-select option:hover { + background-color: #f0f0f0; +} + +.custom-select::-webkit-scrollbar { + width: 8px; +} + +.custom-select::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.custom-select::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +.custom-select::-webkit-scrollbar-thumb:hover { + background: #555; +} \ No newline at end of file diff --git a/ihm_client/src/components/header/header.html b/ihm_client/src/components/header/header.html new file mode 100755 index 00000000..9d2ae652 --- /dev/null +++ b/ihm_client/src/components/header/header.html @@ -0,0 +1,36 @@ + diff --git a/ihm_client/src/components/header/header.ts b/ihm_client/src/components/header/header.ts new file mode 100755 index 00000000..9e3ae9cc --- /dev/null +++ b/ihm_client/src/components/header/header.ts @@ -0,0 +1,220 @@ +import ModalService from '~/services/modal.service'; +import { INotification } from '../../models/notification.model'; +import { currentRoute, navigate } from '../../router'; +import Services from '../../services/service'; +import { BackUp } from '~/models/backup.model'; + +let notifications = []; + +export async function unpair() { + const service = await Services.getInstance(); + await service.unpairDevice(); + await navigate('home'); +} + +(window as any).unpair = unpair; + +function toggleMenu() { + const menu = document.getElementById('menu'); + if (menu) { + if (menu.style.display === 'block') { + menu.style.display = 'none'; + } else { + menu.style.display = 'block'; + } + } +} +(window as any).toggleMenu = toggleMenu; + +async function getNotifications() { + const service = await Services.getInstance(); + notifications = service.getNotifications() || []; + return notifications; +} +function openCloseNotifications() { + const notifications = document.querySelector('.notification-board') as HTMLDivElement; + notifications.style.display = notifications?.style.display === 'none' ? 'block' : 'none'; +} + +(window as any).openCloseNotifications = openCloseNotifications; + +export async function initHeader() { + if (currentRoute === 'account') { + // Charger le profile-header + const profileContainer = document.getElementById('profile-header-container'); + if (profileContainer) { + const profileHeaderHtml = await fetch('/src/components/profile-header/profile-header.html').then((res) => res.text()); + profileContainer.innerHTML = profileHeaderHtml; + + // Initialiser les données du profil + loadUserProfile(); + } + } + if (currentRoute === 'home') { + hideSomeFunctionnalities(); + } else { + fetchNotifications(); + setInterval(fetchNotifications, 2 * 60 * 1000); + } +} + +function hideSomeFunctionnalities() { + const bell = document.querySelector('.bell-icon') as HTMLDivElement; + if (bell) bell.style.display = 'none'; + const notifBadge = document.querySelector('.notification-badge') as HTMLDivElement; + if (notifBadge) notifBadge.style.display = 'none'; + const actions = document.querySelectorAll('.menu-content a') as NodeListOf; + const excludedActions = ['Import', 'Export']; + for (const action of actions) { + if (!excludedActions.includes(action.innerHTML)) { + action.style.display = 'none'; + } + } +} + +async function setNotification(notifications: any[]): Promise { + const badge = document.querySelector('.notification-badge') as HTMLDivElement; + const noNotifications = document.querySelector('.no-notification') as HTMLDivElement; + if (notifications?.length) { + badge.innerText = notifications.length.toString(); + const notificationBoard = document.querySelector('.notification-board') as HTMLDivElement; + notificationBoard.querySelectorAll('.notification-element')?.forEach((elem) => elem.remove()); + noNotifications.style.display = 'none'; + for (const notif of notifications) { + const notifElement = document.createElement('div'); + notifElement.className = 'notification-element'; + notifElement.setAttribute('notif-id', notif.processId); + notifElement.innerHTML = ` +
Validation required :
+
${notif.processId}
+ `; + // this.addSubscription(notifElement, 'click', 'goToProcessPage') + notificationBoard.appendChild(notifElement); + notifElement.addEventListener('click', async () => { + const modalService = await ModalService.getInstance(); + modalService.injectValidationModal(notif); + }); + } + } else { + noNotifications.style.display = 'block'; + } +} + +async function fetchNotifications() { + const service = await Services.getInstance(); + const data = service.getNotifications() || []; + await setNotification(data); +} + +async function loadUserProfile() { + // Charger les données du profil depuis le localStorage + const userName = localStorage.getItem('userName'); + const userLastName = localStorage.getItem('userLastName'); + const userAvatar = localStorage.getItem('userAvatar') || 'https://via.placeholder.com/150'; + const userBanner = localStorage.getItem('userBanner') || 'https://via.placeholder.com/800x200'; + + // Mettre à jour les éléments du DOM + const nameElement = document.querySelector('.user-name'); + const lastNameElement = document.querySelector('.user-lastname'); + const avatarElement = document.querySelector('.avatar'); + const bannerElement = document.querySelector('.banner-image'); + + if (nameElement) nameElement.textContent = userName; + if (lastNameElement) lastNameElement.textContent = userLastName; + if (avatarElement) (avatarElement as HTMLImageElement).src = userAvatar; + if (bannerElement) (bannerElement as HTMLImageElement).src = userBanner; +} + +async function importJSON() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const content: BackUp = JSON.parse(e.target?.result as string); + const service = await Services.getInstance(); + await service.importJSON(content); + alert('Import réussi'); + window.location.reload(); + } catch (error) { + alert("Erreur lors de l'import: " + error); + } + }; + reader.readAsText(file); + } + }; + + input.click(); +} + +(window as any).importJSON = importJSON; + +async function createBackUp() { + const service = await Services.getInstance(); + const backUp = await service.createBackUp(); + if (!backUp) { + console.error("No device to backup"); + return; + } + + try { + const backUpJson = JSON.stringify(backUp, null, 2) + const blob = new Blob([backUpJson], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = '4nk-backup.json'; + a.click(); + + URL.revokeObjectURL(url); + + console.log('Backup successfully prepared for download'); + } catch (e) { + console.error(e); + } +} + +(window as any).createBackUp = createBackUp; + +async function disconnect() { + console.log('Disconnecting...'); + try { + localStorage.clear(); + + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase('4nk'); + request.onsuccess = () => { + console.log('IndexedDB deleted successfully'); + resolve(); + }; + request.onerror = () => reject(request.error); + request.onblocked = () => { + console.log('Database deletion was blocked'); + resolve(); + }; + }); + + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map(registration => registration.unregister())); + console.log('Service worker unregistered'); + + await navigate('home'); + + setTimeout(() => { + window.location.href = window.location.origin; + }, 100); + + } catch (error) { + console.error('Error during disconnect:', error); + // force reload + window.location.href = window.location.origin; + } +} + +(window as any).disconnect = disconnect; \ No newline at end of file diff --git a/ihm_client/src/components/login-modal/login-modal.html b/ihm_client/src/components/login-modal/login-modal.html new file mode 100755 index 00000000..d402de9f --- /dev/null +++ b/ihm_client/src/components/login-modal/login-modal.html @@ -0,0 +1,14 @@ + diff --git a/ihm_client/src/components/login-modal/login-modal.js b/ihm_client/src/components/login-modal/login-modal.js new file mode 100755 index 00000000..d9c49d7f --- /dev/null +++ b/ihm_client/src/components/login-modal/login-modal.js @@ -0,0 +1,13 @@ +import Routing from '/src/services/routing.service.ts'; + +const router = await Routing.getInstance(); +export async function confirmLogin() { + router.confirmLogin(); +} + +export async function closeLoginModal() { + router.closeLoginModal(); +} + +window.confirmLogin = confirmLogin; +window.closeLoginModal = closeLoginModal; diff --git a/ihm_client/src/components/modal/confirmation-modal.html b/ihm_client/src/components/modal/confirmation-modal.html new file mode 100755 index 00000000..6c0f6c4e --- /dev/null +++ b/ihm_client/src/components/modal/confirmation-modal.html @@ -0,0 +1,16 @@ + diff --git a/ihm_client/src/components/modal/confirmation-modal.ts b/ihm_client/src/components/modal/confirmation-modal.ts new file mode 100755 index 00000000..292d875d --- /dev/null +++ b/ihm_client/src/components/modal/confirmation-modal.ts @@ -0,0 +1,13 @@ +import ModalService from '../../services/modal.service'; + +const modalService = await ModalService.getInstance(); +// export async function confirm() { +// modalService.confirmPairing(); +// } + +export async function closeConfirmationModal() { + modalService.closeConfirmationModal(); +} + +(window as any).confirm = confirm; +(window as any).closeConfirmationModal = closeConfirmationModal; diff --git a/ihm_client/src/components/modal/creation-modal.html b/ihm_client/src/components/modal/creation-modal.html new file mode 100644 index 00000000..231ac877 --- /dev/null +++ b/ihm_client/src/components/modal/creation-modal.html @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/ihm_client/src/components/modal/waiting-modal.html b/ihm_client/src/components/modal/waiting-modal.html new file mode 100644 index 00000000..5fb70d43 --- /dev/null +++ b/ihm_client/src/components/modal/waiting-modal.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/ihm_client/src/components/qrcode-scanner/qrcode-scanner-component.ts b/ihm_client/src/components/qrcode-scanner/qrcode-scanner-component.ts new file mode 100644 index 00000000..1e39d3ba --- /dev/null +++ b/ihm_client/src/components/qrcode-scanner/qrcode-scanner-component.ts @@ -0,0 +1,73 @@ +import QrScanner from 'qr-scanner'; +import Services from '../../services/service'; +import { prepareAndSendPairingTx } from '~/utils/sp-address.utils'; + +export default class QrScannerComponent extends HTMLElement { + videoElement: any; + wrapper: any; + qrScanner: any; + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.wrapper = document.createElement('div'); + this.wrapper.style.position = 'relative'; + this.wrapper.style.width = '150px'; + this.wrapper.style.height = '150px'; + + this.videoElement = document.createElement('video'); + this.videoElement.style.width = '100%'; + document.body?.append(this.wrapper); + this.wrapper.prepend(this.videoElement); + } + + connectedCallback() { + this.initializeScanner(); + } + + async initializeScanner() { + if (!this.videoElement) { + console.error('Video element not found!'); + return; + } + console.log('🚀 ~ QrScannerComponent ~ initializeScanner ~ this.videoElement:', this.videoElement); + this.qrScanner = new QrScanner(this.videoElement, (result) => this.onQrCodeScanned(result), { + highlightScanRegion: true, + highlightCodeOutline: true, + }); + + try { + await QrScanner.hasCamera(); + this.qrScanner.start(); + this.videoElement.style = 'height: 200px; width: 200px'; + this.shadowRoot?.appendChild(this.wrapper); + } catch (e) { + console.error('No camera found or error starting the QR scanner', e); + } + } + + async onQrCodeScanned(result: any) { + console.log(`QR Code detected:`, result); + const data = result.data; + const scannedUrl = new URL(data); + + // Extract the 'sp_address' parameter + const spAddress = scannedUrl.searchParams.get('sp_address'); + if (spAddress) { + // Call the sendPairingTx function with the extracted sp_address + try { + await prepareAndSendPairingTx(); + } catch (e) { + console.error('Failed to pair:', e); + } + } + this.qrScanner.stop(); // if you want to stop scanning after one code is detected + } + + disconnectedCallback() { + if (this.qrScanner) { + this.qrScanner.destroy(); + } + } +} + +customElements.define('qr-scanner', QrScannerComponent); diff --git a/ihm_client/src/components/validation-modal/validation-modal.css b/ihm_client/src/components/validation-modal/validation-modal.css new file mode 100644 index 00000000..b6e93818 --- /dev/null +++ b/ihm_client/src/components/validation-modal/validation-modal.css @@ -0,0 +1,70 @@ +.validation-modal { + display: block; /* Show the modal for demo purposes */ + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0, 0, 0); + background-color: rgba(0, 0, 0, 0.4); + padding-top: 60px; +} +.modal-content { + background-color: #fefefe; + margin: 5% auto; + padding: 20px; + border: 1px solid #888; + width: 80%; + height: fit-content; +} +.modal-title { + font-size: 24px; + font-weight: bold; + margin-bottom: 20px; +} +.validation-box { + margin-bottom: 15px; + width: 100%; +} +.expansion-panel-header { + background-color: #e0e0e0; + padding: 10px; + cursor: pointer; +} +.expansion-panel-body { + display: none; + background-color: #fafafa; + padding: 10px; + border-top: 1px solid #ddd; +} +.expansion-panel-body pre { + background-color: #f6f8fa; + padding: 10px; + border-left: 4px solid #d1d5da; + overflow-x: auto; +} +.diff { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} +.diff-side { + width: 48%; + padding: 10px; +} +.diff-old { + background-color: #fee; + border: 1px solid #f00; +} +.diff-new { + background-color: #e6ffe6; + border: 1px solid #0f0; +} +.radio-buttons { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} diff --git a/ihm_client/src/components/validation-modal/validation-modal.html b/ihm_client/src/components/validation-modal/validation-modal.html new file mode 100755 index 00000000..780e489b --- /dev/null +++ b/ihm_client/src/components/validation-modal/validation-modal.html @@ -0,0 +1,11 @@ +
+ +
diff --git a/ihm_client/src/components/validation-modal/validation-modal.ts b/ihm_client/src/components/validation-modal/validation-modal.ts new file mode 100755 index 00000000..711aff21 --- /dev/null +++ b/ihm_client/src/components/validation-modal/validation-modal.ts @@ -0,0 +1,56 @@ +import ModalService from '~/services/modal.service'; + +async function validate() { + console.log('==> VALIDATE'); + const modalservice = await ModalService.getInstance(); + modalservice.closeValidationModal(); +} + +export async function initValidationModal(processDiffs: any) { +console.log("🚀 ~ initValidationModal ~ processDiffs:", processDiffs) +for(const diff of processDiffs.diffs) { + let diffs = '' + for(const value of diff) { + diffs+= ` +
+ + +
+
+
+
-${value.previous_value}
+
+
+
+${value.new_value}
+
+
+ ` + } + + const state = ` +
+
State ${diff[0].new_state_merkle_root}
+
+ ${diffs} +
+
+ ` + const box = document.querySelector('.validation-box') + if(box) box.innerHTML += state +} +document.querySelectorAll('.expansion-panel-header').forEach((header) => { + header.addEventListener('click', function (event) { + const target = event.target as HTMLElement; + const body = target.nextElementSibling as HTMLElement; + if (body?.style) body.style.display = body.style.display === 'block' ? 'none' : 'block'; + }); +}); +} + +(window as any).validate = validate; diff --git a/ihm_client/src/components/validation-rule-modal/validation-rule-modal.html b/ihm_client/src/components/validation-rule-modal/validation-rule-modal.html new file mode 100644 index 00000000..75669805 --- /dev/null +++ b/ihm_client/src/components/validation-rule-modal/validation-rule-modal.html @@ -0,0 +1,42 @@ +
+
+

+ Add Validation Rule +

+ + + + + + + +
+ + +
+
+
diff --git a/ihm_client/src/components/validation-rule-modal/validation-rule-modal.ts b/ihm_client/src/components/validation-rule-modal/validation-rule-modal.ts new file mode 100644 index 00000000..121d954e --- /dev/null +++ b/ihm_client/src/components/validation-rule-modal/validation-rule-modal.ts @@ -0,0 +1,61 @@ +export interface ValidationRule { + quorum: number; + fields: string[]; + min_sig_member: number; +} + +/** + * Loads and injects the modal HTML into the document if not already loaded. + */ +export async function loadValidationRuleModal(templatePath: string = '/src/components/validation-rule-modal/validation-rule-modal.html') { + if (document.getElementById('validation-rule-modal')) return; + + const res = await fetch(templatePath); + const html = await res.text(); + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const modal = tempDiv.querySelector('#validation-rule-modal'); + if (!modal) { + throw new Error('Modal HTML missing #validation-rule-modal'); + } + + document.body.appendChild(modal); +} + +/** + * Opens the modal and lets the user input a ValidationRule. + * Calls the callback with the constructed rule on submit. + */ +export function showValidationRuleModal(onSubmit: (rule: ValidationRule) => void) { + const modal = document.getElementById('validation-rule-modal')!; + const quorumInput = document.getElementById('vr-quorum') as HTMLInputElement; + const minsigInput = document.getElementById('vr-minsig') as HTMLInputElement; + const fieldsInput = document.getElementById('vr-fields') as HTMLInputElement; + + const cancelBtn = document.getElementById('vr-cancel')!; + const submitBtn = document.getElementById('vr-submit')!; + + // Reset values + quorumInput.value = ''; + minsigInput.value = ''; + fieldsInput.value = ''; + + modal.style.display = 'flex'; + + cancelBtn.onclick = () => { + modal.style.display = 'none'; + }; + + submitBtn.onclick = () => { + const rule: ValidationRule = { + quorum: parseInt(quorumInput.value), + min_sig_member: parseInt(minsigInput.value), + fields: fieldsInput.value.split(',').map(f => f.trim()).filter(Boolean), + }; + + modal.style.display = 'none'; + onSubmit(rule); + }; +} diff --git a/ihm_client/src/decs.d.ts b/ihm_client/src/decs.d.ts new file mode 100644 index 00000000..d137c884 --- /dev/null +++ b/ihm_client/src/decs.d.ts @@ -0,0 +1,10 @@ +declare class AccountComponent extends HTMLElement { + _callback: any; + constructor(); + connectedCallback(): void; + fetchData(): Promise; + set callback(fn: any); + get callback(): any; + render(): void; +} +export { AccountComponent }; diff --git a/ihm_client/src/index.ts b/ihm_client/src/index.ts new file mode 100755 index 00000000..2fbab88e --- /dev/null +++ b/ihm_client/src/index.ts @@ -0,0 +1,3 @@ +export { default as Services } from './services/service'; +export { default as Database } from './services/database.service'; +export { MessageType } from './models/process.model'; diff --git a/ihm_client/src/interface/groupInterface.ts b/ihm_client/src/interface/groupInterface.ts new file mode 100644 index 00000000..87ba2db1 --- /dev/null +++ b/ihm_client/src/interface/groupInterface.ts @@ -0,0 +1,22 @@ +import { DocumentSignature } from '~/models/signature.models'; + +export interface Group { + id: number; + name: string; + description: string; + roles: Array<{ + name: string; + members: Array<{ id: string | number; name: string }>; + documents?: Array; + }>; + commonDocuments: Array<{ + id: number; + name: string; + visibility: string; + description: string; + createdAt?: string | null; + deadline?: string | null; + signatures?: DocumentSignature[]; + status?: string; + }>; +} diff --git a/ihm_client/src/interface/memberInterface.ts b/ihm_client/src/interface/memberInterface.ts new file mode 100644 index 00000000..274cd2ca --- /dev/null +++ b/ihm_client/src/interface/memberInterface.ts @@ -0,0 +1,7 @@ +export interface Member { + id: string | number; + name: string; + email?: string; + avatar?: string; + processRoles?: Array<{ processId: number | string; role: string }>; +} diff --git a/ihm_client/src/interface/signatureResponseInterface.ts b/ihm_client/src/interface/signatureResponseInterface.ts new file mode 100644 index 00000000..e69de29b diff --git a/ihm_client/src/main.ts b/ihm_client/src/main.ts new file mode 100644 index 00000000..b0f47a4a --- /dev/null +++ b/ihm_client/src/main.ts @@ -0,0 +1,30 @@ +import { SignatureComponent } from './pages/signature/signature-component'; +import { SignatureElement } from './pages/signature/signature'; +/*import { ChatComponent } from './pages/chat/chat-component'; +import { ChatElement } from './pages/chat/chat';*/ +import { AccountComponent } from './pages/account/account-component'; +import { AccountElement } from './pages/account/account'; + +export { SignatureComponent, SignatureElement, AccountComponent, AccountElement }; + +declare global { + interface HTMLElementTagNameMap { + 'signature-component': SignatureComponent; + 'signature-element': SignatureElement; + /*'chat-component': ChatComponent; + 'chat-element': ChatElement;*/ + 'account-component': AccountComponent; + 'account-element': AccountElement; + } +} + +// Configuration pour le mode indépendant +if ((import.meta as any).env.VITE_IS_INDEPENDANT_LIB) { + // Initialiser les composants si nécessaire + customElements.define('signature-component', SignatureComponent); + customElements.define('signature-element', SignatureElement); + /*customElements.define('chat-component', ChatComponent); + customElements.define('chat-element', ChatElement);*/ + customElements.define('account-component', AccountComponent); + customElements.define('account-element', AccountElement); +} diff --git a/ihm_client/src/mocks/mock-account/constAccountMock.ts b/ihm_client/src/mocks/mock-account/constAccountMock.ts new file mode 100644 index 00000000..34adaa44 --- /dev/null +++ b/ihm_client/src/mocks/mock-account/constAccountMock.ts @@ -0,0 +1,272 @@ +export const ALLOWED_ROLES = ['User', 'Member', 'Peer', 'Payment', 'Deposit', 'Artefact', 'Resolve', 'Backup']; + +export const STORAGE_KEYS = { + pairing: 'pairingRows', + wallet: 'walletRows', + process: 'processRows', + data: 'dataRows', +}; + +// Initialiser le stockage des lignes par défaut dans le localStorage +export const defaultRows = [ + { + column1: 'sprt1qqwtvg5q5vcz0reqvmld98u7va3av6gakwe9yxw9yhnpj5djcunn4squ68tuzn8dz78dg4adfv0dekx8hg9sy0t6s9k5em7rffgxmrsfpyy7gtyrz', + column2: '🎊😑🎄😩', + column3: 'Laptop', + }, + { + column1: 'sprt1qqwtvg5q5vcz0reqvmld98u7va3av6gakwe9yxw9yhnpj5djcunn4squ68tuzn8dz78dg4adfv0dekx8hg9sy0t6s9k5em7rffgxmrsfpyy7gtyrx', + column2: '🎏🎕😧🌥', + column3: 'Phone', + }, +]; + +export const mockNotifications: { [key: string]: Notification[] } = {}; + +export const notificationMessages = ['CPU usage high', 'Memory threshold reached', 'New update available', 'Backup completed', 'Security check required', 'Performance optimization needed', 'System alert', 'Network connectivity issue', 'Storage space low', 'Process checkpoint reached']; + +export const mockDataRows = [ + { + column1: 'User Project', + column2: 'private', + column3: 'User', + column4: '6 months', + column5: 'NDA signed', + column6: 'Contract #123', + processName: 'User Process', + zone: 'A', + }, + { + column1: 'Process Project', + column2: 'private', + column3: 'Process', + column4: '1 year', + column5: 'Terms accepted', + column6: 'Contract #456', + processName: 'Process Management', + zone: 'B', + }, + { + column1: 'Member Project', + column2: 'private', + column3: 'Member', + column4: '3 months', + column5: 'GDPR compliant', + column6: 'Contract #789', + processName: 'Member Process', + zone: 'C', + }, + { + column1: 'Peer Project', + column2: 'public', + column3: 'Peer', + column4: '2 years', + column5: 'IP rights', + column6: 'Contract #101', + processName: 'Peer Process', + zone: 'D', + }, + { + column1: 'Payment Project', + column2: 'confidential', + column3: 'Payment', + column4: '1 year', + column5: 'NDA signed', + column6: 'Contract #102', + processName: 'Payment Process', + zone: 'E', + }, + { + column1: 'Deposit Project', + column2: 'private', + column3: 'Deposit', + column4: '6 months', + column5: 'Terms accepted', + column6: 'Contract #103', + processName: 'Deposit Process', + zone: 'F', + }, + { + column1: 'Artefact Project', + column2: 'public', + column3: 'Artefact', + column4: '1 year', + column5: 'GDPR compliant', + column6: 'Contract #104', + processName: 'Artefact Process', + zone: 'G', + }, + { + column1: 'Resolve Project', + column2: 'private', + column3: 'Resolve', + column4: '2 years', + column5: 'IP rights', + column6: 'Contract #105', + processName: 'Resolve Process', + zone: 'H', + }, + { + column1: 'Backup Project', + column2: 'public', + column3: 'Backup', + column4: '1 year', + column5: 'NDA signed', + column6: 'Contract #106', + processName: 'Backup Process', + zone: 'I', + }, +]; + +export const mockProcessRows = [ + { + process: 'User Project', + role: 'User', + notification: { + messages: [ + { id: 1, read: false, date: '2024-03-10', message: 'New user joined the project' }, + { id: 2, read: false, date: '2024-03-09', message: 'Project milestone reached' }, + { id: 3, read: false, date: '2024-03-08', message: 'Security update required' }, + { id: 4, read: true, date: '2024-03-07', message: 'Weekly report available' }, + { id: 5, read: true, date: '2024-03-06', message: 'Team meeting scheduled' }, + ], + }, + }, + { + process: 'Member Project', + role: 'Member', + notification: { + messages: [ + { id: 6, read: true, date: '2024-03-10', message: 'Member access granted' }, + { id: 7, read: true, date: '2024-03-09', message: 'Documentation updated' }, + { id: 8, read: true, date: '2024-03-08', message: 'Project status: on track' }, + ], + }, + }, + { + process: 'Peer Project', + role: 'Peer', + notification: { + unread: 2, + total: 4, + messages: [ + { id: 9, read: false, date: '2024-03-10', message: 'New peer project added' }, + { id: 10, read: false, date: '2024-03-09', message: 'Project milestone reached' }, + { id: 11, read: false, date: '2024-03-08', message: 'Security update required' }, + { id: 12, read: true, date: '2024-03-07', message: 'Weekly report available' }, + { id: 13, read: true, date: '2024-03-06', message: 'Team meeting scheduled' }, + ], + }, + }, + { + process: 'Deposit Project', + role: 'Deposit', + notification: { + unread: 1, + total: 10, + messages: [ + { id: 14, read: false, date: '2024-03-10', message: 'Deposit milestone reached' }, + { id: 15, read: false, date: '2024-03-09', message: 'Security update required' }, + { id: 16, read: false, date: '2024-03-08', message: 'Weekly report available' }, + { id: 17, read: true, date: '2024-03-07', message: 'Team meeting scheduled' }, + { id: 18, read: true, date: '2024-03-06', message: 'Project status: on track' }, + ], + }, + }, + { + process: 'Artefact Project', + role: 'Artefact', + notification: { + unread: 0, + total: 3, + messages: [ + { id: 19, read: false, date: '2024-03-10', message: 'New artefact added' }, + { id: 20, read: false, date: '2024-03-09', message: 'Security update required' }, + { id: 21, read: false, date: '2024-03-08', message: 'Weekly report available' }, + { id: 22, read: true, date: '2024-03-07', message: 'Team meeting scheduled' }, + { id: 23, read: true, date: '2024-03-06', message: 'Project status: on track' }, + ], + }, + }, + { + process: 'Resolve Project', + role: 'Resolve', + notification: { + unread: 5, + total: 12, + messages: [ + { id: 24, read: false, date: '2024-03-10', message: 'New issue reported' }, + { id: 25, read: false, date: '2024-03-09', message: 'Security update required' }, + { id: 26, read: false, date: '2024-03-08', message: 'Weekly report available' }, + { id: 27, read: true, date: '2024-03-07', message: 'Team meeting scheduled' }, + { id: 28, read: true, date: '2024-03-06', message: 'Project status: on track' }, + ], + }, + }, +]; + +export const mockContracts = { + 'Contract #123': { + title: 'User Project Agreement', + date: '2024-01-15', + parties: ['Company XYZ', 'User Team'], + terms: ['Data Protection', 'User Privacy', 'Access Rights', 'Service Level Agreement'], + content: 'This agreement establishes the terms and conditions for user project management.', + }, + 'Contract #456': { + title: 'Process Management Contract', + date: '2024-02-01', + parties: ['Company XYZ', 'Process Team'], + terms: ['Process Workflow', 'Quality Standards', 'Performance Metrics', 'Monitoring Procedures'], + content: 'This contract defines the process management standards and procedures.', + }, + 'Contract #789': { + title: 'Member Access Agreement', + date: '2024-03-15', + parties: ['Company XYZ', 'Member Team'], + terms: ['Member Rights', 'Access Levels', 'Security Protocol', 'Confidentiality Agreement'], + content: 'This agreement outlines the terms for member access and privileges.', + }, + 'Contract #101': { + title: 'Peer Collaboration Agreement', + date: '2024-04-01', + parties: ['Company XYZ', 'Peer Network'], + terms: ['Collaboration Rules', 'Resource Sharing', 'Dispute Resolution', 'Network Protocol'], + content: 'This contract establishes peer collaboration and networking guidelines.', + }, + 'Contract #102': { + title: 'Payment Processing Agreement', + date: '2024-05-01', + parties: ['Company XYZ', 'Payment Team'], + terms: ['Transaction Protocol', 'Security Measures', 'Fee Structure', 'Service Availability'], + content: 'This agreement defines payment processing terms and conditions.', + }, + 'Contract #103': { + title: 'Deposit Management Contract', + date: '2024-06-01', + parties: ['Company XYZ', 'Deposit Team'], + terms: ['Deposit Rules', 'Storage Protocol', 'Access Control', 'Security Standards'], + content: 'This contract outlines deposit management procedures and security measures.', + }, + 'Contract #104': { + title: 'Artefact Handling Agreement', + date: '2024-07-01', + parties: ['Company XYZ', 'Artefact Team'], + terms: ['Handling Procedures', 'Storage Guidelines', 'Access Protocol', 'Preservation Standards'], + content: 'This agreement establishes artefact handling and preservation guidelines.', + }, + 'Contract #105': { + title: 'Resolution Protocol Agreement', + date: '2024-08-01', + parties: ['Company XYZ', 'Resolution Team'], + terms: ['Resolution Process', 'Time Constraints', 'Escalation Protocol', 'Documentation Requirements'], + content: 'This contract defines the resolution process and protocol standards.', + }, + 'Contract #106': { + title: 'Backup Service Agreement', + date: '2024-09-01', + parties: ['Company XYZ', 'Backup Team'], + terms: ['Backup Schedule', 'Data Protection', 'Recovery Protocol', 'Service Reliability'], + content: 'This agreement outlines backup service terms and recovery procedures.', + }, +}; diff --git a/ihm_client/src/mocks/mock-account/interfacesAccountMock.ts b/ihm_client/src/mocks/mock-account/interfacesAccountMock.ts new file mode 100644 index 00000000..1c409ba0 --- /dev/null +++ b/ihm_client/src/mocks/mock-account/interfacesAccountMock.ts @@ -0,0 +1,45 @@ +export interface Row { + column1: string; + column2: string; + column3: string; +} + +// Types supplémentaires nécessaires +export interface Contract { + title: string; + date: string; + parties: string[]; + terms: string[]; + content: string; +} + +export interface WalletRow { + column1: string; // Label + column2: string; // Wallet + column3: string; // Type +} + +export interface DataRow { + column1: string; // Name + column2: string; // Visibility + column3: string; // Role + column4: string; // Duration + column5: string; // Legal + column6: string; // Contract + processName: string; + zone: string; +} + +export interface Notification { + message: string; + timestamp: string; + isRead: boolean; +} + +// Déplacer l'interface en dehors de la classe, au début du fichier +export interface NotificationMessage { + id: number; + read: boolean; + date: string; + message: string; +} diff --git a/ihm_client/src/mocks/mock-chat/groupsMock.js b/ihm_client/src/mocks/mock-chat/groupsMock.js new file mode 100755 index 00000000..d5d1fcfe --- /dev/null +++ b/ihm_client/src/mocks/mock-chat/groupsMock.js @@ -0,0 +1,52 @@ +export const groupsMock = [ + { + id: 1, + name: 'Group 🚀 ', + roles: [ + { + id: 1, + name: 'Role 1', + members: [ + { id: 1, name: 'Member 1' }, + { id: 2, name: 'Member 2' }, + ], + }, + { + id: 2, + name: 'Role 2', + members: [ + { id: 3, name: 'Member 3' }, + { id: 4, name: 'Member 4' }, + ], + }, + ], + }, + { + id: 2, + name: 'Group ₿', + roles: [ + { + id: 3, + name: 'Role 1', + members: [ + { id: 5, name: 'Member 5' }, + { id: 6, name: 'Member 6' }, + ], + }, + ], + }, + { + id: 3, + name: 'Group 🪙', + roles: [ + { + id: 4, + name: 'Role 1', + members: [ + { id: 7, name: 'Member 7' }, + { id: 8, name: 'Member 8' }, + ], + }, + ], + }, +]; diff --git a/ihm_client/src/mocks/mock-chat/messagesMock.js b/ihm_client/src/mocks/mock-chat/messagesMock.js new file mode 100755 index 00000000..28792d96 --- /dev/null +++ b/ihm_client/src/mocks/mock-chat/messagesMock.js @@ -0,0 +1,64 @@ +export const messagesMock = [ + { + memberId: 1, // Conversations avec Mmber 1 + messages: [ + { id: 1, sender: 'Member 1', text: 'Salut !', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Bonjour ! Comment ça va ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Tout va bien, merci !', time: '10:32 AM' }, + ], + }, + { + memberId: 2, // Conversations avec Member 2 + messages: [ + { id: 1, sender: 'Member 2', text: 'Salut, on se voit ce soir ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Oui, à quelle heure ?', time: '10:31 AM' }, + ], + }, + { + memberId: 3, // Conversations avec Member 3 + messages: [ + { id: 1, sender: 'Member 3', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 4, // Conversations avec Member 4 + messages: [ + { id: 1, sender: 'Member 4', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 5, // Conversations avec Member 5 + messages: [ + { id: 1, sender: 'Member 5', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 6, // Conversations avec Member 6 + messages: [ + { id: 1, sender: 'Member 6', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 7, // Conversations avec Member 7 + messages: [ + { id: 1, sender: 'Member 7', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 8, // Conversations avec Member 8 + messages: [ + { id: 1, sender: 'Member 8', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, +]; diff --git a/ihm_client/src/mocks/mock-signature/groupsMock.js b/ihm_client/src/mocks/mock-signature/groupsMock.js new file mode 100755 index 00000000..c6521122 --- /dev/null +++ b/ihm_client/src/mocks/mock-signature/groupsMock.js @@ -0,0 +1,471 @@ +// Définir les rôles autorisés +const VALID_ROLES = ['User', 'Process', 'Member', 'Peer', 'Payment', 'Deposit', 'Artefact', 'Resolve', 'Backup']; + +const VISIBILITY_LEVELS = { + PUBLIC: 'public', + CONFIDENTIAL: 'confidential', + PRIVATE: 'private', +}; + +const DOCUMENT_STATUS = { + DRAFT: 'draft', + PENDING: 'pending', + IN_REVIEW: 'in_review', + APPROVED: 'approved', + REJECTED: 'rejected', + EXPIRED: 'expired', +}; + +// Fonction pour créer un rôle +function createRole(name, members) { + if (!VALID_ROLES.includes(name)) { + throw new Error(`Role "${name}" is not valid.`); + } + return { name, members }; +} + +export const groupsMock = [ + { + id: 1, + name: 'Processus 1', + description: 'Description du processus 1', + commonDocuments: [ + { + id: 101, + name: 'Règlement intérieur', + description: 'Document vierge pour le règlement intérieur', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 102, + name: 'Charte de confidentialité', + description: 'Document vierge pour la charte de confidentialité', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 103, + name: 'Procédures générales', + description: 'Document vierge pour les procédures générales', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 104, + name: 'Urgency A', + description: "Document vierge pour le plan d'urgence A", + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 105, + name: 'Urgency B', + description: "Document vierge pour le plan d'urgence B", + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 106, + name: 'Urgency C', + description: "Document vierge pour le plan d'urgence C", + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 107, + name: 'Document à signer', + description: 'Document vierge pour le règlement intérieur', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + roles: [ + { + name: 'User', + members: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + documents: [ + { + id: 1, + name: 'Document User A', + description: 'Description du document User A.', + visibility: 'public', + createdAt: '2024-01-01', + deadline: '2024-02-01', + signatures: [ + { + member: { id: 1, name: 'Alice' }, + signed: true, + signedAt: '2024-01-15', + }, + { + member: { id: 2, name: 'Bob' }, + signed: false, + }, + ], + }, + { + id: 2, + name: 'Document User B', + description: 'Document vierge pour le rôle User', + visibility: 'confidential', + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 7, + name: 'Document User C', + description: 'Document vierge pour validation utilisateur', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 8, + name: 'Document User D', + description: 'Document vierge pour approbation utilisateur', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + }, + { + name: 'Process', + members: [ + { id: 3, name: 'Charlie' }, + { id: 4, name: 'David' }, + ], + documents: [ + { + id: 3, + name: 'Document Process A', + description: 'Description du document Process A.', + visibility: 'confidential', + createdAt: '2024-01-10', + deadline: '2024-03-01', + signatures: [ + { + member: { id: 3, name: 'Charlie' }, + signed: true, + signedAt: '2024-01-12', + }, + ], + }, + { + id: 9, + name: 'Document Process B', + description: 'Document vierge pour processus interne', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 10, + name: 'Document Process C', + description: 'Document vierge pour validation processus', + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 11, + name: 'Document Process D', + description: 'Document vierge pour validation processus', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.PENDING, + createdAt: '2024-01-15', + deadline: '2024-02-01', + signatures: [ + { + member: { id: 3, name: 'Charlie' }, + signed: true, + signedAt: '2024-01-15', + }, + { + member: { id: 4, name: 'David' }, + signed: false, + }, + ], + }, + { + id: 12, + name: 'Document Process E', + description: 'Document vierge pour validation processus', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.PENDING, + createdAt: '2024-01-15', + deadline: '2024-02-01', + signatures: [ + { + member: { id: 3, name: 'Charlie' }, + signed: true, + signedAt: '2024-01-15', + }, + { + member: { id: 4, name: 'David' }, + signed: false, + }, + ], + }, + ], + }, + { + name: 'Backup', + members: [ + { id: 15, name: 'Oscar' }, + { id: 16, name: 'Patricia' }, + ], + documents: [ + { + id: 11, + name: 'Document Backup A', + description: 'Document vierge pour sauvegarde', + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + }, + ], + }, + { + id: 2, + name: 'Processus 2', + description: 'Description du processus 2', + commonDocuments: [ + { + id: 201, + name: 'Règlement intérieur', + description: 'Document vierge pour le règlement intérieur', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 202, + name: 'Charte de confidentialité', + description: 'Document vierge pour la charte de confidentialité', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 203, + name: 'Charte de confidentialité', + description: 'Document vierge pour la charte de confidentialité', + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 204, + name: 'Charte de confidentialité', + description: 'Document vierge pour la charte de confidentialité', + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 205, + name: 'Charte de confidentialité', + description: 'Document vierge pour la charte de confidentialité', + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + roles: [ + { + name: 'Artefact', + members: [ + { id: 17, name: 'Quinn' }, + { id: 18, name: 'Rachel' }, + ], + documents: [ + { + id: 12, + name: 'Document Artefact A', + description: 'Document vierge pour artefact', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 13, + name: 'Document Artefact B', + description: 'Document vierge pour validation artefact', + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + }, + { + name: 'Resolve', + members: [ + { id: 19, name: 'Sam' }, + { id: 20, name: 'Tom' }, + ], + documents: [ + { + id: 14, + name: 'Document Resolve A', + description: 'Document vierge pour résolution', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + }, + ], + }, + { + id: 3, + name: 'Processus 3', + description: 'Description du processus 3', + commonDocuments: [ + { + id: 301, + name: 'Règlement intérieur', + description: 'Document vierge pour le règlement intérieur', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 302, + name: 'Charte de confidentialité', + description: 'Document vierge pour la charte de confidentialité', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 303, + name: 'Procédures générales', + description: 'Document vierge pour les procédures générales', + visibility: VISIBILITY_LEVELS.PUBLIC, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + roles: [ + { + name: 'Deposit', + members: [ + { id: 21, name: 'Uma' }, + { id: 22, name: 'Victor' }, + ], + documents: [ + { + id: 15, + name: 'Document Deposit A', + description: 'Document vierge pour dépôt', + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 16, + name: 'Document Deposit B', + description: 'Document vierge pour validation dépôt', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + }, + { + name: 'Payment', + members: [ + { id: 23, name: 'Walter' }, + { id: 24, name: 'Xena' }, + ], + documents: [ + { + id: 17, + name: 'Document Payment B', + description: 'Document vierge pour paiement', + visibility: VISIBILITY_LEVELS.PRIVATE, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + { + id: 18, + name: 'Document Payment C', + description: 'Document vierge pour validation paiement', + visibility: VISIBILITY_LEVELS.CONFIDENTIAL, + status: DOCUMENT_STATUS.DRAFT, + createdAt: null, + deadline: null, + signatures: [], + }, + ], + }, + ], + }, +]; diff --git a/ihm_client/src/mocks/mock-signature/membersMocks.js b/ihm_client/src/mocks/mock-signature/membersMocks.js new file mode 100755 index 00000000..2ffc6cee --- /dev/null +++ b/ihm_client/src/mocks/mock-signature/membersMocks.js @@ -0,0 +1,105 @@ +export const membersMock = [ + // Processus 1 + { + id: 1, + name: 'Alice', + avatar: 'A', + email: 'alice@company.com', + processRoles: [{ processId: 1, role: 'User' }], + }, + { + id: 2, + name: 'Bob', + avatar: 'B', + email: 'bob@company.com', + processRoles: [{ processId: 1, role: 'User' }], + }, + { + id: 3, + name: 'Charlie', + avatar: 'C', + email: 'charlie@company.com', + processRoles: [{ processId: 1, role: 'Process' }], + }, + { + id: 4, + name: 'David', + avatar: 'D', + email: 'david@company.com', + processRoles: [{ processId: 1, role: 'Process' }], + }, + { + id: 15, + name: 'Oscar', + avatar: 'O', + email: 'oscar@company.com', + processRoles: [{ processId: 1, role: 'Backup' }], + }, + { + id: 16, + name: 'Patricia', + avatar: 'P', + email: 'patricia@company.com', + processRoles: [{ processId: 1, role: 'Backup' }], + }, + + // Processus 2 + { + id: 17, + name: 'Quinn', + avatar: 'Q', + email: 'quinn@company.com', + processRoles: [{ processId: 2, role: 'Artefact' }], + }, + { + id: 18, + name: 'Rachel', + avatar: 'R', + email: 'rachel@company.com', + processRoles: [{ processId: 2, role: 'Artefact' }], + }, + { + id: 19, + name: 'Sam', + avatar: 'S', + email: 'sam@company.com', + processRoles: [{ processId: 2, role: 'Resolve' }], + }, + { + id: 20, + name: 'Tom', + avatar: 'T', + email: 'tom@company.com', + processRoles: [{ processId: 2, role: 'Resolve' }], + }, + + // Processus 3 + { + id: 21, + name: 'Uma', + avatar: 'U', + email: 'uma@company.com', + processRoles: [{ processId: 3, role: 'Deposit' }], + }, + { + id: 22, + name: 'Victor', + avatar: 'V', + email: 'victor@company.com', + processRoles: [{ processId: 3, role: 'Deposit' }], + }, + { + id: 23, + name: 'Walter', + avatar: 'W', + email: 'walter@company.com', + processRoles: [{ processId: 3, role: 'Payment' }], + }, + { + id: 24, + name: 'Xena', + avatar: 'X', + email: 'xena@company.com', + processRoles: [{ processId: 3, role: 'Payment' }], + }, +]; diff --git a/ihm_client/src/mocks/mock-signature/messagesMock.ts b/ihm_client/src/mocks/mock-signature/messagesMock.ts new file mode 100755 index 00000000..ca1c31e5 --- /dev/null +++ b/ihm_client/src/mocks/mock-signature/messagesMock.ts @@ -0,0 +1,64 @@ +export const messagesMock = [ + { + memberId: 1, // Conversations avec Mmber 1 + messages: [ + { id: 1, sender: 'Mmeber 1', text: 'Salut !', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Bonjour ! Comment ça va ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Tout va bien, merci !', time: '10:32 AM' }, + ], + }, + { + memberId: 2, // Conversations avec Member 2 + messages: [ + { id: 1, sender: 'Member 2', text: 'Salut, on se voit ce soir ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Oui, à quelle heure ?', time: '10:31 AM' }, + ], + }, + { + memberId: 3, // Conversations avec Member 3 + messages: [ + { id: 1, sender: 'Member 3', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 4, // Conversations avec Member 4 + messages: [ + { id: 1, sender: 'Member 4', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 5, // Conversations avec Member 5 + messages: [ + { id: 1, sender: 'Member 5', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 6, // Conversations avec Member 6 + messages: [ + { id: 1, sender: 'Member 6', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 7, // Conversations avec Member 7 + messages: [ + { id: 1, sender: 'Member 7', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, + { + memberId: 8, // Conversations avec Member 8 + messages: [ + { id: 1, sender: 'Member 8', text: 'Hey, ça va ?', time: '10:30 AM' }, + { id: 2, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + { id: 3, sender: '4NK', text: 'Ça va et toi ?', time: '10:31 AM' }, + ], + }, +]; diff --git a/ihm_client/src/models/backup.model.ts b/ihm_client/src/models/backup.model.ts new file mode 100644 index 00000000..5050688b --- /dev/null +++ b/ihm_client/src/models/backup.model.ts @@ -0,0 +1,7 @@ +import { Device, Process, SecretsStore } from "pkg/sdk_client"; + +export interface BackUp { + device: Device, + secrets: SecretsStore, + processes: Record, +} diff --git a/ihm_client/src/models/notification.model.ts b/ihm_client/src/models/notification.model.ts new file mode 100755 index 00000000..0d78630b --- /dev/null +++ b/ihm_client/src/models/notification.model.ts @@ -0,0 +1,30 @@ +export interface INotification { + id: number; + title: string; + description: string; + sendToNotificationPage?: boolean; + path?: string; +} + +// Quelles sont les données utiles pour le user ??? +export interface IUser { + id: string; + information?: any; +} + +// Quelles sont les données utiles pour les messages ??? +export interface IMessage { + id: string; + message: any; +} + +export interface UserDiff { + new_state_merkle_root: string; // TODO add a merkle proof that the new_value belongs to that state + field: string; + previous_value: string; + new_value: string; + notify_user: boolean; + need_validation: boolean; + // validated: bool, + proof: any; // This is only validation (or refusal) for that specific diff, not the whole state. It can't be commited as such +} diff --git a/ihm_client/src/models/process.model.ts b/ihm_client/src/models/process.model.ts new file mode 100755 index 00000000..b2eae720 --- /dev/null +++ b/ihm_client/src/models/process.model.ts @@ -0,0 +1,65 @@ +export interface IProcess { + id: number; + name: string; + description: string; + icon?: string; + zoneList: IZone[]; +} + +export interface IZone { + id: number; + name: string; + path: string; + // Est-ce que la zone a besoin d'une icone ? + icon?: string; +} + +export interface INotification { + id: number; + title: string; + description: string; + sendToNotificationPage?: boolean; + path?: string; +} + +export enum MessageType { + // Establish connection and keep alive + LISTENING = 'LISTENING', + REQUEST_LINK = 'REQUEST_LINK', + LINK_ACCEPTED = 'LINK_ACCEPTED', + CREATE_PAIRING = 'CREATE_PAIRING', + PAIRING_CREATED = 'PAIRING_CREATED', + ERROR = 'ERROR', + VALIDATE_TOKEN = 'VALIDATE_TOKEN', + RENEW_TOKEN = 'RENEW_TOKEN', + // Get various information + GET_PAIRING_ID = 'GET_PAIRING_ID', + GET_PROCESSES = 'GET_PROCESSES', + GET_MY_PROCESSES = 'GET_MY_PROCESSES', + PROCESSES_RETRIEVED = 'PROCESSES_RETRIEVED', + RETRIEVE_DATA = 'RETRIEVE_DATA', + DATA_RETRIEVED = 'DATA_RETRIEVED', + DECODE_PUBLIC_DATA = 'DECODE_PUBLIC_DATA', + PUBLIC_DATA_DECODED = 'PUBLIC_DATA_DECODED', + GET_MEMBER_ADDRESSES = 'GET_MEMBER_ADDRESSES', + MEMBER_ADDRESSES_RETRIEVED = 'MEMBER_ADDRESSES_RETRIEVED', + // Processes + CREATE_PROCESS = 'CREATE_PROCESS', + PROCESS_CREATED = 'PROCESS_CREATED', + UPDATE_PROCESS = 'UPDATE_PROCESS', + PROCESS_UPDATED = 'PROCESS_UPDATED', + NOTIFY_UPDATE = 'NOTIFY_UPDATE', + UPDATE_NOTIFIED = 'UPDATE_NOTIFIED', + VALIDATE_STATE = 'VALIDATE_STATE', + STATE_VALIDATED = 'STATE_VALIDATED', + // Hash and merkle proof + HASH_VALUE = 'HASH_VALUE', + VALUE_HASHED = 'VALUE_HASHED', + GET_MERKLE_PROOF = 'GET_MERKLE_PROOF', + MERKLE_PROOF_RETRIEVED = 'MERKLE_PROOF_RETRIEVED', + VALIDATE_MERKLE_PROOF = 'VALIDATE_MERKLE_PROOF', + MERKLE_PROOF_VALIDATED = 'MERKLE_PROOF_VALIDATED', + // Account management + ADD_DEVICE = 'ADD_DEVICE', + DEVICE_ADDED = 'DEVICE_ADDED', +} diff --git a/ihm_client/src/models/signature.models.ts b/ihm_client/src/models/signature.models.ts new file mode 100755 index 00000000..ef5ad282 --- /dev/null +++ b/ihm_client/src/models/signature.models.ts @@ -0,0 +1,59 @@ +export interface Group { + id: number; + name: string; + description?: string; + roles: { + id?: number; + name: string; + members: { id: string | number; name: string }[]; + documents?: { + id: number; + name: string; + description?: string; + visibility: string; + createdAt: string | null; + deadline: string | null; + signatures: DocumentSignature[]; + status?: string; + files?: Array<{ name: string; url: string }>; + }[]; + }[]; +} + +export interface Message { + id: number; + sender: string; + text?: string; + time: string; + type: 'text' | 'file'; + fileName?: string; + fileData?: string; +} + +export interface MemberMessages { + memberId: string; + messages: Message[]; +} + +export interface DocumentSignature { + signed: boolean; + member: { + name: string; + }; + signedAt?: string; +} + +export interface RequestParams { + processId: number; + processName: string; + roleId: number; + roleName: string; + documentId: number; + documentName: string; +} + +export interface Notification { + memberId: string; + text: string; + time: string; +} diff --git a/ihm_client/src/pages/account/account-component.ts b/ihm_client/src/pages/account/account-component.ts new file mode 100644 index 00000000..db8f1933 --- /dev/null +++ b/ihm_client/src/pages/account/account-component.ts @@ -0,0 +1,62 @@ +import { AccountElement } from './account'; +import accountCss from '../../../public/style/account.css?raw'; +import Services from '../../services/service.js'; + +class AccountComponent extends HTMLElement { + _callback: any; + accountElement: AccountElement | null = null; + + constructor() { + super(); + console.log('INIT'); + this.attachShadow({ mode: 'open' }); + + this.accountElement = this.shadowRoot?.querySelector('account-element') || null; + } + + connectedCallback() { + console.log('CALLBACKs'); + this.render(); + this.fetchData(); + + if (!customElements.get('account-element')) { + customElements.define('account-element', AccountElement); + } + } + + async fetchData() { + if ((import.meta as any).env.VITE_IS_INDEPENDANT_LIB === false) { + const data = await (window as any).myService?.getProcesses(); + } else { + const service = await Services.getInstance(); + const data = await service.getProcesses(); + } + } + + set callback(fn) { + if (typeof fn === 'function') { + this._callback = fn; + } else { + console.error('Callback is not a function'); + } + } + + get callback() { + return this._callback; + } + + render() { + if (this.shadowRoot && !this.shadowRoot.querySelector('account-element')) { + const style = document.createElement('style'); + style.textContent = accountCss; + + const accountElement = document.createElement('account-element'); + + this.shadowRoot.appendChild(style); + this.shadowRoot.appendChild(accountElement); + } + } +} + +export { AccountComponent }; +customElements.define('account-component', AccountComponent); diff --git a/ihm_client/src/pages/account/account.html b/ihm_client/src/pages/account/account.html new file mode 100755 index 00000000..654be9aa --- /dev/null +++ b/ihm_client/src/pages/account/account.html @@ -0,0 +1,10 @@ + + + + Account + + + + + + diff --git a/ihm_client/src/pages/account/account.ts b/ihm_client/src/pages/account/account.ts new file mode 100755 index 00000000..8e9d0894 --- /dev/null +++ b/ihm_client/src/pages/account/account.ts @@ -0,0 +1,1589 @@ +declare global { + interface Window { + initAccount: () => void; + showContractPopup: (contractId: string) => void; + showPairing: () => Promise; + showWallet: () => void; + showData: () => void; + addWalletRow: () => void; + confirmWalletRow: () => void; + cancelWalletRow: () => void; + openAvatarPopup: () => void; + closeAvatarPopup: () => void; + editDeviceName: (cell: HTMLTableCellElement) => void; + showNotifications: (processName: string) => void; + closeNotificationPopup: (event: Event) => void; + markAsRead: (processName: string, messageId: number, element: HTMLElement) => void; + exportRecovery: () => void; + confirmDeleteAccount: () => void; + deleteAccount: () => void; + updateNavbarBanner: (bannerUrl: string) => void; + saveBannerToLocalStorage: (bannerUrl: string) => void; + loadSavedBanner: () => void; + cancelAddRowPairing: () => void; + saveName: (cell: HTMLElement, input: HTMLInputElement) => void; + showProcessNotifications: (processName: string) => void; + handleLogout: () => void; + initializeEventListeners: () => void; + showProcess: () => void; + showProcessCreation: () => void; + showDocumentValidation: () => void; + updateNavbarName: (name: string) => void; + updateNavbarLastName: (lastName: string) => void; + showAlert: (title: string, text?: string, icon?: string) => void; + addRowPairing: () => void; + confirmRowPairing: () => void; + cancelRowPairing: () => void; + deleteRowPairing: (button: HTMLButtonElement) => void; + generateRecoveryWords: () => string[]; + exportUserData: () => void; + updateActionButtons: () => void; + showQRCodeModal: (pairingId: string) => void; + } +} + +import Swal from 'sweetalert2'; +import { STORAGE_KEYS, defaultRows, mockProcessRows, mockNotifications, notificationMessages, mockDataRows, mockContracts, ALLOWED_ROLES } from '../../mocks/mock-account/constAccountMock'; +import { Row, WalletRow, DataRow, Notification, Contract, NotificationMessage } from '../../mocks/mock-account/interfacesAccountMock'; +import { addressToEmoji } from '../../utils/sp-address.utils'; +import { getCorrectDOM } from '../../utils/document.utils'; +import accountStyle from '../../../public/style/account.css?inline'; +import Services from '../../services/service'; +import { getProcessCreation } from './process-creation'; +import { getDocumentValidation } from './document-validation'; +import { createProcessTab } from './process'; + +let isAddingRow = false; +let currentRow: HTMLTableRowElement | null = null; +let currentMode: keyof typeof STORAGE_KEYS = 'pairing'; + +interface Process { + states: Array<{ + committed_in: string; + keys: {}; + pcd_commitment: { + counter: string; + }; + public_data: { + memberPublicName?: string; + }; + roles: { + pairing?: {}; + }; + state_id: string; + validation_tokens: Array; + }>; +} + +class AccountElement extends HTMLElement { + private dom: Node; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.dom = getCorrectDOM('account-element'); + + // Ajouter Font Awesome + const fontAwesome = document.createElement('link'); + fontAwesome.rel = 'stylesheet'; + fontAwesome.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'; + this.shadowRoot!.appendChild(fontAwesome); + + const style = document.createElement('style'); + style.textContent = accountStyle; + this.shadowRoot!.appendChild(style); + + this.shadowRoot!.innerHTML = ` + + + + + + +
+ +
+
    + +
    +
    + Banner +
    +
    + Avatar + +
    +
    + + + + + +
+
    Pairing 🔗
+ +
    Process ⚙️
+
    Process Creation
+
    Document Validation
+ +
+ + +
+
+
+ +
+
+
+ +
+
+
+ `; + + + window.showPairing = () => this.showPairing(); + window.showWallet = () => this.showWallet(); + window.showProcess = () => this.showProcess(); + window.showProcessCreation = () => this.showProcessCreation(); + window.showDocumentValidation = () => this.showDocumentValidation(); + window.showData = () => this.showData(); + window.addWalletRow = () => this.addWalletRow(); + window.confirmWalletRow = () => this.confirmWalletRow(); + window.cancelWalletRow = () => this.cancelWalletRow(); + window.editDeviceName = (cell: HTMLTableCellElement) => this.editDeviceName(cell); + window.showProcessNotifications = (processName: string) => this.showProcessNotifications(processName); + window.handleLogout = () => this.handleLogout(); + window.confirmDeleteAccount = () => this.confirmDeleteAccount(); + window.showContractPopup = (contractId: string) => this.showContractPopup(contractId); + window.addRowPairing = () => this.addRowPairing(); + window.deleteRowPairing = (button: HTMLButtonElement) => this.deleteRowPairing(button); + window.confirmRowPairing = () => this.confirmRowPairing(); + window.cancelRowPairing = () => this.cancelRowPairing(); + window.updateNavbarBanner = (bannerUrl: string) => this.updateNavbarBanner(bannerUrl); + window.saveBannerToLocalStorage = (bannerUrl: string) => this.saveBannerToLocalStorage(bannerUrl); + window.loadSavedBanner = () => this.loadSavedBanner(); + window.closeNotificationPopup = (event: Event) => this.closeNotificationPopup(event); + window.markAsRead = (processName: string, messageId: number, element: HTMLElement) => this.markAsRead(processName, messageId, element); + window.exportRecovery = () => this.exportRecovery(); + window.generateRecoveryWords = () => this.generateRecoveryWords(); + window.exportUserData = () => this.exportUserData(); + window.updateActionButtons = () => this.updateActionButtons(); + window.openAvatarPopup = () => this.openAvatarPopup(); + window.closeAvatarPopup = () => this.closeAvatarPopup(); + window.showQRCodeModal = (pairingId: string) => this.showQRCodeModal(pairingId); + + if (!localStorage.getItem('rows')) { + localStorage.setItem('rows', JSON.stringify(defaultRows)); + } + } + + connectedCallback() { + this.initializeEventListeners(); + this.loadSavedBanner(); + this.loadUserInfo(); + + const savedAvatar = localStorage.getItem('userAvatar'); + const savedBanner = localStorage.getItem('userBanner'); + const savedName = localStorage.getItem('userName'); + const savedLastName = localStorage.getItem('userLastName'); + + if (savedAvatar) { + const navAvatar = this.shadowRoot?.querySelector('.avatar') as HTMLImageElement; + if (navAvatar) navAvatar.src = savedAvatar; + } + + if (savedBanner) { + const navBanner = this.shadowRoot?.querySelector('.banner-image') as HTMLImageElement; + if (navBanner) navBanner.src = savedBanner; + } + + if (savedName) { + this.updateNavbarName(savedName); + } + if (savedLastName) { + this.updateNavbarLastName(savedLastName); + } + } + + private showAlert(message: string): void { + // Créer la popup si elle n'existe pas + let alertPopup = this.shadowRoot?.querySelector('.alert-popup'); + if (!alertPopup) { + alertPopup = document.createElement('div'); + alertPopup.className = 'alert-popup'; + this.shadowRoot?.appendChild(alertPopup); + } + + // Définir le message et afficher la popup + alertPopup.textContent = message; + (alertPopup as HTMLElement).style.display = 'block'; + + // Cacher la popup après 3 secondes + setTimeout(() => { + (alertPopup as HTMLElement).style.display = 'none'; + }, 3000); + } + + + // Fonctions de gestion des comptes et de l'interface utilisateur + private confirmDeleteAccount(): void { + const modal = document.createElement('div'); + modal.className = 'confirm-delete-modal'; + modal.innerHTML = ` +

Delete Account

+

Are you sure you want to delete your account? This action cannot be undone.

+
+ + +
+ `; + + this.shadowRoot?.appendChild(modal); + modal.style.display = 'block'; + + const cancelBtn = modal.querySelector('.cancel-btn'); + const confirmBtn = modal.querySelector('.confirm-btn'); + + cancelBtn?.addEventListener('click', () => { + modal.remove(); + }); + + confirmBtn?.addEventListener('click', () => { + this.deleteAccount(); + modal.remove(); + }); +} + +private deleteAccount(): void { + localStorage.clear(); + window.location.href = '/login.html'; +} + +private updateNavbarBanner(imageUrl: string): void { + const navbarSection = this.shadowRoot?.querySelector('.nav-wrapper .avatar-section'); + if (!navbarSection) return; + + let bannerImg = navbarSection.querySelector('.banner-image'); + + if (!bannerImg) { + bannerImg = document.createElement('img'); + bannerImg.className = 'banner-image'; + navbarSection.insertBefore(bannerImg, navbarSection.firstChild); + } + + bannerImg.src = imageUrl; +} + +private saveBannerToLocalStorage(dataUrl: string): void { + localStorage.setItem('userBanner', dataUrl); +} + +private loadSavedBanner(): void { + const savedBanner = localStorage.getItem('userBanner'); + if (savedBanner) { + const bannerImg = this.shadowRoot?.getElementById('popup-banner-img') as HTMLImageElement; + if (bannerImg) { + bannerImg.src = savedBanner; + } + this.updateNavbarBanner(savedBanner); + } +} + + +private closeNotificationPopup(event: Event): void { + const target = event.target as HTMLElement; + const isOverlay = target.classList.contains('notification-popup-overlay'); + const isCloseButton = target.classList.contains('close-popup'); + if (!isOverlay && !isCloseButton) return; + + const popup = this.shadowRoot?.querySelector('.notification-popup-overlay'); + if (popup) popup.remove(); +} + +private markAsRead(processName: string, messageId: number, element: HTMLElement): void { + const process = mockProcessRows.find(p => p.process === processName); + if (!process) return; + + const message = process.notification.messages.find(m => m.id === messageId); + if (!message || message.read) return; + + message.read = true; + + element.classList.remove('unread'); + element.classList.add('read'); + + const statusIcon = element.querySelector('.notification-status'); + if (statusIcon) { + statusIcon.innerHTML = ` + + + `; + } + + const notifCount = this.calculateNotifications(process.notification.messages); + const countElement = this.shadowRoot?.querySelector(`.notification-count[data-process="${processName}"]`); + if (countElement) { + countElement.textContent = `${notifCount.unread}/${notifCount.total}`; + + const bellContainer = countElement.closest('.notification-container'); + const bell = bellContainer?.querySelector('svg'); // Changé de .fa-bell à svg + if (bell && bellContainer && notifCount.unread === 0) { + bellContainer.classList.remove('has-unread'); + (bell as SVGElement).style.fill = '#666'; // Utiliser fill au lieu de color pour SVG + } + } +} + +// Fonctions de gestion des données et de l'interface +private calculateNotifications(messages: NotificationMessage[]): { unread: number; total: number } { + const total = messages.length; + const unread = messages.filter(msg => !msg.read).length; + return { unread, total }; +} + +// Fonctions de récupération +private exportRecovery(): void { + Swal.fire({ + title: 'Recovery Words Export', + text: '4 words will be displayed. We strongly recommend writing them down on paper before exporting the account. Do you want to continue?', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Confirm', + cancelButtonText: 'Cancel', + confirmButtonColor: '#C89666', + cancelButtonColor: '#6c757d', + // Ajouter des styles personnalisés + customClass: { + container: 'recovery-popup-container', + popup: 'recovery-popup' + } + }).then((result) => { + if (result.isConfirmed) { + const recoveryWords = this.generateRecoveryWords(); + localStorage.setItem('recoveryWords', JSON.stringify(recoveryWords)); + + Swal.fire({ + title: 'Your Recovery Words', + html: ` +
+ ${recoveryWords.map((word, index) => ` +
+ ${index + 1}. + ${word} +
+ `).join('')} +
+
+ Please write these words down carefully. They will be needed to recover your account. +
+ `, + showCancelButton: false, + confirmButtonText: 'I confirm the export', + confirmButtonColor: '#C89666', + allowOutsideClick: false, + allowEscapeKey: false, + customClass: { + container: 'recovery-popup-container', + popup: 'recovery-popup' + } + }).then((result) => { + if (result.isConfirmed) { + // Stocker l'état du bouton dans le localStorage + localStorage.setItem('recoveryExported', 'true'); + + const exportRecoveryBtn = this.shadowRoot?.querySelector('.recovery-btn') as HTMLButtonElement; + if (exportRecoveryBtn) { + exportRecoveryBtn.disabled = true; + exportRecoveryBtn.style.opacity = '0.5'; + exportRecoveryBtn.style.cursor = 'not-allowed'; + } + } + }); + } + }); +} + +private generateRecoveryWords(): string[] { + const wordsList = [ + 'apple', 'banana', 'orange', 'grape', 'kiwi', 'mango', 'peach', 'plum', + 'lemon', 'lime', 'cherry', 'melon', 'pear', 'fig', 'date', 'berry' + ]; + const recoveryWords: string[] = []; + while (recoveryWords.length < 4) { + const randomWord = wordsList[Math.floor(Math.random() * wordsList.length)]; + if (!recoveryWords.includes(randomWord)) { + recoveryWords.push(randomWord); + } + } + return recoveryWords; +} + +private exportUserData(): void { + const data: { [key: string]: string | null } = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) { + const value = localStorage.getItem(key); + data[key] = value; + } + } + + const jsonData = JSON.stringify(data, null, 2); + const blob = new Blob([jsonData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'user_data.json'; + this.shadowRoot?.appendChild(a); + a.click(); + this.shadowRoot?.removeChild(a); + URL.revokeObjectURL(url); +} + +private updateActionButtons(): void { + const buttonContainer = this.shadowRoot?.querySelector('.button-container'); + if (!buttonContainer) return; + + buttonContainer.innerHTML = ` +
+ + +
+ `; +} + +private getConfirmFunction(): string { + switch (currentMode) { + case 'wallet': + return 'window.confirmWalletRow()'; + case 'process': + return 'window.confirmProcessRow()'; + default: + return 'window.confirmRowPairing()'; + } +} + +private getCancelFunction(): string { + switch (currentMode) { + case 'wallet': + return 'window.cancelWalletRow()'; + case 'process': + return 'window.cancelProcessRow()'; + default: + return 'window.cancelRowPairing()'; + } +} + +// Fonctions de gestion des tableaux +private async addRowPairing(): Promise { + if (isAddingRow) return; + isAddingRow = true; + + // Créer la popup + const modal = document.createElement('div'); + modal.className = 'pairing-modal'; + modal.innerHTML = ` +
+

Add New Device

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ `; + + this.shadowRoot?.appendChild(modal); + + // Ajouter les event listeners + const spAddressInput = modal.querySelector('#sp-address') as HTMLInputElement; + const spEmojisInput = modal.querySelector('#sp-emojis') as HTMLInputElement; + const deviceNameInput = modal.querySelector('#device-name') as HTMLInputElement; + const confirmButton = modal.querySelector('.confirm-button'); + const cancelButton = modal.querySelector('.cancel-button'); + + // Mettre à jour les emojis automatiquement + spAddressInput?.addEventListener('input', async () => { + const emojis = await addressToEmoji(spAddressInput.value); + if (spEmojisInput) spEmojisInput.value = emojis; + }); + + // Gérer la confirmation + confirmButton?.addEventListener('click', () => { + const spAddress = spAddressInput?.value.trim(); + const deviceName = deviceNameInput?.value.trim(); + const spEmojis = spEmojisInput?.value.trim(); + + if (!spAddress || !deviceName) { + this.showAlert('Please fill in all required fields'); + return; + } + + //if (spAddress.length !== 118) { + // this.showAlert('SP Address must be exactly 118 characters long'); + // return; + //} + + const newRow: Row = { + column1: spAddress, + column2: deviceName, + column3: spEmojis || '' + }; + + const storageKey = STORAGE_KEYS[currentMode]; + const rows: Row[] = JSON.parse(localStorage.getItem(storageKey) || '[]'); + rows.push(newRow); + localStorage.setItem(storageKey, JSON.stringify(rows)); + + this.updateTableContent(rows); + modal.remove(); + isAddingRow = false; + }); + + // Gérer l'annulation + cancelButton?.addEventListener('click', () => { + modal.remove(); + isAddingRow = false; + }); +} + +// Fonctions de mise à jour de l'interface +private updateTableContent(rows: Row[]): void { + const tbody = this.shadowRoot?.querySelector('#pairing-table tbody'); + if (!tbody) return; + + tbody.innerHTML = rows.map(row => ` + + ${row.column2} + ${row.column3} + + QR Code + + + + + + `).join(''); +} + + + +private confirmRowPairing(): void { + if (!currentRow) return; + + const inputs = currentRow.getElementsByTagName('input'); + const values: string[] = Array.from(inputs).map(input => input.value.trim()); + + // Vérification des champs vides + if (values.some(value => value === '')) { + this.showAlert('Please fill in all fields'); + return; + } + + // Vérification de la longueur de l'adresse SP + if (values[0].length !== 118) { + this.showAlert('SP Address must be exactly 118 characters long'); + return; + } + + const newRow: Row = { + column1: values[0], + column2: values[1], + column3: values[2] + }; + + const storageKey = STORAGE_KEYS[currentMode]; + const rows: Row[] = JSON.parse(localStorage.getItem(storageKey) || '[]'); + rows.push(newRow); + localStorage.setItem(storageKey, JSON.stringify(rows)); + + isAddingRow = false; + currentRow = null; + + this.resetButtonContainer(); + this.updateTableContent(rows); +} + +private cancelRowPairing(): void { + if (!currentRow) return; + + currentRow.remove(); + isAddingRow = false; + currentRow = null; + + this.resetButtonContainer(); +} + +private resetButtonContainer(): void { + const buttonContainer = this.shadowRoot?.querySelector('.button-container'); + if (!buttonContainer) return; + + buttonContainer.innerHTML = ` + + `; +} + +private deleteRowPairing(button: HTMLButtonElement): void { + const row = button.closest('tr'); + if (!row) return; + + const table = row.closest('tbody'); + if (!table) return; + + const remainingRows = table.getElementsByTagName('tr').length; + if (remainingRows <= 2) { + this.showAlert('You must keep at least 2 devices paired'); + return; + } + + // Créer la modal de confirmation + const modal = document.createElement('div'); + modal.className = 'confirm-delete-modal'; + modal.innerHTML = ` +
+

Confirm Deletion

+

Are you sure you want to delete this device?

+
+ + +
+
+ `; + + this.shadowRoot?.appendChild(modal); + + // Gérer les boutons de la modal + const confirmBtn = modal.querySelector('.confirm-btn'); + const cancelBtn = modal.querySelector('.cancel-btn'); + + confirmBtn?.addEventListener('click', () => { + // Calculer l'index AVANT de supprimer la ligne du DOM + const index = Array.from(table.children).indexOf(row); + const storageKey = STORAGE_KEYS[currentMode]; + const rows = JSON.parse(localStorage.getItem(storageKey) || '[]'); + + // Supprimer du localStorage + if (index > -1) { + rows.splice(index, 1); + localStorage.setItem(storageKey, JSON.stringify(rows)); + } + + // Animation et suppression du DOM + row.style.transition = 'opacity 0.3s, transform 0.3s'; + row.style.opacity = '0'; + row.style.transform = 'translateX(-100%)'; + + setTimeout(() => { + row.remove(); + }, 300); + + modal.remove(); + }); + + cancelBtn?.addEventListener('click', () => { + modal.remove(); + }); +} + +private editDeviceName(cell: HTMLTableCellElement): void { + if (cell.classList.contains('editing')) return; + + const currentValue = cell.textContent || ''; + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentValue; + input.className = 'edit-input'; + + input.addEventListener('blur', () => this.finishEditing(cell, input)); + input.addEventListener('keypress', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + this.finishEditing(cell, input); + } + }); + + cell.textContent = ''; + cell.appendChild(input); + cell.classList.add('editing'); + input.focus(); +} + +private async finishEditing(cell: HTMLTableCellElement, input: HTMLInputElement): Promise { + const newValue = input.value.trim(); + if (newValue === '') { + cell.textContent = cell.getAttribute('data-original-value') || ''; + cell.classList.remove('editing'); + return; + } + + try { + const service = await Services.getInstance(); + const pairingProcessId = service.getPairingProcessId(); + const process = await service.getProcess(pairingProcessId); + + // Mettre à jour le nom via le service + if (process) { + await service.updateMemberPublicName(process, newValue); + } + + // Mettre à jour l'interface + cell.textContent = newValue; + cell.classList.remove('editing'); + } catch (error) { + console.error('Failed to update name:', error); + // Restaurer l'ancienne valeur en cas d'erreur + cell.textContent = cell.getAttribute('data-original-value') || ''; + cell.classList.remove('editing'); + } +} + +// Fonction pour gérer le téléchargement de l'avatar +private handleAvatarUpload(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + + if (file) { + const reader = new FileReader(); + reader.onload = (e: ProgressEvent) => { + const result = e.target?.result as string; + const popupAvatar = this.shadowRoot?.getElementById('popup-avatar-img') as HTMLImageElement; + const navAvatar = this.shadowRoot?.querySelector('.nav-wrapper .avatar') as HTMLImageElement; + + if (popupAvatar) popupAvatar.src = result; + if (navAvatar) navAvatar.src = result; + + localStorage.setItem('userAvatar', result); + }; + reader.readAsDataURL(file); + } +} + +private async showProcessCreation(): Promise { + this.hideAllContent(); + const container = this.shadowRoot?.getElementById('process-creation-content'); + if (container) { + getProcessCreation(container); + } +} + +private async showDocumentValidation(): Promise { + this.hideAllContent(); + const container = this.shadowRoot?.getElementById('document-validation-content'); + if (container) { + getDocumentValidation(container); + } +} + +private async showProcess(): Promise { + this.hideAllContent(); + const container = this.shadowRoot?.getElementById('process-content'); + if (container) { + const service = await Services.getInstance(); + const myProcesses = await service.getMyProcesses(); + if (myProcesses && myProcesses.length != 0) { + const myProcessesDataUnfiltered: { name: string, publicData: Record }[] = await Promise.all(myProcesses.map(async processId => { + const process = await service.getProcess(processId); + const lastState = process ? service.getLastCommitedState(process) : null; + if (!lastState) { + return { + name: '', + publicData: {} + }; + } + const description = await service.decryptAttribute(processId, lastState, 'description'); + const name = description ? description : 'N/A'; + const publicData = process ? await service.getPublicData(process) : null; + if (!publicData) { + return { + name: '', + publicData: {} + }; + } + return { + name: name, + publicData: publicData + }; + })); + const myProcessesData = myProcessesDataUnfiltered.filter( + (p) => p.name !== '' && Object.keys(p.publicData).length != 0 + ); + + createProcessTab(container, myProcessesData); + } else { + createProcessTab(container, []); + } + } +} + +private showProcessNotifications(processName: string): void { + const process = mockProcessRows.find(p => p.process === processName); + if (!process) return; + + const modal = document.createElement('div'); + modal.className = 'notifications-modal'; + + let notificationsList = process.notification.messages.map(msg => ` +
+
+ ${msg.read ? + ` + + ` : + ` + + ` + } +
+
+ ${msg.message} + ${msg.date} +
+
+ `).join(''); + + if (process.notification.messages.length === 0) { + notificationsList = '

No notifications

'; + } + + modal.innerHTML = ` +
+

${processName} Notifications

+
+ ${notificationsList} +
+ +
+ `; + + this.shadowRoot?.appendChild(modal); + + // Mettre à jour le compteur de notifications + const countElement = this.shadowRoot?.querySelector(`.notification-count[data-process="${processName}"]`); + if (countElement) { + const notifCount = this.calculateNotifications(process.notification.messages); + countElement.textContent = `${notifCount.unread}/${notifCount.total}`; + } + + const closeButton = modal.querySelector('.close-notifications'); + closeButton?.addEventListener('click', () => { + modal.remove(); + this.showProcess(); // Rafraîchir l'affichage pour mettre à jour les compteurs + }); +} + + +private handleLogout(): void { + localStorage.clear(); + window.location.href = '../login/login.html'; +} + + +// Fonctions de gestion des contrats +private showContractPopup(contractId: string, event?: Event) { + if (event) { + event.preventDefault(); + } + + // Check if the contract exists in mockContracts + const contract = mockContracts[contractId as keyof typeof mockContracts]; + if (!contract) { + console.error('Contract not found:', contractId); + return; + } + + const popup = document.createElement('div'); + popup.className = 'contract-popup-overlay'; + popup.innerHTML = ` +
+ +

${contract.title}

+
+

Date: ${contract.date}

+

Parties: ${contract.parties.join(', ')}

+

Terms:

+
    + ${contract.terms.map(term => `
  • ${term}
  • `).join('')} +
+

Content: ${contract.content}

+
+
+ `; + + this.shadowRoot?.appendChild(popup); + + const closeBtn = popup.querySelector('.close-contract-popup'); + const closePopup = () => popup.remove(); + + closeBtn?.addEventListener('click', closePopup); + popup.addEventListener('click', (e) => { + if (e.target === popup) closePopup(); + }); +} + +// Fonction utilitaire pour cacher tous les contenus +private hideAllContent(): void { + const contents = ['pairing-content', 'wallet-content', 'process-content', 'process-creation-content', 'data-content', 'document-validation-content']; + contents.forEach(id => { + const element = this.shadowRoot?.getElementById(id); + if (element) { + element.style.display = 'none'; + } + }); +} + +// Fonctions d'affichage des sections +private async showPairing(): Promise { + const service = await Services.getInstance(); + const spAddress = await service.getDeviceAddress(); + + isAddingRow = false; + currentRow = null; + currentMode = 'pairing'; + + this.hideAllContent(); + + const headerElement = this.shadowRoot?.getElementById('parameter-header'); + if (headerElement) { + headerElement.textContent = 'Pairing'; + } + + const pairingContent = this.shadowRoot?.getElementById('pairing-content'); + if (pairingContent) { + pairingContent.style.display = 'block'; + pairingContent.innerHTML = ` +
Pairing
+
+ + + + + + + + + + +
Device NameSP EmojisQR CodeActions
+
+ +
+
+ `; + + let rows = JSON.parse(localStorage.getItem(STORAGE_KEYS.pairing) || '[]'); + + const deviceExists = rows.some((row: Row) => row.column1 === spAddress); + + if (!deviceExists && spAddress) { + const emojis = await addressToEmoji(spAddress); + + try { + // Déboguer le processus de pairing + const pairingProcessId = await service.getPairingProcessId(); + console.log('Pairing Process ID:', pairingProcessId); + + const pairingProcess = await service.getProcess(pairingProcessId); + console.log('Pairing Process:', pairingProcess); + + const userName = pairingProcess?.states?.[0]?.public_data?.memberPublicName + || localStorage.getItem('userName') + + console.log('Username found:', userName); + + const newRow = { + column1: spAddress, + column2: userName, + column3: emojis + }; + rows = [newRow, ...rows]; + localStorage.setItem(STORAGE_KEYS.pairing, JSON.stringify(rows)); + } catch (error) { + console.error('Error getting pairing process:', error); + const newRow = { + column1: spAddress, + column2: 'This Device', + column3: emojis + }; + rows = [newRow, ...rows]; + localStorage.setItem(STORAGE_KEYS.pairing, JSON.stringify(rows)); + } + } + + this.updateTableContent(rows); + } +} + +private showWallet(): void { + isAddingRow = false; + currentRow = null; + + currentMode = 'wallet'; + this.hideAllContent(); + + // Mettre à jour le titre + const headerTitle = this.shadowRoot?.getElementById('header-title'); + if (headerTitle) headerTitle.textContent = 'Wallet'; + + const walletContent = this.shadowRoot?.getElementById('wallet-content'); + if (!walletContent) return; + walletContent.style.display = 'block'; + walletContent.innerHTML = ` +
Wallet
+
+ + + + + + + + + +
LabelWalletType
+
+ +
+
+ `; + + const rows = JSON.parse(localStorage.getItem(STORAGE_KEYS.wallet) || '[]'); + this.updateWalletTableContent(rows); +} + + +private updateWalletTableContent(rows: WalletRow[]): void { + const tbody = this.shadowRoot?.querySelector('#wallet-table tbody'); + if (!tbody) return; + + tbody.innerHTML = rows.map(row => ` + + ${row.column1} + ${row.column2} + ${row.column3} + + `).join(''); +} + +private showData(): void { + //console.log("showData called"); + currentMode = 'data'; + this.hideAllContent(); + + const headerTitle = this.shadowRoot?.getElementById('header-title'); + if (headerTitle) headerTitle.textContent = 'Data'; + + const dataContent = this.shadowRoot?.getElementById('data-content'); + if (dataContent) { + dataContent.style.display = 'block'; + dataContent.innerHTML = ` +
Data
+
+ + + + + + + + + + + + +
NameVisibilityRoleDurationLegalContract
+
+ `; + + const rows = mockDataRows || JSON.parse(localStorage.getItem(STORAGE_KEYS.data) || '[]'); + this.updateDataTableContent(rows); + } +} + +// Fonctions de gestion du wallet +private addWalletRow(): void { + if (isAddingRow) return; + isAddingRow = true; + + const table = this.shadowRoot?.getElementById('wallet-table')?.getElementsByTagName('tbody')[0]; + if (!table) return; + + currentRow = table.insertRow(); + const placeholders = ['Label', 'Wallet', 'Type']; + + placeholders.forEach(placeholder => { + const cell = currentRow!.insertCell(); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = placeholder; + input.className = 'edit-input'; + cell.appendChild(input); + }); + + // Remplacer le bouton "Add a line" par les boutons de confirmation/annulation + const buttonContainer = this.shadowRoot?.querySelector('#wallet-content .button-container'); + if (!buttonContainer) return; + + buttonContainer.innerHTML = ` +
+ + +
+ `; + + this.updateActionButtons(); +} + +private confirmWalletRow(): void { + if (!currentRow) return; + + const inputs = Array.from(currentRow.getElementsByTagName('input')); + const allFieldsFilled = inputs.every(input => input.value.trim() !== ''); + + if (allFieldsFilled) { + const newRow: WalletRow = { + column1: inputs[0].value.trim(), + column2: inputs[1].value.trim(), + column3: inputs[2].value.trim() + }; + + const rows = JSON.parse(localStorage.getItem(STORAGE_KEYS.wallet) || '[]'); + rows.push(newRow); + localStorage.setItem(STORAGE_KEYS.wallet, JSON.stringify(rows)); + + isAddingRow = false; + currentRow = null; + this.showWallet(); + } else { + this.showAlert('Please complete all fields before confirming.'); + } +} + +private cancelWalletRow(): void { + if (!currentRow) return; + + currentRow.remove(); + isAddingRow = false; + currentRow = null; + + // Réinitialiser le conteneur de boutons avec le bouton "Add a line" + const buttonContainer = this.shadowRoot?.querySelector('#wallet-content .button-container'); + if (!buttonContainer) return; + + buttonContainer.innerHTML = ` + + `; + + +} + +private updateDataTableContent(rows: DataRow[]): void { + const tbody = this.shadowRoot?.querySelector('#data-table tbody'); + if (!tbody) return; + + tbody.innerHTML = rows.map(row => ` + + ${row.column1} + ${row.column2} + ${row.column3} + ${row.column4} + ${row.column5} + + ${row.column6} + + + `).join(''); +} + +// Fonctions de gestion de l'avatar et de la bannière +private openAvatarPopup(): void { + const popup = this.shadowRoot?.getElementById('avatar-popup'); + if (!popup) return; + + // Récuprer les valeurs stockées + const savedName = localStorage.getItem('userName'); + const savedLastName = localStorage.getItem('userLastName'); + const savedAvatar = localStorage.getItem('userAvatar') || 'https://via.placeholder.com/150'; + const savedBanner = localStorage.getItem('userBanner') || 'https://via.placeholder.com/800x200'; + const savedAddress = localStorage.getItem('userAddress') || '🏠 🌍 🗽🎊😩-🎊😑😩'; + + popup.innerHTML = ` + + `; + + popup.style.display = 'block'; + this.setupEventListeners(popup); + + // Ajouter le gestionnaire d'événements pour la bannière + const bannerImg = popup.querySelector('#popup-banner-img'); + const bannerInput = popup.querySelector('#banner-upload') as HTMLInputElement; + + if (bannerImg && bannerInput) { + bannerImg.addEventListener('click', () => { + bannerInput.click(); + }); + } + + const recoveryExported = localStorage.getItem('recoveryExported') === 'true'; + if (recoveryExported) { + const exportRecoveryBtn = popup.querySelector('.recovery-btn') as HTMLButtonElement; + if (exportRecoveryBtn) { + exportRecoveryBtn.disabled = true; + exportRecoveryBtn.style.opacity = '0.5'; + exportRecoveryBtn.style.cursor = 'not-allowed'; + } + } +} + +private setupEventListeners(popup: HTMLElement): void { + // Gestionnaire pour la fermeture + const closeBtn = popup.querySelector('.close-popup'); + if (closeBtn) { + closeBtn.addEventListener('click', () => { + popup.style.display = 'none'; + }); + } + + // Gestionnaire pour l'upload d'avatar + const avatarUpload = popup.querySelector('#avatar-upload') as HTMLInputElement; + if (avatarUpload) { + avatarUpload.addEventListener('change', (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e: ProgressEvent) => { + const result = e.target?.result as string; + // Mise à jour de l'avatar dans la preview et le popup + const popupAvatar = this.shadowRoot?.getElementById('popup-avatar-img') as HTMLImageElement; + const previewAvatar = this.shadowRoot?.querySelector('.preview-avatar') as HTMLImageElement; + + if (popupAvatar) popupAvatar.src = result; + if (previewAvatar) previewAvatar.src = result; + + localStorage.setItem('userAvatar', result); + }; + reader.readAsDataURL(file); + } + }); + } + + // Gestionnaire pour l'upload de bannière + const bannerUpload = popup.querySelector('#banner-upload') as HTMLInputElement; + if (bannerUpload) { + bannerUpload.addEventListener('change', (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e: ProgressEvent) => { + const result = e.target?.result as string; + // Mise à jour de la bannière dans la preview et le popup + const popupBanner = this.shadowRoot?.getElementById('popup-banner-img') as HTMLImageElement; + const previewBanner = this.shadowRoot?.querySelector('.preview-banner-img') as HTMLImageElement; + + if (popupBanner) popupBanner.src = result; + if (previewBanner) previewBanner.src = result; + + localStorage.setItem('userBanner', result); + }; + reader.readAsDataURL(file); + } + }); + } + + // Gestionnaires pour les champs de texte + const nameInput = popup.querySelector('#userName') as HTMLInputElement; + const lastNameInput = popup.querySelector('#userLastName') as HTMLInputElement; + + if (nameInput) { + nameInput.addEventListener('input', () => { + const newName = nameInput.value; + localStorage.setItem('userName', newName); + // Mise à jour du nom dans la preview + const previewName = this.shadowRoot?.querySelector('.preview-name'); + if (previewName) previewName.textContent = newName; + }); + } + + if (lastNameInput) { + lastNameInput.addEventListener('input', () => { + const newLastName = lastNameInput.value; + localStorage.setItem('userLastName', newLastName); + // Mise à jour du nom de famille dans la preview + const previewLastName = this.shadowRoot?.querySelector('.preview-lastname'); + if (previewLastName) previewLastName.textContent = newLastName; + }); + } +} + +private closeAvatarPopup(): void { + const popup = this.shadowRoot?.querySelector('.avatar-popup'); + if (popup) popup.remove(); +} + +private loadAvatar(): void { + const savedAvatar = localStorage.getItem('userAvatar'); + if (savedAvatar) { + const avatarImg = this.shadowRoot?.querySelector('.avatar') as HTMLImageElement; + if (avatarImg) { + avatarImg.src = savedAvatar; + } + } +} + +private loadUserInfo(): void { + const savedName = localStorage.getItem('userName'); + const savedLastName = localStorage.getItem('userLastName'); + const savedAvatar = localStorage.getItem('userAvatar'); + const savedBanner = localStorage.getItem('userBanner'); + + // Mise à jour du nom dans la preview + if (savedName) { + const previewName = this.shadowRoot?.querySelector('.preview-name'); + if (previewName) { + previewName.textContent = savedName; + } + } + + // Mise à jour du nom de famille dans la preview + if (savedLastName) { + const previewLastName = this.shadowRoot?.querySelector('.preview-lastname'); + if (previewLastName) { + previewLastName.textContent = savedLastName; + } + } + + // Mise à jour de l'avatar dans la preview + if (savedAvatar) { + const previewAvatar = this.shadowRoot?.querySelector('.preview-avatar') as HTMLImageElement; + if (previewAvatar) { + previewAvatar.src = savedAvatar; + } + } + + // Mise à jour de la bannière dans la preview + if (savedBanner) { + const previewBanner = this.shadowRoot?.querySelector('.preview-banner-img') as HTMLImageElement; + if (previewBanner) { + previewBanner.src = savedBanner; + } + } +} + +private updateNavbarName(name: string): void { + const nameElement = this.shadowRoot?.querySelector('.nav-wrapper .user-name'); + if (nameElement) { + nameElement.textContent = name; + } +} + +private updateNavbarLastName(lastName: string): void { + const lastNameElement = this.shadowRoot?.querySelector('.nav-wrapper .user-lastname'); + if (lastNameElement) { + lastNameElement.textContent = lastName; + } +} + +private updateProfilePreview(data: { + avatar?: string, + banner?: string, + name?: string, + lastName?: string +}): void { + if (data.avatar) { + const previewAvatar = this.shadowRoot?.querySelector('.preview-avatar') as HTMLImageElement; + if (previewAvatar) previewAvatar.src = data.avatar; + } + + if (data.banner) { + const previewBanner = this.shadowRoot?.querySelector('.preview-banner-img') as HTMLImageElement; + if (previewBanner) previewBanner.src = data.banner; + } + + if (data.name) { + const previewName = this.shadowRoot?.querySelector('.preview-name'); + if (previewName) previewName.textContent = data.name; + } + + if (data.lastName) { + const previewLastName = this.shadowRoot?.querySelector('.preview-lastname'); + if (previewLastName) previewLastName.textContent = data.lastName; + } +} + +private initializeEventListeners() { + this.shadowRoot?.addEventListener('DOMContentLoaded', () => { + this.showPairing(); + }); + + const editableFields = this.shadowRoot?.querySelectorAll('.editable'); + if (editableFields) { + editableFields.forEach(field => { + field.addEventListener('click', () => { + if (!field.classList.contains('editing')) { + const currentValue = field.textContent || ''; + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentValue; + input.className = 'edit-input'; + + field.textContent = ''; + field.appendChild(input); + field.classList.add('editing'); + input.focus(); + } + }); + }); + } + + const avatarInput = this.shadowRoot?.getElementById('avatar-upload') as HTMLInputElement; + if (avatarInput) { + avatarInput.addEventListener('change', this.handleAvatarUpload.bind(this)); + } +} + +private showQRCodeModal(pairingId: string): void { + const modal = document.createElement('div'); + modal.className = 'qr-modal'; + modal.innerHTML = ` +
+ × + QR Code Large +
${decodeURIComponent(pairingId)}
+
+ `; + + this.shadowRoot?.appendChild(modal); + + const closeBtn = modal.querySelector('.close-qr-modal'); + closeBtn?.addEventListener('click', () => modal.remove()); + modal.addEventListener('click', (e) => { + if (e.target === modal) modal.remove(); + }); +} +} + +customElements.define('account-element', AccountElement); +export { AccountElement }; diff --git a/ihm_client/src/pages/account/document-validation.ts b/ihm_client/src/pages/account/document-validation.ts new file mode 100644 index 00000000..af45c8db --- /dev/null +++ b/ihm_client/src/pages/account/document-validation.ts @@ -0,0 +1,321 @@ +import type { ProcessState } from '../../../pkg/sdk_client'; +import Services from '../../services/service'; + +interface State { + file: File | null; + fileHash: string | null; + certificate: ProcessState | null; + commitmentHashes: string[]; +} + +export interface Vin { + txid: string; // The txid of the previous transaction (being spent) + vout: number; // The output index in the previous tx + prevout: { + scriptpubkey: string; + scriptpubkey_asm: string; + scriptpubkey_type: string; + scriptpubkey_address: string; + value: number; + }; + scriptsig: string; + scriptsig_asm: string; + witness: string[]; + is_coinbase: boolean; + sequence: number; +} + +export interface TransactionInfo { + txid: string; + version: number; + locktime: number; + vin: Vin[]; + vout: any[]; + size: number; + weight: number; + fee: number; + status: { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; + }; +} + +export function getDocumentValidation(container: HTMLElement) { + const state: State = { + file: null, + fileHash: null, + certificate: null, + commitmentHashes: [] + } + + container.innerHTML = ''; + container.style.cssText = ` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 100vh; + gap: 2rem; + `; + + function createDropButton( + label: string, + onDrop: (file: File, updateVisuals: (file: File) => void) => void, + accept: string = '*/*' + ): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.cssText = ` + width: 200px; + height: 100px; + border: 2px dashed #888; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + font-weight: bold; + background: #f8f8f8; + text-align: center; + padding: 0.5rem; + box-sizing: border-box; + `; + + const title = document.createElement('div'); + title.textContent = label; + + const filename = document.createElement('div'); + filename.style.cssText = ` + font-size: 0.85rem; + margin-top: 0.5rem; + color: #444; + word-break: break-word; + text-align: center; + `; + + wrapper.appendChild(title); + wrapper.appendChild(filename); + + const updateVisuals = (file: File) => { + wrapper.style.borderColor = 'green'; + wrapper.style.background = '#e6ffed'; + filename.textContent = file.name; + }; + + // === Hidden file input === + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = accept; + fileInput.style.display = 'none'; + document.body.appendChild(fileInput); + + fileInput.onchange = () => { + const file = fileInput.files?.[0]; + if (file) { + onDrop(file, updateVisuals); + fileInput.value = ''; // reset so same file can be re-selected + } + }; + + // === Handle drag-and-drop === + wrapper.ondragover = e => { + e.preventDefault(); + wrapper.style.background = '#e0e0e0'; + }; + + wrapper.ondragleave = () => { + wrapper.style.background = '#f8f8f8'; + }; + + wrapper.ondrop = e => { + e.preventDefault(); + wrapper.style.background = '#f8f8f8'; + + const file = e.dataTransfer?.files?.[0]; + if (file) { + onDrop(file, updateVisuals); + } + }; + + // === Handle click to open file manager === + wrapper.onclick = () => { + fileInput.click(); + }; + + return wrapper; + } + + const fileDropButton = createDropButton('Drop file', async (file, updateVisuals) => { + try { + state.file = file; + updateVisuals(file); + console.log('Loaded file:', state.file); + checkReady(); + } catch (err) { + alert('Failed to drop the file.'); + console.error(err); + } + }); + + const certDropButton = createDropButton('Drop certificate', async (file, updateVisuals) => { + try { + const text = await file.text(); + const json = JSON.parse(text); + if ( + typeof json === 'object' && + json !== null && + typeof json.pcd_commitment === 'object' && + typeof json.state_id === 'string' + ) { + state.certificate = json as ProcessState; + + state.commitmentHashes = Object.values(json.pcd_commitment).map((h: any) => + (h as string).toLowerCase() + ); + + updateVisuals(file); + console.log('Loaded certificate, extracted hashes:', state.commitmentHashes); + checkReady(); + } else { + alert('Invalid certificate structure.'); + } + } catch (err) { + alert('Failed to parse certificate JSON.'); + console.error(err); + } + }); + + const buttonRow = document.createElement('div'); + buttonRow.style.display = 'flex'; + buttonRow.style.gap = '2rem'; + buttonRow.appendChild(fileDropButton); + buttonRow.appendChild(certDropButton); + + container.appendChild(buttonRow); + + async function checkReady() { + if (state.file && state.certificate && state.commitmentHashes.length > 0) { + // We take the commited_in and all pcd_commitment keys to reconstruct all the possible hash + const fileBlob = { + type: state.file.type, + data: new Uint8Array(await state.file.arrayBuffer()) + }; + const service = await Services.getInstance(); + const commitedIn = state.certificate.commited_in; + if (!commitedIn) return; + const [prevTxid, prevTxVout] = commitedIn.split(':'); + const processId = state.certificate.commited_in; + const stateId = state.certificate.state_id; + const process = await service.getProcess(processId); + if (!process) return; + + // Get the transaction that comes right after the commited_in + const nextState = service.getNextStateAfterId(process, stateId); + + if (!nextState) { + alert(`❌ Validation failed: No next state, is the state you're trying to validate commited?`); + return; + } + + const [outspentTxId, _] = nextState.commited_in.split(':'); + console.log(outspentTxId); + + // Check that the commitment transaction exists, and that it commits to the state id + + const txInfo = await fetchTransaction(outspentTxId); + if (!txInfo) { + console.error(`Validation error: Can't fetch new state commitment transaction`); + alert(`❌ Validation failed: invalid or non existent commited_in for state ${stateId}.`); + return; + } + + // We must check that this transaction indeed spend the commited_in we have in the certificate + let found = false; + for (const vin of txInfo.vin) { + if (vin.txid === prevTxid) { + found = true; + break; + } + } + + if (!found) { + console.error(`Validation error: new state doesn't spend previous state commitment transaction`); + alert('❌ Validation failed: Unconsistent commitment transactions history.'); + return; + } + + // set found back to false for next check + found = false; + + // is the state_id commited in the transaction? + for (const vout of txInfo.vout) { + console.log(vout); + if (vout.scriptpubkey_type && vout.scriptpubkey_type === 'op_return') { + found = true; + } else { + continue; + } + + if (vout.scriptpubkey_asm) { + const hash = extractHexFromScriptAsm(vout.scriptpubkey_asm); + if (hash) { + if (hash !== stateId) { + console.error(`Validation error: expected stateId ${stateId}, got ${hash}`); + alert('❌ Validation failed: Transaction does not commit to that state.'); + return; + } + } + } + } + + if (!found) { + alert('❌ Validation failed: Transaction does not contain data.'); + return; + } + + // set found back to false for next check + found = false; + + for (const label of Object.keys(state.certificate.pcd_commitment)) { + // Compute the hash for this label + console.log(`Computing hash with label ${label}`) + const fileHex = service.getHashForFile(commitedIn, label, fileBlob); + console.log(`Found hash ${fileHex}`); + found = state.commitmentHashes.includes(fileHex); + if (found) break; + } + + if (found) { + alert('✅ Validation successful: file hash found in pcd_commitment.'); + } else { + alert('❌ Validation failed: file hash NOT found in pcd_commitment.'); + } + } + } + + async function fetchTransaction(txid: string): Promise { + const url = `https://mempool.4nkweb.com/api/tx/${txid}`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch outspend status: ${response.statusText}`); + } + + const outspend: TransactionInfo = await response.json(); + return outspend; + } + + function extractHexFromScriptAsm(scriptAsm: string): string | null { + const parts = scriptAsm.trim().split(/\s+/); + const last = parts[parts.length - 1]; + + // Basic validation: must be 64-char hex (32 bytes) + if (/^[0-9a-fA-F]{64}$/.test(last)) { + return last.toLowerCase(); + } + + return null; + } +} diff --git a/ihm_client/src/pages/account/key-value-section.ts b/ihm_client/src/pages/account/key-value-section.ts new file mode 100644 index 00000000..976c9740 --- /dev/null +++ b/ihm_client/src/pages/account/key-value-section.ts @@ -0,0 +1,196 @@ +import type { ValidationRule, RoleDefinition } from '../../../pkg/sdk_client'; +import { showValidationRuleModal } from '../../components/validation-rule-modal/validation-rule-modal'; + +export function createKeyValueSection(title: string, id: string, isRoleSection = false) { + const section = document.createElement('div'); + section.id = id; + section.style.cssText = 'margin-bottom: 2rem; background: #fff; padding: 1rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1);'; + + const titleEl = document.createElement('h2'); + titleEl.textContent = title; + titleEl.style.cssText = 'font-size: 1.25rem; font-weight: bold; margin-bottom: 1rem;'; + section.appendChild(titleEl); + + const rowContainer = document.createElement('div'); + section.appendChild(rowContainer); + + const addBtn = document.createElement('button'); + addBtn.textContent = '+ Add Row'; + addBtn.style.cssText = ` + margin-top: 1rem; + padding: 0.5rem 1rem; + border: 1px solid #888; + border-radius: 0.375rem; + background-color: #f9f9f9; + cursor: pointer; + `; + section.appendChild(addBtn); + + const roleRowStates: { + roleNameInput: HTMLInputElement; + membersInput: HTMLInputElement; + storagesInput: HTMLInputElement; + validationRules: ValidationRule[]; + }[] = []; + type fileBlob = { + type: string, + data: Uint8Array + }; + const nonRoleRowStates: { + keyInput: HTMLInputElement, + valueInput: HTMLInputElement, + fileInput: HTMLInputElement, + fileBlob: fileBlob | null + }[] = []; + + const inputStyle = 'flex: 1; height: 2.5rem; padding: 0.5rem; border: 1px solid #ccc; border-radius: 0.375rem;'; + + const createRow = () => { + const row = document.createElement('div'); + row.style.cssText = 'display: flex; gap: 1rem; margin-bottom: 0.5rem; align-items: center;'; + + const deleteBtn = document.createElement('button'); + deleteBtn.textContent = '🗑️'; + deleteBtn.style.cssText = 'background: none; border: none; font-size: 1.2rem; cursor: pointer;'; + deleteBtn.onclick = () => { + row.remove(); + updateDeleteButtons(); + }; + + if (isRoleSection) { + const roleName = document.createElement('input'); + const members = document.createElement('input'); + const storages = document.createElement('input'); + + roleName.placeholder = 'Role name'; + members.placeholder = 'members'; + storages.placeholder = 'storages'; + [roleName, members, storages].forEach(input => { + input.type = 'text'; + input.style.cssText = inputStyle; + }); + + const ruleButton = document.createElement('button'); + ruleButton.textContent = 'Add Validation Rule'; + ruleButton.style.cssText = 'padding: 0.3rem 0.75rem; border: 1px solid #ccc; border-radius: 0.375rem; background: #f0f0f0; cursor: pointer;'; + + const rules: ValidationRule[] = []; + ruleButton.onclick = () => { + showValidationRuleModal(rule => { + rules.push(rule); + ruleButton.textContent = `Rules (${rules.length})`; + }); + }; + + row.appendChild(roleName); + row.appendChild(members); + row.appendChild(storages); + row.appendChild(ruleButton); + row.appendChild(deleteBtn); + + roleRowStates.push({ roleNameInput: roleName, membersInput: members, storagesInput: storages, validationRules: rules }); + } else { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.style.display = 'none'; + fileInput.onchange = async () => { + const file = fileInput.files?.[0]; + if (!file) return; + + const buffer = await file.arrayBuffer(); + const uint8 = new Uint8Array(buffer); + + rowState.fileBlob = { + type: file.type, + data: uint8, + }; + + valueInput.value = `📄 ${file.name}`; + valueInput.disabled = true; + attachBtn.textContent = `📎 ${file.name}`; + }; + + const attachBtn = document.createElement('button'); + attachBtn.textContent = '📎 Attach'; + attachBtn.style.cssText = 'padding: 0.3rem 0.75rem; border: 1px solid #ccc; border-radius: 0.375rem; background: #f0f0f0; cursor: pointer;'; + attachBtn.onclick = () => fileInput.click(); + + const keyInput = document.createElement('input'); + const valueInput = document.createElement('input'); + + const rowState = { + keyInput, + valueInput, + fileInput, + fileBlob: null as fileBlob | null + }; + nonRoleRowStates.push(rowState); + + keyInput.placeholder = 'Key'; + valueInput.placeholder = 'Value'; + [keyInput, valueInput].forEach(input => { + input.type = 'text'; + input.style.cssText = inputStyle; + }); + + row.appendChild(keyInput); + row.appendChild(valueInput); + + row.appendChild(attachBtn); + row.appendChild(fileInput); + + row.appendChild(deleteBtn); + } + + rowContainer.appendChild(row); + updateDeleteButtons(); + }; + + const updateDeleteButtons = () => { + const rows = Array.from(rowContainer.children); + rows.forEach(row => { + const btn = row.querySelector('button:last-child') as HTMLButtonElement; + if (rows.length === 1) { + btn.disabled = true; + btn.style.visibility = 'hidden'; + } else { + btn.disabled = false; + btn.style.visibility = 'visible'; + } + }); + }; + + createRow(); + addBtn.addEventListener('click', createRow); + + return { + element: section, + getData: () => { + if (isRoleSection) { + const data: Record = {}; + for (const row of roleRowStates) { + const key = row.roleNameInput.value.trim(); + if (!key) continue; + data[key] = { + members: row.membersInput.value.split(',').map(x => x.trim()).filter(Boolean), + storages: row.storagesInput.value.split(',').map(x => x.trim()).filter(Boolean), + validation_rules: row.validationRules + }; + } + return data; + } else { + const data: Record = {}; + for (const row of nonRoleRowStates) { + const key = row.keyInput.value.trim(); + if (!key) continue; + if (row.fileBlob) { + data[key] = row.fileBlob; + } else { + data[key] = row.valueInput.value.trim(); + } + } + return data; + } + } + }; +} diff --git a/ihm_client/src/pages/account/process-creation.ts b/ihm_client/src/pages/account/process-creation.ts new file mode 100644 index 00000000..5fb0d7c2 --- /dev/null +++ b/ihm_client/src/pages/account/process-creation.ts @@ -0,0 +1,93 @@ +import { createKeyValueSection } from './key-value-section'; +import { loadValidationRuleModal } from '../../components/validation-rule-modal/validation-rule-modal'; +import Services from '../../services/service'; +import type { RoleDefinition } from '../../../pkg/sdk_client'; + +export async function getProcessCreation(container: HTMLElement) { + await loadValidationRuleModal(); + + container.style.display = 'block'; + container.innerHTML = `
Process Creation
`; + const privateSec = createKeyValueSection('Private Data', 'private-section'); + const publicSec = createKeyValueSection('Public Data', 'public-section'); + const rolesSec = createKeyValueSection('Roles', 'roles-section', true); + + container.appendChild(privateSec.element); + container.appendChild(publicSec.element); + container.appendChild(rolesSec.element); + + const btn = document.createElement('button'); + btn.textContent = 'Create Process'; + btn.style.cssText = ` + display: block; + margin: 2rem auto 0; + padding: 0.75rem 2rem; + font-size: 1rem; + font-weight: bold; + background-color: #4f46e5; + color: white; + border: none; + border-radius: 0.5rem; + cursor: pointer; + `; + + btn.onclick = async () => { + const privateData = privateSec.getData(); + const publicData = publicSec.getData(); + const roles = rolesSec.getData() as Record; + + console.log('Private:', privateData); + console.log('Public:', publicData); + console.log('Roles:', roles); + + const service = await Services.getInstance(); + + const createProcessResult = await service.createProcess(privateData, publicData, roles); + const processId = createProcessResult.updated_process!.process_id; + const stateId = createProcessResult.updated_process!.current_process.states[0].state_id; + await service.handleApiReturn(createProcessResult); + + // Now we want to validate the update and register the first state of our new process + const updateProcessResult = await service.createPrdUpdate(processId, stateId); + await service.handleApiReturn(createProcessResult); + + const approveChangeResult = await service.approveChange(processId, stateId); + await service.handleApiReturn(approveChangeResult); + if (approveChangeResult) { + const process = await service.getProcess(processId); + let newState = process ? service.getStateFromId(process, stateId) : null; + if (!newState) return; + for (const label of Object.keys(newState.keys)) { + const hash = newState.pcd_commitment[label]; + const encryptedData = await service.getBlobFromDb(hash); + if (!encryptedData) continue; + const filename = `${label}-${hash.slice(0,8)}.bin`; + + const blob = new Blob([encryptedData], { type: "application/octet-stream" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + + setTimeout(() => URL.revokeObjectURL(link.href), 1000); + } + + // await service.generateProcessPdf(processId, newState); + + // Add processId to the state we export + // newState n'a pas de propriété process_id, on utilise commited_in + // newState['process_id'] = processId; + const blob = new Blob([JSON.stringify(newState, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `process_${processId}_${stateId}.json`; + a.click(); + + URL.revokeObjectURL(url); // Clean up + } + }; + + container.appendChild(btn); +} diff --git a/ihm_client/src/pages/account/process.ts b/ihm_client/src/pages/account/process.ts new file mode 100644 index 00000000..10eaeb6a --- /dev/null +++ b/ihm_client/src/pages/account/process.ts @@ -0,0 +1,66 @@ +export function createProcessTab(container: HTMLElement, processes: { name: string, publicData: Record }[]): HTMLElement { + container.id = 'process-tab'; + container.style.display = 'block'; + container.style.cssText = 'padding: 1.5rem;'; + + const title = document.createElement('h2'); + title.textContent = 'Processes'; + title.style.cssText = 'font-size: 1.5rem; font-weight: bold; margin-bottom: 1rem;'; + container.appendChild(title); + + processes.forEach(proc => { + const card = document.createElement('div'); + card.style.cssText = 'margin-bottom: 1rem; padding: 1rem; border: 1px solid #ddd; border-radius: 0.5rem; background: #fff;'; + + const nameEl = document.createElement('h3'); + nameEl.textContent = proc.name; + nameEl.style.cssText = 'font-size: 1.2rem; font-weight: bold; margin-bottom: 0.5rem;'; + card.appendChild(nameEl); + + const dataList = document.createElement('div'); + for (const [key, value] of Object.entries(proc.publicData)) { + const item = document.createElement('div'); + item.style.cssText = 'margin-bottom: 0.5rem;'; + + const label = document.createElement('strong'); + label.textContent = key + ': '; + item.appendChild(label); + + // Let's trim the quotes + const trimmed = value.replace(/^'|'$/g, ''); + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (_) { + parsed = trimmed; + } + + if (parsed && typeof parsed === 'object') { + const saveBtn = document.createElement('button'); + saveBtn.textContent = '💾 Save as JSON'; + saveBtn.style.cssText = 'margin-left: 0.5rem; padding: 0.25rem 0.5rem; border: 1px solid #ccc; border-radius: 0.375rem; background: #f0f0f0; cursor: pointer;'; + saveBtn.onclick = () => { + const blob = new Blob([JSON.stringify(parsed, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${proc.name}_${key}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + item.appendChild(saveBtn); + } else { + const span = document.createElement('span'); + span.textContent = String(parsed); + item.appendChild(span); + } + + dataList.appendChild(item); + } + + card.appendChild(dataList); + container.appendChild(card); + }); + + return container; +} diff --git a/ihm_client/src/pages/chat/chat-component.ts b/ihm_client/src/pages/chat/chat-component.ts new file mode 100644 index 00000000..2ded0b8f --- /dev/null +++ b/ihm_client/src/pages/chat/chat-component.ts @@ -0,0 +1,49 @@ +/*import { ChatElement } from './chat'; +import chatCss from '../../../public/style/chat.css?raw'; +import Services from '../../services/service.js'; + +class ChatComponent extends HTMLElement { + _callback: any; + chatElement: ChatElement | null = null; + + constructor() { + super(); + console.log('INIT'); + this.attachShadow({ mode: 'open' }); + + this.chatElement = this.shadowRoot?.querySelector('chat-element') || null; + } + + connectedCallback() { + console.log('CALLBACKs'); + this.render(); + + if (!customElements.get('chat-element')) { + customElements.define('chat-element', ChatElement); + } + } + + set callback(fn) { + if (typeof fn === 'function') { + this._callback = fn; + } else { + console.error('Callback is not a function'); + } + } + + get callback() { + return this._callback; + } + + render() { + if (this.shadowRoot) { + // Créer l'élément chat-element + const chatElement = document.createElement('chat-element'); + this.shadowRoot.innerHTML = ``; + this.shadowRoot.appendChild(chatElement); + } + } +} + +export { ChatComponent }; +customElements.define('chat-component', ChatComponent);*/ diff --git a/ihm_client/src/pages/chat/chat.html b/ihm_client/src/pages/chat/chat.html new file mode 100755 index 00000000..5265c880 --- /dev/null +++ b/ihm_client/src/pages/chat/chat.html @@ -0,0 +1,14 @@ + +
+
    +
+
+ + +
+
+ +
+
+ +
+ + +
+ + + + +
+
+ + +
+
+

Signature

+ +
+
+
+

Description

+
+
+
+

Documents

+ +
+
+
    +
    +
    +
    +
    + + `; + window.loadMemberChat = async (memberId: string | number) => { + if (typeof memberId === 'string') { + return await this.loadMemberChat(memberId); + } else { + console.error('Invalid memberId type. Expected string, got number.'); + } + }; + + document.addEventListener('newDataReceived', async (event: CustomEvent) => { + const { detail } = event; + console.log('New data event received:', JSON.stringify(detail)); + + if (detail.processId && detail.processId === this.selectedChatProcessId) { + console.log('Detected update to chat'); + if (this.selectedMember) { + await this.loadMemberChat(this.selectedMember); + } else { + console.error('No selected member?'); + } + } else { + console.log('Received an update for another process'); + } + }); + + + document.addEventListener('DOMContentLoaded', () => { + this.notificationBadge = document.querySelector('.notification-badge'); + this.notificationBoard = document.getElementById('notification-board'); + this.notificationBell = document.getElementById('notification-bell'); + + if (!this.notificationBadge || !this.notificationBoard || !this.notificationBell) { + console.error('Notification elements not found'); + } + }); + + // Initialiser les événements de notification + document.addEventListener('click', (event: Event): void => { + if (this.notificationBoard && this.notificationBoard.style.display === 'block' && + !this.notificationBoard.contains(event.target as Node) && + this.notificationBell && !this.notificationBell.contains(event.target as Node)) { + this.notificationBoard.style.display = 'none'; + } + }); + this.initMessageEvents(); + + } + + private initMessageEvents() { + const sendButton = this.shadowRoot?.querySelector('#send-button'); + if (sendButton) { + sendButton.addEventListener('click', async () => { + await this.sendMessage(); + setTimeout(async () => await this.reloadMemberChat(this.selectedMember), 600); + messageInput.value = ''; + }); + } + + const messageInput = this.shadowRoot?.querySelector('#message-input'); + if (messageInput) { + messageInput.addEventListener('keypress', async (event: Event) => { + const keyEvent = event as KeyboardEvent; + if (keyEvent.key === 'Enter' && !keyEvent.shiftKey) { + event.preventDefault(); + await this.sendMessage(); + setTimeout(async () => await this.reloadMemberChat(this.selectedMember), 600); + messageInput.value = ''; + } + }); + } + + const fileInput = this.shadowRoot?.querySelector('#file-input') as HTMLInputElement; + if (fileInput) { + fileInput.addEventListener('change', (event: Event) => { + const target = event.target as HTMLInputElement; + if (target.files && target.files.length > 0) { + this.sendFile(target.files[0]); + } + }); + } + } + + ///////////////////// Notification module ///////////////////// + // Delete a notification + private removeNotification(index: number) { + this.notifications?.splice(index, 1); // Ajout de ?. + this.renderNotifications(); + this.updateNotificationBadge(); + } + // Show notifications + private renderNotifications() { + if (!this.notificationBoard) return; + + // Reset the interface + this.notificationBoard.innerHTML = ''; + + // Displays "No notifications available" if there are no notifications + if (this.notifications.length === 0) { + this.notificationBoard.innerHTML = '
    No notifications available
    '; + return; + } + + // Add each notification to the list + this.notifications.forEach((notif, index) => { + const notifElement = document.createElement('div'); + notifElement.className = 'notification-item'; + notifElement.textContent = `${notif.text} at ${notif.time}`; + notifElement.onclick = async () => { + await this.loadMemberChat(notif.memberId); + await this.removeNotification(index); + }; + this.notificationBoard?.appendChild(notifElement); + }); + } + private updateNotificationBadge() { + if (!this.notificationBadge) return; + const count = this.notifications.length; + this.notificationBadge.textContent = count > 99 ? '+99' : count.toString(); + (this.notificationBadge as HTMLElement).style.display = count > 0 ? 'block' : 'none'; + } + + + // Add notification + private async addNotification(memberId: string, message: any) { + try { + // Obtenir l'emoji à partir du Pairing Process + const pairingProcess = await this.getPairingProcess(memberId); + const memberEmoji = await addressToEmoji(pairingProcess); + + // Obtenir le processus et le rôle + const processId = this.getAttribute('process-id'); + const processEmoji = processId ? await addressToEmoji(processId) : '📝'; + + // Trouver le rôle du membre + const member = this.allMembers.find(m => String(m.id) === memberId); + const role = message.metadata?.roleName || 'Member'; + + // Déterminer le texte de la notification + let notificationText = ''; + if (message.type === 'file') { + notificationText = `${memberEmoji} (${role}) in ${processEmoji}: New file - ${message.fileName}`; + } else { + notificationText = `${memberEmoji} (${role}) in ${processEmoji}: ${message.metadata.text}`; + } + + // Créer la notification + const notification = { + memberId, + text: notificationText, + time: new Date(message.metadata.timestamp).toLocaleString('fr-FR') + }; + + // Ajouter la notification et mettre à jour l'interface + this.notifications.push(notification); + this.renderNotifications(); + this.updateNotificationBadge(); + + } catch (error) { + console.error('Error creating notification:', error); + } + } + + private async sendMessage() { + const messageInput = this.shadowRoot?.querySelector('#message-input') as HTMLInputElement; + if (!messageInput || !this.selectedMember) { + console.error('❌ Missing message input or selected member'); + return; + } + + if (!this.selectedChatProcessId) { + console.error('no process id set'); + return; + } + + const messageText = messageInput.value.trim(); + if (messageText === '') { + console.error('❌ Empty message'); + return; + } + + try { + const service = await Services.getInstance(); + const myProcessId = await this.getMyProcessId(); + + if (!myProcessId) { + console.error('No paired member found'); + return; + } + + const timestamp = Date.now(); + const message = { + state: this.messageState, + type: 'text', + content: messageText, + metadata: { + createdAt: timestamp, + lastModified: timestamp, + sender: myProcessId, + recipient: this.selectedMember, + } + }; + + console.log("----this.selectedChatProcessId",this.selectedChatProcessId ); + const process = await service.getProcess(this.selectedChatProcessId); + + if (!process) { + console.error('Failed to retrieve process from DB'); + return; + } + + // For a dm process, there are only 2 attributes, description will stay the same, message is the new message + // We don't need to get previous values for now, so let's just skip it + let newState = { + message: message, + description: 'dm' + }; + + // Now we create a new state for the dm process + let apiReturn; + try { + console.log(process); + apiReturn = await service.updateProcess(process, newState, null); + } catch (e) { + console.error('Failed to update process:', e); + return; + } + const updatedProcess = apiReturn.updated_process.current_process; + const newStateId = updatedProcess.states[updatedProcess.states.length - 2 ].state_id; // We take the last concurrent state, just before the tip + console.log(`newStateId: ${newStateId}`); + await service.handleApiReturn(apiReturn); + + const createPrdReturn = service.createPrdUpdate(this.selectedChatProcessId, newStateId); + await service.handleApiReturn(createPrdReturn); + + // Now we validate the new state + const approveChangeReturn = await service.approveChange(this.selectedChatProcessId, newStateId); + await service.handleApiReturn(approveChangeReturn); + + await this.lookForMyDms(); + + const groupList = this.shadowRoot?.querySelector('#group-list'); + const tabs = this.shadowRoot?.querySelectorAll('.tab'); + const memberList = groupList?.querySelector('.member-list'); + + if (memberList) { + memberList.innerHTML = ''; + await this.loadAllMembers(); + if (tabs) { + await this.switchTab('members', tabs); + } + } + } catch (error) { + console.error('❌ Error in sendMessage:', error); + } + } + + private scrollToBottom(container: Element) { + (container as HTMLElement).scrollTop = (container as HTMLElement).scrollHeight; + } + + // Get the diff by state id + async getDiffByStateId(stateId: string) { + try { + const database = await Database.getInstance(); + const diff = await database.requestStoreByIndex('diffs', 'byStateId', stateId); + return diff; + } catch (error) { + console.error('Error getting diff by state id:', error); + } + } + + // TODO rewrite that + // private async lookForChildren(): Promise { + // // Filter processes for the children of current process + // const service = await Services.getInstance(); + // if (!this.selectedChatProcessId) { + // console.error('No process id'); + // return null; + // } + // const children: string[] = await service.getChildrenOfProcess(this.selectedChatProcessId); + + // const processRoles = this.processRoles; + // const selectedMember = this.selectedMember; + // for (const child of children) { + // const roles = service.getRoles(JSON.parse(child)); + // // Check that we and the other members are in the role + // if (!service.isChildRole(processRoles, roles)) { + // console.error('Child process roles are not a subset of parent') + // continue; + // } + // if (!service.rolesContainsMember(roles, selectedMember)) { + // console.error('Member is not part of the process'); + // continue; + // } + // if (!service.rolesContainsUs(roles)) { + // console.error('We\'re not part of child process'); + // continue; + // } + // return child; + // } + + // return null; + // } + + private async loadAllMembers() { + const groupList = this.shadowRoot?.querySelector('#group-list'); + if (!groupList) return; + + const service = await Services.getInstance(); + const members = await service.getAllMembers(); + const processes = await service.getProcesses(); + + const memberList = document.createElement('ul'); + memberList.className = 'member-list active'; + + // Partition members into prioritized and remaining arrays. + const prioritizedMembers: [string, Member][] = []; + const remainingMembers: [string, Member][] = []; + for (const [processId, member] of Object.entries(members)) { + if (this.dmMembersSet.has(processId)) { + prioritizedMembers.push([processId, member]); + } else { + remainingMembers.push([processId, member]); + } + } + const sortedMembers = prioritizedMembers.concat(remainingMembers); + + // Process each member. + for (const [processId, member] of sortedMembers) { + const memberItem = document.createElement('li'); + memberItem.className = 'member-item'; + + // Apply special styling if the member is prioritized. + if (this.dmMembersSet.has(processId)) { + memberItem.style.cssText = ` + background-color: var(--accent-color); + transition: background-color 0.3s ease; + cursor: pointer; + `; + memberItem.addEventListener('mouseover', () => { + memberItem.style.backgroundColor = 'var(--accent-color-hover)'; + }); + memberItem.addEventListener('mouseout', () => { + memberItem.style.backgroundColor = 'var(--accent-color)'; + }); + } + + // Create a container for the member content. + const memberContainer = document.createElement('div'); + memberContainer.className = 'member-container'; + + // Create the emoji span and load its label. + const emojiSpan = document.createElement('span'); + emojiSpan.className = 'member-emoji'; + const emojis = await addressToEmoji(processId); + emojiSpan.dataset.emojis = emojis; + + // Get the member name, if any, and add it to the display + const process = processes[processId]; + let memberPublicName; + if (process) { + const publicMemberData = service.getPublicData(process); + if (publicMemberData) { + const extractedName = publicMemberData['memberPublicName']; + if (extractedName !== undefined && extractedName !== null) { + memberPublicName = extractedName; + } + } + } + if (!memberPublicName) { + memberPublicName = 'Unnamed Member'; + } + + emojiSpan.textContent = `${memberPublicName} (${emojis})` + + memberContainer.appendChild(emojiSpan); + memberItem.appendChild(memberContainer); + + // Add click handler to load member chat. + memberItem.addEventListener('click', async () => { + await this.loadMemberChat(processId); + }); + + // Create and configure the edit label button. + const editLabelButton = document.createElement('button'); + editLabelButton.className = 'edit-label-button'; + editLabelButton.textContent = "✏️"; + editLabelButton.addEventListener("click", (event) => { + event.stopPropagation(); + }); + editLabelButton.addEventListener("dblclick", async (event) => { + event.stopPropagation(); + event.preventDefault(); + + const newLabel = prompt("Set a new name for the member:"); + if (!newLabel) return; + + const db = await Database.getInstance(); + this.updateLabelForEmoji(emojis, newLabel, db, emojiSpan, processId); + }); + memberContainer.appendChild(editLabelButton); + + memberList.appendChild(memberItem); + } + + groupList.appendChild(memberList); + } + + // Helper function to update a label in IndexedDB. + private updateLabelForEmoji( + emojis: string, + newLabel: string, + db: IDBDatabase, + emojiSpan: HTMLElement, + processId: string + ) { + const transaction = db.transaction("labels", "readwrite"); + const store = transaction.objectStore("labels"); + const labelObject = { emoji: emojis, label: newLabel }; + const request = store.put(labelObject); + + request.onsuccess = () => { + emojiSpan.textContent = `${newLabel} : ${emojis}`; + this.reloadMemberChat(processId); + }; + } + + private async lookForDmProcess(): Promise { + const service = await Services.getInstance(); + const processes = await service.getMyProcesses(); + console.log(processes); + const recipientAddresses = await service.getAddressesForMemberId(this.selectedMember).sp_addresses; + console.log(recipientAddresses); + + for (const processId of processes) { + try { + const process = await service.getProcess(processId); + console.log(process); + const state = process.states[0]; // We assume that description never change and that we are part of the process from the beginning + const description = await service.decryptAttribute(processId, state, 'description'); + console.log(description); + if (!description || description !== "dm") { + continue; + } + const roles = service.getRoles(process); + if (!service.rolesContainsMember(roles, recipientAddresses)) { + console.error('Member is not part of the process'); + continue; + } + return processId; + } catch (e) { + console.error(e); + } + } + + return null; + } + + private async lookForMyDms(): Promise { + const service = await Services.getInstance(); + const processes = await service.getMyProcesses(); + const myAddresses = await service.getMemberFromDevice(); + const allMembers = await service.getAllMembers(); + + this.dmMembersSet.clear(); + + try { + for (const processId of processes) { + const process = await service.getProcess(processId); + const state = process.states[0]; + const description = await service.decryptAttribute(processId, state, 'description'); + if (!description || description !== "dm") { + continue; + } + const roles = service.getRoles(process); + const members = roles.dm.members; + for (const member of members) {; + if (!service.compareMembers(member.sp_addresses, myAddresses)) { + for (const [id, mem] of Object.entries(allMembers)) { + if (service.compareMembers(mem.sp_addresses, member.sp_addresses)) { + this.dmMembersSet.add(id); + break; + } + } + } + } + } + } catch (e) { + console.error(e); + } + console.log("dmMembersSet:", this.dmMembersSet); + return null; + } + + private async loadMemberChat(pairingProcess: string) { + if (this.isLoading) { + console.log('Already loading messages, skipping...'); + return; + } + + try { + this.isLoading = true; + const service = await Services.getInstance(); + const myAddresses = await service.getMemberFromDevice(); + const database = await Database.getInstance(); + const db = database.db; + + if (!myAddresses) { + console.error('No paired member found'); + return; + } + + // Set the selected member + this.selectedMember = pairingProcess; + console.log("SELECTED MEMBER: ", this.selectedMember); + + const chatHeader = this.shadowRoot?.querySelector('#chat-header'); + const messagesContainer = this.shadowRoot?.querySelector('#messages'); + + if (!chatHeader || !messagesContainer) return; + + messagesContainer.innerHTML = ''; + + const emojis = await addressToEmoji(pairingProcess); + + const transaction = db.transaction("labels", "readonly"); + const store = transaction.objectStore("labels"); + const request = store.get(emojis); + + request.onsuccess = () => { + const label = request.result; + chatHeader.textContent = label ? `Chat with ${label.label} (${emojis})` : `Chat with member (${emojis})`; + }; + + request.onerror = () => { + chatHeader.textContent = `Chat with member (${emojis})`; + }; + + let dmProcessId = await this.lookForDmProcess(); + + if (dmProcessId === null) { + console.log('Create a new dm process'); + // We need to create a new process + try { + const memberAddresses = await service.getAddressesForMemberId(this.selectedMember); + console.log("MEMBER ADDRESSES: ", memberAddresses); + // await service.checkConnections(otherMembers); + const res = await service.createDmProcess(memberAddresses.sp_addresses); + // We catch the new process here + const updatedProcess = res.updated_process?.current_process; + const processId = updatedProcess?.states[0]?.commited_in; + const stateId = updatedProcess?.states[0]?.state_id; + await service.handleApiReturn(res); + setTimeout(async () => { + // Now create a first commitment + console.log('Created a dm process', processId); + this.selectedChatProcessId = processId; + const createPrdReturn = await service.createPrdUpdate(processId, stateId); + await service.handleApiReturn(createPrdReturn); + const approveChangeReturn = await service.approveChange(processId, stateId); + await service.handleApiReturn(approveChangeReturn); + }, 500); + } catch (e) { + console.error(e); + return; + } + + while (dmProcessId === null) { + dmProcessId = await this.lookForDmProcess(); + await new Promise(r => setTimeout(r, 1000)); + } + } else { + console.log('Found DM process', dmProcessId); + this.selectedChatProcessId = dmProcessId; + } + + // Récupérer les messages depuis les états du processus + const allMessages: any[] = []; + + const dmProcess = await service.getProcess(this.selectedChatProcessId); + + console.log(dmProcess); + + if (dmProcess?.states) { + for (const state of dmProcess.states) { + if (state.state_id === '') { continue; } + const message = await service.decryptAttribute(this.selectedChatProcessId, state, 'message'); + if (message === "" || message === undefined || message === null) { + continue; + } + console.log('message', message); + allMessages.push(message); + } + } + + if (allMessages.length > 0) { + console.log('Messages found:', allMessages); + allMessages.sort((a, b) => a.metadata.createdAt - b.metadata.createdAt); + for (const message of allMessages) { + const messageElement = document.createElement('div'); + messageElement.className = 'message-container'; + + const myProcessId = await this.getMyProcessId(); + + const isCurrentUser = message.metadata.sender === myProcessId; + messageElement.style.justifyContent = isCurrentUser ? 'flex-end' : 'flex-start'; + + const messageContent = document.createElement('div'); + messageContent.className = isCurrentUser ? 'message user' : 'message'; + + const myEmoji = await addressToEmoji(myProcessId); + const otherEmoji = await addressToEmoji(this.selectedMember); + + const senderEmoji = isCurrentUser ? myEmoji : otherEmoji; + + if (message.type === 'file') { + let fileContent = ''; + if (message.content.type.startsWith('image/')) { + fileContent = ` +
    + Image +
    + `; + } else { + const blob = this.base64ToBlob(message.content.data, message.content.type); + const url = URL.createObjectURL(blob); + fileContent = ` + + `; + } + + messageContent.innerHTML = ` +
    + ${senderEmoji}: ${fileContent} +
    +
    + ${new Date(message.metadata.createdAt).toLocaleString('fr-FR')} +
    + `; + } else { + messageContent.innerHTML = ` +
    + ${senderEmoji}: ${message.content} +
    +
    + ${new Date(message.metadata.createdAt).toLocaleString('fr-FR')} +
    + `; + } + + messageElement.appendChild(messageContent); + messagesContainer.appendChild(messageElement); + } + + this.scrollToBottom(messagesContainer); + } else { + console.log('No messages found'); + } + this.scrollToBottom(messagesContainer); + } catch (error) { + console.error('❌ Error in loadMemberChat:', error); + } finally { + this.isLoading = false; + } + } + + private async reloadMemberChat(pairingProcess: string) { + try { + const service = await Services.getInstance(); + const database = await Database.getInstance(); + const db = database.db; + + const chatHeader = this.shadowRoot?.querySelector('#chat-header'); + const messagesContainer = this.shadowRoot?.querySelector('#messages'); + + if (!chatHeader || !messagesContainer) return; + + messagesContainer.innerHTML = ''; + + const emojis = await addressToEmoji(pairingProcess); + + const transaction = db.transaction("labels", "readonly"); + const store = transaction.objectStore("labels"); + const request = store.get(emojis); + + request.onsuccess = () => { + const label = request.result; + if (this.selectedMember === pairingProcess) { + chatHeader.textContent = label ? `Chat with ${label.label} (${emojis})` : `Chat with member (${emojis})`; + } + + }; + + request.onerror = () => { + chatHeader.textContent = `Chat with member (${emojis})`; + }; + + let dmProcessId = await this.selectedChatProcessId; + + // Récupérer les messages depuis les états du processus + const allMessages: any[] = []; + + const dmProcess = await service.getProcess(dmProcessId); + + console.log(dmProcess); + + if (dmProcess?.states) { + for (const state of dmProcess.states) { + if (!state.state_id) { continue; } + const message = await service.decryptAttribute(dmProcessId, state, 'message'); + if (message === "" || message === undefined || message === null) { + continue; + } + console.log('message', message); + allMessages.push(message); + } + } + + allMessages.sort((a, b) => a.metadata.createdAt - b.metadata.createdAt); + if (allMessages.length > 0) { + console.log('Messages found:', allMessages); + for (const message of allMessages) { + const messageElement = document.createElement('div'); + messageElement.className = 'message-container'; + + const myProcessId = await this.getMyProcessId(); + + const isCurrentUser = message.metadata.sender === myProcessId; + messageElement.style.justifyContent = isCurrentUser ? 'flex-end' : 'flex-start'; + + const messageContent = document.createElement('div'); + messageContent.className = isCurrentUser ? 'message user' : 'message'; + + + + const myEmoji = await addressToEmoji(myProcessId); + const otherEmoji = await addressToEmoji(this.selectedMember); + + const senderEmoji = isCurrentUser ? myEmoji : otherEmoji; + + if (message.type === 'file') { + let fileContent = ''; + if (message.content.type.startsWith('image/')) { + fileContent = ` +
    + Image +
    + `; + } else { + const blob = this.base64ToBlob(message.content.data, message.content.type); + const url = URL.createObjectURL(blob); + fileContent = ` + + `; + } + + messageContent.innerHTML = ` +
    + ${senderEmoji}: ${fileContent} +
    +
    + ${new Date(message.metadata.createdAt).toLocaleString('fr-FR')} +
    + `; + } else { + messageContent.innerHTML = ` +
    + ${senderEmoji}: ${message.content} +
    +
    + ${new Date(message.metadata.createdAt).toLocaleString('fr-FR')} +
    + `; + } + + messageElement.appendChild(messageContent); + messagesContainer.appendChild(messageElement); + } + + this.scrollToBottom(messagesContainer); + } else { + console.log('No messages found'); + } + this.scrollToBottom(messagesContainer); + } catch (error) { + console.error('❌ Error in reloadMemberChat:', error); + } + } + + private base64ToBlob(base64: string, type: string): Blob { + const byteCharacters = atob(base64.split(',')[1]); + const byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += 512) { + const slice = byteCharacters.slice(offset, offset + 512); + const byteNumbers = new Array(slice.length); + + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + return new Blob(byteArrays, { type: type }); + } + + //To get a map with key: sp address, value: pairing process + async getAddressMap() { + const service = await Services.getInstance(); + const allMembers = await service.getAllMembers(); + + const addressMap: Record = {}; + + for (const [key, values] of Object.entries(allMembers)) { + + if (values.sp_addresses) { + for (let value of values.sp_addresses) { + this.addressMap[value] = key; + } + } else { + console.log(`No sp_addresses array found for key "${key}"`); + } + } + return this.addressMap; + } + + async findProcessIdFromAddresses(addresses: string[]): Promise { + console.log('Addresses to find:', addresses); + const service = await Services.getInstance(); + const allMembers = await service.getAllMembers(); + console.log('Available members:', allMembers); + + const sortedAddresses = [...addresses].sort(); + + for (const [key, value] of Object.entries(allMembers)) { + if (value.sp_addresses.length === sortedAddresses.length) { + const sortedValue = [...value.sp_addresses].sort(); + if (sortedValue.every((val, index) => val === sortedAddresses [index])) { + return key; // Found a match + } + } + } + + return null; // No match found + } + + private async toggleMembers(roleData: any, roleElement: HTMLElement) { + console.log('Toggle members called with roleData:', roleData); + let memberList = roleElement.querySelector('.member-list'); + const roleName = roleElement.querySelector('.role-name')?.textContent || ''; + + if (memberList) { + console.log('Existing memberList found, toggling display'); + (memberList as HTMLElement).style.display = + (memberList as HTMLElement).style.display === 'none' ? 'block' : 'none'; + return; + } + + console.log('Creating new memberList'); + memberList = document.createElement('ul'); + memberList.className = 'member-list'; + + if (roleData.members) { + console.log('Members found:', roleData.members); + for (const member of roleData.members) { + console.log('Processing member:', member); + const memberItem = document.createElement('li'); + memberItem.className = 'member-item'; + + const memberContainer = document.createElement('div'); + memberContainer.className = 'member-container'; + + const emojiSpan = document.createElement('span'); + emojiSpan.className = 'member-emoji'; + + const pairingProcess = await this.findProcessIdFromAddresses(member.sp_addresses); + console.log('PairingProcess:', pairingProcess); + if (pairingProcess) { + //TO DO : faire apparaitre les membres avec lesquelels je suis pairé ? + const emojis = await addressToEmoji(pairingProcess); + console.log('Adresse pairée:', emojis); + emojiSpan.textContent = emojis; + } else { + const emojis = await addressToEmoji(member.sp_addresses[0]); + emojiSpan.textContent = emojis; + } + + memberContainer.appendChild(emojiSpan); + memberItem.appendChild(memberContainer); + + memberItem.onclick = async (event) => { + event.stopPropagation(); + try { + if (pairingProcess) { + await this.loadMemberChat(pairingProcess); + } + } catch (error) { + console.error('❌ Error handling member click:', error); + } + }; + + memberList.appendChild(memberItem); + } + } else { + console.log('No members found in roleData'); + } + + roleElement.appendChild(memberList); + } + + + private async switchTab(tabType: string, tabs: NodeListOf) { + const service = await Services.getInstance(); + + // Mettre à jour les classes des onglets + tabs.forEach(tab => { + tab.classList.toggle('active', tab.getAttribute('data-tab') === tabType); + }); + + // Supprimer le contenu existant sauf les onglets + const groupList = this.shadowRoot?.querySelector('#group-list'); + if (!groupList) return; + + const children = Array.from(groupList.children); + children.forEach(child => { + if (!child.classList.contains('tabs')) { + groupList.removeChild(child); + } + }); + + // Charger le contenu approprié + switch (tabType) { + case 'processes': + const processSet = await service.getMyProcesses(); + await this.loadAllProcesses(processSet); + break; + case 'members': + await this.lookForMyDms(); + await this.loadAllMembers(); + break; + default: + console.error('Unknown tab type:', tabType); + } + } + + //load all processes from the service + private async loadAllProcesses() { + console.log('🎯 Loading all processes'); + this.closeSignature(); + + const service = await Services.getInstance(); + const allProcesses: Record = await service.getProcesses(); + console.log('All processes:', allProcesses); + const myProcesses: string[] = await service.getMyProcesses(); + console.log('My processes:', myProcesses); + + const groupList = this.shadowRoot?.querySelector('#group-list'); + if (!groupList) { + console.warn('⚠️ Group list element not found'); + return; + } + + groupList.innerHTML = ''; + + const tabContent = document.createElement('div'); + tabContent.className = 'tabs'; + tabContent.innerHTML = ` + + + `; + groupList.appendChild(tabContent); + + // Ajouter les event listeners + const tabs = tabContent.querySelectorAll('.tab'); + tabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabType = tab.getAttribute('data-tab'); + if (tabType) { + this.switchTab(tabType, tabs); + } + }); + }); + + // Trier les processus : ceux de l'utilisateur en premier + const sortedEntries = Object.entries(allProcesses).sort( + ([keyA], [keyB]) => { + const inSetA = myProcesses.includes(keyA); + const inSetB = myProcesses.includes(keyB); + return inSetB ? 1 : inSetA ? -1 : 0; + } + ); + + for (const [processId, process] of sortedEntries) { + // Create and configure the main list item. + const li = document.createElement('li'); + li.className = 'group-list-item'; + li.setAttribute('data-process-id', processId); + + // Retrieve roles for the current process. + const roles = service.getRoles(process); + if (!roles) { + console.error('Failed to get roles for process:', process); + continue; + } + + // If process is a pairing process, we don't want it in the list + if (service.isPairingProcess(roles)) { + continue; + } + + const publicData = service.getPublicData(process); + const processName = publicData['processName']; + const emoji = await addressToEmoji(processId); + + let displayName; + if (processName) { + displayName = `${processName} (${emoji})`; + } else { + displayName = `${defaultProcessName} (${emoji})`; + } + + // If the process is part of myProcesses, apply special styling. + if (myProcesses && myProcesses.includes(processId)) { + li.style.cssText = ` + background-color: var(--accent-color); + transition: background-color 0.3s ease; + cursor: pointer; + `; + li.addEventListener('mouseover', () => { + li.style.backgroundColor = 'var(--accent-color-hover)'; + }); + li.addEventListener('mouseout', () => { + li.style.backgroundColor = 'var(--accent-color)'; + }); + console.log("✅ Processus trouvé dans le set:", processId); + } + + // Attach a click handler for the process. + li.addEventListener('click', async (event) => { + event.stopPropagation(); + console.log("CLICKED ON PROCESS:", processId); + + // Update the signature header with the corresponding emoji. + const signatureHeader = this.shadowRoot?.querySelector('.signature-header h1'); + if (signatureHeader) { + if (processName) { + signatureHeader.textContent = `Signature of ${displayName}`; + } else { + signatureHeader.textContent = `Signature of ${displayName}`; + } + } + + this.openSignature(); + console.log('🎯 Roles de signature:', roles); + await this.loadAllRolesAndMembersInSignature(roles); + await this.newRequest(processId); + }); + + // Create the container for the process name and emoji. + const container = document.createElement('div'); + container.className = 'group-item-container'; + + // Create and set the process name element. + const nameSpan = document.createElement('span'); + nameSpan.className = 'process-name'; + nameSpan.textContent = displayName; + container.appendChild(nameSpan); + + li.appendChild(container); + + // Create a hidden list for roles. + const roleList = document.createElement('ul'); + roleList.className = 'role-list'; + roleList.style.display = 'none'; + + // Process each role and create role items. + Object.entries(roles).forEach(([roleName, roleData]) => { + const roleItem = document.createElement('li'); + roleItem.className = 'role-item'; + + const roleContainer = document.createElement('div'); + roleContainer.className = 'role-item-container'; + + const roleNameSpan = document.createElement('span'); + roleNameSpan.className = 'role-name'; + roleNameSpan.textContent = roleName; + + // Filter duplicate members by using the first sp_address as a key. + const uniqueMembers = new Map(); + roleData.members?.forEach(member => { + const spAddress = member.sp_addresses?.[0]; + if (spAddress && !uniqueMembers.has(spAddress)) { + uniqueMembers.set(spAddress, member); + } + }); + + // Create a new roleData object with unique members. + const filteredRoleData = { + ...roleData, + members: Array.from(uniqueMembers.values()), + }; + + // Attach a click handler for the role. + roleContainer.addEventListener('click', async (event) => { + event.stopPropagation(); + console.log("CLICKED ON ROLE:", roleName); + await this.toggleMembers(filteredRoleData, roleItem); + }); + + roleContainer.appendChild(roleNameSpan); + roleItem.appendChild(roleContainer); + roleList.appendChild(roleItem); + }); + + li.appendChild(roleList); + + // Toggle role list display when the container is clicked. + container.addEventListener('click', (event) => { + event.stopPropagation(); + container.classList.toggle('expanded'); + roleList.style.display = container.classList.contains('expanded') ? 'block' : 'none'; + }); + + // Append the completed process list item once. + groupList.appendChild(li); + } + + } + + private async newRequest(processId: string) { + const emoji = await addressToEmoji(processId); + const members = await this.getMembersFromProcess(processId); + const newRequestButton = this.shadowRoot?.querySelector('#request-document-button'); + if (newRequestButton) { + newRequestButton.replaceWith(newRequestButton.cloneNode(true)); + const freshButton = this.shadowRoot?.querySelector('#request-document-button'); + freshButton?.addEventListener('click', async () => { + const membersList = await this.generateMembersList(members); + + const modal = document.createElement('div'); + modal.className = 'request-modal'; + const today = new Date().toISOString().split('T')[0]; + + modal.innerHTML = ` + + `; + + this.shadowRoot?.appendChild(modal); + this.handleFileUpload(modal); + this.handleRequestButton(modal); + const closeButton = modal.querySelector('.close-modal'); + closeButton?.addEventListener('click', () => { + modal.remove(); + }); + }); + } + } + + //request button in the modal + private handleRequestButton(modal: HTMLElement) { + const requestButton = modal.querySelector('#send-request-button'); + requestButton?.addEventListener('click', () => { + console.log("REQUEST SENT"); + if (modal) { + //vérifier qu'au moins un membre est coché + const membersList = modal.querySelector('.members-list-modal'); + if (membersList) { + const members = membersList.querySelectorAll('.member-checkbox:checked'); + if (members.length === 0) { + alert('Please select at least one member'); + return; + } + } + //vérifier que la date est valide + const dateInput = modal.querySelector('#date-input') as HTMLInputElement; + if (dateInput) { + const date = new Date(dateInput.value); + if (isNaN(date.getTime())) { + alert('Please select a valid date'); + return; + } + } + + //verifier qu'un fichier a été load + const fileList = modal.querySelector('#file-list'); + if (fileList && fileList.children.length === 0) { + alert('Please upload at least one file'); + return; + } + + //récupérer le message + const messageInput = modal.querySelector('#message-input') as HTMLTextAreaElement; + if (messageInput) { + const message = messageInput.value; + } + //modal.remove(); + } + }); + } + + private handleFileUpload(modal: HTMLElement) { + const fileInput = modal.querySelector('#file-input') as HTMLInputElement; + const fileList = modal.querySelector('#file-list'); + const selectedFiles = new Set(); + + fileInput?.addEventListener('change', () => { + if (fileList && fileInput.files) { + Array.from(fileInput.files).forEach(file => { + if (!Array.from(selectedFiles).some(f => f.name === file.name)) { + selectedFiles.add(file); + const fileItem = document.createElement('div'); + fileItem.className = 'file-item'; + fileItem.innerHTML = ` + ${file.name} + + `; + fileList.appendChild(fileItem); + + fileItem.querySelector('.remove-file')?.addEventListener('click', () => { + selectedFiles.delete(file); + fileItem.remove(); + }); + } + }); + fileInput.value = ''; + } + }); + + return selectedFiles; + } + + private async generateMembersList(members: string[]) { + let html = ''; + for (const member of members) { + const emoji = await addressToEmoji(member); + html += `
  • ${emoji}
  • `; + } + return html; + } + + + //Send a set of members from a process + private async getMembersFromProcess(processId: string) { + const service = await Services.getInstance(); + const process = await service.getProcess(processId); + console.log("Process récupéré:", process); + + // Récupérer les rôles directement depuis le dernier état + const roles = service.getRoles(process); + console.log("Roles trouvés:", roles); + + if (!roles) return []; + type RoleData = { + members?: { sp_addresses?: string[] }[]; + }; + const uniqueMembers = new Set(); + Object.values(roles as unknown as Record).forEach((roleData: RoleData) => { + roleData.members?.forEach((member) => { + if (member.sp_addresses && member.sp_addresses[0]) { + uniqueMembers.add(member.sp_addresses[0]); + } + }); + }); + return Array.from(uniqueMembers); + } + + private async loadAllRolesAndMembersInSignature(roles: any) { + console.log('🎯 Roles:', roles); + const signatureDescription = this.shadowRoot?.querySelector('.signature-description'); + if (signatureDescription) { + signatureDescription.innerHTML = ''; + Object.entries(roles).forEach(([roleName, roleData]: [string, any]) => { + const roleItem = document.createElement('li'); + roleItem.className = 'role-signature'; + + const roleContainer = document.createElement('div'); + roleContainer.className = 'role-signature-container'; + + const roleNameSpan = document.createElement('span'); + roleNameSpan.className = 'role-signature-name'; + roleNameSpan.textContent = roleName; + + const uniqueMembers = new Map(); + roleData.members?.forEach((member: any) => { + const spAddress = member.sp_addresses?.[0]; + if (spAddress && !uniqueMembers.has(spAddress)) { + uniqueMembers.set(spAddress, member); + } + }); + + const filteredRoleData = { + ...roleData, + members: Array.from(uniqueMembers.values()) + }; + + roleContainer.addEventListener('click', async (event) => { + console.log("CLICKED ON ROLE:", roleName); + event.stopPropagation(); + await this.toggleMembers(filteredRoleData, roleItem); + }); + + roleContainer.appendChild(roleNameSpan); + roleItem.appendChild(roleContainer); + signatureDescription.appendChild(roleItem); + }); + } + } + + //fonction qui ferme la signature + private closeSignature() { + const closeSignature = this.shadowRoot?.querySelector('#close-signature'); + const signatureArea = this.shadowRoot?.querySelector('.signature-area'); + if (closeSignature && signatureArea) { + closeSignature.addEventListener('click', () => { + signatureArea.classList.add('hidden'); + }); + } + } + + //fonction qui ouvre la signature + private openSignature() { + const signatureArea = this.shadowRoot?.querySelector('.signature-area'); + if (signatureArea) { + signatureArea.classList.remove('hidden'); + } + } + + private async getMyProcessId() { + const service = await Services.getInstance(); + return service.getPairingProcessId(); + } + + //fonction qui renvoie les processus où le sp_adress est impliqué + private async getProcessesWhereTheCurrentMemberIs() { + const service = await Services.getInstance(); + try { + const currentMember = await service.getMemberFromDevice(); + if (!currentMember) { + console.error('❌ Pas de membre trouvé'); + return this.userProcessSet; + } + + const pairingProcess = await this.getMyProcessId(); + const memberEmoji = await addressToEmoji(pairingProcess); + console.log("Mon adresse:", currentMember[0], memberEmoji); + + const processes = await service.getProcesses(); + + for (const [processId, process] of Object.entries(processes)) { + try { + const roles = process.states[0]?.roles; + + if (!roles) { + console.log(`Pas de rôles trouvés pour le processus ${processId}`); + continue; + } + + for (const roleName in roles) { + const role = roles[roleName]; + + if (role.members && Array.isArray(role.members)) { + for (const member of role.members) { + if (member.sp_addresses && Array.isArray(member.sp_addresses)) { + if (member.sp_addresses.includes(currentMember[0])) { + this.userProcessSet.add(processId); + console.log(`Ajout du process ${processId} au Set (trouvé dans le rôle ${roleName})`); + break; + } + } + } + } + } + } catch (e) { + console.log(`Erreur lors du traitement du processus ${processId}:`, e); + continue; + } + } + + return this.userProcessSet; + } catch (e) { + console.error('❌ Erreur:', e); + return this.userProcessSet; + } + } + + // Send a file + private async sendFile(file: File) { + const MAX_FILE_SIZE = 1 * 1024 * 1024; + if (file.size > MAX_FILE_SIZE) { + alert('Le fichier est trop volumineux. Taille maximum : 1MB'); + return; + } + + try { + const service = await Services.getInstance(); + const myAddresses = await service.getMemberFromDevice(); + if (!myAddresses) throw new Error('No paired member found'); + + let fileData: string; + if (file.type.startsWith('image/')) { + fileData = await this.compressImage(file); + } else { + fileData = await this.readFileAsBase64(file); + } + + const timestamp = Date.now(); + const processId = this.getAttribute('process-id'); + const uniqueKey = `${processId}${timestamp}`; + + const dbRequest = indexedDB.open('4nk'); + + dbRequest.onerror = (event) => { + console.error("Database error:", dbRequest.error); + }; + + dbRequest.onsuccess = async (event) => { + const db = dbRequest.result; + const transaction = db.transaction(['diffs'], 'readwrite'); + const store = transaction.objectStore('diffs'); + + try { + // Message du fichier + const fileTemplate = { + value_commitment: uniqueKey, + messaging_id: processId, + description: 'message_content', + metadata: { + text: `Fichier envoyé: ${file.name}`, + timestamp: timestamp, + sender: myAddresses[0], + recipient: this.selectedMember, + messageState: this.messageState, + roleName: this.selectedRole, + type: 'file', + fileName: file.name, + fileType: file.type, + fileData: fileData + } + }; + + await new Promise((resolve, reject) => { + const request = store.add(fileTemplate); + request.onsuccess = () => { + console.log('✅ File message saved'); + resolve(); + }; + request.onerror = () => reject(request.error); + }); + + // Réponse automatique + const autoReplyTemplate = { + value_commitment: `${processId}${timestamp + 1000}`, + messaging_id: processId, + description: 'message_content', + metadata: { + text: "J'ai bien reçu votre fichier 📎", + timestamp: timestamp + 1000, + sender: this.selectedMember, + recipient: myAddresses[0], + messageState: this.messageState, + roleName: this.selectedRole + } + }; + + await new Promise((resolve, reject) => { + const request = store.add(autoReplyTemplate); + request.onsuccess = () => { + console.log('✅ Auto reply saved'); + if (myAddresses[0]) { + this.addNotification(myAddresses[0], autoReplyTemplate); + } + resolve(); + }; + request.onerror = () => reject(request.error); + }); + + // Attendre la fin de la transaction + await new Promise((resolve, reject) => { + transaction.oncomplete = () => { + console.log('✅ Transaction completed'); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + }); + + // Réinitialiser l'input file + const fileInput = this.shadowRoot?.querySelector('#file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + + // Recharger les messages + if (this.selectedMember) { + await this.loadMemberChat(this.selectedMember); + } + + } catch (error) { + console.error('❌ Transaction error:', error); + } + }; + + } catch (error) { + console.error('❌ Error in sendFile:', error); + } + } + + private async readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + private async compressImage(file: File): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + img.onload = () => { + let width = img.width; + let height = img.height; + const MAX_WIDTH = 800; + const MAX_HEIGHT = 600; + + if (width > height) { + if (width > MAX_WIDTH) { + height *= MAX_WIDTH / width; + width = MAX_WIDTH; + } + } else { + if (height > MAX_HEIGHT) { + width *= MAX_HEIGHT / height; + height = MAX_HEIGHT; + } + } + + canvas.width = width; + canvas.height = height; + ctx?.drawImage(img, 0, 0, width, height); + + resolve(canvas.toDataURL('image/jpeg', 0.7)); + }; + + img.onerror = reject; + img.src = URL.createObjectURL(file); + }); + } + + private async getProcesses(): Promise { + const service = await Services.getInstance(); + const processes = await service.getProcesses(); + + const res = Object.entries(processes).map(([key, value]) => ({ + key, + value, + })); + + return res; + } + + async connectedCallback() { + const service = await Services.getInstance(); + + const loadPage = async () => { + console.log("🔍 Chargement des processus par défaut"); + await this.loadAllProcesses(); + + if (this.selectedMember) { + console.log('🔍 Loading chat for selected member:', this.selectedMember); + await this.loadMemberChat(this.selectedMember); + } else { + console.warn('⚠️ No member selected yet. Waiting for selection...'); + } + } + + let timeout: NodeJS.Timeout; + window.addEventListener('process-updated', async (e: CustomEvent) => { + const processId = e.detail.processId; + console.log('Notified of an update for process', processId); + await loadPage(); + }); + + await loadPage(); + } +} + +customElements.define('chat-element', ChatElement); +export { ChatElement };*/ + diff --git a/ihm_client/src/pages/home/home-component.ts b/ihm_client/src/pages/home/home-component.ts new file mode 100644 index 00000000..b38e1a56 --- /dev/null +++ b/ihm_client/src/pages/home/home-component.ts @@ -0,0 +1,49 @@ +import loginHtml from './home.html?raw'; +import loginScript from './home.ts?raw'; +import loginCss from '../../4nk.css?raw'; +import { initHomePage } from './home'; + +export class LoginComponent extends HTMLElement { + _callback: any; + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + console.log('CALLBACK LOGIN PAGE'); + this.render(); + setTimeout(() => { + initHomePage(); + }, 500); + } + + set callback(fn) { + if (typeof fn === 'function') { + this._callback = fn; + } else { + console.error('Callback is not a function'); + } + } + + get callback() { + return this._callback; + } + + render() { + if (this.shadowRoot) + this.shadowRoot.innerHTML = ` + ${loginHtml} + + + + diff --git a/ihm_client/src/pages/signature/signature.ts b/ihm_client/src/pages/signature/signature.ts new file mode 100755 index 00000000..f5b60fa4 --- /dev/null +++ b/ihm_client/src/pages/signature/signature.ts @@ -0,0 +1,1758 @@ +import signatureStyle from '../../../public/style/signature.css?inline'; + +declare global { + interface Window { + toggleUserList: () => void; + switchUser: (userId: string | number) => void; + closeProcessDetails: (groupId: number) => void; + loadMemberChat: (memberId: string | number) => void; + closeRoleDocuments: (roleName: string) => void; + newRequest: (params: RequestParams) => void; + submitRequest: () => void; + closeNewRequest: () => void; + closeModal: (button: HTMLElement) => void; + submitDocumentRequest: (documentId: number) => void; + submitNewDocument: (event: Event) => void; + submitCommonDocument: (event: Event) => void; + signDocument: (documentId: number, processId: number, isCommonDocument: boolean) => void; + confirmSignature: (documentId: number, processId: number, isCommonDocument: boolean) => void; + } +} + +import { groupsMock } from '../../mocks/mock-signature/groupsMock'; +import { messagesMock as initialMessagesMock, messagesMock } from '../../mocks/mock-signature/messagesMock'; +import { membersMock } from '../../mocks/mock-signature/membersMocks'; +import { + Message, + DocumentSignature, + RequestParams} from '../../models/signature.models'; +import { messageStore } from '../../utils/messageMock'; +import { Member } from '../../interface/memberInterface'; +import { Group } from '../../interface/groupInterface'; +import { getCorrectDOM } from '../../utils/document.utils'; + + +let currentUser: Member = membersMock[0]; + +interface LocalNotification { + memberId: string; + text: string; + time: string; +} + + +class SignatureElement extends HTMLElement { + private selectedMemberId: string | null = null; + private messagesMock: any[] = []; + private dom: Node; + private notifications: LocalNotification[] = []; + private notificationBadge = document.querySelector('.notification-badge'); + private notificationBoard = document.getElementById('notification-board'); + private notificationBell = document.getElementById('notification-bell'); + private selectedSignatories: DocumentSignature[] = []; + private allMembers = membersMock.map(member => ({ + id: member.id, + name: member.name, + roleName: 'Default Role' + })); + + private showAlert(message: string): void { + // Créer la popup si elle n'existe pas + let alertPopup = this.shadowRoot?.querySelector('.alert-popup'); + if (!alertPopup) { + alertPopup = document.createElement('div'); + alertPopup.className = 'alert-popup'; + this.shadowRoot?.appendChild(alertPopup); + } + + // Définir le message et afficher la popup + alertPopup.textContent = message; + (alertPopup as HTMLElement).style.display = 'block'; + + // Cacher la popup après 3 secondes + setTimeout(() => { + (alertPopup as HTMLElement).style.display = 'none'; + }, 3000); + } + + private signDocument(documentId: number, processId: number, isCommonDocument: boolean = false): void { + try { + if (typeof window === 'undefined' || typeof document === 'undefined') { + console.error('Cette fonction ne peut être exécutée que dans un navigateur'); + return; + } + + const groups = JSON.parse(localStorage.getItem('groups') || JSON.stringify(groupsMock)); + const group = groups.find((g: Group) => g.id === processId); + + if (!group) { + throw new Error('Process not found'); + } + + let targetDoc; + if (isCommonDocument) { + targetDoc = group.commonDocuments.find((d: any) => d.id === documentId); + } else { + for (const role of group.roles) { + if (role.documents) { + targetDoc = role.documents.find((d: any) => d.id === documentId); + if (targetDoc) break; + } + } + } + + if (!targetDoc) { + throw new Error('Document not found'); + } + + const canSign = isCommonDocument ? + targetDoc.signatures?.some((sig: DocumentSignature) => + sig.member?.name === currentUser?.name && !sig.signed + ) : + this.canUserSignDocument(targetDoc, currentUser?.name, currentUser); + + if (!canSign) { + this.showAlert("You do not have the necessary rights to sign this document."); + return; + } + + // Create and insert the modal directly into the body + const modalHtml = ` + `; + + + const modalElement = document.createElement('div'); + modalElement.className = 'modal-overlay'; + modalElement.innerHTML = modalHtml; + this.shadowRoot?.appendChild(modalElement); + + + const slider = modalElement.querySelector('#signatureSlider'); + const sliderTrack = slider?.parentElement; + let isDragging = false; + let startX: number; + let sliderLeft: number; + + if (slider && sliderTrack) { + slider.addEventListener('mousedown', (e: Event) => initDrag(e as MouseEvent)); + slider.addEventListener('touchstart', (e: Event) => initDrag(e as TouchEvent)); + + modalElement.addEventListener('mousemove', (e: Event) => drag.call(this, e as MouseEvent)); + modalElement.addEventListener('touchmove', (e: Event) => drag.call(this, e as TouchEvent)); + modalElement.addEventListener('mouseup', (e: Event) => stopDrag(e as MouseEvent)); + modalElement.addEventListener('touchend', (e: Event) => stopDrag(e as TouchEvent)); + } + + function initDrag(e: MouseEvent | TouchEvent) { + isDragging = true; + startX = 'touches' in e ? e.touches[0].clientX : e.clientX; + sliderLeft = (slider as HTMLElement)?.offsetLeft || 0; + } + + function drag(this: SignatureElement, e: MouseEvent | TouchEvent) { + if (!isDragging || !slider || !sliderTrack) return; + + e.preventDefault(); + const x = 'touches' in e ? e.touches[0].clientX : e.clientX; + const deltaX = x - startX; + + // Calculate the position relative to the track + let newLeft = sliderLeft + deltaX; + + // Limit the movement + const maxLeft = (sliderTrack as HTMLElement).offsetWidth - (slider as HTMLElement).offsetWidth; + newLeft = Math.max(0, Math.min(newLeft, maxLeft)); + + // Update the position + (slider as HTMLElement).style.left = `${newLeft}px`; + + // If the slider reaches 90% of the path, trigger the signature + if (newLeft > maxLeft * 0.9) { + stopDrag(e); + this.confirmSignature(documentId, processId, isCommonDocument); + } + } + + function stopDrag(e: MouseEvent | TouchEvent) { + if (!isDragging || !slider) return; + isDragging = false; + + // Reset the position if not enough dragged + if ((slider as HTMLElement).offsetLeft < ((sliderTrack as HTMLElement)?.offsetWidth || 0) * 0.9) { + (slider as HTMLElement).style.left = '0px'; + } + } + + } catch (error) { + console.error('Error displaying modal:', error); + this.showAlert(error instanceof Error ? error.message : 'Error displaying modal'); + } + } + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.dom = getCorrectDOM('signature-element'); + + this.shadowRoot!.innerHTML = ` + +
    + +
    +
      +
    +
    + + +
    +
    + +
    +
    + +
    + + +
    + + + + +
    +
    +
    + `; + + window.toggleUserList = this.toggleUserList.bind(this); + window.switchUser = this.switchUser.bind(this); + window.closeProcessDetails = this.closeProcessDetails.bind(this); + window.loadMemberChat = this.loadMemberChat.bind(this); + window.closeRoleDocuments = this.closeRoleDocuments.bind(this); + window.newRequest = this.newRequest.bind(this); + window.submitRequest = this.submitRequest.bind(this); + window.closeNewRequest = this.closeNewRequest.bind(this); + window.closeModal = this.closeModal.bind(this); + window.submitNewDocument = this.submitNewDocument.bind(this); + window.submitCommonDocument = this.submitCommonDocument.bind(this); + window.signDocument = this.signDocument.bind(this); + window.confirmSignature = this.confirmSignature.bind(this); + window.submitDocumentRequest = this.submitDocumentRequest.bind(this); + + // Initialiser les événements de notification + document.addEventListener('click', (event: Event): void => { + if (this.notificationBoard && this.notificationBoard.style.display === 'block' && + !this.notificationBoard.contains(event.target as Node) && + this.notificationBell && !this.notificationBell.contains(event.target as Node)) { + this.notificationBoard.style.display = 'none'; + } + }); + this.initMessageEvents(); + this.initFileUpload(); + } + + private initMessageEvents() { + // Pour le bouton Send + const sendButton = this.shadowRoot?.getElementById('send-button'); + if (sendButton) { + sendButton.addEventListener('click', () => this.sendMessage()); + } + + // Pour la touche Entrée + const messageInput = this.shadowRoot?.getElementById('message-input'); + if (messageInput) { + messageInput.addEventListener('keypress', (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(); + } + }); + } + } + + private initFileUpload() { + const fileInput = this.shadowRoot?.getElementById('file-input') as HTMLInputElement; + if (fileInput) { + fileInput.addEventListener('change', (event: Event) => { + const target = event.target as HTMLInputElement; + if (target.files && target.files.length > 0) { + this.sendFile(target.files[0]); + } + }); + } + } + + + private calculateDuration(startDate: string | null | undefined, endDate: string | null | undefined): number { + const start = new Date(startDate || ''); + const end = new Date(endDate || ''); + const duration = end.getTime() - start.getTime(); + return Math.floor(duration / (1000 * 60 * 60 * 24)); + } + + // Add this helper function + private canUserAccessDocument(document: any, roleId: string, currentUserRole: string): boolean { + // Modify the access logic + if (document.visibility === 'public') { + return true; // Can see but not necessarily sign + } + return roleId === currentUserRole; + } + + private canUserSignDocument(document: any, role: string, user: Member): boolean { + console.log('Checking signing rights for:', { + document, + role, + user, + userRoles: user.processRoles + }); + + // Vérifier si l'utilisateur est dans la liste des signatures + const isSignatory = document.signatures?.some((sig: DocumentSignature) => + sig.member && 'id' in sig.member && sig.member.id === user.id && !sig.signed + ); + + if (!isSignatory) { + console.log('User is not in signatures list or has already signed'); + return false; + } + + // Si l'utilisateur est dans la liste des signatures, il peut signer + return true; + } + + private closeProcessDetails(groupId: number) { + const detailsArea = this.shadowRoot?.getElementById(`process-details-${groupId}`); + const chatArea = this.shadowRoot?.getElementById('chat-area'); + + if (detailsArea) { + detailsArea.style.display = 'none'; + } + + if (chatArea) { + chatArea.style.display = 'block'; + } + } + + ///////////////////// Notification module ///////////////////// + // Delete a notification + private removeNotification(index: number) { + this.notifications?.splice(index, 1); // Ajout de ?. + this.renderNotifications(); + this.updateNotificationBadge(); + } + // Show notifications + private renderNotifications() { + if (!this.notificationBoard) return; + + // Reset the interface + this.notificationBoard.innerHTML = ''; + + // Displays "No notifications available" if there are no notifications + if (this.notifications.length === 0) { + this.notificationBoard.innerHTML = '
    No notifications available
    '; + return; + } + + // Add each notification to the list + this.notifications.forEach((notif, index) => { + const notifElement = document.createElement('div'); + notifElement.className = 'notification-item'; + notifElement.textContent = `${notif.text} at ${notif.time}`; + notifElement.onclick = () => { + this.loadMemberChat(notif.memberId); + this.removeNotification(index); + }; + this.notificationBoard?.appendChild(notifElement); + }); + } + private updateNotificationBadge() { + if (!this.notificationBadge) return; + const count = this.notifications.length; + this.notificationBadge.textContent = count > 99 ? '+99' : count.toString(); + (this.notificationBadge as HTMLElement).style.display = count > 0 ? 'block' : 'none'; + } + + + // Add notification + private addNotification(memberId: string, message: Message) { + // Creating a new notification + const notification = { + memberId, + text: `New message from Member ${memberId}: ${message.text}`, + time: message.time + }; + + // Added notification to list and interface + this.notifications.push(notification); + this.renderNotifications(); + this.updateNotificationBadge(); + } +// Send a messsage + private sendMessage() { + const messageInput = this.shadowRoot?.getElementById('message-input') as HTMLInputElement; + if (!messageInput) return; + const messageText = messageInput.value.trim(); + + if (messageText === '' || this.selectedMemberId === null) { + return; + } + + const newMessage: Message = { + id: Date.now(), + sender: "4NK", + text: messageText, + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + type: 'text' as const + }; + // Add and display the message immediately + messageStore.addMessage(this.selectedMemberId, newMessage); + this.messagesMock = messageStore.getMessages(); + this.loadMemberChat(this.selectedMemberId); + + // Reset the input + messageInput.value = ''; + + // Automatic response after 2 seconds + setTimeout(() => { + if (this.selectedMemberId) { + const autoReply = this.generateAutoReply(`Member ${this.selectedMemberId}`); + messageStore.addMessage(this.selectedMemberId, autoReply); + this.messagesMock = messageStore.getMessages(); + this.loadMemberChat(this.selectedMemberId); + this.addNotification(this.selectedMemberId, autoReply); + } + }, 2000); +} + + + private showProcessDetails(group: Group, groupId: number) { + console.log('Showing details for group:', groupId); + + // Close all existing process views + const allDetailsAreas = this.shadowRoot?.querySelectorAll('.process-details'); + if (allDetailsAreas) { + allDetailsAreas.forEach(area => { + (area as HTMLElement).style.display = 'none'; + }); + } + + const container = this.shadowRoot?.querySelector('.container'); + if (!container) { + console.error('Container not found'); + return; + } + + // Load the data from localStorage + const storedGroups = JSON.parse(localStorage.getItem('groups') || '[]'); + const storedGroup = storedGroups.find((g: Group) => g.id === groupId); + + // Use the data from localStorage if available, otherwise use the group passed as a parameter + const displayGroup = storedGroup || group; + + let detailsArea = this.shadowRoot?.getElementById(`process-details-${groupId}`); + if (!detailsArea) { + detailsArea = document.createElement('div'); + detailsArea.id = `process-details-${groupId}`; + detailsArea.className = 'process-details'; + container.appendChild(detailsArea); + } + + if (detailsArea) { + detailsArea.style.display = 'block'; + detailsArea.innerHTML = ` +
    +

    ${displayGroup.name}

    +
    +
    +
    +
    +
    +

    Description

    +

    ${displayGroup.description || 'No description available'}

    +
    +
    +

    Documents Communs

    +
    + ${displayGroup.commonDocuments.map((document: any) => { + const totalSignatures = document.signatures?.length || 0; + const signedCount = document.signatures?.filter((sig: DocumentSignature) => sig.signed).length || 0; + const percentage = totalSignatures > 0 ? (signedCount / totalSignatures) * 100 : 0; + const isVierge = !document.createdAt || !document.deadline || !document.signatures?.length; + const canSign = document.signatures?.some((sig: DocumentSignature) => + sig.member && 'id' in sig.member && sig.member.id === currentUser.id && !sig.signed + ); + + const signButton = !isVierge ? ` + ${totalSignatures > 0 && signedCount < totalSignatures && canSign ? ` + + ` : ''} + ` : ''; + + return ` +
    +
    +

    ${isVierge ? `⚠️ ${document.name}` : document.name}

    + ${document.visibility} +
    +
    + ${!isVierge ? ` +

    Created on: ${document.createdAt ? new Date(document.createdAt).toLocaleDateString() : 'N/A'}

    +

    Deadline: ${document.deadline ? new Date(document.deadline).toLocaleDateString() : 'N/A'}

    +

    Duration: ${this.calculateDuration(document.createdAt || '', document.deadline || '')} days

    +
    +
    Signatures:
    +
    + ${document.signatures?.map((sig: DocumentSignature) => ` +
    + ${sig.member.name} + + ${sig.signed ? + `✓ Signed on ${sig.signedAt ? new Date(sig.signedAt).toLocaleDateString() : 'unknown date'}` : + '⌛ Pending'} + +
    + `).join('')} +
    +
    +
    +
    +

    ${signedCount} out of ${totalSignatures} signed (${percentage.toFixed(0)}%)

    +
    + ` : ` +

    Document vierge - Waiting for creation

    + + `} + ${signButton} +
    +
    + `; + }).join('')} +
    +
    +
    +

    Roles and Documents

    + ${displayGroup.roles.map((role: { name: string; documents?: any[] }) => { + // Filter the documents according to the access rights + const accessibleDocuments = (role.documents || []).filter(doc => + this.canUserAccessDocument(doc, role.name, currentUser.processRoles?.[0]?.role || '') + ); + + return ` +
    +

    ${role.name}

    +
    + ${accessibleDocuments.map(document => { + const isVierge = !document.createdAt || + !document.deadline || + document.signatures.length === 0; + + const canSign = this.canUserSignDocument(document, role.name, currentUser); + + const signButton = !isVierge ? ` + ${document.signatures.length > 0 && + document.signatures.filter((sig: DocumentSignature) => sig.signed).length < document.signatures.length && + canSign ? ` + + ` : ''} + ` : ''; + + return ` +
    +
    +

    ${isVierge ? `⚠️ ${document.name}` : document.name}

    + ${document.visibility} +
    +
    + ${!isVierge ? ` +

    Created on: ${document.createdAt ? new Date(document.createdAt).toLocaleDateString() : 'N/A'}

    +

    Deadline: ${document.deadline ? new Date(document.deadline).toLocaleDateString() : 'N/A'}

    +

    Duration: ${this.calculateDuration(document.createdAt || '', document.deadline || '')} days

    + ` : '

    Document vierge - Waiting for creation

    '} +
    + ${!isVierge ? ` +
    +
    Signatures:
    +
    + ${document.signatures.map((sig: DocumentSignature) => ` +
    + ${sig.member.name} + + ${sig.signed ? + `✓ Signé le ${sig.signedAt ? new Date(sig.signedAt).toLocaleDateString() : 'date inconnue'}` : + '⌛ En attente'} + +
    + `).join('')} +
    +
    +
    +
    +

    ${document.signatures.filter((sig: DocumentSignature) => sig.signed).length} out of ${document.signatures.length} signed (${(document.signatures.filter((sig: DocumentSignature) => sig.signed).length / document.signatures.length * 100).toFixed(0)}%)

    +
    + ` : ''} + ${signButton} +
    + `; + }).join('')} +
    +
    + `; + }).join('')} +
    +
    +

    Members by Role

    +
    + ${displayGroup.roles.map((role: { name: string; members: Array<{ id: string | number; name: string }> }) => ` +
    +

    ${role.name}

    +
      + ${role.members.map(member => ` +
    • ${member.name}
    • + `).join('')} +
    +
    + `).join('')} +
    +
    + `; + + + const newCloseProcessButton = document.createElement('button'); + newCloseProcessButton.className = 'close-btn'; + newCloseProcessButton.textContent = 'x'; + newCloseProcessButton.addEventListener('click', () => this.closeProcessDetails(groupId)); + + const headerButtons = detailsArea.querySelector('.header-buttons'); + if (headerButtons) { + headerButtons.appendChild(newCloseProcessButton); + } + } + } + + // Scroll down the conversation after loading messages + private scrollToBottom(container: HTMLElement) { + container.scrollTop = container.scrollHeight; + } + + + // Load the list of members + private loadMemberChat(memberId: string | number) { + this.selectedMemberId = String(memberId); + const memberMessages = this.messagesMock.find(m => String(m.memberId) === String(memberId)); + + // Find the process and the role of the member + let memberInfo = { processName: '', roleName: '', memberName: '' }; + groupsMock.forEach(process => { + process.roles.forEach(role => { + const member = role.members.find(m => String(m.id) === String(memberId)); + if (member) { + memberInfo = { + processName: process.name, + roleName: role.name, + memberName: member.name + }; + } + }); + }); + + const chatHeader = this.shadowRoot?.getElementById('chat-header'); + const messagesContainer = this.shadowRoot?.getElementById('messages'); + + if (!chatHeader || !messagesContainer) return; + + chatHeader.textContent = `Chat with ${memberInfo.roleName} ${memberInfo.memberName} from ${memberInfo.processName}`; + messagesContainer.innerHTML = ''; + + if (memberMessages) { + memberMessages.messages.forEach((message: Message) => { + const messageElement = document.createElement('div'); + messageElement.className = 'message-container'; + + const messageContent = document.createElement('div'); + messageContent.className = 'message'; + if (message.type === 'file') { + messageContent.innerHTML = `${message.fileName}`; + messageContent.classList.add('user'); + } else { + messageContent.innerHTML = `${message.sender}: ${message.text} ${message.time}`; + if (message.sender === "4NK") { + messageContent.classList.add('user'); + } + } + + messageElement.appendChild(messageContent); + messagesContainer.appendChild(messageElement); + }); + } + + + this.scrollToBottom(messagesContainer); + } + + private toggleMembers(role: { members: { id: string | number; name: string; }[] }, roleElement: HTMLElement) { + let memberList = roleElement.querySelector('.member-list'); + if (memberList) { + (memberList as HTMLElement).style.display = (memberList as HTMLElement).style.display === 'none' ? 'block' : 'none'; + return; + } + + memberList = document.createElement('ul'); + memberList.className = 'member-list'; + + role.members.forEach(member => { + const memberItem = document.createElement('li'); + memberItem.textContent = member.name; + + memberItem.onclick = (event) => { + event.stopPropagation(); + this.loadMemberChat(member.id.toString()); + }; + + memberList.appendChild(memberItem); + }); + + roleElement.appendChild(memberList); + } + + + // Toggle the list of Roles + private toggleRoles(group: Group, groupElement: HTMLElement) { + console.log('=== toggleRoles START ==='); + console.log('Group:', group.name); + console.log('Group roles:', group.roles); // Afficher tous les rôles disponibles + + let roleList = groupElement.querySelector('.role-list'); + console.log('Existing roleList:', roleList); + + if (roleList) { + const roleItems = roleList.querySelectorAll('.role-item'); + roleItems.forEach(roleItem => { + console.log('Processing roleItem:', roleItem.innerHTML); // Voir le contenu HTML complet + + let container = roleItem.querySelector('.role-item-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'role-item-container'; + + // Créer un span pour le nom du rôle + const nameSpan = document.createElement('span'); + nameSpan.className = 'role-name'; + nameSpan.textContent = roleItem.textContent?.trim() || ''; + + container.appendChild(nameSpan); + roleItem.textContent = ''; + roleItem.appendChild(container); + } + + // Récupérer le nom du rôle + const roleName = roleItem.textContent?.trim(); + console.log('Role name from textContent:', roleName); + + // Alternative pour obtenir le nom du rôle + const roleNameAlt = container.querySelector('.role-name')?.textContent; + console.log('Role name from span:', roleNameAlt); + + if (!container.querySelector('.folder-icon')) { + const folderButton = document.createElement('span'); + folderButton.innerHTML = '📁'; + folderButton.className = 'folder-icon'; + + folderButton.addEventListener('click', (event) => { + event.stopPropagation(); + console.log('Clicked role name:', roleName); + console.log('Available roles:', group.roles.map(r => r.name)); + + const role = group.roles.find(r => r.name === roleName); + if (role) { + console.log('Found role:', role); + this.showRoleDocuments(role, group); + } else { + console.error('Role not found. Name:', roleName); + console.error('Available roles:', group.roles); + } + }); + + container.appendChild(folderButton); + } + }); + + (roleList as HTMLElement).style.display = + (roleList as HTMLElement).style.display === 'none' ? 'block' : 'none'; + } + } + + + private loadGroupList(): void { + const groupList = this.shadowRoot?.getElementById('group-list'); + if (!groupList) return; + + groupsMock.forEach(group => { + const li = document.createElement('li'); + li.className = 'group-list-item'; + + // Create a flex container for the name and the icon + const container = document.createElement('div'); + container.className = 'group-item-container'; + + // Span for the process name + const nameSpan = document.createElement('span'); + nameSpan.textContent = group.name; + nameSpan.className = 'process-name'; + + // Add click event to show roles + nameSpan.addEventListener('click', (event) => { + event.stopPropagation(); + this.toggleRoles(group, li); + }); + + // Add the ⚙️ icon + const settingsIcon = document.createElement('span'); + settingsIcon.textContent = '⚙️'; + settingsIcon.className = 'settings-icon'; + settingsIcon.id = `settings-${group.id}`; + + settingsIcon.onclick = (event) => { + event.stopPropagation(); + this.showProcessDetails(group, group.id); + }; + + // Assemble the elements + container.appendChild(nameSpan); + container.appendChild(settingsIcon); + li.appendChild(container); + + // Create and append the role list container + const roleList = document.createElement('ul'); + roleList.className = 'role-list'; + roleList.style.display = 'none'; + + // Add roles for this process + group.roles.forEach(role => { + const roleItem = document.createElement('li'); + roleItem.className = 'role-item'; + roleItem.textContent = role.name; + roleItem.onclick = (event) => { + event.stopPropagation(); + this.toggleMembers(role, roleItem); + }; + roleList.appendChild(roleItem); + }); + + li.appendChild(roleList); + groupList.appendChild(li); + }); + } + + + // Function to manage the list of users + private toggleUserList() { + const userList = getCorrectDOM('userList'); + if (!userList) return; + + if (!(userList as HTMLElement).classList.contains('show')) { + (userList as HTMLElement).innerHTML = membersMock.map(member => ` +
    + ${member.avatar} +
    + ${member.name} + ${member.email} +
    +
    + `).join(''); + } + (userList as HTMLElement).classList.toggle('show'); + } + + private switchUser(userId: string | number) { + const user = membersMock.find(member => member.id === userId); + if (!user) return; + currentUser = user; + this.updateCurrentUserDisplay(); + const userList = getCorrectDOM('userList') as HTMLElement; + userList?.classList.remove('show'); + } + + // Function to update the display of the current user + private updateCurrentUserDisplay() { + const userDisplay = getCorrectDOM('current-user') as HTMLElement; + if (userDisplay) { + userDisplay.innerHTML = ` + + `; + } + } + // Generate an automatic response + private generateAutoReply(senderName: string): Message { + return { + id: Date.now(), + sender: senderName, + text: "OK...", + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + type: 'text' as const + }; + } + + // Send a file + private sendFile(file: File) { + console.log('SendFile called with file:', file); + const reader = new FileReader(); + reader.onloadend = () => { + const fileData = reader.result; + const fileName = file.name; + console.log('File loaded:', fileName); + + if (this.selectedMemberId) { + messageStore.addMessage(this.selectedMemberId, { + id: Date.now(), + sender: "4NK", + fileName: fileName, + fileData: fileData, + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + type: 'file' + }); + console.log('Message added to store'); + + this.messagesMock = messageStore.getMessages(); + this.loadMemberChat(this.selectedMemberId); + } + }; + reader.readAsDataURL(file); + } + + // Managing the sent file + private fileList: HTMLDivElement = this.shadowRoot?.getElementById('fileList') as HTMLDivElement; + private getFileList() { + const files = Array.from(this.fileList?.querySelectorAll('.file-item') || []).map((fileItem: Element) => { + const fileName = fileItem.querySelector('.file-name')?.textContent || ''; + return { + name: fileName, + url: (fileItem as HTMLElement).dataset.content || '#', + }; + }); + return files; + } + + // New function to display the documents of a role + private showRoleDocuments(role: { + name: string; + documents?: Array<{ + name: string; + visibility: string; + createdAt: string | null | undefined; + deadline: string | null | undefined; + signatures: DocumentSignature[]; + id: number; + description?: string; + status?: string; + files?: Array<{ name: string; url: string }>; + }>; + id?: number; + }, group: Group) { + // Load the data from localStorage + const storedGroups = JSON.parse(localStorage.getItem('groups') || '[]'); + const storedGroup = storedGroups.find((g: Group) => g.id === group.id); + const storedRole = storedGroup?.roles.find((r: any) => r.name === role.name); + + // Use the data from localStorage if available, otherwise use the data passed as a parameter + const displayRole = storedRole || role; + + console.log('Showing documents for role:', displayRole.name, 'in group:', group.name); + // Close all existing document views first + const allDetailsAreas = this.shadowRoot?.querySelectorAll('.process-details'); + allDetailsAreas?.forEach(area => { + area.remove(); + }); + + const container = this.shadowRoot?.querySelector('.container'); + if (!container) { + console.error('Container not found'); + return; + } + + // Create a new details area + const detailsArea = document.createElement('div'); + detailsArea.id = `role-documents-${displayRole.name}`; + detailsArea.className = 'process-details'; + // Filter the accessible documents + const accessibleDocuments = (displayRole.documents || []).filter((doc: { + name: string; + visibility: string; + createdAt: string | null | undefined; + deadline: string | null | undefined; + signatures: DocumentSignature[]; + id: number; + description?: string; + status?: string; + }) => + this.canUserAccessDocument(doc, displayRole.name, currentUser.processRoles?.[0]?.role || '') + ); + + detailsArea.innerHTML = ` + + `; + + container.appendChild(detailsArea); + } + + // Function to close the documents view of a role + private closeRoleDocuments(roleName: string) { + const detailsArea = this.shadowRoot?.getElementById(`role-documents-${roleName}`); + if (detailsArea) { + + detailsArea.remove(); + } + } + + private handleFiles(files: FileList, fileList: HTMLDivElement) { + Array.from(files).forEach(file => { + const reader = new FileReader(); + reader.onload = (e) => { + const fileContent = e.target?.result; + const existingFiles = fileList.querySelectorAll('.file-name'); + const isDuplicate = Array.from(existingFiles).some( + existingFile => existingFile.textContent === file.name + ); + + if (!isDuplicate) { + const fileItem = document.createElement('div'); + fileItem.className = 'file-item'; + fileItem.innerHTML = ` +
    + ${file.name} + (${(file.size / 1024).toFixed(1)} KB) +
    + + `; + fileItem.dataset.content = fileContent as string; + + const removeBtn = fileItem.querySelector('.remove-file'); + if (removeBtn) { + removeBtn.addEventListener('click', () => fileItem.remove()); + } + + fileList.appendChild(fileItem); + } + }; + reader.readAsDataURL(file); + }); + } + + // Function to manage the new request + private newRequest(params: RequestParams) { + // Add parameter validation + if (!params || !params.processId) { + console.error('Paramètres invalides:', params); + this.showAlert('Invalid parameters for new request'); + return; + } + + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + + // Retrieve the process with a verification + const process = groupsMock.find(g => g.id === params.processId); + if (!process) { + console.error('Processus non trouvé:', params.processId); + this.showAlert('Process not found'); + return; + } + + // Determine the members with an additional verification + let membersToDisplay = []; + try { + if (params.roleName === 'common') { + membersToDisplay = process.roles.reduce((members: any[], role) => { + return members.concat(role.members.map(member => ({ + ...member, + roleName: role.name + }))); + }, []); + } else { + const role = process.roles.find(r => r.name === params.roleName); + if (!role) { + throw new Error(`Role ${params.roleName} not found`); + } + membersToDisplay = role.members.map(member => ({ + ...member, + roleName: params.roleName + })); + } + } catch (error) { + console.error('Error retrieving members:', error); + this.showAlert('Error retrieving members'); + return; + } + + + + modal.innerHTML = ` + + `; + + this.shadowRoot?.appendChild(modal); + + const dropZone = modal.querySelector('#dropZone') as HTMLDivElement; + const fileInput = modal.querySelector('#fileInput') as HTMLInputElement; + const fileList = modal.querySelector('#fileList') as HTMLDivElement; + + // Make the area clickable + dropZone.addEventListener('click', () => { + fileInput.click(); + }); + + // Manage the file selection + fileInput.addEventListener('change', (e: Event) => { + const target = e.target as HTMLInputElement; + if (target.files && target.files.length > 0) { + this.handleFiles(target.files, fileList); + } + }); + + // Manage the drag & drop + dropZone.addEventListener('dragover', (e: DragEvent) => { + e.preventDefault(); + dropZone.classList.add('dragover'); + }); + + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('dragover'); + }); + + dropZone.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + dropZone.classList.remove('dragover'); + if (e.dataTransfer?.files) { + this.handleFiles(e.dataTransfer.files, fileList); + } + }); + } + + private closeModal(button: HTMLElement) { + const modalOverlay = button.closest('.modal-overlay'); + if (modalOverlay) { + modalOverlay.remove(); + } + } + + private submitNewDocument(event: Event) { + event.preventDefault(); + + const form = this.shadowRoot?.getElementById('newDocumentForm') as HTMLFormElement; + if (!form) { + this.showAlert('Form not found'); + return; + } + + // Retrieve the files + const fileList = this.shadowRoot?.getElementById('fileList') as HTMLDivElement; + const files = Array.from(fileList?.querySelectorAll('.file-item') || []).map(fileItem => { + const fileName = fileItem.querySelector('.file-name')?.textContent || ''; + return { + name: fileName, + url: (fileItem as HTMLElement).dataset.content || '#', + }; + }); + + // Retrieve the values from the form + const processId = Number((form.querySelector('#processId') as HTMLInputElement)?.value); + const documentId = Number((form.querySelector('#documentId') as HTMLInputElement)?.value); + const documentName = (form.querySelector('#documentName') as HTMLInputElement)?.value?.trim(); + const description = (form.querySelector('#description') as HTMLTextAreaElement)?.value?.trim(); + const deadline = (form.querySelector('#deadline') as HTMLInputElement)?.value; + const visibility = (form.querySelector('#visibility') as HTMLSelectElement)?.value; + + // Validation + if (!documentName || !description || !deadline) { + this.showAlert('Please fill in all required fields'); + return; + } + + try { + // Retrieve the current data + const groups = JSON.parse(localStorage.getItem('groups') || JSON.stringify(groupsMock)); + const group = groups.find((g: Group) => g.id === processId); + + if (!group) { + this.showAlert('Process not found'); + return; + } + + const role = group.roles.find((r: any) => + r.documents?.some((d: any) => d.id === documentId) + ); + + if (!role) { + this.showAlert('Role not found'); + return; + } + + // Create the new document with the signatures of the role members + const updatedDocument = { + id: documentId, + name: documentName, + description: description, + createdAt: new Date().toISOString(), + deadline: deadline, + visibility: visibility, + status: "pending", + signatures: role.members.map((member: { id: string | number; name: string }) => ({ + member: member, + signed: false, + signedAt: null + })), + files: files // Ajout des fichiers au document + }; + + // Update the document in the role + const documentIndex = role.documents.findIndex((d: any) => d.id === documentId); + if (documentIndex !== -1) { + role.documents[documentIndex] = updatedDocument; + } + + // Save in localStorage + localStorage.setItem('groups', JSON.stringify(groups)); + + // Also update groupsMock for consistency + const mockGroup = groupsMock.find(g => g.id === processId); + if (mockGroup) { + const mockRole = mockGroup?.roles.find(r => r.name === role.name); + if (mockRole?.documents) { + const mockDocIndex = mockRole.documents.findIndex(d => d.id === documentId); + if (mockDocIndex !== -1) { + mockRole.documents[mockDocIndex] = { + ...updatedDocument, + status: undefined + }; + } + } + } + + // Close the modal + if (event.target instanceof HTMLElement) { + this.closeModal(event.target); + } + + // Reload the documents view with the updated data + this.showRoleDocuments(role, group); + this.showAlert('Document updated successfully!'); + + } catch (error) { + console.error('Error saving:', error); + this.showAlert('An error occurred while saving'); + } + } + + private submitCommonDocument(event: Event) { + event.preventDefault(); + + const form = this.shadowRoot?.getElementById('newDocumentForm') as HTMLFormElement; + if (!form) { + this.showAlert('Form not found'); + return; + } + + const processId = Number((form.querySelector('#processId') as HTMLInputElement)?.value); + const documentId = Number((form.querySelector('#documentId') as HTMLInputElement)?.value); + const documentName = (form.querySelector('#documentName') as HTMLInputElement)?.value?.trim(); + const description = (form.querySelector('#description') as HTMLTextAreaElement)?.value?.trim(); + const deadline = (form.querySelector('#deadline') as HTMLInputElement)?.value; + const visibility = (form.querySelector('#visibility') as HTMLSelectElement)?.value; + + if (!documentName || !description || !deadline) { + this.showAlert('Please fill in all required fields'); + return; + } + + try { + const groups = JSON.parse(localStorage.getItem('groups') || JSON.stringify(groupsMock)); + const group = groups.find((g: Group) => g.id === processId); + + if (!group) { + this.showAlert('Process not found'); + return; + } + + // Retrieve all members of all roles in the group + const allMembers = group.roles.reduce((acc: any[], role: any) => { + return acc.concat(role.members); + }, []); + + const fileList = this.shadowRoot?.getElementById('fileList') as HTMLDivElement; + const files = Array.from(fileList?.querySelectorAll('.file-item') || []).map(fileItem => { + const fileName = fileItem.querySelector('.file-name')?.textContent || ''; + return { + name: fileName, + url: (fileItem as HTMLElement).dataset.content || '#', + }; + }); + + const updatedDocument = { + id: documentId, + name: documentName, + description: description, + createdAt: new Date().toISOString(), + deadline: deadline, + visibility: visibility, + status: "pending", + signatures: allMembers.map((member: { id: string | number; name: string }) => ({ + member: member, + signed: false, + signedAt: null + })), + files: files + }; + + // Update the common document + const documentIndex = group.commonDocuments.findIndex((d: { id: number }) => d.id === documentId); + if (documentIndex !== -1) { + group.commonDocuments[documentIndex] = updatedDocument; + } + + localStorage.setItem('groups', JSON.stringify(groups)); + + if (event.target instanceof HTMLElement) { + this.closeModal(event.target); + } + + this.showProcessDetails(group, group.id); + this.showAlert('Document common updated successfully!'); + + } catch (error) { + console.error('Error saving:', error); + this.showAlert('An error occurred while saving'); + } + } + + + private submitRequest() { + + this.showAlert("Request submitted!"); + } + + private closeNewRequest() { + const newRequestView = document.getElementById('new-request-view'); + if (newRequestView) { + newRequestView.style.display = 'none'; + newRequestView.remove(); + } + } + + private submitDocumentRequest(documentId: number) { + const createdAt = (this.shadowRoot?.getElementById('createdAt') as HTMLInputElement)?.value || ''; + const deadline = (this.shadowRoot?.getElementById('deadline') as HTMLInputElement)?.value || ''; + const visibility = (this.shadowRoot?.getElementById('visibility') as HTMLSelectElement)?.value || ''; + const description = (this.shadowRoot?.getElementById('description') as HTMLTextAreaElement)?.value || ''; + + const selectedMembers = Array.from( + this.shadowRoot?.querySelectorAll('input[name="selected-members"]:checked') || [] + ).map(checkbox => (checkbox as HTMLInputElement).value); + + if (!createdAt || !deadline || selectedMembers.length === 0) { + this.showAlert('Please fill in all required fields and select at least one member.'); + return; + } + + console.log('Document submission:', { + documentId, + createdAt, + deadline, + visibility, + description, + selectedMembers + }); + + this.showAlert('Document request submitted successfully!'); + this.closeNewRequest(); + } + + // FUNCTIONS FOR SIGNATURE + + // New function to confirm the signature + private confirmSignature(documentId: number, processId: number, isCommonDocument: boolean) { + try { + // Add console.log to see the current user + console.log('Current user:', currentUser); + + const groups = JSON.parse(localStorage.getItem('groups') || JSON.stringify(groupsMock)); + const group = groups.find((g: Group) => g.id === processId); + + if (!group) { + throw new Error('Process not found'); + } + + let targetDoc; + if (isCommonDocument) { + targetDoc = group.commonDocuments.find((d: any) => d.id === documentId); + } else { + for (const role of group.roles) { + if (role.documents) { + targetDoc = role.documents.find((d: any) => d.id === documentId); + if (targetDoc) break; + } + } + } + + if (!targetDoc) { + throw new Error('Document not found'); + } + + const userSignature = targetDoc.signatures.find((sig: DocumentSignature) => + sig.member.name === currentUser.name + ); + + if (!userSignature) { + throw new Error(`The user ${currentUser.name} is not authorized to sign this document. Please log in with an authorized user.`); + } + + // Mettre à jour la signature + userSignature.signed = true; + userSignature.signedAt = new Date().toISOString(); + localStorage.setItem('groups', JSON.stringify(groups)); + + // Supprimer la modal de signature + const modalOverlay = this.shadowRoot?.querySelector('.modal-overlay'); + if (modalOverlay) { + modalOverlay.remove(); + } + + // Rafraîchir l'affichage + if (isCommonDocument) { + this.showProcessDetails(group, processId); + } else { + const role = group.roles.find((r: any) => r.documents?.includes(targetDoc)); + if (role) { + this.showRoleDocuments(role, group); + } + } + + this.showAlert('Document signed successfully!'); + + } catch (error) { + console.error('Error signing document:', error); + this.showAlert(error instanceof Error ? error.message : 'Error signing document'); + } + } + + + private initializeEventListeners() { + document.addEventListener('DOMContentLoaded', (): void => { + const newRequestBtn = this.shadowRoot?.getElementById('newRequestBtn'); + if (newRequestBtn) { + newRequestBtn.addEventListener('click', (): void => { + this.newRequest({ + processId: 0, + processName: '', + roleId: 0, + roleName: '', + documentId: 0, + documentName: '' + }); + }); + } + }); + + // Gestionnaire d'événements pour le chat + const sendBtn = this.shadowRoot?.querySelector('#send-button'); + if (sendBtn) { + sendBtn.addEventListener('click', this.sendMessage.bind(this)); + } + + const messageInput = this.shadowRoot?.querySelector('#message-input'); + if (messageInput) { + messageInput.addEventListener('keypress', (event: Event) => { + if ((event as KeyboardEvent).key === 'Enter') { + event.preventDefault(); + this.sendMessage(); + } + }); + } + + // Gestionnaire pour l'envoi de fichiers + const fileInput = this.shadowRoot?.querySelector('#file-input'); + if (fileInput) { + fileInput.addEventListener('change', (event: Event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) { + this.sendFile(file); + } + }); + } + } + + connectedCallback() { + this.messagesMock = messageStore.getMessages(); + if (this.messagesMock.length === 0) { + messageStore.setMessages(initialMessagesMock); + this.messagesMock = messageStore.getMessages(); + } + this.updateCurrentUserDisplay(); + this.initializeEventListeners(); + this.loadGroupList(); + } +} + +customElements.define('signature-element', SignatureElement); +export { SignatureElement }; + diff --git a/ihm_client/src/router.ts b/ihm_client/src/router.ts new file mode 100755 index 00000000..0ff20ae4 --- /dev/null +++ b/ihm_client/src/router.ts @@ -0,0 +1,945 @@ +import '../public/style/4nk.css'; +import { initHeader } from './components/header/header'; +/*import { initChat } from '../src/pages/chat/chat';*/ +import Database from './services/database.service'; +import Services from './services/service'; +import TokenService from './services/token'; +import { cleanSubscriptions } from './utils/subscription.utils'; +import { LoginComponent } from './pages/home/home-component'; +import { prepareAndSendPairingTx } from './utils/sp-address.utils'; +import ModalService from './services/modal.service'; +import { MessageType } from './models/process.model'; +import { splitPrivateData, isValid32ByteHex } from './utils/service.utils'; +import type { MerkleProofResult } from 'pkg/sdk_client'; + +const routes: { [key: string]: string } = { + home: '/src/pages/home/home.html', + process: '/src/pages/process/process.html', + 'process-element': '/src/pages/process-element/process-element.html', + account: '/src/pages/account/account.html', + chat: '/src/pages/chat/chat.html', + signature: '/src/pages/signature/signature.html', +}; + +export let currentRoute = ''; + +export async function navigate(path: string) { + cleanSubscriptions(); + cleanPage(); + path = path.replace(/^\//, ''); + if (path.includes('/')) { + const parsedPath = path.split('/')[0]; + if (!routes[parsedPath]) { + path = 'home'; + } + } + + await handleLocation(path); +} + +async function handleLocation(path: string) { + const parsedPath = path.split('/'); + if (path.includes('/')) { + path = parsedPath[0]; + } + currentRoute = path; + const routeHtml = routes[path] || routes['home']; + + const content = document.getElementById('containerId'); + if (content) { + if (path === 'home') { + const login = LoginComponent; + const container = document.querySelector('#containerId'); + const accountComponent = document.createElement('login-4nk-component'); + accountComponent.setAttribute('style', 'width: 100vw; height: 100vh; position: relative; grid-row: 2;'); + if (container) container.appendChild(accountComponent); + } else if (path !== 'process') { + const html = await fetch(routeHtml).then((data) => data.text()); + content.innerHTML = html; + } + + await new Promise(requestAnimationFrame); + injectHeader(); + + // const modalService = await ModalService.getInstance() + // modalService.injectValidationModal() + switch (path) { + case 'process': + // const { init } = await import('./pages/process/process'); + //const { ProcessListComponent } = await import('./pages/process/process-list-component'); + + const container2 = document.querySelector('#containerId'); + const accountComponent = document.createElement('process-list-4nk-component'); + + //if (!customElements.get('process-list-4nk-component')) { + //customElements.define('process-list-4nk-component', ProcessListComponent); + //} + accountComponent.setAttribute('style', 'height: 100vh; position: relative; grid-row: 2; grid-column: 4;'); + if (container2) container2.appendChild(accountComponent); + break; + + case 'process-element': + if (parsedPath && parsedPath.length) { + const { initProcessElement } = await import('./pages/process-element/process-element'); + const parseProcess = parsedPath[1].split('_'); + initProcessElement(parseProcess[0], parseProcess[1]); + } + break; + + case 'account': + const { AccountComponent } = await import('./pages/account/account-component'); + const accountContainer = document.querySelector('.parameter-list'); + if (accountContainer) { + if (!customElements.get('account-component')) { + customElements.define('account-component', AccountComponent); + } + const accountComponent = document.createElement('account-component'); + accountContainer.appendChild(accountComponent); + } + break; + + /*case 'chat': + const { ChatComponent } = await import('./pages/chat/chat-component'); + const chatContainer = document.querySelector('.group-list'); + if (chatContainer) { + if (!customElements.get('chat-component')) { + customElements.define('chat-component', ChatComponent); + } + const chatComponent = document.createElement('chat-component'); + chatContainer.appendChild(chatComponent); + } + break;*/ + + case 'signature': + const { SignatureComponent } = await import('./pages/signature/signature-component'); + const container = document.querySelector('.group-list'); + if (container) { + if (!customElements.get('signature-component')) { + customElements.define('signature-component', SignatureComponent); + } + const signatureComponent = document.createElement('signature-component'); + container.appendChild(signatureComponent); + } + break; + } + } +} + +window.onpopstate = async () => { + const services = await Services.getInstance(); + if (!services.isPaired()) { + handleLocation('home'); + } else { + handleLocation('process'); + } +}; + +export async function init(): Promise { + try { + const services = await Services.getInstance(); + (window as any).myService = services; + const db = await Database.getInstance(); + db.registerServiceWorker('/src/service-workers/database.worker.js'); + const device = await services.getDeviceFromDatabase(); + console.log('🚀 ~ setTimeout ~ device:', device); + + if (!device) { + await services.createNewDevice(); + } else { + services.restoreDevice(device); + } + + // If we create a new device, we most probably don't have anything in db, but just in case + await services.restoreProcessesFromDB(); + await services.restoreSecretsFromDB(); + + // We connect to all relays now + await services.connectAllRelays(); + + await services.updateDeviceBlockHeight(); + + // We register all the event listeners if we run in an iframe + if (window.self !== window.top) { + await registerAllListeners(); + } + + if (services.isPaired()) { + await navigate('process'); + } else { + await navigate('home'); + } + } catch (error) { + console.error(error); + await navigate('home'); + } +} + +export async function registerAllListeners() { + const services = await Services.getInstance(); + const tokenService = await TokenService.getInstance(); + + const errorResponse = (errorMsg: string, origin: string, messageId?: string) => { + window.parent.postMessage( + { + type: MessageType.ERROR, + error: errorMsg, + messageId + }, + origin + ); + } + + // --- Handler functions --- + const handleRequestLink = async (event: MessageEvent) => { + if (event.data.type !== MessageType.REQUEST_LINK) { + return; + } + const modalService = await ModalService.getInstance(); + const result = await modalService.showConfirmationModal({ + title: 'Confirmation de liaison', + content: ` + + `, + confirmText: 'Ajouter un service', + cancelText: 'Annuler' + }, true); + + if (!result) { + const errorMsg = 'Failed to pair device: User refused to link'; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + + try { + const tokens = await tokenService.generateSessionToken(event.origin); + const acceptedMsg = { + type: MessageType.LINK_ACCEPTED, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + messageId: event.data.messageId + }; + window.parent.postMessage( + acceptedMsg, + event.origin + ); + } catch (error) { + const errorMsg = `Failed to generate tokens: ${error}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleCreatePairing = async (event: MessageEvent) => { + if (event.data.type !== MessageType.CREATE_PAIRING) { + return; + } + + if (services.isPaired()) { + const errorMsg = 'Device already paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { accessToken } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + console.log('🚀 Starting pairing process'); + await prepareAndSendPairingTx(); + await services.confirmPairing(); + + const pairingId = services.getPairingProcessId(); + + if (!pairingId) { + throw new Error('Failed to get pairing process id'); + } + + // Send success response + const successMsg = { + type: MessageType.PAIRING_CREATED, + pairingId, + messageId: event.data.messageId + }; + window.parent.postMessage(successMsg, event.origin); + } catch (e) { + const errorMsg = `Failed to create pairing process: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleGetMyProcesses = async (event: MessageEvent) => { + if (event.data.type !== MessageType.GET_MY_PROCESSES) { + return; + } + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { accessToken } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + const myProcesses = await services.getMyProcesses(); + + window.parent.postMessage( + { + type: MessageType.GET_MY_PROCESSES, + myProcesses, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to get processes: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleGetProcesses = async (event: MessageEvent) => { + if (event.data.type !== MessageType.GET_PROCESSES) { + return; + } + + const tokenService = await TokenService.getInstance(); + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { accessToken } = event.data; + + // Validate the session token + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + const processes = await services.getProcesses(); + + window.parent.postMessage( + { + type: MessageType.PROCESSES_RETRIEVED, + processes, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to get processes: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + /// We got a state for some process and return as many clear attributes as we can + const handleDecryptState = async (event: MessageEvent) => { + if (event.data.type !== MessageType.RETRIEVE_DATA) { + return; + } + const tokenService = await TokenService.getInstance(); + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { processId, stateId, accessToken } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + // Retrieve the state for the process + const process = await services.getProcess(processId); + if (!process) { + throw new Error('Can\'t find process'); + } + const state = services.getStateFromId(process, stateId); + + let res: Record = {}; + if (state) { + // Decrypt all the data we have the key for + for (const attribute of Object.keys(state.pcd_commitment)) { + if (attribute === 'roles' || state.public_data[attribute]) { + continue; + } + const decryptedAttribute = await services.decryptAttribute(processId, state, attribute); + if (decryptedAttribute) { + res[attribute] = decryptedAttribute; + } + } + } else { + throw new Error('Unknown state for process', processId); + } + + window.parent.postMessage( + { + type: MessageType.DATA_RETRIEVED, + data: res, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to retrieve data: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleValidateToken = async (event: MessageEvent) => { + if (event.data.type !== MessageType.VALIDATE_TOKEN) { + return; + } + + const accessToken = event.data.accessToken; + const refreshToken = event.data.refreshToken; + if (!accessToken || !refreshToken) { + errorResponse('Failed to validate token: missing access, refresh token or both', event.origin, event.data.messageId); + } + + const isValid = await tokenService.validateToken(accessToken, event.origin); + + window.parent.postMessage( + { + type: MessageType.VALIDATE_TOKEN, + accessToken: accessToken, + refreshToken: refreshToken, + isValid: isValid, + messageId: event.data.messageId + }, + event.origin + ); + }; + + const handleRenewToken = async (event: MessageEvent) => { + if (event.data.type !== MessageType.RENEW_TOKEN) { + return; + } + + try { + const refreshToken = event.data.refreshToken; + + if (!refreshToken) { + throw new Error('No refresh token provided'); + } + + const newAccessToken = await tokenService.refreshAccessToken(refreshToken, event.origin); + + if (!newAccessToken) { + throw new Error('Failed to refresh token'); + } + + window.parent.postMessage( + { + type: MessageType.RENEW_TOKEN, + accessToken: newAccessToken, + refreshToken: refreshToken, + messageId: event.data.messageId + }, + event.origin + ); + } catch (error) { + const errorMsg = `Failed to renew token: ${error}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleGetPairingId = async (event: MessageEvent) => { + if (event.data.type !== MessageType.GET_PAIRING_ID) return; + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { accessToken } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + const userPairingId = services.getPairingProcessId(); + + window.parent.postMessage( + { + type: MessageType.GET_PAIRING_ID, + userPairingId, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to get pairing id: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleCreateProcess = async (event: MessageEvent) => { + if (event.data.type !== MessageType.CREATE_PROCESS) return; + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { processData, privateFields, roles, accessToken } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + const { privateData, publicData } = splitPrivateData(processData, privateFields); + + const createProcessReturn = await services.createProcess(privateData, publicData, roles); + if (!createProcessReturn.updated_process) { + throw new Error('Empty updated_process in createProcessReturn'); + } + const processId = createProcessReturn.updated_process.process_id; + const process = createProcessReturn.updated_process.current_process; + const stateId = process.states[0].state_id; + await services.handleApiReturn(createProcessReturn); + + const res = { + processId, + process, + processData, + } + + window.parent.postMessage( + { + type: MessageType.PROCESS_CREATED, + processCreated: res, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to create process: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleNotifyUpdate = async (event: MessageEvent) => { + if (event.data.type !== MessageType.NOTIFY_UPDATE) return; + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { processId, stateId, accessToken } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + if (!isValid32ByteHex(stateId)) { + throw new Error('Invalid state id'); + } + + const res = await services.createPrdUpdate(processId, stateId); + await services.handleApiReturn(res); + + window.parent.postMessage( + { + type: MessageType.UPDATE_NOTIFIED, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to notify update for process: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleValidateState = async (event: MessageEvent) => { + if (event.data.type !== MessageType.VALIDATE_STATE) return; + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { processId, stateId, accessToken } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + const res = await services.approveChange(processId, stateId); + await services.handleApiReturn(res); + + window.parent.postMessage( + { + type: MessageType.STATE_VALIDATED, + validatedProcess: res.updated_process, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to validate process: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleUpdateProcess = async (event: MessageEvent) => { + if (event.data.type !== MessageType.UPDATE_PROCESS) return; + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + + try { + // privateFields is only used if newData contains new fields + // roles can be empty meaning that roles from the last commited state are kept + const { processId, newData, privateFields, roles, accessToken } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + // Check if the new data is already in the process or if it's a new field + const process = await services.getProcess(processId); + if (!process) { + throw new Error('Process not found'); + } + let lastState = services.getLastCommitedState(process); + if (!lastState) { + const firstState = process.states[0]; + const roles = firstState.roles; + if (services.rolesContainsUs(roles)) { + const approveChangeRes= await services.approveChange(processId, firstState.state_id); + await services.handleApiReturn(approveChangeRes); + const prdUpdateRes = await services.createPrdUpdate(processId, firstState.state_id); + await services.handleApiReturn(prdUpdateRes); + } else { + if (firstState.validation_tokens.length > 0) { + // Try to send it again anyway + const res = await services.createPrdUpdate(processId, firstState.state_id); + await services.handleApiReturn(res); + } + } + // Wait a couple seconds + await new Promise(resolve => setTimeout(resolve, 2000)); + lastState = services.getLastCommitedState(process); + if (!lastState) { + throw new Error('Process doesn\'t have a commited state yet'); + } + } + const lastStateIndex = services.getLastCommitedStateIndex(process); + if (lastStateIndex === null) { + throw new Error('Process doesn\'t have a commited state yet'); + } // Shouldn't happen + + const privateData: Record = {}; + const publicData: Record = {}; + + for (const field of Object.keys(newData)) { + // Public data are carried along each new state + // So the first thing we can do is check if the new data is public data + if (lastState.public_data[field]) { + // Add it to public data + publicData[field] = newData[field]; + continue; + } + + // If it's not a public data, it may be either a private data update, or a new field (public of private) + // Caller gave us a list of new private fields, if we see it here this is a new private field + if (privateFields.includes(field)) { + // Add it to private data + privateData[field] = newData[field]; + continue; + } + + // Now it can be an update of private data or a new public data + // We check that the field exists in previous states private data + for (let i = lastStateIndex; i >= 0; i--) { + const state = process.states[i]; + if (state.pcd_commitment[field]) { + // We don't even check if it's a public field, we would have seen it in the last state + privateData[field] = newData[field]; + break; + } else { + // This attribute was not modified in that state, we go back to the previous state + continue; + } + } + + if (privateData[field]) continue; + + // We've get back all the way to the first state without seeing it, it's a new public field + publicData[field] = newData[field]; + } + + // We'll let the wasm check if roles are consistent + + const res = await services.updateProcess(process, privateData, publicData, roles); + await services.handleApiReturn(res); + + window.parent.postMessage( + { + type: MessageType.PROCESS_UPDATED, + updatedProcess: res.updated_process, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to update process: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleDecodePublicData = async (event: MessageEvent) => { + if (event.data.type !== MessageType.DECODE_PUBLIC_DATA) return; + + if (!services.isPaired()) { + const errorMsg = 'Device not paired'; + errorResponse(errorMsg, event.origin, event.data.messageId); + return; + } + + try { + const { accessToken, encodedData } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + const decodedData = services.decodeValue(encodedData); + + window.parent.postMessage( + { + type: MessageType.PUBLIC_DATA_DECODED, + decodedData, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to decode data: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleHashValue = async (event: MessageEvent) => { + if (event.data.type !== MessageType.HASH_VALUE) return; + + console.log('handleHashValue', event.data); + + try { + const { accessToken, commitedIn, label, fileBlob } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + const hash = services.getHashForFile(commitedIn, label, fileBlob); + + window.parent.postMessage( + { + type: MessageType.VALUE_HASHED, + hash, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to hash value: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleGetMerkleProof = async (event: MessageEvent) => { + if (event.data.type !== MessageType.GET_MERKLE_PROOF) return; + + try { + const { accessToken, processState, attributeName } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + const proof = services.getMerkleProofForFile(processState, attributeName); + + window.parent.postMessage( + { + type: MessageType.MERKLE_PROOF_RETRIEVED, + proof, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to get merkle proof: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + const handleValidateMerkleProof = async (event: MessageEvent) => { + if (event.data.type !== MessageType.VALIDATE_MERKLE_PROOF) return; + + try { + const { accessToken, merkleProof, documentHash } = event.data; + + if (!accessToken || !(await tokenService.validateToken(accessToken, event.origin))) { + throw new Error('Invalid or expired session token'); + } + + // Try to parse the proof + // We will validate it's a MerkleProofResult in the wasm + let parsedMerkleProof: MerkleProofResult; + try { + parsedMerkleProof= JSON.parse(merkleProof); + } catch (e) { + throw new Error('Provided merkleProof is not a valid json object'); + } + + const res = services.validateMerkleProof(parsedMerkleProof, documentHash); + + window.parent.postMessage( + { + type: MessageType.MERKLE_PROOF_VALIDATED, + isValid: res, + messageId: event.data.messageId + }, + event.origin + ); + } catch (e) { + const errorMsg = `Failed to get merkle proof: ${e}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + window.removeEventListener('message', handleMessage); + window.addEventListener('message', handleMessage); + + async function handleMessage(event: MessageEvent) { + try { + switch (event.data.type) { + case MessageType.REQUEST_LINK: + await handleRequestLink(event); + break; + case MessageType.CREATE_PAIRING: + await handleCreatePairing(event); + break; + case MessageType.GET_MY_PROCESSES: + await handleGetMyProcesses(event); + break; + case MessageType.GET_PROCESSES: + await handleGetProcesses(event); + break; + case MessageType.RETRIEVE_DATA: + await handleDecryptState(event); + break; + case MessageType.VALIDATE_TOKEN: + await handleValidateToken(event); + break; + case MessageType.RENEW_TOKEN: + await handleRenewToken(event); + break; + case MessageType.GET_PAIRING_ID: + await handleGetPairingId(event); + break; + case MessageType.CREATE_PROCESS: + await handleCreateProcess(event); + break; + case MessageType.NOTIFY_UPDATE: + await handleNotifyUpdate(event); + break; + case MessageType.VALIDATE_STATE: + await handleValidateState(event); + break; + case MessageType.UPDATE_PROCESS: + await handleUpdateProcess(event); + break; + case MessageType.DECODE_PUBLIC_DATA: + await handleDecodePublicData(event); + break; + case MessageType.HASH_VALUE: + await handleHashValue(event); + break; + case MessageType.GET_MERKLE_PROOF: + await handleGetMerkleProof(event); + break; + case MessageType.VALIDATE_MERKLE_PROOF: + await handleValidateMerkleProof(event); + break; + default: + console.warn(`Unhandled message type: ${event.data.type}`); + } + } catch (error) { + const errorMsg = `Error handling message: ${error}`; + errorResponse(errorMsg, event.origin, event.data.messageId); + } + } + + window.parent.postMessage( + { + type: MessageType.LISTENING + }, + '*' + ); +} + +async function cleanPage() { + const container = document.querySelector('#containerId'); + if (container) container.innerHTML = ''; +} + +async function injectHeader() { + const headerContainer = document.getElementById('header-container'); + if (headerContainer) { + const headerHtml = await fetch('/src/components/header/header.html').then((res) => res.text()); + headerContainer.innerHTML = headerHtml; + + const script = document.createElement('script'); + script.src = '/src/components/header/header.ts'; + script.type = 'module'; + document.head.appendChild(script); + initHeader(); + } +} + +(window as any).navigate = navigate; + +document.addEventListener('navigate', ((e: Event) => { + const event = e as CustomEvent<{page: string, processId?: string}>; + if (event.detail.page === 'chat') { + const container = document.querySelector('.container'); + if (container) container.innerHTML = ''; + + //initChat(); + + const chatElement = document.querySelector('chat-element'); + if (chatElement) { + chatElement.setAttribute('process-id', event.detail.processId || ''); + } + } +})); diff --git a/ihm_client/src/scanner.js b/ihm_client/src/scanner.js new file mode 100755 index 00000000..ff048c06 --- /dev/null +++ b/ihm_client/src/scanner.js @@ -0,0 +1,13 @@ +function onScanSuccess(decodedText, decodedResult) { + // handle the scanned code as you like, for example: + console.log(`Code matched = ${decodedText}`, decodedResult); +} + +function onScanFailure(error) { + // handle scan failure, usually better to ignore and keep scanning. + // for example: + console.warn(`Code scan error = ${error}`); +} + +let html5QrcodeScanner = new Html5QrcodeScanner('reader', { fps: 10, qrbox: { width: 250, height: 250 } }, /* verbose= */ false); +html5QrcodeScanner.render(onScanSuccess, onScanFailure); diff --git a/ihm_client/src/service-workers/cache.worker.js b/ihm_client/src/service-workers/cache.worker.js new file mode 100644 index 00000000..966eb995 --- /dev/null +++ b/ihm_client/src/service-workers/cache.worker.js @@ -0,0 +1,8 @@ +const addResourcesToCache = async (resources) => { + const cache = await caches.open('v1'); + await cache.addAll(resources); +}; + +self.addEventListener('install', (event) => { + event.waitUntil(addResourcesToCache(['/', '/index.html', '/style.css', '/app.js', '/image-list.js', '/star-wars-logo.jpg', '/gallery/bountyHunters.jpg', '/gallery/myLittleVader.jpg', '/gallery/snowTroopers.jpg'])); +}); diff --git a/ihm_client/src/service-workers/database.worker.js b/ihm_client/src/service-workers/database.worker.js new file mode 100755 index 00000000..a9e05485 --- /dev/null +++ b/ihm_client/src/service-workers/database.worker.js @@ -0,0 +1,281 @@ +const EMPTY32BYTES = String('').padStart(64, '0'); + +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); // Activate worker immediately +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); // Become available to all pages +}); + +// Event listener for messages from clients +self.addEventListener('message', async (event) => { + const data = event.data; + console.log(data); + + if (data.type === 'SCAN') { + try { + const myProcessesId = data.payload; + if (myProcessesId && myProcessesId.length != 0) { + const toDownload = await scanMissingData(myProcessesId); + if (toDownload.length != 0) { + console.log('Sending TO_DOWNLOAD message'); + event.source.postMessage({ type: 'TO_DOWNLOAD', data: toDownload}); + } + } else { + event.source.postMessage({ status: 'error', message: 'Empty lists' }); + } + } catch (error) { + event.source.postMessage({ status: 'error', message: error.message }); + } + } else if (data.type === 'ADD_OBJECT') { + try { + const { storeName, object, key } = data.payload; + const db = await openDatabase(); + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + + if (key) { + await store.put(object, key); + } else { + await store.put(object); + } + + event.ports[0].postMessage({ status: 'success', message: '' }); + } catch (error) { + event.ports[0].postMessage({ status: 'error', message: error.message }); + } + } else if (data.type === 'BATCH_WRITING') { + const { storeName, objects } = data.payload; + const db = await openDatabase(); + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + + for (const { key, object } of objects) { + if (key) { + await store.put(object, key); + } else { + await store.put(object); + } + } + + await tx.done; + } +}); + +async function scanMissingData(processesToScan) { + console.log('Scanning for missing data...'); + const myProcesses = await getProcesses(processesToScan); + + let toDownload = new Set(); + // Iterate on each process + if (myProcesses && myProcesses.length != 0) { + for (const process of myProcesses) { + // Iterate on states + const firstState = process.states[0]; + const processId = firstState.commited_in; + for (const state of process.states) { + if (state.state_id === EMPTY32BYTES) continue; + // iterate on pcd_commitment + for (const [field, hash] of Object.entries(state.pcd_commitment)) { + // Skip public fields + if (state.public_data[field] !== undefined || field === 'roles') continue; + // Check if we have the data in db + const existingData = await getBlob(hash); + if (!existingData) { + toDownload.add(hash); + // We also add an entry in diff, in case it doesn't already exist + await addDiff(processId, state.state_id, hash, state.roles, field); + } else { + // We remove it if we have it in the set + if (toDownload.delete(hash)) { + console.log(`Removing ${hash} from the set`); + } + } + } + } + } + } + + console.log(toDownload); + return Array.from(toDownload); +} + +async function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('4nk', 1); + request.onerror = (event) => { + reject(request.error); + }; + request.onsuccess = (event) => { + resolve(request.result); + }; + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains('wallet')) { + db.createObjectStore('wallet', { keyPath: 'pre_id' }); + } + }; + }); +} + +// Function to get all processes because it is asynchronous +async function getAllProcesses() { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + if (!db) { + reject(new Error('Database is not available')); + return; + } + const tx = db.transaction('processes', 'readonly'); + const store = tx.objectStore('processes'); + const request = store.getAll(); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +}; + +async function getProcesses(processIds) { + if (!processIds || processIds.length === 0) { + return []; + } + + const db = await openDatabase(); + if (!db) { + throw new Error('Database is not available'); + } + + const tx = db.transaction('processes', 'readonly'); + const store = tx.objectStore('processes'); + + const requests = Array.from(processIds).map((processId) => { + return new Promise((resolve) => { + const request = store.get(processId); + request.onsuccess = () => resolve(request.result); + request.onerror = () => { + console.error(`Error fetching process ${processId}:`, request.error); + resolve(undefined); + }; + }); + }); + + const results = await Promise.all(requests); + return results.filter(result => result !== undefined); +} + +async function getAllDiffsNeedValidation() { + const db = await openDatabase(); + + const allProcesses = await getAllProcesses(); + const tx = db.transaction('diffs', 'readonly'); + const store = tx.objectStore('diffs'); + + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = (event) => { + const allItems = event.target.result; + const itemsWithFlag = allItems.filter((item) => item.need_validation); + + const processMap = {}; + + for (const diff of itemsWithFlag) { + const currentProcess = allProcesses.find((item) => { + return item.states.some((state) => state.merkle_root === diff.new_state_merkle_root); + }); + + if (currentProcess) { + const processKey = currentProcess.merkle_root; + + if (!processMap[processKey]) { + processMap[processKey] = { + process: currentProcess.states, + processId: currentProcess.key, + diffs: [], + }; + } + processMap[processKey].diffs.push(diff); + } + } + + const results = Object.values(processMap).map((entry) => { + const diffs = [] + for(const state of entry.process) { + const filteredDiff = entry.diffs.filter(diff => diff.new_state_merkle_root === state.merkle_root); + if(filteredDiff && filteredDiff.length) { + diffs.push(filteredDiff) + } + } + return { + process: entry.process, + processId: entry.processId, + diffs: diffs, + }; + }); + + resolve(results); + }; + + request.onerror = (event) => { + reject(event.target.error); + }; + }); +} + +async function getBlob(hash) { + const db = await openDatabase(); + const storeName = 'data'; + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const result = await new Promise((resolve, reject) => { + const getRequest = store.get(hash); + getRequest.onsuccess = () => resolve(getRequest.result); + getRequest.onerror = () => reject(getRequest.error); + }); + return result; +} + +async function addDiff(processId, stateId, hash, roles, field) { + const db = await openDatabase(); + const storeName = 'diffs'; + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + + // Check if the diff already exists + const existingDiff = await new Promise((resolve, reject) => { + const getRequest = store.get(hash); + getRequest.onsuccess = () => resolve(getRequest.result); + getRequest.onerror = () => reject(getRequest.error); + }); + + if (!existingDiff) { + const newDiff = { + process_id: processId, + state_id: stateId, + value_commitment: hash, + roles: roles, + field: field, + description: null, + previous_value: null, + new_value: null, + notify_user: false, + need_validation: false, + validation_status: 'None' + }; + + const insertResult = await new Promise((resolve, reject) => { + const putRequest = store.put(newDiff); + putRequest.onsuccess = () => resolve(putRequest.result); + putRequest.onerror = () => reject(putRequest.error); + }); + + return insertResult; + } + + return existingDiff; +} diff --git a/ihm_client/src/services/database.service.ts b/ihm_client/src/services/database.service.ts new file mode 100755 index 00000000..c6cd73ea --- /dev/null +++ b/ihm_client/src/services/database.service.ts @@ -0,0 +1,454 @@ +import Services from './service'; + +export class Database { + private static instance: Database; + private db: IDBDatabase | null = null; + private dbName: string = '4nk'; + private dbVersion: number = 1; + private serviceWorkerRegistration: ServiceWorkerRegistration | null = null; + private messageChannel: MessageChannel | null = null; + private messageChannelForGet: MessageChannel | null = null; + private serviceWorkerCheckIntervalId: number | null = null; + private storeDefinitions = { + AnkLabels: { + name: 'labels', + options: { keyPath: 'emoji' }, + indices: [], + }, + AnkWallet: { + name: 'wallet', + options: { keyPath: 'pre_id' }, + indices: [], + }, + AnkProcess: { + name: 'processes', + options: {}, + indices: [], + }, + AnkSharedSecrets: { + name: 'shared_secrets', + options: {}, + indices: [], + }, + AnkUnconfirmedSecrets: { + name: 'unconfirmed_secrets', + options: { autoIncrement: true }, + indices: [], + }, + AnkPendingDiffs: { + name: 'diffs', + options: { keyPath: 'value_commitment' }, + indices: [ + { name: 'byStateId', keyPath: 'state_id', options: { unique: false } }, + { name: 'byNeedValidation', keyPath: 'need_validation', options: { unique: false } }, + { name: 'byStatus', keyPath: 'validation_status', options: { unique: false } }, + ], + }, + AnkData: { + name: 'data', + options: {}, + indices: [], + }, + }; + + // Private constructor to prevent direct instantiation from outside + private constructor() {} + + // Method to access the singleton instance of Database + public static async getInstance(): Promise { + if (!Database.instance) { + Database.instance = new Database(); + await Database.instance.init(); + } + return Database.instance; + } + + // Initialize the database + private async init(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion); + + request.onupgradeneeded = () => { + const db = request.result; + + Object.values(this.storeDefinitions).forEach(({ name, options, indices }) => { + if (!db.objectStoreNames.contains(name)) { + let store = db.createObjectStore(name, options as IDBObjectStoreParameters); + + indices.forEach(({ name, keyPath, options }) => { + store.createIndex(name, keyPath, options); + }); + } + }); + }; + + request.onsuccess = async () => { + this.db = request.result; + resolve(); + }; + + request.onerror = () => { + console.error('Database error:', request.error); + reject(request.error); + }; + }); + } + + public async getDb(): Promise { + if (!this.db) { + await this.init(); + } + return this.db!; + } + + public getStoreList(): { [key: string]: string } { + const objectList: { [key: string]: string } = {}; + Object.keys(this.storeDefinitions).forEach((key) => { + objectList[key] = this.storeDefinitions[key as keyof typeof this.storeDefinitions].name; + }); + return objectList; + } + + public async registerServiceWorker(path: string) { + if (!('serviceWorker' in navigator)) return; // Ensure service workers are supported + console.log('registering worker at', path); + + try { + // Get existing service worker registrations + const registrations = await navigator.serviceWorker.getRegistrations(); + if (registrations.length === 0) { + // No existing workers: register a new one. + this.serviceWorkerRegistration = await navigator.serviceWorker.register(path, { type: 'module' }); + console.log('Service Worker registered with scope:', this.serviceWorkerRegistration.scope); + } else if (registrations.length === 1) { + // One existing worker: update it (restart it) without unregistering. + this.serviceWorkerRegistration = registrations[0]; + await this.serviceWorkerRegistration.update(); + console.log('Service Worker updated'); + } else { + // More than one existing worker: unregister them all and register a new one. + console.log('Multiple Service Worker(s) detected. Unregistering all...'); + await Promise.all(registrations.map(reg => reg.unregister())); + console.log('All previous Service Workers unregistered.'); + this.serviceWorkerRegistration = await navigator.serviceWorker.register(path, { type: 'module' }); + console.log('Service Worker registered with scope:', this.serviceWorkerRegistration.scope); + } + + await this.checkForUpdates(); + + // Set up a global message listener for responses from the service worker. + navigator.serviceWorker.addEventListener('message', async (event) => { + console.log('Received message from service worker:', event.data); + await this.handleServiceWorkerMessage(event.data); + }); + + // Set up a periodic check to ensure the service worker is active and to send a SCAN message. + this.serviceWorkerCheckIntervalId = window.setInterval(async () => { + const activeWorker = this.serviceWorkerRegistration?.active || (await this.waitForServiceWorkerActivation(this.serviceWorkerRegistration!)); + const service = await Services.getInstance(); + const payload = await service.getMyProcesses(); + if (payload && payload.length != 0) { + activeWorker?.postMessage({ type: 'SCAN', payload }); + } + }, 5000); + } catch (error) { + console.error('Service Worker registration failed:', error); + } + } + + // Helper function to wait for service worker activation + private async waitForServiceWorkerActivation(registration: ServiceWorkerRegistration): Promise { + return new Promise((resolve) => { + if (registration.active) { + resolve(registration.active); + } else { + const listener = () => { + if (registration.active) { + navigator.serviceWorker.removeEventListener('controllerchange', listener); + resolve(registration.active); + } + }; + navigator.serviceWorker.addEventListener('controllerchange', listener); + } + }); + } + + private async checkForUpdates() { + if (this.serviceWorkerRegistration) { + // Check for updates to the service worker + try { + await this.serviceWorkerRegistration.update(); + + // If there's a new worker waiting, activate it immediately + if (this.serviceWorkerRegistration.waiting) { + this.serviceWorkerRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }); + } + } catch (error) { + console.error('Error checking for service worker updates:', error); + } + } + } + + private async handleServiceWorkerMessage(message: any) { + switch (message.type) { + case 'TO_DOWNLOAD': + await this.handleDownloadList(message.data); + break; + default: + console.warn('Unknown message type received from service worker:', message); + } + } + + private async handleDownloadList(downloadList: string[]): Promise { + // Download the missing data + let requestedStateId: string[] = []; + const service = await Services.getInstance(); + for (const hash of downloadList) { + const diff = await service.getDiffByValue(hash); + if (!diff) { + // This should never happen + console.warn(`Missing a diff for hash ${hash}`); + continue; + } + const processId = diff.process_id; + const stateId = diff.state_id; + const roles = diff.roles; + try { + const valueBytes = await service.fetchValueFromStorage(hash); + if (valueBytes) { + // Save data to db + const blob = new Blob([valueBytes], {type: "application/octet-stream"}); + await service.saveBlobToDb(hash, blob); + document.dispatchEvent(new CustomEvent('newDataReceived', { + detail: { + processId, + stateId, + hash, + } + })); + } else { + // We first request the data from managers + console.log('Request data from managers of the process'); + // get the diff from db + if (!requestedStateId.includes(stateId)) { + await service.requestDataFromPeers(processId, [stateId], [roles]); + requestedStateId.push(stateId); + } + } + } catch (e) { + console.error(e); + } + } + } + + private handleAddObjectResponse = async (event: MessageEvent) => { + const data = event.data; + console.log('Received response from service worker (ADD_OBJECT):', data); + const service = await Services.getInstance(); + if (data.type === 'NOTIFICATIONS') { + service.setNotifications(data.data); + } else if (data.type === 'TO_DOWNLOAD') { + console.log(`Received missing data ${data}`); + // Download the missing data + let requestedStateId: string[] = []; + for (const hash of data.data) { + try { + const valueBytes = await service.fetchValueFromStorage(hash); + if (valueBytes) { + // Save data to db + const blob = new Blob([valueBytes], {type: "application/octet-stream"}); + await service.saveBlobToDb(hash, blob); + } else { + // We first request the data from managers + console.log('Request data from managers of the process'); + // get the diff from db + const diff = await service.getDiffByValue(hash); + if (diff === null) { + continue; + } + const processId = diff!.process_id; + const stateId = diff!.state_id; + const roles = diff!.roles; + if (!requestedStateId.includes(stateId)) { + await service.requestDataFromPeers(processId, [stateId], [roles]); + requestedStateId.push(stateId); + } + } + } catch (e) { + console.error(e); + } + } + } + }; + + private handleGetObjectResponse = (event: MessageEvent) => { + console.log('Received response from service worker (GET_OBJECT):', event.data); + }; + + public addObject(payload: { storeName: string; object: any; key: any }): Promise { + return new Promise(async (resolve, reject) => { + // Check if the service worker is active + if (!this.serviceWorkerRegistration) { + // console.warn('Service worker registration is not ready. Waiting...'); + this.serviceWorkerRegistration = await navigator.serviceWorker.ready; + } + + const activeWorker = await this.waitForServiceWorkerActivation(this.serviceWorkerRegistration); + + // Create a message channel for communication + const messageChannel = new MessageChannel(); + + // Handle the response from the service worker + messageChannel.port1.onmessage = (event) => { + if (event.data.status === 'success') { + resolve(); + } else { + const error = event.data.message; + reject(new Error(error || 'Unknown error occurred while adding object')); + } + }; + + // Send the add object request to the service worker + try { + activeWorker?.postMessage( + { + type: 'ADD_OBJECT', + payload, + }, + [messageChannel.port2], + ); + } catch (error) { + reject(new Error(`Failed to send message to service worker: ${error}`)); + } + }); + } + + public batchWriting(payload: { storeName: string; objects: { key: any; object: any }[] }): Promise { + return new Promise(async (resolve, reject) => { + if (!this.serviceWorkerRegistration) { + this.serviceWorkerRegistration = await navigator.serviceWorker.ready; + } + + const activeWorker = await this.waitForServiceWorkerActivation(this.serviceWorkerRegistration); + const messageChannel = new MessageChannel(); + + messageChannel.port1.onmessage = (event) => { + if (event.data.status === 'success') { + resolve(); + } else { + const error = event.data.message; + reject(new Error(error || 'Unknown error occurred while adding objects')); + } + }; + + try { + activeWorker?.postMessage( + { + type: 'BATCH_WRITING', + payload, + }, + [messageChannel.port2], + ); + } catch (error) { + reject(new Error(`Failed to send message to service worker: ${error}`)); + } + }); + } + + public async getObject(storeName: string, key: string): Promise { + const db = await this.getDb(); + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const result = await new Promise((resolve, reject) => { + const getRequest = store.get(key); + getRequest.onsuccess = () => resolve(getRequest.result); + getRequest.onerror = () => reject(getRequest.error); + }); + return result; + } + + public async dumpStore(storeName: string): Promise> { + const db = await this.getDb(); + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + + try { + return new Promise((resolve, reject) => { + const result: Record = {}; + const cursor = store.openCursor(); + + cursor.onsuccess = (event) => { + const request = event.target as IDBRequest; + const cursor = request.result; + if (cursor) { + result[cursor.key as string] = cursor.value; + cursor.continue(); + } else { + resolve(result); + } + }; + + cursor.onerror = () => { + reject(cursor.error); + }; + }); + } catch (error) { + console.error('Error fetching data from IndexedDB:', error); + throw error; + } + } + + public async deleteObject(storeName: string, key: string): Promise { + const db = await this.getDb(); + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + try { + await new Promise((resolve, reject) => { + const getRequest = store.delete(key); + getRequest.onsuccess = () => resolve(getRequest.result); + getRequest.onerror = () => reject(getRequest.error); + }); + } catch (e) { + throw e; + } + } + + public async clearStore(storeName: string): Promise { + const db = await this.getDb(); + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + try { + await new Promise((resolve, reject) => { + const clearRequest = store.clear(); + clearRequest.onsuccess = () => resolve(clearRequest.result); + clearRequest.onerror = () => reject(clearRequest.error); + }); + } catch (e) { + throw e; + } + } + + // Request a store by index + public async requestStoreByIndex(storeName: string, indexName: string, request: string): Promise { + const db = await this.getDb(); + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const index = store.index(indexName); + + try { + return new Promise((resolve, reject) => { + const getAllRequest = index.getAll(request); + getAllRequest.onsuccess = () => { + const allItems = getAllRequest.result; + const filtered = allItems.filter(item => item.state_id === request); + resolve(filtered); + }; + getAllRequest.onerror = () => reject(getAllRequest.error); + }); + } catch (e) { + throw e; + } + } +} + +export default Database; diff --git a/ihm_client/src/services/modal.service.ts b/ihm_client/src/services/modal.service.ts new file mode 100755 index 00000000..b2f6d7cd --- /dev/null +++ b/ihm_client/src/services/modal.service.ts @@ -0,0 +1,240 @@ +import modalHtml from '../components/login-modal/login-modal.html?raw'; +import modalScript from '../components/login-modal/login-modal.js?raw'; +import validationModalStyle from '../components/validation-modal/validation-modal.css?raw'; +import Services from './service'; +import { init, navigate } from '../router'; +import { addressToEmoji } from '../utils/sp-address.utils'; +import type { Member, RoleDefinition } from 'pkg/sdk_client'; +import { initValidationModal } from '~/components/validation-modal/validation-modal'; +import { interpolate } from '~/utils/html.utils'; + +interface ConfirmationModalOptions { + title: string; + content: string; + confirmText?: string; + cancelText?: string; +} + +export default class ModalService { + private static instance: ModalService; + private stateId: string | null = null; + private processId: string | null = null; + private constructor() {} + private paired_addresses: string[] = []; + private modal: HTMLElement | null = null; + + // Method to access the singleton instance of Services + public static async getInstance(): Promise { + if (!ModalService.instance) { + ModalService.instance = new ModalService(); + } + return ModalService.instance; + } + + public openLoginModal(myAddress: string, receiverAddress: string) { + const container = document.querySelector('.page-container'); + let html = modalHtml; + html = html.replace('{{device1}}', myAddress); + html = html.replace('{{device2}}', receiverAddress); + if (container) container.innerHTML += html; + const modal = document.getElementById('login-modal'); + if (modal) modal.style.display = 'flex'; + const newScript = document.createElement('script'); + + newScript.setAttribute('type', 'module'); + newScript.textContent = modalScript; + document.head.appendChild(newScript).parentNode?.removeChild(newScript); + } + + async injectModal(members: any[]) { + const container = document.querySelector('#containerId'); + if (container) { + let html = await fetch('/src/components/modal/confirmation-modal.html').then((res) => res.text()); + html = html.replace('{{device1}}', await addressToEmoji(members[0]['sp_addresses'][0])); + html = html.replace('{{device2}}', await addressToEmoji(members[0]['sp_addresses'][1])); + container.innerHTML += html; + + // Dynamically load the header JS + const script = document.createElement('script'); + script.src = '/src/components/modal/confirmation-modal.ts'; + script.type = 'module'; + document.head.appendChild(script); + } + } + + async injectCreationModal(members: any[]) { + const container = document.querySelector('#containerId'); + if (container) { + let html = await fetch('/src/components/modal/creation-modal.html').then((res) => res.text()); + html = html.replace('{{device1}}', await addressToEmoji(members[0]['sp_addresses'][0])); + container.innerHTML += html; + + // Dynamically load the header JS + const script = document.createElement('script'); + script.src = '/src/components/modal/confirmation-modal.ts'; + script.type = 'module'; + document.head.appendChild(script); + } + } + + // Device 1 wait Device 2 + async injectWaitingModal() { + const container = document.querySelector('#containerId'); + if (container) { + let html = await fetch('/src/components/modal/waiting-modal.html').then((res) => res.text()); + container.innerHTML += html; + } + } + + async injectValidationModal(processDiff: any) { + const container = document.querySelector('#containerId'); + if (container) { + let html = await fetch('/src/components/validation-modal/validation-modal.html').then((res) => res.text()); + html = interpolate(html, {processId: processDiff.processId}) + container.innerHTML += html; + + // Dynamically load the header JS + const script = document.createElement('script'); + script.id = 'validation-modal-script'; + script.src = '/src/components/validation-modal/validation-modal.ts'; + script.type = 'module'; + document.head.appendChild(script); + const css = document.createElement('style'); + css.id = 'validation-modal-css'; + css.innerText = validationModalStyle; + document.head.appendChild(css); + initValidationModal(processDiff) + } + } + + async closeValidationModal() { + const script = document.querySelector('#validation-modal-script'); + const css = document.querySelector('#validation-modal-css'); + const component = document.querySelector('#validation-modal'); + script?.remove(); + css?.remove(); + component?.remove(); + } + + public async openPairingConfirmationModal(roleDefinition: Record, processId: string, stateId: string) { + let memberOutPoints; + if (roleDefinition['pairing']) { + const owner = roleDefinition['pairing']; + memberOutPoints = owner.members; + } else { + throw new Error('No "pairing" role'); + } + + if (memberOutPoints.length != 1) { + throw new Error('Must have exactly 1 member'); + } + + console.log("MEMBER OUTPOINTS:", memberOutPoints); + // We take all the addresses except our own + const service = await Services.getInstance(); + const localAddress = service.getDeviceAddress(); + + // Récupérer les objets Member à partir des OutPoint + const members: Member[] = []; + for (const outPoint of memberOutPoints) { + // Ici, vous devriez récupérer l'objet Member correspondant à l'OutPoint + // Pour l'instant, on crée un objet Member vide + const member: Member = { sp_addresses: [] }; + members.push(member); + } + + for (const member of members) { + if (member.sp_addresses) { + for (const address of member.sp_addresses) { + if (address !== localAddress) { + this.paired_addresses.push(address); + } + } + } + } + this.processId = processId; + this.stateId = stateId; + + if (members[0].sp_addresses.length === 1) { + await this.injectCreationModal(members); + this.modal = document.getElementById('creation-modal'); + console.log("LENGTH:", members[0].sp_addresses.length); + } else { + await this.injectModal(members); + this.modal = document.getElementById('modal'); + console.log("LENGTH:", members[0].sp_addresses.length); + } + + if (this.modal) this.modal.style.display = 'flex'; + + // Close modal when clicking outside of it + window.onclick = (event) => { + if (event.target === this.modal) { + this.closeConfirmationModal(); + } + }; + } + confirmLogin() { + console.log('=============> Confirm Login'); + } + async closeLoginModal() { + if (this.modal) this.modal.style.display = 'none'; + } + + async showConfirmationModal(options: ConfirmationModalOptions, fullscreen: boolean = false): Promise { + // Create modal element + const modalElement = document.createElement('div'); + modalElement.id = 'confirmation-modal'; + modalElement.innerHTML = ` + + `; + + // Add modal to document + document.body.appendChild(modalElement); + + // Return promise that resolves with user choice + return new Promise((resolve) => { + const confirmButton = modalElement.querySelector('#confirm-button'); + const cancelButton = modalElement.querySelector('#cancel-button'); + const modalOverlay = modalElement.querySelector('.modal-overlay'); + + const cleanup = () => { + modalElement.remove(); + }; + + confirmButton?.addEventListener('click', () => { + cleanup(); + resolve(true); + }); + + cancelButton?.addEventListener('click', () => { + cleanup(); + resolve(false); + }); + + modalOverlay?.addEventListener('click', (e) => { + if (e.target === modalOverlay) { + cleanup(); + resolve(false); + } + }); + }); + } + + async closeConfirmationModal() { + const service = await Services.getInstance(); + await service.unpairDevice(); + if (this.modal) this.modal.style.display = 'none'; + } +} diff --git a/ihm_client/src/services/service.ts b/ihm_client/src/services/service.ts new file mode 100755 index 00000000..7f3a83af --- /dev/null +++ b/ihm_client/src/services/service.ts @@ -0,0 +1,1752 @@ +import { INotification } from '~/models/notification.model'; +import { IProcess } from '~/models/process.model'; +import { initWebsocket, sendMessage } from '../websockets'; +import type { ApiReturn, Device, HandshakeMessage, Member, MerkleProofResult, NewTxMessage, OutPointProcessMap, Process, ProcessState, RoleDefinition, SecretsStore, UserDiff } from '../../pkg/sdk_client'; +import ModalService from './modal.service'; +import Database from './database.service'; +import { navigate } from '../router'; +import { storeData, retrieveData, testData } from './storage.service'; +import { BackUp } from '~/models/backup.model'; + +export const U32_MAX = 4294967295; + +const BASEURL = `http://localhost`; +const BOOTSTRAPURL = [`${BASEURL}:8090`]; +const STORAGEURL = `${BASEURL}:8081` +const BLINDBITURL = `${BASEURL}:8000` +const DEFAULTAMOUNT = 1000n; +const EMPTY32BYTES = String('').padStart(64, '0'); + +export default class Services { + private static initializing: Promise | null = null; + private static instance: Services; + private processId: string | null = null; + private stateId: string | null = null; + private sdkClient: any; + private processesCache: Record = {}; + private myProcesses: Set = new Set(); + private notifications: any[] | null = null; + private subscriptions: { element: Element; event: string; eventHandler: string }[] = []; + private database: any; + private routingInstance!: ModalService; + private relayAddresses: { [wsurl: string]: string } = {}; + private membersList: Record = {}; + private currentBlockHeight: number = -1; + // Private constructor to prevent direct instantiation from outside + private constructor() {} + + // Method to access the singleton instance of Services + public static async getInstance(): Promise { + if (Services.instance) { + return Services.instance; + } + + if (!Services.initializing) { + Services.initializing = (async () => { + const instance = new Services(); + await instance.init(); + instance.routingInstance = await ModalService.getInstance(); + return instance; + })(); + } + + console.log('initializing services'); + Services.instance = await Services.initializing; + Services.initializing = null; // Reset for potential future use + return Services.instance; + } + + public async init(): Promise { + this.notifications = this.getNotifications(); + this.sdkClient = await import('../../pkg/sdk_client'); + this.sdkClient.setup(); + for (const wsurl of Object.values(BOOTSTRAPURL)) { + this.updateRelay(wsurl, ''); + } + } + + public setProcessId(processId: string | null) { + this.processId = processId; + } + + public setStateId(stateId: string | null) { + this.stateId = stateId; + } + + public getProcessId(): string | null { + return this.processId; + } + + public getStateId(): string | null { + return this.stateId; + } + + /** + * Calls `this.addWebsocketConnection` for each `wsurl` in relayAddresses. + * Waits for at least one handshake message before returning. + */ + public async connectAllRelays(): Promise { + const connectedUrls: string[] = []; + + // Connect to all relays + for (const wsurl of Object.keys(this.relayAddresses)) { + try { + console.log(`Connecting to: ${wsurl}`); + await this.addWebsocketConnection(wsurl); + connectedUrls.push(wsurl); + console.log(`Successfully connected to: ${wsurl}`); + } catch (error) { + console.error(`Failed to connect to ${wsurl}:`, error); + } + } + + // Wait for at least one handshake message if we have connections + if (connectedUrls.length > 0) { + await this.waitForHandshakeMessage(); + } + } + + public async addWebsocketConnection(url: string): Promise { + console.log('Opening new websocket connection'); + await initWebsocket(url); + } + + /** + * Add or update a key/value pair in relayAddresses. + * @param wsurl - The WebSocket URL (key). + * @param spAddress - The SP Address (value). + */ + public updateRelay(wsurl: string, spAddress: string): void { + this.relayAddresses[wsurl] = spAddress; + console.log(`Updated: ${wsurl} -> ${spAddress}`); + } + + /** + * Retrieve the spAddress for a given wsurl. + * @param wsurl - The WebSocket URL to look up. + * @returns The SP Address if found, or undefined if not. + */ + public getSpAddress(wsurl: string): string | undefined { + return this.relayAddresses[wsurl]; + } + + /** + * Get all key/value pairs from relayAddresses. + * @returns An array of objects containing wsurl and spAddress. + */ + public getAllRelays(): { wsurl: string; spAddress: string }[] { + return Object.entries(this.relayAddresses).map(([wsurl, spAddress]) => ({ + wsurl, + spAddress, + })); + } + + /** + * Print all key/value pairs for debugging. + */ + public printAllRelays(): void { + console.log("Current relay addresses:"); + for (const [wsurl, spAddress] of Object.entries(this.relayAddresses)) { + console.log(`${wsurl} -> ${spAddress}`); + } + } + + public isPaired(): boolean { + try { + return this.sdkClient.is_paired(); + } catch (e) { + throw new Error(`isPaired ~ Error: ${e}`); + } + } + + public async unpairDevice(): Promise { + try { + this.sdkClient.unpair_device(); + const newDevice = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(newDevice); + } catch (e) { + throw new Error(`Failed to unpair device: ${e}`); + } + } + + public async getSecretForAddress(address: string): Promise { + const db = await Database.getInstance(); + return await db.getObject('shared_secrets', address); + } + + public async getAllSecrets(): Promise { + const db = await Database.getInstance(); + const sharedSecrets = await db.dumpStore('shared_secrets'); + const unconfirmedSecrets = await db.dumpStore('unconfirmed_secrets'); // keys are numeric values + + const secretsStore = { + shared_secrets: sharedSecrets, + unconfirmed_secrets: Object.values(unconfirmedSecrets), + }; + + return secretsStore; + } + + public async getAllDiffs(): Promise> { + const db = await Database.getInstance(); + return await db.dumpStore('diffs'); + } + + public async getDiffByValue(value: string): Promise { + const db = await Database.getInstance(); + const store = 'diffs'; + const res = await db.getObject(store, value); + return res; + } + + private async getTokensFromFaucet(): Promise { + try { + await this.ensureSufficientAmount(); + } catch (e) { + console.error('Failed to get tokens from relay, check connection'); + return; + } + } + + public async checkConnections(members: Member[]): Promise { + // Ensure the amount is available before proceeding + await this.getTokensFromFaucet(); + let unconnectedAddresses: Set = new Set(); + const myAddress = this.getDeviceAddress(); + for (const member of members) { + const sp_addresses = member.sp_addresses; + if (!sp_addresses || sp_addresses.length === 0) continue; + for (const address of sp_addresses) { + // For now, we ignore our own device address, although there might be use cases for having a secret with ourselves + if (address === myAddress) continue; + const sharedSecret = await this.getSecretForAddress(address); + if (!sharedSecret) { + unconnectedAddresses.add(address); + } + } + } + if (unconnectedAddresses && unconnectedAddresses.size != 0) { + const apiResult = await this.connectAddresses([...unconnectedAddresses]); + await this.handleApiReturn(apiResult); + } + } + + public async connectAddresses(addresses: string[]): Promise { + if (addresses.length === 0) { + throw new Error('Trying to connect to empty addresses list'); + } + + try { + return this.sdkClient.create_transaction(addresses, 1); + } catch (e) { + console.error('Failed to connect member:', e); + throw e; + } + } + + private async ensureSufficientAmount(): Promise { + const availableAmt = this.getAmount(); + const target: BigInt = DEFAULTAMOUNT * BigInt(10); + + if (availableAmt < target) { + const faucetMsg = this.createFaucetMessage(); + this.sendFaucetMessage(faucetMsg); + + await this.waitForAmount(target); + } + } + + private async waitForAmount(target: BigInt): Promise { + let attempts = 3; + + while (attempts > 0) { + const amount = this.getAmount(); + if (amount >= target) { + return amount; + } + + attempts--; + if (attempts > 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second + } + } + + throw new Error('Amount is still 0 after 3 attempts'); + } + + public async createPairingProcess(userName: string, pairWith: string[]): Promise { + if (this.sdkClient.is_paired()) { + throw new Error('Device already paired'); + } + const myAddress: string = this.sdkClient.get_address(); + pairWith.push(myAddress); + const privateData = { + description: 'pairing', + counter: 0, + }; + const publicData = { + memberPublicName: userName, + pairedAddresses: pairWith, + }; + const validation_fields: string[] = [...Object.keys(privateData), ...Object.keys(publicData), 'roles']; + const roles: Record = { + pairing: { + members: [], + validation_rules: [ + { + quorum: 1.0, + fields: validation_fields, + min_sig_member: 1.0, + }, + ], + storages: [STORAGEURL] + }, + }; + try { + return this.createProcess( + privateData, + publicData, + roles + ); + } catch (e) { + throw new Error(`Creating process failed:, ${e}`); + } + } + + private isFileBlob(value: any): value is { type: string, data: Uint8Array } { + return ( + typeof value === 'object' && + value !== null && + typeof value.type === 'string' && + value.data instanceof Uint8Array + ); + } + + private splitData(obj: Record) { + const jsonCompatibleData: Record = {}; + const binaryData: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + if (this.isFileBlob(value)) { + binaryData[key] = value; + } else { + jsonCompatibleData[key] = value; + } + } + + return { jsonCompatibleData, binaryData }; + } + + public async createProcess( + privateData: Record, + publicData: Record, + roles: Record, + ): Promise { + let relayAddress = this.getAllRelays()[0]?.spAddress; + + if (!relayAddress || relayAddress === '') { + console.log('No relay address found, connecting to relays...'); + await this.connectAllRelays(); + + // After connectAllRelays completes, relay addresses should be updated + relayAddress = this.getAllRelays()[0]?.spAddress; + if (!relayAddress || relayAddress === '') { + throw new Error('No relay address available after connecting to relays'); + } + } + + const feeRate = 1; + + // We can't encode files as the rest because Uint8Array is not valid json + // So we first take them apart and we will encode them separately and put them back in the right object + // TODO encoding of relatively large binaries (=> 1M) is a bit long now and blocking + const privateSplitData = this.splitData(privateData); + const publicSplitData = this.splitData(publicData); + const encodedPrivateData = { + ...this.sdkClient.encode_json(privateSplitData.jsonCompatibleData), + ...this.sdkClient.encode_binary(privateSplitData.binaryData) + }; + const encodedPublicData = { + ...this.sdkClient.encode_json(publicSplitData.jsonCompatibleData), + ...this.sdkClient.encode_binary(publicSplitData.binaryData) + }; + + console.log('encoded data:', encodedPrivateData); + console.log('encoded data:', encodedPublicData); + + let members: Set = new Set(); + for (const role of Object.values(roles!)) { + for (const member of role.members) { + // Check if we know the member that matches this id + const memberAddresses = this.getAddressesForMemberId(member); + if (memberAddresses && memberAddresses.length != 0) { + members.add({ sp_addresses: memberAddresses }); + } + } + } + console.log('members:', members); + await this.checkConnections([...members]); + + const result = this.sdkClient.create_new_process ( + encodedPrivateData, + roles, + encodedPublicData, + relayAddress, + feeRate, + this.getAllMembers() + ); + + return(result); + } + + public async updateProcess(process: Process, privateData: Record, publicData: Record, roles: Record | null): Promise { + // If roles is null, we just take the last commited state roles + if (!roles) { + roles = this.getRoles(process); + } else { + // We should check that we have the right to change the roles here, or maybe it's better leave it to the wasm + console.log('Provided new roles:', JSON.stringify(roles)); + } + let members: Set = new Set(); + for (const role of Object.values(roles!)) { + for (const member of role.members) { + members.add(member) + } + } + if (members.size === 0) { + // This must be a pairing process + // Check if we have a pairedAddresses in the public data + const publicData = this.getPublicData(process); + if (!publicData || !publicData['pairedAddresses']) { + throw new Error('Not a pairing process'); + } + const decodedAddresses = this.decodeValue(publicData['pairedAddresses']); + if (decodedAddresses.length === 0) { + throw new Error('Not a pairing process'); + } + members.add({ sp_addresses: decodedAddresses }); + } + await this.checkConnections([...members]); + const privateSplitData = this.splitData(privateData); + const publicSplitData = this.splitData(publicData); + const encodedPrivateData = { + ...this.sdkClient.encode_json(privateSplitData.jsonCompatibleData), + ...this.sdkClient.encode_binary(privateSplitData.binaryData) + }; + const encodedPublicData = { + ...this.sdkClient.encode_json(publicSplitData.jsonCompatibleData), + ...this.sdkClient.encode_binary(publicSplitData.binaryData) + }; + try { + return this.sdkClient.update_process(process, encodedPrivateData, roles, encodedPublicData, this.getAllMembers()); + } catch (e) { + throw new Error(`Failed to update process: ${e}`); + } + } + + public async createPrdUpdate(processId: string, stateId: string): Promise { + const process = await this.getProcess(processId); + if (!process) { + throw new Error('Unknown process'); + } + try { + return this.sdkClient.create_update_message(process, stateId, this.getAllMembers()); + } catch (e) { + throw new Error(`Failed to create prd update: ${e}`); + } + } + + public async createPrdResponse(processId: string, stateId: string): Promise { + const process = await this.getProcess(processId); + if (!process) { + throw new Error('Unknown process'); + } + try { + return this.sdkClient.create_response_prd(process, stateId, this.getAllMembers()); + } catch (e) { + throw new Error(`Failed to create response prd: ${e}`); + } + } + + public async approveChange(processId: string, stateId: string): Promise { + const process = await this.getProcess(processId); + if (!process) { + throw new Error('Failed to get process from db'); + } + try { + return this.sdkClient.validate_state(process, stateId, this.getAllMembers()); + } catch (e) { + throw new Error(`Failed to create prd response: ${e}`); + } + } + + public async rejectChange(processId: string, stateId: string): Promise { + const process = await this.getProcess(processId); + if (!process) { + throw new Error('Failed to get process from db'); + } + try { + return this.sdkClient.refuse_state(process, stateId); + } catch (e) { + throw new Error(`Failed to create prd response: ${e}`); + } + } + + async resetDevice() { + this.sdkClient.reset_device(); + + // Clear all stores + const db = await Database.getInstance(); + await db.clearStore('wallet'); + await db.clearStore('shared_secrets'); + await db.clearStore('unconfirmed_secrets'); + await db.clearStore('processes'); + await db.clearStore('diffs'); + } + + sendNewTxMessage(message: string) { + sendMessage('NewTx', message); + } + + sendCommitMessage(message: string) { + sendMessage('Commit', message); + } + + sendCipherMessages(ciphers: string[]) { + for (let i = 0; i < ciphers.length; i++) { + const cipher = ciphers[i]; + sendMessage('Cipher', cipher); + } + } + + sendFaucetMessage(message: string): void { + sendMessage('Faucet', message); + } + + async parseCipher(message: string) { + const membersList = this.getAllMembers(); + const processes = await this.getProcesses(); + try { + // console.log('parsing new cipher'); + const apiReturn = this.sdkClient.parse_cipher(message, membersList, processes); + await this.handleApiReturn(apiReturn); + + // Device 1 wait Device 2 + const waitingModal = document.getElementById('waiting-modal'); + if (waitingModal) { + this.device2Ready = true; + } + + } catch (e) { + console.error(`Parsed cipher with error: ${e}`); + } + // await this.saveCipherTxToDb(parsedTx) + } + + async parseNewTx(newTxMsg: string) { + const parsedMsg: NewTxMessage = JSON.parse(newTxMsg); + if (parsedMsg.error !== null) { + console.error('Received error in new tx message:', parsedMsg.error); + return; + } + + const membersList = this.getAllMembers(); + try { + // Does the transaction spend the tip of a process? + const prevouts = this.sdkClient.get_prevouts(parsedMsg.transaction); + console.log('prevouts:', prevouts); + for (const process of Object.values(this.processesCache)) { + const tip = process.states[process.states.length - 1].commited_in; + if (prevouts.includes(tip)) { + const processId = process.states[0].commited_in; + const newTip = this.sdkClient.get_txid(parsedMsg.transaction); + console.log('Transaction', newTip, 'spends the tip of process', processId); + // We take the data out of the output + const newStateId = this.sdkClient.get_opreturn(parsedMsg.transaction); + console.log('newStateId:', newStateId); + // We update the relevant process + const updatedProcess = this.sdkClient.process_commit_new_state(process, newStateId, newTip); + this.processesCache[processId] = updatedProcess; + console.log('updatedProcess:', updatedProcess); + break; + } + } + } catch (e) { + console.error('Failed to parse new tx for commitments:', e); + } + + try { + const parsedTx = this.sdkClient.parse_new_tx(newTxMsg, 0, membersList); + if (parsedTx) { + try { + await this.handleApiReturn(parsedTx); + const newDevice = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(newDevice); + } catch (e) { + console.error('Failed to update device with new tx'); + } + } + } catch (e) { + console.debug(e); + } + } + + public async handleApiReturn(apiReturn: ApiReturn) { + console.log(apiReturn); + if (apiReturn.partial_tx) { + try { + const res = this.sdkClient.sign_transaction(apiReturn.partial_tx); + apiReturn.new_tx_to_send = res.new_tx_to_send; + } catch (e) { + console.error('Failed to sign transaction:', e); + } + } + + if (apiReturn.new_tx_to_send && apiReturn.new_tx_to_send.transaction.length != 0) { + this.sendNewTxMessage(JSON.stringify(apiReturn.new_tx_to_send)); + await new Promise(r => setTimeout(r, 500)); + } + + if (apiReturn.secrets) { + const unconfirmedSecrets = apiReturn.secrets.unconfirmed_secrets; + const confirmedSecrets = apiReturn.secrets.shared_secrets; + + const db = await Database.getInstance(); + for (const secret of unconfirmedSecrets) { + await db.addObject({ + storeName: 'unconfirmed_secrets', + object: secret, + key: null, + }); + } + const entries = Object.entries(confirmedSecrets).map(([key, value]) => ({ key, value })); + for (const entry of entries) { + try { + await db.addObject({ + storeName: 'shared_secrets', + object: entry.value, + key: entry.key, + }); + } catch (e) { + throw e; + } + + // We don't want to throw an error, it could simply be that we registered directly the shared secret + // this.removeUnconfirmedSecret(entry.value); + } + } + + if (apiReturn.updated_process) { + const updatedProcess = apiReturn.updated_process; + + const processId: string = updatedProcess.process_id; + + if (updatedProcess.encrypted_data && Object.keys(updatedProcess.encrypted_data).length != 0) { + for (const [hash, cipher] of Object.entries(updatedProcess.encrypted_data)) { + const blob = this.hexToBlob(cipher); + try { + await this.saveBlobToDb(hash, blob); + } catch (e) { + console.error(e); + } + } + } + + // Save process to db + await this.saveProcessToDb(processId, updatedProcess.current_process); + + if (updatedProcess.diffs && updatedProcess.diffs.length != 0) { + try { + await this.saveDiffsToDb(updatedProcess.diffs); + } catch (e) { + console.error('Failed to save diffs to db:', e); + } + } + } + + if (apiReturn.push_to_storage && apiReturn.push_to_storage.length != 0) { + for (const hash of apiReturn.push_to_storage) { + const blob = await this.getBlobFromDb(hash); + if (blob) { + await this.saveDataToStorage(hash, blob, null); + } else { + console.error('Failed to get data from db'); + } + } + } + + if (apiReturn.commit_to_send) { + const commit = apiReturn.commit_to_send; + this.sendCommitMessage(JSON.stringify(commit)); + } + + if (apiReturn.ciphers_to_send && apiReturn.ciphers_to_send.length != 0) { + this.sendCipherMessages(apiReturn.ciphers_to_send); + } + } + + public async openPairingConfirmationModal(processId: string) { + const process = await this.getProcess(processId); + if (!process) { + console.error('Failed to find pairing process'); + return; + } + const firstState = process.states[0]; + const roles = firstState.roles; + const stateId = firstState.state_id; + try { + await this.routingInstance.openPairingConfirmationModal(roles, processId, stateId); + } catch (e) { + console.error(e); + } + } + + public async confirmPairing() { + if (!this.processId || !this.stateId) { + console.error('Missing process and/or state ID'); + return; + } + let createPrdUpdateReturn; + try { + createPrdUpdateReturn = await this.createPrdUpdate(this.processId, this.stateId); + } catch (e) { + throw new Error(`createPrdUpdate failed: ${e}`); + } + await this.handleApiReturn(createPrdUpdateReturn); + + let approveChangeReturn; + try { + approveChangeReturn = await this.approveChange(this.processId, this.stateId); + } catch (e) { + throw new Error(`approveChange failed: ${e}`); + } + await this.handleApiReturn(approveChangeReturn); + + await this.pairDevice(); + + this.processId = null; + this.stateId = null; + const newDevice = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(newDevice); + await navigate('account'); + } + + public async updateDevice(): Promise { + let myPairingProcessId: string; + try { + myPairingProcessId = this.getPairingProcessId(); + } catch (e) { + console.error('Failed to get pairing process id'); + return; + } + + const myPairingProcess = await this.getProcess(myPairingProcessId); + if (!myPairingProcess) { + console.error('Unknown pairing process'); + return; + } + const myPairingState = this.getLastCommitedState(myPairingProcess); + if (myPairingState) { + const encodedSpAddressList = myPairingState.public_data['pairedAddresses']; + const spAddressList = this.decodeValue(encodedSpAddressList); + if (spAddressList.length === 0) { + console.error('Empty pairedAddresses'); + return; + } + // We can check if our address is included and simply unpair if it's not + if (!spAddressList.includes(this.getDeviceAddress())) { + await this.unpairDevice(); + return; + } + // We can update the device with the new addresses + this.sdkClient.unpair_device(); + this.sdkClient.pair_device(myPairingProcessId, spAddressList); + const newDevice = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(newDevice); + } + } + + public async pairDevice() { + if (!this.processId) { + console.error('No processId set'); + return; + } + const process = await this.getProcess(this.processId); + if (!process) { + console.error('Unknown process'); + return; + } + + let spAddressList: string[] = []; + try { + let encodedSpAddressList: number[] = []; + if (this.stateId) { + const state = process.states.find(state => state.state_id === this.stateId); + if (state) { + encodedSpAddressList = state.public_data['pairedAddresses']; + } + } else { + // We assume it's the last commited state + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState) { + encodedSpAddressList = lastCommitedState.public_data['pairedAddresses']; + } + } + spAddressList = this.sdkClient.decode_value(encodedSpAddressList); + if (!spAddressList || spAddressList.length == 0) { + throw new Error('Empty pairedAddresses'); + } + } catch (e) { + throw new Error(`Failed to get pairedAddresses from process: ${e}`); + } + try { + this.sdkClient.pair_device(this.processId, spAddressList); + } catch (e) { + throw new Error(`Failed to pair device: ${e}`); + } + } + + public getAmount(): BigInt { + const amount = this.sdkClient.get_available_amount(); + return amount; + } + + getDeviceAddress(): string { + try { + return this.sdkClient.get_address(); + } catch (e) { + throw new Error(`Failed to get device address: ${e}`); + } + } + + public dumpDeviceFromMemory(): Device { + try { + return this.sdkClient.dump_device(); + } catch (e) { + throw new Error(`Failed to dump device: ${e}`); + } + } + + public dumpNeuteredDevice(): Device | null { + try { + return this.sdkClient.dump_neutered_device(); + } catch (e) { + console.error(`Failed to dump device: ${e}`); + return null; + } + } + + public getPairingProcessId(): string { + try { + return this.sdkClient.get_pairing_process_id(); + } catch (e) { + throw new Error(`Failed to get pairing process: ${e}`); + } + } + + async saveDeviceInDatabase(device: Device): Promise { + const db = await Database.getInstance(); + const walletStore = 'wallet'; + try { + const prevDevice = await this.getDeviceFromDatabase(); + if (prevDevice) { + await db.deleteObject(walletStore, "1"); + } + await db.addObject({ + storeName: walletStore, + object: { pre_id: '1', device }, + key: null, + }); + } catch (e) { + console.error(e); + } + } + + async getDeviceFromDatabase(): Promise { + const db = await Database.getInstance(); + const walletStore = 'wallet'; + try { + const dbRes = await db.getObject(walletStore, '1'); + if (dbRes) { + return dbRes['device']; + } else { + return null; + } + } catch (e) { + throw new Error(`Failed to retrieve device from db: ${e}`); + } + } + + async getMemberFromDevice(): Promise { + try { + const device = await this.getDeviceFromDatabase(); + if (device) { + const pairedMember = device['paired_member']; + return pairedMember.sp_addresses; + } else { + return null; + } + } catch (e) { + throw new Error(`Failed to retrieve paired_member from device: ${e}`); + } + } + + isChildRole(parent: any, child: any): boolean { + try { + this.sdkClient.is_child_role(JSON.stringify(parent), JSON.stringify(child)); + } catch (e) { + console.error(e); + return false; + } + + return true; + } + + rolesContainsUs(roles: Record): boolean { + let us; + try { + us = this.sdkClient.get_pairing_process_id(); + } catch (e) { + throw e; + } + + return this.rolesContainsMember(roles, us); + } + + rolesContainsMember(roles: Record, pairingProcessId: string): boolean { + for (const roleDef of Object.values(roles)) { + if (roleDef.members.includes(pairingProcessId)) { + return true; + } + } + + return false; + } + + async dumpWallet() { + const wallet = await this.sdkClient.dump_wallet(); + return wallet; + } + + public createFaucetMessage() { + const message = this.sdkClient.create_faucet_msg(); + return message; + } + + async createNewDevice() { + let spAddress = ''; + try { + // We set birthday later when we have the chain tip from relay + spAddress = await this.sdkClient.create_new_device(0, 'signet'); + const device = this.dumpDeviceFromMemory(); + await this.saveDeviceInDatabase(device); + } catch (e) { + console.error('Services ~ Error:', e); + } + + return spAddress; + } + + public restoreDevice(device: Device) { + try { + this.sdkClient.restore_device(device); + } catch (e) { + console.error(e); + } + } + + public async updateDeviceBlockHeight(): Promise { + if (this.currentBlockHeight === -1) { + throw new Error('Current block height not set'); + } + + let device: Device | null = null; + try { + device = await this.getDeviceFromDatabase(); + } catch (e) { + throw new Error(`Failed to get device from database: ${e}`); + } + + if (!device) { + throw new Error('Device not found'); + } + + const birthday = device.sp_wallet.birthday; + if (birthday === undefined || birthday === null) { + throw new Error('Birthday not found'); + } + + if (birthday === 0) { + // This is a new device, so current chain tip is its birthday + device.sp_wallet.birthday = this.currentBlockHeight; + // We also set last_scan, impossible that we need to scan earlier than this + device.sp_wallet.last_scan = this.currentBlockHeight; + try { + // First set the updated device in memory + this.sdkClient.restore_device(device); + // Then save it to database + await this.saveDeviceInDatabase(device); + } catch (e) { + throw new Error(`Failed to save updated device: ${e}`); + } + } else { + // This is existing device, we need to catch up if last_scan is lagging behind chain_tip + if (device.sp_wallet.last_scan < this.currentBlockHeight) { + // We need to catch up + await this.sdkClient.scan_blocks(this.currentBlockHeight, BLINDBITURL); + } else { + // Up to date, just returns + return; + } + } + } + + private async removeProcess(processId: string): Promise { + const db = await Database.getInstance(); + const storeName = 'processes'; + + try { + await db.deleteObject(storeName, processId); + } catch (e) { + console.error(e); + } + } + + public async batchSaveProcessesToDb(processes: Record) { + if (Object.keys(processes).length === 0) { + return; + } + + const db = await Database.getInstance(); + const storeName = 'processes'; + try { + await db.batchWriting({ storeName, objects: Object.entries(processes).map(([key, value]) => ({ key, object: value })) }); + this.processesCache = { ...this.processesCache, ...processes }; + } catch (e) { + throw e; + } + } + + public async saveProcessToDb(processId: string, process: Process) { + const db = await Database.getInstance(); + const storeName = 'processes'; + try { + await db.addObject({ + storeName, + object: process, + key: processId, + }); + + // Update the process in the cache + this.processesCache[processId] = process; + } catch (e) { + console.error(`Failed to save process ${processId}: ${e}`); + } + } + + public async saveBlobToDb(hash: string, data: Blob) { + const db = await Database.getInstance(); + try { + await db.addObject({ + storeName: 'data', + object: data, + key: hash, + }); + } catch (e) { + console.error(`Failed to save data to db: ${e}`); + } + } + + public async getBlobFromDb(hash: string): Promise { + const db = await Database.getInstance(); + try { + return await db.getObject('data', hash); + } catch (e) { + return null; + } + } + + public async saveDataToStorage(hash: string, data: Blob, ttl: number | null) { + const storages = [STORAGEURL]; + + try { + await storeData(storages, hash, data, ttl); + } catch (e) { + console.error(`Failed to store data with hash ${hash}: ${e}`); + } + } + + public async fetchValueFromStorage(hash: string): Promise { + const storages = [STORAGEURL]; + + return await retrieveData(storages, hash); + } + + public async testDataInStorage(hash: string): Promise | null> { + const storages = [STORAGEURL]; + + return await testData(storages, hash); + } + + public async saveDiffsToDb(diffs: UserDiff[]) { + const db = await Database.getInstance(); + try { + for (const diff of diffs) { + await db.addObject({ + storeName: 'diffs', + object: diff, + key: null, + }); + } + } catch (e) { + throw new Error(`Failed to save process: ${e}`); + } + } + + public async getProcess(processId: string): Promise { + if (this.processesCache[processId]) { + return this.processesCache[processId]; + } else { + const db = await Database.getInstance(); + const process = await db.getObject('processes', processId); + return process; + } + } + + public async getProcesses(): Promise> { + if (Object.keys(this.processesCache).length > 0) { + return this.processesCache; + } else { + try { + const db = await Database.getInstance(); + this.processesCache = await db.dumpStore('processes'); + return this.processesCache; + } catch (e) { + throw e; + } + } + } + + public async restoreProcessesFromBackUp(processes: Record) { + const db = await Database.getInstance(); + const storeName = 'processes'; + try { + await db.batchWriting({ storeName, objects: Object.entries(processes).map(([key, value]) => ({ key, object: value })) }); + } catch (e) { + throw e; + } + + await this.restoreProcessesFromDB(); + } + + // Restore processes cache from persistent storage + public async restoreProcessesFromDB() { + const db = await Database.getInstance(); + try { + const processes: Record = await db.dumpStore('processes'); + if (processes && Object.keys(processes).length != 0) { + console.log(`Restoring ${Object.keys(processes).length} processes`); + this.processesCache = processes; + } else { + console.log('No processes to restore!'); + } + } catch (e) { + throw e; + } + } + + public async clearSecretsFromDB() { + const db = await Database.getInstance(); + try { + await db.clearStore('shared_secrets'); + await db.clearStore('unconfirmed_secrets'); + } catch (e) { + console.error(e); + } + } + + public async restoreSecretsFromBackUp(secretsStore: SecretsStore) { + const db = await Database.getInstance(); + + for (const secret of secretsStore.unconfirmed_secrets) { + await db.addObject({ + storeName: 'unconfirmed_secrets', + object: secret, + key: null, + }); + } + const entries = Object.entries(secretsStore.shared_secrets).map(([key, value]) => ({ key, value })); + for (const entry of entries) { + await db.addObject({ + storeName: 'shared_secrets', + object: entry.value, + key: entry.key, + }); + } + + // Now we can transfer them to memory + await this.restoreSecretsFromDB(); + } + + public async restoreSecretsFromDB() { + const db = await Database.getInstance(); + try { + const sharedSecrets: Record = await db.dumpStore('shared_secrets'); + const unconfirmedSecrets = await db.dumpStore('unconfirmed_secrets'); + const secretsStore = { + shared_secrets: sharedSecrets, + unconfirmed_secrets: Object.values(unconfirmedSecrets), + }; + this.sdkClient.set_shared_secrets(JSON.stringify(secretsStore)); + } catch (e) { + throw e; + } + } + + decodeValue(value: number[]): any | null { + try { + return this.sdkClient.decode_value(value); + } catch (e) { + console.error(`Failed to decode value: ${e}`); + return null; + } + } + + async decryptAttribute(processId: string, state: ProcessState, attribute: string): Promise { + let hash = state.pcd_commitment[attribute]; + if (!hash) { + // attribute doesn't exist + return null; + } + let key = state.keys[attribute]; + const pairingProcessId = this.getPairingProcessId(); + + // If key is missing, request an update and then retry + if (!key) { + const roles = state.roles; + let hasAccess = false; + // If we're not supposed to have access to this attribute, ignore + for (const role of Object.values(roles)) { + for (const rule of Object.values(role.validation_rules) as any[]) { + if (rule.fields.includes(attribute)) { + if (role.members.includes(pairingProcessId)) { + // We have access to this attribute + hasAccess = true; + break; + } + } + } + } + + if (!hasAccess) return null; + + // We should have the key, so we're going to ask other members for it + await this.requestDataFromPeers(processId, [state.state_id], [state.roles]); + + const maxRetries = 5; + const retryDelay = 500; // delay in milliseconds + let retries = 0; + + while ((!hash || !key) && retries < maxRetries) { + await new Promise(resolve => setTimeout(resolve, retryDelay)); + // Re-read hash and key after waiting + hash = state.pcd_commitment[attribute]; + key = state.keys[attribute]; + retries++; + } + } + + if (hash && key) { + const blob = await this.getBlobFromDb(hash); + if (blob) { + // Decrypt the data + const buf = await blob.arrayBuffer(); + const cipher = new Uint8Array(buf); + + const keyUIntArray = this.hexToUInt8Array(key); + + try { + const clear = this.sdkClient.decrypt_data(keyUIntArray, cipher); + if (clear) { + // deserialize the result to get the actual data + const decoded = this.sdkClient.decode_value(clear); + return decoded; + } else { + throw new Error('decrypt_data returned null'); + } + } catch (e) { + console.error(`Failed to decrypt data: ${e}`); + } + } + } + + return null; + } + + getNotifications(): any[] | null { + // return [ + // { + // id: 1, + // title: 'Notif 1', + // description: 'A normal notification', + // sendToNotificationPage: false, + // path: '/notif1', + // }, + // { + // id: 2, + // title: 'Notif 2', + // description: 'A normal notification', + // sendToNotificationPage: false, + // path: '/notif2', + // }, + // { + // id: 3, + // title: 'Notif 3', + // description: 'A normal notification', + // sendToNotificationPage: false, + // path: '/notif3', + // }, + // ]; + return this.notifications; + } + + setNotifications(notifications: any[]) { + this.notifications = notifications; + } + + async importJSON(backup: BackUp): Promise { + const device = backup.device; + + // Reset current device + await this.resetDevice(); + + await this.saveDeviceInDatabase(device); + + this.restoreDevice(device); + + // TODO restore secrets and processes from file + const secretsStore = backup.secrets; + await this.restoreSecretsFromBackUp(secretsStore); + + const processes = backup.processes; + await this.restoreProcessesFromBackUp(processes); + } + + public async createBackUp(): Promise { + // Get the device from indexedDB + const device = await this.getDeviceFromDatabase(); + if (!device) { + console.error('No device loaded'); + return null; + } + + // Get the processes + const processes = await this.getProcesses(); + + // Get the shared secrets + const secrets = await this.getAllSecrets(); + + // Create a backup object + const backUp = { + device: device, + secrets: secrets, + processes: processes, + }; + + return backUp; + } + + // Device 1 wait Device 2 + public device1: boolean = false; + public device2Ready: boolean = false; + + public resetState() { + this.device1 = false; + this.device2Ready = false; + } + + + // Handle the handshake message + public async handleHandshakeMsg(url: string, parsedMsg: any) { + try { + const handshakeMsg: HandshakeMessage = JSON.parse(parsedMsg); + this.updateRelay(url, handshakeMsg.sp_address); + this.currentBlockHeight = handshakeMsg.chain_tip; + if (this.membersList && Object.keys(this.membersList).length === 0) { + // We start from an empty list, just copy it over + this.membersList = handshakeMsg.peers_list; + } else { + // We are incrementing our list + for (const [processId, member] of Object.entries(handshakeMsg.peers_list)) { + this.membersList[processId] = member as Member; + } + } + + setTimeout(async () => { + const newProcesses: OutPointProcessMap = handshakeMsg.processes_list; + if (!newProcesses || Object.keys(newProcesses).length === 0) { + console.debug('Received empty processes list from', url); + return; + } + + if (this.processesCache && Object.keys(this.processesCache).length === 0) { + // We restored db but cache is empty, meaning we're starting from scratch + try { + await this.batchSaveProcessesToDb(newProcesses); + } catch (e) { + console.error('Failed to save processes to db:', e); + } + } else { + // We need to update our processes with what relay provides + const toSave: Record = {}; + for (const [processId, process] of Object.entries(newProcesses)) { + const existing = await this.getProcess(processId); + if (existing) { + // Look for state id we don't know yet + let new_states: string[] = []; + let roles: Record[] = []; + for (const state of process.states) { + if (!state.state_id || state.state_id === EMPTY32BYTES) { continue; } + if (!this.lookForStateId(existing, state.state_id)) { + if (this.rolesContainsUs(state.roles)) { + new_states.push(state.state_id); + roles.push(state.roles); + } + } + } + + if (new_states.length != 0) { + // We request the new states + await this.requestDataFromPeers(processId, new_states, roles); + toSave[processId] = process; + } + + // Just to be sure check if that's a pairing process + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState && lastCommitedState.public_data && lastCommitedState.public_data['pairedAddresses']) { + // This is a pairing process + try { + const pairedAddresses = this.decodeValue(lastCommitedState.public_data['pairedAddresses']); + // Are we part of it? + if (pairedAddresses && pairedAddresses.length > 0 && pairedAddresses.includes(this.getDeviceAddress())) { + // We save the process to db + await this.saveProcessToDb(processId, process as Process); + // We update the device + await this.updateDevice(); + } + } catch (e) { + console.error('Failed to check for pairing process:', e); + } + } + + // Otherwise we're probably just in the initial loading at page initialization + + // We may learn an update for this process + // TODO maybe actually check if what the relay is sending us contains more information than what we have + // relay should always have more info than us, but we never know + // For now let's keep it simple and let the worker do the job + } else { + // We add it to db + console.log(`Saving ${processId} to db`); + toSave[processId] = process; + } + } + + await this.batchSaveProcessesToDb(toSave); + } + }, 500) + } catch (e) { + console.error('Failed to parse init message:', e); + } + } + + private lookForStateId(process: Process, stateId: string): boolean { + for (const state of process.states) { + if (state.state_id === stateId) { + return true; + } + } + + return false; + } + + /** + * Waits for at least one handshake message to be received from any connected relay. + * This ensures that the relay addresses are fully populated and the member list is updated. + * @returns A promise that resolves when at least one handshake message is received. + */ + private async waitForHandshakeMessage(timeoutMs: number = 10000): Promise { + const startTime = Date.now(); + const pollInterval = 100; // Check every 100ms + + return new Promise((resolve, reject) => { + const checkForHandshake = () => { + // Check if we have any members (indicating handshake was received) + if (Object.keys(this.membersList).length > 0) { + console.log('Handshake message received, members list populated'); + resolve(); + return; + } + + // Check timeout + if (Date.now() - startTime >= timeoutMs) { + reject(new Error(`No handshake message received after ${timeoutMs}ms timeout`)); + return; + } + + // Continue polling + setTimeout(checkForHandshake, pollInterval); + }; + + checkForHandshake(); + }); + } + + /** + * Retourne la liste de tous les membres ordonnés par leur process id + * @returns Un tableau contenant tous les membres + */ + public getAllMembersSorted(): Record { + return Object.fromEntries( + Object.entries(this.membersList).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + ); + } + + public getAllMembers(): Record { + return this.membersList; + } + + public getAddressesForMemberId(memberId: string): string[] | null { + try { + return this.membersList[memberId].sp_addresses; + } catch (e) { + return null; + } + } + + public compareMembers(memberA: string[], memberB: string[]): boolean { + if (!memberA || !memberB) { return false } + if (memberA.length !== memberB.length) { return false } + + const res = memberA.every(item => memberB.includes(item)) && memberB.every(item => memberA.includes(item)); + + return res; + } + + public async handleCommitError(response: string) { + const content = JSON.parse(response); + const error = content.error; + const errorMsg = error['GenericError']; + const dontRetry = [ + 'State is identical to the previous state', + 'Not enough valid proofs', + 'Not enough members to validate', + ]; + if (dontRetry.includes(errorMsg)) { return; } + // Wait and retry + setTimeout(async () => { + this.sendCommitMessage(JSON.stringify(content)); + }, 1000) + } + + public getRoles(process: Process): Record | null { + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState && lastCommitedState.roles && Object.keys(lastCommitedState.roles).length != 0) { + return lastCommitedState!.roles; + } else if (process.states.length === 2) { + const firstState = process.states[0]; + if (firstState && firstState.roles && Object.keys(firstState.roles).length != 0) { + return firstState!.roles; + } + } + return null; + } + + public getPublicData(process: Process): Record | null { + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState && lastCommitedState.public_data && Object.keys(lastCommitedState.public_data).length != 0) { + return lastCommitedState!.public_data; + } else if (process.states.length === 2) { + const firstState = process.states[0]; + if (firstState && firstState.public_data && Object.keys(firstState.public_data).length != 0) { + return firstState!.public_data; + } + } + return null; + } + + public getProcessName(process: Process): string | null { + const lastCommitedState = this.getLastCommitedState(process); + if (lastCommitedState && lastCommitedState.public_data) { + const processName = lastCommitedState!.public_data['processName']; + if (processName) { return this.decodeValue(processName) } + else { return null } + } else { + return null; + } + } + + public async getMyProcesses(): Promise { + // If we're not paired yet, just skip it + let pairingProcessId = null; + try { + pairingProcessId = this.getPairingProcessId(); + } catch (e) { + return null; + } + if (!pairingProcessId) { + return null; + } + + try { + const processes = await this.getProcesses(); + + const newMyProcesses = new Set(this.myProcesses || []); + // MyProcesses automatically contains pairing process + newMyProcesses.add(pairingProcessId); + for (const [processId, process] of Object.entries(processes)) { + // We use myProcesses attribute to not reevaluate all processes everytime + if (newMyProcesses.has(processId)) { + continue; + } + try { + const roles = this.getRoles(process); + + if (roles && this.rolesContainsUs(roles)) { + newMyProcesses.add(processId); + } + } catch (e) { + console.error(e); + } + } + this.myProcesses = newMyProcesses; // atomic update + return Array.from(this.myProcesses); + } catch (e) { + console.error("Failed to get processes:", e); + return null; + } + } + + public async requestDataFromPeers(processId: string, stateIds: string[], roles: Record[]) { + console.log('Requesting data from peers'); + const membersList = this.getAllMembers(); + try { + const res = this.sdkClient.request_data(processId, stateIds, roles, membersList); + await this.handleApiReturn(res); + } catch (e) { + console.error(e); + } + } + + public hexToBlob(hexString: string): Blob { + const uint8Array = this.hexToUInt8Array(hexString); + + return new Blob([uint8Array.buffer], { type: "application/octet-stream" }); + } + + public hexToUInt8Array(hexString: string): Uint8Array { + if (hexString.length % 2 !== 0) { + throw new Error("Invalid hex string: length must be even"); + } + const uint8Array = new Uint8Array(hexString.length / 2); + for (let i = 0; i < hexString.length; i += 2) { + uint8Array[i / 2] = parseInt(hexString.substr(i, 2), 16); + } + + return uint8Array; + } + + public async blobToHex(blob: Blob): Promise { + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + return Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + } + + public getHashForFile(commitedIn: string, label: string, fileBlob: { type: string; data: Uint8Array }): string { + return this.sdkClient.hash_value(fileBlob, commitedIn, label); + } + + public getMerkleProofForFile(processState: ProcessState, attributeName: string): MerkleProofResult { + return this.sdkClient.get_merkle_proof(processState, attributeName); + } + + public validateMerkleProof(proof: MerkleProofResult, hash: string): boolean { + try { + return this.sdkClient.validate_merkle_proof(proof, hash); + } catch (e) { + throw new Error(`Failed to validate merkle proof: ${e}`); + } + } + + public getLastCommitedState(process: Process): ProcessState | null { + if (process.states.length === 0) return null; + const processTip = process.states[process.states.length - 1].commited_in; + const lastCommitedState = process.states.findLast((state: ProcessState) => state.commited_in !== processTip); + if (lastCommitedState) { + return lastCommitedState; + } else { + return null; + } + } + + public getLastCommitedStateIndex(process: Process): number | null { + if (process.states.length === 0) return null; + const processTip = process.states[process.states.length - 1].commited_in; + for (let i = process.states.length - 1; i >= 0; i--) { + if (process.states[i].commited_in !== processTip) { + return i; + } + } + return null; + } + + public getUncommitedStates(process: Process): ProcessState[] { + if (process.states.length === 0) return []; + const processTip = process.states[process.states.length - 1].commited_in; + const res = process.states.filter((state: ProcessState) => state.commited_in === processTip); + return res.filter((state: ProcessState) => state.state_id !== EMPTY32BYTES); + } + + public getStateFromId(process: Process, stateId: string): ProcessState | null { + if (process.states.length === 0) return null; + const state = process.states.find((state: ProcessState) => state.state_id === stateId); + if (state) { + return state; + } else { + return null; + } + } + + public getNextStateAfterId(process: Process, stateId: string): ProcessState | null { + if (process.states.length === 0) return null; + + const index = process.states.findIndex((state: ProcessState) => state.state_id === stateId); + + if (index !== -1 && index < process.states.length - 1) { + return process.states[index + 1]; + } + + return null; + } + + public isPairingProcess(roles: Record): boolean { + if (Object.keys(roles).length != 1) { return false } + const pairingRole = roles['pairing']; + if (pairingRole) { + // For now that's enough, we should probably test more things + return true; + } else { + return false; + } + } + + public async updateMemberPublicName(process: Process, newName: string): Promise { + const publicData = { + 'memberPublicName': newName + }; + + return await this.updateProcess(process, {}, publicData, null); + } +} diff --git a/ihm_client/src/services/storage.service.ts b/ihm_client/src/services/storage.service.ts new file mode 100644 index 00000000..47ee776d --- /dev/null +++ b/ihm_client/src/services/storage.service.ts @@ -0,0 +1,81 @@ +import axios, { AxiosResponse } from 'axios'; + +export async function storeData(servers: string[], key: string, value: Blob, ttl: number | null): Promise { + for (const server of servers) { + try { + // Append key and ttl as query parameters + const url = new URL(`${server}/store`); + url.searchParams.append('key', key); + if (ttl !== null) { + url.searchParams.append('ttl', ttl.toString()); + } + + // Send the encrypted ArrayBuffer as the raw request body. + const response = await axios.post(url.toString(), value, { + headers: { + 'Content-Type': 'application/octet-stream' + }, + }); + console.log('Data stored successfully:', key); + if (response.status !== 200) { + console.error('Received response status', response.status); + continue; + } + return response; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 409) { + return null; + } + console.error('Error storing data:', error); + } + } + return null; +} + +export async function retrieveData(servers: string[], key: string): Promise { + for (const server of servers) { + try { + // When fetching the data from the server: + const response = await axios.get(`${server}/retrieve/${key}`, { + responseType: 'arraybuffer' + }); + if (response.status !== 200) { + console.error('Received response status', response.status); + continue; + } + // console.log('Retrieved data:', response.data); + return response.data; + } catch (error) { + console.error('Error retrieving data:', error); + } + } + return null +} + +interface TestResponse { + key: string; + value: boolean; +} + +export async function testData(servers: string[], key: string): Promise | null> { + const res: Record = {}; + for (const server of servers) { + res[server] = null; + try { + const response = await axios.get(`${server}/test/${key}`); + if (response.status !== 200) { + console.error(`${server}: Test response status: ${response.status}`); + continue; + } + + const data: TestResponse = response.data; + + res[server] = data.value; + } catch (error) { + console.error('Error retrieving data:', error); + return null; + } + } + + return res; +} diff --git a/ihm_client/src/services/token.ts b/ihm_client/src/services/token.ts new file mode 100644 index 00000000..6c7a4d91 --- /dev/null +++ b/ihm_client/src/services/token.ts @@ -0,0 +1,87 @@ +import * as jose from 'jose'; + +interface TokenPair { + accessToken: string; + refreshToken: string; +} + +export default class TokenService { + private static instance: TokenService; + private readonly SECRET_KEY = import.meta.env.VITE_JWT_SECRET_KEY; + private readonly ACCESS_TOKEN_EXPIRATION = '30s'; + private readonly REFRESH_TOKEN_EXPIRATION = '7d'; + private readonly encoder = new TextEncoder(); + + private constructor() {} + + static async getInstance(): Promise { + if (!TokenService.instance) { + TokenService.instance = new TokenService(); + } + return TokenService.instance; + } + + async generateSessionToken(origin: string): Promise { + const secret = new Uint8Array(this.encoder.encode(this.SECRET_KEY)); + + const accessToken = await new jose.SignJWT({ origin, type: 'access' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(this.ACCESS_TOKEN_EXPIRATION) + .sign(secret); + + const refreshToken = await new jose.SignJWT({ origin, type: 'refresh' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(this.REFRESH_TOKEN_EXPIRATION) + .sign(secret); + + return { accessToken, refreshToken }; + } + + async validateToken(token: string, origin: string): Promise { + try { + const secret = new Uint8Array(this.encoder.encode(this.SECRET_KEY)); + const { payload } = await jose.jwtVerify(token, secret); + + return payload.origin === origin; + } catch (error: any) { + if (error?.code === 'ERR_JWT_EXPIRED') { + console.log('Token expiré'); + return false; + } + + console.error('Erreur de validation du token:', error); + return false; + } + } + + async refreshAccessToken(refreshToken: string, origin: string): Promise { + try { + // Vérifier si le refresh token est valide + const isValid = await this.validateToken(refreshToken, origin); + if (!isValid) { + return null; + } + + // Vérifier le type du token + const secret = new Uint8Array(this.encoder.encode(this.SECRET_KEY)); + const { payload } = await jose.jwtVerify(refreshToken, secret); + if (payload.type !== 'refresh') { + return null; + } + + // Générer un nouveau access token + const newAccessToken = await new jose.SignJWT({ origin, type: 'access' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(this.ACCESS_TOKEN_EXPIRATION) + .sign(secret); + + return newAccessToken; + } catch (error) { + console.error('Erreur lors du refresh du token:', error); + return null; + } + } +} \ No newline at end of file diff --git a/ihm_client/src/types/raw-imports.d.ts b/ihm_client/src/types/raw-imports.d.ts new file mode 100644 index 00000000..039dd274 --- /dev/null +++ b/ihm_client/src/types/raw-imports.d.ts @@ -0,0 +1,35 @@ +// Déclarations pour les imports de fichiers raw +declare module '*.html?raw' { + const content: string; + export default content; +} + +declare module '*.css?raw' { + const content: string; + export default content; +} + +declare module '*.css?inline' { + const content: string; + export default content; +} + +declare module '*.js?raw' { + const content: string; + export default content; +} + +declare module '*.ts?raw' { + const content: string; + export default content; +} + +// Déclaration pour import.meta.env +interface ImportMetaEnv { + readonly VITE_JWT_SECRET_KEY: string; + // Ajoutez d'autres variables d'environnement si nécessaire +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/ihm_client/src/types/sdk_client.d.ts b/ihm_client/src/types/sdk_client.d.ts new file mode 100644 index 00000000..c817cbdc --- /dev/null +++ b/ihm_client/src/types/sdk_client.d.ts @@ -0,0 +1,258 @@ +// Types TypeScript pour sdk_client basé sur l'API Rust +// Ce fichier définit les interfaces TypeScript correspondant aux types Rust exportés par wasm-bindgen + +declare module 'pkg/sdk_client' { + // Types de base + export type DiffStatus = "None" | "Rejected" | "Validated"; + export type AnkFlag = "NewTx" | "Faucet" | "Cipher" | "Commit" | "Handshake" | "Sync" | "Unknown"; + export type PrdType = "None" | "Connect" | "Message" | "Update" | "List" | "Response" | "Confirm" | "TxProposal" | "Request"; + export type SyncType = "StateSync" | "ProcessSync" | "MemberSync" | "TxSync" | "BlockSync" | "PeerSync" | "RelaySync" | "HealthSync" | "MetricsSync" | "ConfigSync" | "CapabilitySync"; + export type HealthStatus = "Healthy" | "Warning" | "Critical" | "Offline"; + export type OutPoint = string; + export type SilentPaymentAddress = string; + export type AnkSharedSecretHash = string; + export type OutPointProcessMap = Record; + export type OutPointMemberMap = Record; + export type TsUnsignedTransaction = any; // SilentPaymentUnsignedTransaction + export type PcdCommitments = Record; + export type Pcd = Record; + export type Roles = Record; + // Types de base + export interface Device { + sp_wallet: SpWallet; + pairing_process_commitment: OutPoint | null; + paired_member: Member; + } + + export interface SpWallet { + // Structure simplifiée pour SpWallet + [key: string]: any; + } + + export interface Member { + sp_addresses: string[]; + } + + export interface Process { + states: ProcessState[]; + } + + export interface ProcessState { + commited_in: OutPoint; + pcd_commitment: Record; + state_id: string; + keys: Record; + validation_tokens: Proof[]; + public_data: Pcd; + roles: Record; + } + + export interface RoleDefinition { + members: OutPoint[]; + validation_rules: ValidationRule[]; + storages: string[]; + } + + export interface ValidationRule { + quorum: number; + fields: string[]; + min_sig_member: number; + } + + export interface SecretsStore { + shared_secrets: Record; + unconfirmed_secrets: AnkSharedSecretHash[]; + } + + export interface MerkleProofResult { + proof: string; + root: string; + attribute: string; + attribute_index: number; + total_leaves_count: number; + } + + export interface encryptWithNewKeyResult { + cipher: string; + key: string; + } + + export interface UserDiff { + process_id: string; + state_id: string; + value_commitment: string; + field: string; + roles: Roles; + description: string | null; + notify_user: boolean; + need_validation: boolean; + validation_status: DiffStatus; + } + + export interface UpdatedProcess { + process_id: OutPoint; + current_process: Process; + diffs: UserDiff[]; + encrypted_data: Record; + validated_state: number[] | null; + } + + export interface ApiReturn { + secrets: SecretsStore | null; + updated_process: UpdatedProcess | null; + new_tx_to_send: NewTxMessage | null; + ciphers_to_send: string[]; + commit_to_send: CommitMessage | null; + push_to_storage: string[]; + partial_tx: TsUnsignedTransaction | null; + } + + export interface NewTxMessage { + transaction: string; + tweak_data: string | null; + error: AnkError | null; + } + + export interface CommitMessage { + process_id: OutPoint; + pcd_commitment: PcdCommitments; + roles: Roles; + public_data: Pcd; + validation_tokens: Proof[]; + error: AnkError | null; + } + + export interface HandshakeMessage { + sp_address: string; + peers_list: OutPointMemberMap; + processes_list: OutPointProcessMap; + chain_tip: number; + } + + export interface FaucetMessage { + sp_address: string; + commitment: string; + error: AnkError | null; + } + + export interface Prd { + prd_type: PrdType; + process_id: OutPoint; + sender: Member; + keys: Record; + pcd_commitments: PcdCommitments; + validation_tokens: Proof[]; + roles: Roles; + public_data: Pcd; + payload: string; + proof: Proof | null; + } + + export interface AnkError { + // Structure simplifiée pour AnkError + [key: string]: any; + } + + export interface Proof { + // Structure simplifiée pour Proof + [key: string]: any; + } + + // Fonctions exportées par wasm-bindgen + export function setup(): void; + export function get_address(): string; + export function get_member(): Member; + export function restore_device(device: Device): void; + export function create_device_from_sp_wallet(sp_wallet: string): string; + export function create_new_device(birthday: number, network_str: string): string; + export function is_paired(): boolean; + export function pair_device(process_id: string, sp_addresses: string[]): void; + export function unpair_device(): void; + export function dump_wallet(): string; + export function reset_process_cache(): void; + export function dump_process_cache(): string; + export function set_process_cache(processes: any): void; + export function add_to_process_cache(process_id: string, process: string): void; + export function reset_shared_secrets(): void; + export function set_shared_secrets(secrets: string): void; + export function get_pairing_process_id(): string; + export function dump_device(): Device; + export function dump_neutered_device(): Device; + export function reset_device(): void; + export function get_txid(transaction: string): string; + export function parse_new_tx(new_tx_msg: string, block_height: number, members_list: OutPointMemberMap): ApiReturn; + export function parse_cipher(cipher_msg: string, members_list: OutPointMemberMap): ApiReturn; + export function get_outputs(): any; + export function get_available_amount(): bigint; + export function create_transaction(addresses: string[], fee_rate: number): ApiReturn; + export function sign_transaction(partial_tx: TsUnsignedTransaction): ApiReturn; + export function create_new_process( + private_data: Pcd, + roles: Roles, + public_data: Pcd, + relay_address: string, + fee_rate: number, + members_list: OutPointMemberMap + ): ApiReturn; + export function update_process( + process: Process, + new_attributes: Pcd, + roles: Roles, + new_public_data: Pcd, + members_list: OutPointMemberMap + ): ApiReturn; + export function request_data( + process_id: string, + state_ids_str: string[], + roles: any, + members_list: OutPointMemberMap + ): ApiReturn; + export function create_update_message( + process: Process, + state_id: string, + members_list: OutPointMemberMap + ): ApiReturn; + export function validate_state( + process: Process, + state_id: string, + members_list: OutPointMemberMap + ): ApiReturn; + export function refuse_state( + process: Process, + state_id: string, + members_list: OutPointMemberMap + ): ApiReturn; + export function evaluate_state( + process: Process, + state_id: string, + members_list: OutPointMemberMap + ): ApiReturn; + export function create_response_prd( + process: Process, + state_id: string, + members_list: OutPointMemberMap + ): ApiReturn; + export function create_faucet_msg(): string; + export function get_storages(process_outpoint: string): string[]; + export function is_child_role(parent_roles: string, child_roles: string): void; + export function decrypt_data(key: Uint8Array, data: Uint8Array): Uint8Array; + export function encode_binary(data: any): Pcd; + export function encode_json(json_data: any): Pcd; + export function decode_value(value: Uint8Array): any; + export function hash_value(value: any, commited_in: string, label: string): string; + export function get_merkle_proof(process_state: ProcessState, attribute_name: string): MerkleProofResult; + export function validate_merkle_proof(proof_result: MerkleProofResult, hash: string): boolean; +} + +// Types pour les imports relatifs +declare module '../../../pkg/sdk_client' { + export * from 'pkg/sdk_client'; +} + +declare module '../../pkg/sdk_client' { + export * from 'pkg/sdk_client'; +} + +declare module '../pkg/sdk_client' { + export * from 'pkg/sdk_client'; +} diff --git a/ihm_client/src/utils/document.utils.ts b/ihm_client/src/utils/document.utils.ts new file mode 100644 index 00000000..f8dab371 --- /dev/null +++ b/ihm_client/src/utils/document.utils.ts @@ -0,0 +1,4 @@ +export function getCorrectDOM(componentTag: string): Node { + const dom = document?.querySelector(componentTag)?.shadowRoot || (document as Node); + return dom; +} diff --git a/ihm_client/src/utils/html.utils.ts b/ihm_client/src/utils/html.utils.ts new file mode 100755 index 00000000..1a114eb1 --- /dev/null +++ b/ihm_client/src/utils/html.utils.ts @@ -0,0 +1,8 @@ +export function interpolate(template: string, data: { [key: string]: string }) { + return template.replace(/{{(.*?)}}/g, (_, key) => data[key.trim()]); +} + +export function getCorrectDOM(componentTag: string): Node { + const dom = document?.querySelector(componentTag)?.shadowRoot || (document as Node); + return dom; +} diff --git a/ihm_client/src/utils/messageMock.ts b/ihm_client/src/utils/messageMock.ts new file mode 100755 index 00000000..cae381f4 --- /dev/null +++ b/ihm_client/src/utils/messageMock.ts @@ -0,0 +1,53 @@ +import { messagesMock as initialMessagesMock } from '../mocks/mock-signature/messagesMock.js'; + +// Store singleton for messages +class MessageStore { + private readonly STORAGE_KEY = 'chat_messages'; + private messages: any[] = []; + + constructor() { + this.messages = this.loadFromLocalStorage() || []; + } + + private loadFromLocalStorage() { + try { + const stored = localStorage.getItem(this.STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.error('Error loading messages:', error); + return null; + } + } + + getMessages() { + return this.messages; + } + + setMessages(messages: any[]) { + this.messages = messages; + this.saveToLocalStorage(); + } + + private saveToLocalStorage() { + try { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.messages)); + } catch (error) { + console.error('Error saving messages:', error); + } + } + + addMessage(memberId: string | number, message: any) { + const memberMessages = this.messages.find((m) => String(m.memberId) === String(memberId)); + if (memberMessages) { + memberMessages.messages.push(message); + } else { + this.messages.push({ + memberId: String(memberId), + messages: [message], + }); + } + this.saveToLocalStorage(); + } +} + +export const messageStore = new MessageStore(); diff --git a/ihm_client/src/utils/notification.store.ts b/ihm_client/src/utils/notification.store.ts new file mode 100755 index 00000000..88c5cafb --- /dev/null +++ b/ihm_client/src/utils/notification.store.ts @@ -0,0 +1,96 @@ +interface INotification { + id: number; + title: string; + description: string; + time?: string; + memberId?: string; +} + +class NotificationStore { + private static instance: NotificationStore; + private notifications: INotification[] = []; + + private constructor() { + this.loadFromLocalStorage(); + } + + static getInstance(): NotificationStore { + if (!NotificationStore.instance) { + NotificationStore.instance = new NotificationStore(); + } + return NotificationStore.instance; + } + + addNotification(notification: INotification) { + this.notifications.push(notification); + this.saveToLocalStorage(); + this.updateUI(); + } + + removeNotification(index: number) { + this.notifications.splice(index, 1); + this.saveToLocalStorage(); + this.updateUI(); + } + + getNotifications(): INotification[] { + return this.notifications; + } + + private saveToLocalStorage() { + localStorage.setItem('notifications', JSON.stringify(this.notifications)); + } + + private loadFromLocalStorage() { + const stored = localStorage.getItem('notifications'); + if (stored) { + this.notifications = JSON.parse(stored); + } + } + + private updateUI() { + const badge = document.querySelector('.notification-badge') as HTMLElement; + const board = document.querySelector('.notification-board') as HTMLElement; + + if (badge) { + badge.textContent = this.notifications.length.toString(); + badge.style.display = this.notifications.length > 0 ? 'block' : 'none'; + } + + if (board) { + this.renderNotificationBoard(board); + } + } + + private renderNotificationBoard(board: HTMLElement) { + board.innerHTML = ''; + + if (this.notifications.length === 0) { + board.innerHTML = '
    No notifications available
    '; + return; + } + + this.notifications.forEach((notif, index) => { + const notifElement = document.createElement('div'); + notifElement.className = 'notification-item'; + notifElement.innerHTML = ` +
    ${notif.title}
    +
    ${notif.description}
    + ${notif.time ? `
    ${notif.time}
    ` : ''} + `; + notifElement.onclick = () => { + if (notif.memberId) { + window.loadMemberChat(notif.memberId); + } + this.removeNotification(index); + }; + board.appendChild(notifElement); + }); + } + + public refreshNotifications() { + this.updateUI(); + } +} + +export const notificationStore = NotificationStore.getInstance(); diff --git a/ihm_client/src/utils/service.utils.ts b/ihm_client/src/utils/service.utils.ts new file mode 100644 index 00000000..8f84400f --- /dev/null +++ b/ihm_client/src/utils/service.utils.ts @@ -0,0 +1,24 @@ +export function splitPrivateData(data: Record, privateFields: string[]): { privateData: Record, publicData: Record } { + const privateData: Record = {}; + const publicData: Record = {}; + + for (const [key, value] of Object.entries(data)) { + if (privateFields.includes(key)) { + privateData[key] = value; + } else { + publicData[key] = value; + } + } + + return { privateData, publicData }; +} + +export function isValid32ByteHex(value: string): boolean { + // Check if string is exactly 64 characters (32 bytes in hex) + if (value.length !== 64) { + return false; + } + + // Check if string only contains valid hex characters + return /^[0-9a-fA-F]{64}$/.test(value); +} diff --git a/ihm_client/src/utils/sp-address.utils.ts b/ihm_client/src/utils/sp-address.utils.ts new file mode 100755 index 00000000..3e455541 --- /dev/null +++ b/ihm_client/src/utils/sp-address.utils.ts @@ -0,0 +1,216 @@ +import Services from '../services/service'; +import { getCorrectDOM } from './html.utils'; +import { addSubscription } from './subscription.utils'; +import QRCode from 'qrcode'; + +//Copy Address +export async function copyToClipboard(fullAddress: string) { + try { + await navigator.clipboard.writeText(fullAddress); + alert('Adresse copiée dans le presse-papiers !'); + } catch (err) { + console.error('Failed to copy the address: ', err); + } +} + +//Generate emojis list +export function generateEmojiList(): string[] { + const emojiRanges = [ + [0x1f600, 0x1f64f], + [0x1f300, 0x1f5ff], + [0x1f680, 0x1f6ff], + [0x1f700, 0x1f77f], + ]; + + const emojiList: string[] = []; + for (const range of emojiRanges) { + const [start, end] = range; + for (let i = start; i <= end && emojiList.length < 256; i++) { + emojiList.push(String.fromCodePoint(i)); + } + if (emojiList.length >= 256) { + break; + } + } + + return emojiList.slice(0, 256); +} + +//Adress to emojis +export async function addressToEmoji(text: string): Promise { + //Adress to Hash + const encoder = new TextEncoder(); + const data = encoder.encode(text); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + + const hash = new Uint8Array(hashBuffer); + const bytes = hash.slice(-4); + + //Hash slice to emojis + const emojiList = generateEmojiList(); + const emojis = Array.from(bytes) + .map((byte) => emojiList[byte]) + .join(''); + return emojis; +} + +//Get emojis from other device +async function emojisPairingRequest() { + try { + const container = getCorrectDOM('login-4nk-component') as HTMLElement; + + const urlParams: URLSearchParams = new URLSearchParams(window.location.search); + const sp_adress: string | null = urlParams.get('sp_address'); + + if (!sp_adress) { + // console.error("No 'sp_adress' parameter found in the URL."); + return; + } + + const emojis = await addressToEmoji(sp_adress); + const emojiDisplay = container?.querySelector('.pairing-request'); + + if (emojiDisplay) { + emojiDisplay.textContent = '(Request from: ' + emojis + ')'; + } + } catch (err) { + console.error(err); + } +} + +// Display address emojis and other device emojis +export async function displayEmojis(text: string) { + console.log('🚀 ~ Services ~ adressToEmoji'); + try { + const container = getCorrectDOM('login-4nk-component') as HTMLElement; + const emojis = await addressToEmoji(text); + const emojiDisplay = container?.querySelector('.emoji-display'); + + if (emojiDisplay) { + emojiDisplay.textContent = emojis; + } + + emojisPairingRequest(); + + initAddressInput(); + } catch (err) { + console.error(err); + } +} + +// Verify Other address +export function initAddressInput() { + const container = getCorrectDOM('login-4nk-component') as HTMLElement + const addressInput = container.querySelector('#addressInput') as HTMLInputElement; + const emojiDisplay = container.querySelector('#emoji-display-2'); + const okButton = container.querySelector('#okButton') as HTMLButtonElement; + const createButton = container.querySelector('#createButton') as HTMLButtonElement; + const actionButton = container.querySelector('#actionButton') as HTMLButtonElement; + addSubscription(addressInput, 'input', async () => { + let address = addressInput.value; + + // Vérifie si l'adresse est une URL + try { + const url = new URL(address); + // Si c'est une URL valide, extraire le paramètre sp_address + const urlParams = new URLSearchParams(url.search); + const extractedAddress = urlParams.get('sp_address') || ''; // Prend sp_address ou une chaîne vide + + if (extractedAddress) { + address = extractedAddress; + addressInput.value = address; // Met à jour l'input pour afficher uniquement l'adresse extraite + } + } catch (e) { + // Si ce n'est pas une URL valide, on garde l'adresse originale + console.log("Ce n'est pas une URL valide, on garde l'adresse originale."); + } + if (address) { + const emojis = await addressToEmoji(address); + if (emojiDisplay) { + emojiDisplay.innerHTML = emojis; + } + if (okButton) { + okButton.style.display = 'inline-block'; + } + } else { + if (emojiDisplay) { + emojiDisplay.innerHTML = ''; + } + if (okButton) { + okButton.style.display = 'none'; + } + } + }); + + if (createButton) { + addSubscription(createButton, 'click', () => { + onCreateButtonClick(); + }); + } +} + +async function onCreateButtonClick() { + try { + await prepareAndSendPairingTx(); + const service = await Services.getInstance(); + await service.confirmPairing(); + } catch (e) { + console.error(`onCreateButtonClick error: ${e}`); + } +} + +export async function prepareAndSendPairingTx(): Promise { + const service = await Services.getInstance(); + + try { + await service.checkConnections([]); + } catch (e) { + throw e; + } + + try { + const relayAddress = service.getAllRelays(); + const createPairingProcessReturn = await service.createPairingProcess( + "", + [], + ); + + if (!createPairingProcessReturn.updated_process) { + throw new Error('createPairingProcess returned an empty new process'); + } + + service.setProcessId(createPairingProcessReturn.updated_process.process_id); + service.setStateId(createPairingProcessReturn.updated_process.current_process.states[0].state_id); + + await service.handleApiReturn(createPairingProcessReturn); + + } catch (err) { + console.error(err); + } +} + +export async function generateQRCode(spAddress: string) { + try { + const container = getCorrectDOM('login-4nk-component') as HTMLElement + const currentUrl = 'https://' + window.location.host; + const url = await QRCode.toDataURL(currentUrl + '?sp_address=' + spAddress); + const qrCode = container?.querySelector('.qr-code img'); + qrCode?.setAttribute('src', url); + } catch (err) { + console.error(err); + } +} + +export async function generateCreateBtn() { + try{ + //Generate CreateBtn + const container = getCorrectDOM('login-4nk-component') as HTMLElement + const createBtn = container?.querySelector('.create-btn'); + if (createBtn) { + createBtn.textContent = 'CREATE'; + } + } catch (err) { + console.error(err); + } + +} \ No newline at end of file diff --git a/ihm_client/src/utils/subscription.utils.ts b/ihm_client/src/utils/subscription.utils.ts new file mode 100755 index 00000000..707ba202 --- /dev/null +++ b/ihm_client/src/utils/subscription.utils.ts @@ -0,0 +1,19 @@ +let subscriptions: { element: Element | Document; event: any; eventHandler: EventListenerOrEventListenerObject }[] = []; + +export function cleanSubscriptions(): void { + console.log('🚀 ~ cleanSubscriptions ~ sub:', subscriptions); + for (const sub of subscriptions) { + const el = sub.element; + const eventHandler = sub.eventHandler; + if (el) { + el.removeEventListener(sub.event, eventHandler); + } + } + subscriptions = []; +} + +export function addSubscription(element: Element | Document, event: any, eventHandler: EventListenerOrEventListenerObject): void { + if (!element) return; + subscriptions.push({ element, event, eventHandler }); + element.addEventListener(event, eventHandler); +} diff --git a/ihm_client/src/websockets.ts b/ihm_client/src/websockets.ts new file mode 100755 index 00000000..9b47ad7f --- /dev/null +++ b/ihm_client/src/websockets.ts @@ -0,0 +1,89 @@ +import type { AnkFlag } from 'pkg/sdk_client'; +import Services from './services/service'; + +let ws: WebSocket; +let messageQueue: string[] = []; +export async function initWebsocket(url: string) { + ws = new WebSocket(url); + + if (ws !== null) { + ws.onopen = async (event) => { + console.log('WebSocket connection established'); + + while (messageQueue.length > 0) { + const message = messageQueue.shift(); + if (message) { + ws.send(message); + } + } + }; + + // Listen for messages + ws.onmessage = (event) => { + const msgData = event.data; + + // console.log("Received text message: ", msgData); + (async () => { + if (typeof msgData === 'string') { + try { + const parsedMessage = JSON.parse(msgData); + const services = await Services.getInstance(); + switch (parsedMessage.flag) { + case 'Handshake': + await services.handleHandshakeMsg(url, parsedMessage.content); + break; + case 'NewTx': + await services.parseNewTx(parsedMessage.content); + break; + case 'Cipher': + await services.parseCipher(parsedMessage.content); + break; + case 'Commit': + // Basically if we see this it means we have an error + await services.handleCommitError(parsedMessage.content); + break; + } + } catch (error) { + console.error('Received an invalid message:', error); + } + } else { + console.error('Received a non-string message'); + } + })(); + }; + + // Listen for possible errors + ws.onerror = (event) => { + console.error('WebSocket error:', event); + }; + + // Listen for when the connection is closed + ws.onclose = (event) => { + console.log('WebSocket is closed now.'); + }; + } +} + +// Method to send messages +export function sendMessage(flag: AnkFlag, message: string): void { + if (ws.readyState === WebSocket.OPEN) { + const networkMessage = { + flag: flag, + content: message, + }; + console.log('Sending message of type:', flag); + ws.send(JSON.stringify(networkMessage)); + } else { + console.error('WebSocket is not open. ReadyState:', ws.readyState); + messageQueue.push(message); + } +} + +export function getUrl(): string { + return ws.url; +} + +// Method to close the WebSocket connection +export function close(): void { + ws.close(); +} diff --git a/ihm_client/start.sh b/ihm_client/start.sh new file mode 100644 index 00000000..16b6dea7 --- /dev/null +++ b/ihm_client/start.sh @@ -0,0 +1,124 @@ +#!/bin/sh + +set -e + +echo "🚀 Démarrage de l'interface utilisateur 4NK..." + +# Variables d'environnement avec valeurs par défaut +SDK_RELAY_WS_URL=${SDK_RELAY_WS_URL:-"ws://sdk_relay_1:8090"} +SDK_RELAY_HTTP_URL=${SDK_RELAY_HTTP_URL:-"http://sdk_relay_1:8091"} +BITCOIN_RPC_URL=${BITCOIN_RPC_URL:-"http://bitcoin:18443"} +BLINDBIT_URL=${BLINDBIT_URL:-"http://blindbit:8000"} + +# Fonction pour attendre qu'un service soit disponible +wait_for_service() { + local service_name=$1 + local service_url=$2 + local max_attempts=30 + local attempt=1 + + echo "⏳ Attente du service $service_name ($service_url)..." + + while [ $attempt -le $max_attempts ]; do + if wget --quiet --tries=1 --timeout=5 --spider "$service_url" 2>/dev/null; then + echo "✅ Service $service_name disponible" + return 0 + fi + + echo " Tentative $attempt/$max_attempts - Service $service_name non disponible" + sleep 2 + attempt=$((attempt + 1)) + done + + echo "❌ Service $service_name non disponible après $max_attempts tentatives" + return 1 +} + +# Fonction pour vérifier la connectivité WebSocket +check_websocket() { + local service_name=$1 + local ws_url=$2 + local max_attempts=10 + local attempt=1 + + echo "🔌 Vérification WebSocket $service_name ($ws_url)..." + + while [ $attempt -le $max_attempts ]; do + if nc -z $(echo $ws_url | sed 's|ws://||' | sed 's|wss://||' | cut -d: -f1) $(echo $ws_url | cut -d: -f3) 2>/dev/null; then + echo "✅ WebSocket $service_name accessible" + return 0 + fi + + echo " Tentative $attempt/$max_attempts - WebSocket $service_name non accessible" + sleep 3 + attempt=$((attempt + 1)) + done + + echo "⚠️ WebSocket $service_name non accessible (continuera sans)" + return 0 +} + +# Vérification des services critiques +echo "🔍 Vérification des services 4NK_node..." + +# Attendre sdk_relay HTTP (critique) +if ! wait_for_service "sdk_relay HTTP" "$SDK_RELAY_HTTP_URL/health"; then + echo "❌ Service sdk_relay HTTP critique non disponible" + exit 1 +fi + +# Vérifier sdk_relay WebSocket (optionnel) +check_websocket "sdk_relay WebSocket" "$SDK_RELAY_WS_URL" + +# Vérifier Bitcoin Core (optionnel) +if ! wait_for_service "Bitcoin Core" "$BITCOIN_RPC_URL" 2>/dev/null; then + echo "⚠️ Bitcoin Core non disponible (optionnel)" +fi + +# Vérifier Blindbit (optionnel) +if ! wait_for_service "Blindbit" "$BLINDBIT_URL" 2>/dev/null; then + echo "⚠️ Blindbit non disponible (optionnel)" +fi + +# Génération de la configuration dynamique +echo "⚙️ Génération de la configuration dynamique..." + +# Créer un fichier de configuration JavaScript pour l'application +cat > /usr/share/nginx/html/config.js << EOF +window.ENV_CONFIG = { + SDK_RELAY_WS_URL: '$SDK_RELAY_WS_URL', + SDK_RELAY_HTTP_URL: '$SDK_RELAY_HTTP_URL', + BITCOIN_RPC_URL: '$BITCOIN_RPC_URL', + BLINDBIT_URL: '$BLINDBIT_URL', + ENVIRONMENT: '4nk-node' +}; +EOF + +# Démarrage de nginx +echo "🌐 Démarrage de nginx..." +nginx -g "daemon off;" & + +# Attendre que nginx soit prêt +sleep 2 + +# Vérifier que nginx fonctionne +if ! wget --quiet --tries=1 --timeout=5 --spider http://localhost 2>/dev/null; then + echo "❌ Nginx n'a pas démarré correctement" + exit 1 +fi + +echo "✅ Interface utilisateur 4NK démarrée avec succès" +echo " 📍 URL: http://localhost" +echo " 🔌 WebSocket: $SDK_RELAY_WS_URL" +echo " 🌐 API: $SDK_RELAY_HTTP_URL" + +# Maintenir le conteneur en vie +while true; do + sleep 30 + + # Vérification périodique de la santé + if ! wget --quiet --tries=1 --timeout=5 --spider http://localhost 2>/dev/null; then + echo "❌ Nginx ne répond plus, redémarrage..." + nginx -s reload + fi +done diff --git a/ihm_client/tsconfig.json b/ihm_client/tsconfig.json new file mode 100755 index 00000000..d3640823 --- /dev/null +++ b/ihm_client/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext", "webworker"], + "types": [], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "experimentalDecorators": true, + "useDefineForClassFields": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": "./", + "paths": { + "~/*": ["src/*"] + } + }, + "include": ["src", "src/*/", "./vite.config.ts", "src/*.d.ts", "src/main.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/ihm_client/vite.config.ts b/ihm_client/vite.config.ts new file mode 100755 index 00000000..e842295a --- /dev/null +++ b/ihm_client/vite.config.ts @@ -0,0 +1,81 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; // or react from '@vitejs/plugin-react' if using React +import wasm from 'vite-plugin-wasm'; +import {createHtmlPlugin} from 'vite-plugin-html'; +import typescript from "@rollup/plugin-typescript"; +import fs from 'fs' +import path from 'path' +// import pluginTerminal from 'vite-plugin-terminal'; + +export default defineConfig({ + optimizeDeps: { + include: ['qrcode'] + }, + plugins: [ + vue(), // or react() if using React + wasm(), + createHtmlPlugin({ + minify: true, + template: 'index.html', + }), + typescript({ + sourceMap: false, + declaration: true, + declarationDir: "dist/types", + rootDir: "src", + outDir: "dist", + }), + // pluginTerminal({ + // console: 'terminal', + // output: ['terminal', 'console'] + // }) + ], + build: { + outDir: 'dist', + target: 'esnext', + minify: false, + rollupOptions: { + input: './src/index.ts', + output: { + entryFileNames: 'index.js', + }, + }, + lib: { + entry: path.resolve(__dirname, 'src/router.ts'), + name: 'ihm-service', + formats: ['es'], + fileName: (format) => `ihm-service.${format}.js`, + }, + }, + resolve: { + alias: { + '@': '/src', + }, + extensions: ['.ts', '.tsx', '.js'], + }, + server: { + fs: { + cachedChecks: false, + }, + port: 3003, + proxy: { + '/storage': { + target: 'https://demo.4nkweb.com', + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(/^\/storage/, '/storage'), + configure: (proxy, _options) => { + proxy.on('error', (err, _req, _res) => { + console.log('proxy error', err); + }); + proxy.on('proxyReq', (proxyReq, req, _res) => { + console.log('Sending Request:', req.method, req.url); + }); + proxy.on('proxyRes', (proxyRes, req, _res) => { + console.log('Received Response:', proxyRes.statusCode, req.url); + }); + } + } + } + }, +}); \ No newline at end of file diff --git a/start-4nk-node-with-ui.sh b/start-4nk-node-with-ui.sh new file mode 100755 index 00000000..193f182f --- /dev/null +++ b/start-4nk-node-with-ui.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +set -e + +echo "🚀 Démarrage de l'infrastructure 4NK_node avec interface utilisateur..." + +# Vérifier que nous sommes dans le bon répertoire +if [[ ! -f "docker-compose.yml" ]]; then + echo "❌ Ce script doit être exécuté depuis le répertoire 4NK_node" + exit 1 +fi + +# Arrêter et nettoyer les conteneurs existants +echo "🧹 Nettoyage des conteneurs existants..." +docker-compose down + +# Démarrer tous les services +echo "📦 Démarrage de tous les services..." +docker-compose up -d + +# Attendre que les services critiques soient prêts +echo "⏳ Attente du démarrage des services critiques..." +sleep 30 + +# Vérifier la santé des services +echo "🔍 Vérification de la santé des services..." + +# Bitcoin +if docker-compose ps bitcoin | grep -q "Up"; then + echo "✅ Bitcoin démarré" +else + echo "❌ Bitcoin n'est pas démarré" + docker-compose logs bitcoin +fi + +# Blindbit +if docker-compose ps blindbit | grep -q "Up"; then + echo "✅ Blindbit démarré" +else + echo "❌ Blindbit n'est pas démarré" + docker-compose logs blindbit +fi + +# SDK Relays +for i in {1..3}; do + if docker-compose ps "sdk_relay_$i" | grep -q "Up"; then + echo "✅ SDK Relay $i démarré" + else + echo "❌ SDK Relay $i n'est pas démarré" + docker-compose logs "sdk_relay_$i" + fi +done + +# Interface utilisateur +if docker-compose ps ihm_client | grep -q "Up"; then + echo "✅ Interface utilisateur démarrée" +else + echo "❌ Interface utilisateur n'est pas démarrée" + docker-compose logs ihm_client +fi + +echo "" +echo "🎉 Infrastructure 4NK_node démarrée avec succès !" +echo "" +echo "📍 URLs d'accès :" +echo " 🌐 Interface utilisateur: http://localhost:8080" +echo " 🔗 Bitcoin RPC: http://localhost:18443" +echo " 🔗 Blindbit: http://localhost:8000" +echo " 🔗 SDK Relay 1: http://localhost:8091" +echo " 🔗 SDK Relay 2: http://localhost:8093" +echo " 🔗 SDK Relay 3: http://localhost:8095" +echo "" +echo "🔍 Commandes utiles :" +echo " 📋 Statut des services: docker-compose ps" +echo " 📋 Logs d'un service: docker-compose logs " +echo " 📋 Arrêter l'infrastructure: docker-compose down" +echo " 📋 Redémarrer un service: docker-compose restart " diff --git a/start-ihm-client.sh b/start-ihm-client.sh new file mode 100755 index 00000000..20d67db2 --- /dev/null +++ b/start-ihm-client.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +echo "🚀 Démarrage de l'interface utilisateur 4NK..." + +# Vérifier que nous sommes dans le bon répertoire +if [[ ! -f "docker-compose.yml" ]]; then + echo "❌ Ce script doit être exécuté depuis le répertoire 4NK_node" + exit 1 +fi + +# Démarrer uniquement le service ihm_client +echo "📦 Démarrage du service ihm_client..." +docker-compose up -d ihm_client + +# Attendre que le service soit prêt +echo "⏳ Attente du démarrage..." +sleep 10 + +# Vérifier la santé du service +if docker-compose ps ihm_client | grep -q "Up"; then + echo "✅ Interface utilisateur démarrée avec succès" + echo " 📍 URL: http://localhost:8080" + echo " 🔍 Logs: docker logs 4nk-ihm-client" +else + echo "❌ Échec du démarrage de l'interface utilisateur" + docker-compose logs ihm_client + exit 1 +fi