Compare commits

...

253 Commits

Author SHA1 Message Date
Vincent Guillet
00208f08c9 Merge remote-tracking branch 'origin/dev'
merge
2025-12-05 15:28:32 +01:00
b79068623f Update jenkinsfile 2025-12-05 14:27:59 +00:00
Vincent Guillet
fd538f376f Merge branch 'dev' 2025-12-05 15:14:50 +01:00
Vincent Guillet
3eed3d251f Refactor CORS configuration to use allowed origins and enhance header handling 2025-12-05 15:14:16 +01:00
Vincent Guillet
cefb3c54c3 Merge remote-tracking branch 'origin/dev'
Merge from dev
2025-12-05 14:58:32 +01:00
7dcc85ac95 Update api/src/main/java/fr/gameovergne/api/controller/auth/AuthController.java 2025-12-05 13:57:42 +00:00
79bd33fe41 Merge pull request 'merge from dev' (#1) from dev into main
Reviewed-on: #1
2025-12-05 13:43:04 +00:00
ec9eb0dc7d Update api/src/main/java/fr/gameovergne/api/config/SecurityConfig.java 2025-12-05 13:40:48 +00:00
01cafd5904 Update docker-compose.prod.yml 2025-12-05 13:35:48 +00:00
321e2fd546 Update jenkinsfile 2025-12-05 13:27:16 +00:00
696e0ac817 Update jenkinsfile 2025-12-05 13:26:43 +00:00
888ddc1362 Update jenkinsfile 2025-12-05 13:24:13 +00:00
3026f0a13f Update jenkinsfile 2025-12-05 13:23:58 +00:00
52d17e5ad8 Update jenkinsfile 2025-12-05 12:56:15 +00:00
2803e910bd Add docker-compose.prod.yml 2025-12-05 12:54:54 +00:00
653ce83c33 Add docker-compose.dev.yml 2025-12-05 12:54:08 +00:00
ce618deecf Update docker-compose.yml.OLD 2025-12-05 12:53:06 +00:00
5331ce7866 Update docker-compose.yml 2025-12-04 09:19:07 +00:00
Vincent Guillet
6f6d033be3 Center align items in main navbar container for improved layout 2025-12-03 22:48:26 +01:00
Vincent Guillet
ff8536b448 Update SecurityConfig to require authentication for /api/app/** endpoints 2025-12-03 22:47:14 +01:00
Vincent Guillet
60593f6c11 Update base URL in index.html for proper routing in Game Over'gne app 2025-12-03 22:46:34 +01:00
Vincent Guillet
1708c1bead Update base URL in index.html for proper routing in Game Over'gne app 2025-12-03 22:11:33 +01:00
Vincent Guillet
dc33d762a1 Refactor app configuration to use hrefBase for base URL and improve provider imports 2025-12-03 21:59:42 +01:00
Vincent Guillet
e04cac3345 Enhance main navbar styles for better overflow handling and safe area support 2025-12-03 21:49:52 +01:00
Vincent Guillet
00f45ae6c7 Add loading indicators to product CRUD and dialog components 2025-12-03 21:46:18 +01:00
Vincent Guillet
1a5d3a570a Add image deletion functionality to product dialog carousel 2025-12-03 21:21:50 +01:00
Vincent Guillet
9763289c2f Export resizeImage function and integrate it into Prestashop service 2025-12-03 19:48:19 +01:00
Vincent Guillet
fd6c730ae3 Add image resizing utility and integrate it into product image upload process 2025-12-03 19:46:31 +01:00
Vincent Guillet
eb94697955 Refactor application.properties to set maximum file and request sizes for multipart uploads 2025-12-03 16:40:57 +01:00
Vincent Guillet
a72957648e Refactor PrestashopClient to improve image upload response handling and enhance error reporting 2025-12-03 15:47:12 +01:00
Vincent Guillet
b15c331295 Refactor PrestashopClient to store basic auth header for improved readability and maintainability 2025-12-03 15:32:44 +01:00
Vincent Guillet
503cbee641 Refactor PrestashopClient to enhance manual multipart image upload handling and improve error logging 2025-12-03 15:19:26 +01:00
Vincent Guillet
078cef0585 Refactor PrestashopClient to enhance image upload logging and improve filename handling 2025-12-03 15:06:53 +01:00
Vincent Guillet
48f7e84ef9 Refactor PrestashopClient to improve multipart image upload construction and enhance header management 2025-12-03 14:53:39 +01:00
Vincent Guillet
f975e57110 Refactor PrestashopClient to improve multipart image upload handling and logging 2025-12-03 14:46:32 +01:00
Vincent Guillet
389beca604 Refactor PrestashopClient to construct absolute URL for image uploads and improve logging 2025-12-03 14:36:40 +01:00
Vincent Guillet
fa7a1c2f26 Refactor PrestashopClient to streamline image upload process and enhance error handling 2025-12-03 14:30:34 +01:00
Vincent Guillet
df98dfe38e Refactor PrestashopClient to enhance image upload handling and improve error logging 2025-12-03 14:22:47 +01:00
Vincent Guillet
65559bbb48 Refactor PrestashopProxyController to simplify image upload API path 2025-12-03 13:51:54 +01:00
Vincent Guillet
f317d15ac5 Refactor PrestashopProxyController to simplify image upload API path 2025-12-03 13:43:29 +01:00
Vincent Guillet
68ccb164e2 Refactor PrestashopProxyController to improve path extraction and enhance logging for proxy requests 2025-12-03 13:33:56 +01:00
Vincent Guillet
a8f9c5f49a Refactor PrestashopProxyController to unify API path handling and enhance error logging for proxy requests 2025-12-03 13:22:43 +01:00
Vincent Guillet
ff331e1630 Refactor PrestashopProxyController to streamline API endpoint handling and improve code readability 2025-12-03 12:28:22 +01:00
Vincent Guillet
d076286728 Add image upload functionality to PrestashopClient and PrestashopProxyController 2025-12-03 12:00:48 +01:00
Vincent Guillet
de942e0d96 Enhance PrestashopClient to use UTF-8 encoding for XML body in POST and PUT methods 2025-12-03 11:39:55 +01:00
Vincent Guillet
ce3389f2e6 Enhance PrestashopClient and PrestashopProxyController to add error handling for POST, PUT, and DELETE methods 2025-12-03 11:26:23 +01:00
Vincent Guillet
8680b2fc92 Enhance PrestashopProxyController and PrestashopClient to support POST, PUT, and DELETE methods with raw query handling 2025-12-03 11:03:26 +01:00
Vincent Guillet
5068390a14 Refactor package structure for Prestashop components and enhance raw query handling in PrestashopProxyController 2025-12-03 10:25:59 +01:00
Vincent Guillet
4fe16b0cb1 restore previous version 2025-12-03 10:04:22 +01:00
Vincent Guillet
42c1e655f1 Refactor PrestashopClient to normalize base URL, enhance URL building, and improve raw query handling 2025-12-03 09:33:30 +01:00
Vincent Guillet
fdb6c40bb9 Refactor PrestashopClient to normalize base URL, enhance URL building, and improve raw query handling 2025-12-02 17:48:27 +01:00
Vincent Guillet
72f3791616 Refactor PrestashopClient and PrestashopProxyController to support raw query handling and simplify proxy GET requests 2025-12-02 17:35:53 +01:00
Vincent Guillet
db8085c0aa Refactor PrestashopClient to enhance header configuration and improve query parameter encoding 2025-12-02 17:23:31 +01:00
Vincent Guillet
177eb2eb5c Refactor URI building in PrestashopClient to simplify query parameter encoding 2025-12-02 16:54:49 +01:00
Vincent Guillet
bceedc8620 Add support for raw query handling in PrestashopClient and update response handling in PrestashopProxyController 2025-12-02 16:35:42 +01:00
Vincent Guillet
14a6f66742 Update Prestashop service to return ID for all valid responses 2025-12-02 16:28:38 +01:00
Vincent Guillet
28faf2ed2b Refactor PrestashopClient to improve URI building and parameter encoding 2025-12-02 16:28:27 +01:00
Vincent Guillet
02387e9a50 Refactor Prestashop service endpoints to remove redundant reference-data prefix 2025-11-30 11:58:02 +01:00
Vincent Guillet
c09316189e Update PrestashopAdminController request mapping to remove redundant API prefix 2025-11-30 11:54:13 +01:00
Vincent Guillet
ad441b8dbc Update Prestashop service endpoints to remove redundant API prefix 2025-11-30 11:53:42 +01:00
Vincent Guillet
9759f2cf8e Update PrestashopAdminController request mapping to include duplicate API prefix 2025-11-30 11:47:57 +01:00
Vincent Guillet
16bd098954 Update API URLs in production environment configuration for improved routing 2025-11-30 11:47:35 +01:00
Vincent Guillet
7e5f75e482 Update API URLs in production environment configuration for consistency 2025-11-30 11:44:10 +01:00
Vincent Guillet
d866924130 Update PrestashopAdminController request mapping to include API prefix 2025-11-30 11:43:28 +01:00
Vincent Guillet
9eb8256fd7 Update PrestashopAdminController request mapping to remove API prefix 2025-11-30 11:39:43 +01:00
Vincent Guillet
e5411f2fdc Rename Prestashop API base URL property for consistency 2025-11-29 12:00:39 +01:00
Vincent Guillet
d64fed8157 Refactor PrestashopService and ps-product-dialog component to enhance API integration and improve product management functionality 2025-11-29 11:53:25 +01:00
Vincent Guillet
007cb34c81 Enhance PrestashopClient and PrestashopProxyController with new proxy method and improved response handling 2025-11-29 11:32:41 +01:00
Vincent Guillet
aead87b1bc Refactor PrestashopService to improve API integration and enhance product management functionality 2025-11-29 10:43:57 +01:00
Vincent Guillet
e30fb83043 Add PrestashopAdminController and PrestashopAdminService for managing admin resources 2025-11-29 10:36:09 +01:00
Vincent Guillet
44764a5f14 Refactor PrestashopProxyController to enhance proxy response handling and ensure consistent JSON output 2025-11-29 10:05:21 +01:00
Vincent Guillet
4c16e356a3 Refactor PrestashopClient and PrestashopProxyController to enhance API request handling and improve error logging 2025-11-29 09:58:51 +01:00
Vincent Guillet
8a750b94d0 Refactor PrestashopClient and PrestashopProxyController to improve query handling and simplify request processing 2025-11-29 09:47:02 +01:00
Vincent Guillet
9ae60a087a Refactor PrestashopClient and PrestashopProxyController to improve query handling and simplify request processing 2025-11-29 09:33:10 +01:00
Vincent Guillet
9d2f89f805 Refactor PrestashopClient and PrestashopProxyController to enhance API request handling and simplify query processing 2025-11-29 09:04:47 +01:00
Vincent Guillet
d802418c29 Refactor PrestashopProxyController to simplify endpoint handling and enhance query processing 2025-11-29 08:52:59 +01:00
504fb4fe8e Update docker-compose.yml 2025-11-28 22:09:49 +00:00
Vincent Guillet
5c42db7540 Refactor PrestashopClient and PrestashopProxyController to use API key for authentication and simplify request handling 2025-11-28 23:08:56 +01:00
Vincent Guillet
14e19ac2ea Refactor PrestashopClient and PrestashopProxyController to enhance query handling and response logging 2025-11-28 22:39:04 +01:00
Vincent Guillet
136f9c1732 Update environment configuration and simplify nginx settings for API routing 2025-11-28 22:20:44 +01:00
Vincent Guillet
60ce19f72f Refactor PrestashopClient and PrestashopProxyController to enhance query handling and response logging 2025-11-28 22:19:26 +01:00
659b16f700 Update client/nginx.conf 2025-11-28 19:33:09 +00:00
664123cc22 Update client/nginx.conf 2025-11-28 19:23:14 +00:00
fec615db26 Update docker-compose.yml 2025-11-28 18:59:45 +00:00
6388db1026 Update docker-compose.yml 2025-11-28 18:57:05 +00:00
35a5f0d755 Update docker-compose.yml 2025-11-28 18:55:13 +00:00
e6efbdeafe Update docker-compose.yml 2025-11-28 18:50:30 +00:00
dba9e19c6c Update client/nginx.conf 2025-11-28 18:50:04 +00:00
9b2a4b55a2 Update client/nginx.conf 2025-11-28 18:47:15 +00:00
ad66b1bf31 Update docker-compose.yml 2025-11-28 18:46:26 +00:00
785c482057 Update jenkinsfile 2025-11-28 18:46:00 +00:00
bbd1b94524 Update client/nginx.conf 2025-11-28 18:39:45 +00:00
9d07e4d14e revert 8088d3efb7
revert Update client/nginx.conf
2025-11-28 18:38:38 +00:00
8088d3efb7 Update client/nginx.conf 2025-11-28 18:23:38 +00:00
Vincent Guillet
e5adb9356f Add PrestaShop API configuration to application properties 2025-11-28 19:11:35 +01:00
Vincent Guillet
b8aa3e61ed Refactor AuthController to enhance refresh token handling and support CORS 2025-11-28 18:50:35 +01:00
Vincent Guillet
411c407a40 Refactor AuthController to support both POST and GET methods for refresh token 2025-11-28 18:47:15 +01:00
9e350ec2b5 Update client/nginx.conf 2025-11-28 17:38:00 +00:00
6427f08ed8 Update client/Dockerfile 2025-11-28 17:37:48 +00:00
40eb58aa4d Update client/Dockerfile 2025-11-28 17:26:12 +00:00
c9d8186f21 Update client/nginx.conf 2025-11-28 17:26:03 +00:00
8e5819db38 Update docker-compose.yml 2025-11-28 17:23:28 +00:00
1659ac6ad2 Update docker-compose.yml 2025-11-28 17:16:28 +00:00
0d68975f41 Update client/src/index.html 2025-11-28 17:14:23 +00:00
0d71596e70 Update client/nginx.conf 2025-11-28 17:13:54 +00:00
03661021cc Update client/Dockerfile 2025-11-28 17:13:45 +00:00
82de928f3e Update client/nginx.conf 2025-11-28 17:03:15 +00:00
bfa90c5b31 Update client/Dockerfile 2025-11-28 17:03:04 +00:00
136532947f Update docker-compose.yml 2025-11-28 16:45:01 +00:00
7028d1094b Update docker-compose.yml 2025-11-28 16:38:34 +00:00
00fd7e2069 Update client/src/environments/environment.prod.ts 2025-11-28 16:30:41 +00:00
d388ce2d1d Update client/src/environments/environment.ts 2025-11-28 16:30:27 +00:00
939bb6159c Update client/nginx.conf 2025-11-28 16:30:00 +00:00
3a6b26ac38 Update client/Dockerfile 2025-11-28 16:29:43 +00:00
dabfd03d0c Update docker-compose.yml 2025-11-28 16:29:25 +00:00
d8410b7463 Update docker-compose.yml 2025-11-28 15:56:28 +00:00
d526b8ab39 Update client/src/environments/environment.prod.ts 2025-11-28 15:49:10 +00:00
687400ebd9 Update client/src/environments/environment.ts 2025-11-28 15:48:57 +00:00
44abcda2e8 Update client/src/index.html 2025-11-28 15:48:38 +00:00
6e47c4b4d9 Update client/nginx.conf 2025-11-28 15:48:21 +00:00
6cc3423451 Update client/Dockerfile 2025-11-28 15:48:08 +00:00
1cda6f4660 Update docker-compose.yml 2025-11-28 15:47:43 +00:00
2a4b71f52f Update client/src/environments/environment.prod.ts 2025-11-28 15:23:26 +00:00
edd8011efc Update client/src/index.html 2025-11-28 15:22:57 +00:00
f2ba047fc4 Update docker-compose.yml 2025-11-28 15:22:31 +00:00
9b8c8b05b3 Update client/src/index.html 2025-11-28 15:18:47 +00:00
30e740e70f Update client/Dockerfile 2025-11-28 15:18:23 +00:00
669980ea93 Update docker-compose.yml 2025-11-28 15:18:07 +00:00
1a670ae930 Update client/angular.json 2025-11-28 15:13:12 +00:00
b42b4fd015 Update client/Dockerfile 2025-11-28 15:01:20 +00:00
d6dba27b16 Update client/angular.json 2025-11-28 15:01:04 +00:00
9a27cd3789 Update docker-compose.yml 2025-11-28 14:54:23 +00:00
b9c4a7fdb4 Update docker-compose.yml 2025-11-28 14:47:28 +00:00
b888089e22 Update client/src/index.html 2025-11-28 14:38:20 +00:00
Vincent Guillet
bfe7176388 Add serve options to angular.json for development environment 2025-11-28 15:27:24 +01:00
facfd5d32b Update jenkinsfile 2025-11-28 13:29:48 +00:00
97f97450f4 Update jenkinsfile 2025-11-28 13:19:07 +00:00
dd0478970b Update jenkinsfile 2025-11-28 13:17:10 +00:00
Vincent Guillet
f44ca08f6a Add Vite configuration for Angular development server 2025-11-28 13:45:12 +01:00
dc21f3820c Update docker-compose.yml 2025-11-28 12:28:35 +00:00
9a8e59e07e Update docker-compose.yml 2025-11-28 11:11:12 +00:00
Vincent Guillet
669a4cbe00 Refactor Dockerfile and update environment configurations for improved API integration 2025-11-28 12:10:56 +01:00
b98995b7ae Update docker-compose.yml 2025-11-28 10:48:18 +00:00
a40fd2c7ac Update docker-compose.yml 2025-11-28 10:33:51 +00:00
Vincent Guillet
d73275572f Remove PrestaShop proxy configuration from nginx.conf 2025-11-28 11:33:23 +01:00
Vincent Guillet
ad6567efa2 Add PrestashopClient and PrestashopProxyController for API integration 2025-11-28 11:33:15 +01:00
f8358594d5 Update docker-compose.yml 2025-11-28 10:12:35 +00:00
1261e90fd7 Update docker-compose.yml 2025-11-28 09:54:38 +00:00
60f6ac4823 Update client/nginx.conf 2025-11-28 09:53:53 +00:00
734320a405 Update docker-compose.yml 2025-11-28 09:52:44 +00:00
Vincent Guillet
6f05f66ea6 Refactor environment configurations and nginx settings for improved API routing and path handling 2025-11-28 10:32:03 +01:00
Vincent Guillet
e5cce7668f Update API URL in production environment configuration for correct routing 2025-11-26 15:34:43 +01:00
Vincent Guillet
b16eff2e76 Refactor environment and nginx configuration for improved API routing and proxy handling 2025-11-26 15:14:53 +01:00
Vincent Guillet
8cdfab9596 Refactor PrestashopProxyController to enhance request forwarding and error handling 2025-11-26 14:46:37 +01:00
Vincent Guillet
f9a9e81713 Refactor PrestashopClient and PrestashopProxyController to improve query handling and enhance code clarity 2025-11-26 14:25:25 +01:00
Vincent Guillet
f4696b5f5b Refactor PrestashopClient and PrestashopProxyController for improved error handling and response management 2025-11-26 14:08:33 +01:00
Vincent Guillet
d94ce06d95 Refactor PrestashopClient and PrestashopProxyController for improved API request handling and response management 2025-11-26 12:14:07 +01:00
Vincent Guillet
103e4c055d Enhance responsiveness of navigation and product CRUD components with CSS adjustments for better layout on small screens 2025-11-26 11:57:48 +01:00
Vincent Guillet
005bbcc678 Enhance responsiveness of navigation and product CRUD components with CSS adjustments for better layout on small screens 2025-11-26 10:01:40 +01:00
Vincent Guillet
b9320c7383 Enhance responsiveness of product CRUD component with CSS adjustments for toolbar and table layout 2025-11-26 09:34:20 +01:00
Vincent Guillet
1efe158631 Enhance password validation in registration component with improved regex pattern and increased max length 2025-11-26 09:30:59 +01:00
Vincent Guillet
7c82cf0d3f Remove unused image association from User model 2025-11-25 21:38:55 +01:00
Vincent Guillet
4b29e116cf Refactor PrestashopClient and PrestashopProxyController to enhance API request handling and improve error logging 2025-11-25 21:35:11 +01:00
Vincent Guillet
21cdb3dc30 Delete unused files 2025-11-25 21:23:38 +01:00
Vincent Guillet
64f00d5b30 Delete unused files 2025-11-25 21:21:30 +01:00
Vincent Guillet
8317fc24d9 Refactor PrestashopClient and PrestashopProxyController for improved API handling 2025-11-25 20:58:21 +01:00
Vincent Guillet
4a16aa715f Add display full parameter to PrestashopClient API query 2025-11-25 19:55:23 +01:00
61e164732c Update api/src/main/java/fr/gameovergne/api/service/prestashop/PrestashopClient.java 2025-11-25 18:35:55 +00:00
d3f47549f9 Update api/src/main/java/fr/gameovergne/api/controller/prestashop/PrestashopProxyController.java 2025-11-25 18:21:01 +00:00
b289a53154 Update api/src/main/java/fr/gameovergne/api/service/prestashop/PrestashopClient.java 2025-11-25 18:20:41 +00:00
47edec0d33 Update api/src/main/java/fr/gameovergne/api/service/prestashop/PrestashopClient.java 2025-11-25 18:05:56 +00:00
Vincent Guillet
c7b9b68d42 Refactor PrestashopClient to use HttpClient and simplify PrestashopProxyController 2025-11-25 18:55:16 +01:00
Vincent Guillet
e839aae4dd Add WebClient configuration and update PrestashopProxyController for reactive support 2025-11-25 18:33:53 +01:00
019b8f4c01 Update api/src/main/java/fr/gameovergne/api/service/prestashop/PrestashopClient.java 2025-11-25 17:18:52 +00:00
844c132915 Update api/src/main/java/fr/gameovergne/api/service/prestashop/PrestashopClient.java 2025-11-25 17:02:09 +00:00
c9ceb46746 Update api/src/main/java/fr/gameovergne/api/service/prestashop/PrestashopClient.java 2025-11-25 16:28:18 +00:00
e4dacdd61a Update docker-compose.yml 2025-11-25 16:17:41 +00:00
7229364a59 Update api/src/main/java/fr/gameovergne/api/config/SecurityConfig.java 2025-11-25 16:12:13 +00:00
e6769bb1d5 Update api/src/main/java/fr/gameovergne/api/config/SecurityConfig.java 2025-11-25 15:46:43 +00:00
1b876d2d6c Update client/src/environments/environment.prod.ts 2025-11-25 15:29:42 +00:00
2b9b80b031 Update docker-compose.yml 2025-11-25 15:24:05 +00:00
1ba498e862 Update jenkinsfile 2025-11-25 13:57:55 +00:00
9241cbd4e2 Update docker-compose.yml 2025-11-25 13:57:33 +00:00
Vincent Guillet
20573d73b3 feat: update PrestashopService to use dynamic base URL from environment configuration 2025-11-22 21:48:44 +01:00
Vincent Guillet
3c700348df feat: add PrestashopProxyController and PrestashopClient for API proxying 2025-11-22 21:45:38 +01:00
Vincent Guillet
cb38b80134 feat: add password field to UserDTO and update UserMapper for password handling 2025-11-22 19:08:42 +01:00
d84a2ad122 Update api/src/main/java/fr/gameovergne/api/config/SecurityConfig.java 2025-11-22 17:28:24 +00:00
93d575f7c5 Update client/src/environments/environment.prod.ts 2025-11-22 16:53:25 +00:00
60c3c4a3fa Update client/nginx.conf 2025-11-22 16:35:59 +00:00
6b6ef1cbab Update client/Dockerfile 2025-11-22 16:35:38 +00:00
05c998be1f Update docker-compose.yml 2025-11-22 14:52:02 +00:00
9fef690989 Update client/Dockerfile 2025-11-22 13:44:03 +00:00
6eeda7c4ba Update docker-compose.yml 2025-11-21 15:38:18 +00:00
716a73c7a9 Add client/nginx.conf 2025-11-21 15:21:27 +00:00
c6b95ef9c7 Update client/Dockerfile 2025-11-21 15:20:44 +00:00
5c99a2eada Update docker-compose.yml 2025-11-21 15:03:50 +00:00
03c0179f62 Update docker-compose.yml 2025-11-21 14:44:11 +00:00
bfaca86a29 Update client/Dockerfile 2025-11-21 14:43:26 +00:00
f1d2c9b33b Update client/angular.json 2025-11-21 14:25:20 +00:00
Vincent Guillet
e38c7f8241 feat: add allowed origin for gameovergne in CORS configuration 2025-11-20 22:35:30 +01:00
Vincent Guillet
ccd73cd478 feat: update environment configurations and improve Docker health checks 2025-11-20 22:34:24 +01:00
0db746afd3 Update docker-compose.yml 2025-11-20 20:41:06 +00:00
Vincent Guillet
8eccdc75d5 feat: update angular.json and docker-compose.yml for gameovergne configuration 2025-11-20 21:27:37 +01:00
adc83b2d70 Update jenkinsfile 2025-11-20 18:15:41 +00:00
714706532c Update jenkinsfile 2025-11-20 18:01:53 +00:00
Vincent Guillet
3f16faceb0 feat: configure MySQL service in Docker; update datasource URL and adjust port mappings 2025-11-20 19:00:40 +01:00
368a7f3227 Update jenkinsfile 2025-11-20 17:53:12 +00:00
df179c7791 Update jenkinsfile 2025-11-20 17:45:31 +00:00
877af6ada3 Update jenkinsfile 2025-11-20 17:36:01 +00:00
42906cc1d9 Update jenkinsfile 2025-11-20 17:29:41 +00:00
b1c1aae383 Update jenkinsfile 2025-11-20 17:21:54 +00:00
Vincent Guillet
7d20b396ad chore: allow dev.unifihomenetwork.com in dev-server 2025-11-20 17:22:51 +01:00
47c6693d1d Update jenkinsfile 2025-11-20 15:51:51 +00:00
19dc42f0a5 Update docker-compose.yml 2025-11-20 15:47:45 +00:00
1cb7057911 Update docker-compose.yml 2025-11-20 15:44:52 +00:00
910f283f8a Update jenkinsfile 2025-11-20 15:38:51 +00:00
bf3a7c33c0 Update docker-compose.yml 2025-11-20 15:30:29 +00:00
Vincent Guillet
f83a8cc26b update compose 2025-11-20 16:24:18 +01:00
Vincent Guillet
028bd80669 add jenkins pipeline 2025-11-20 16:10:38 +01:00
Vincent Guillet
6ef5b998a4 feat: configure MySQL service in Docker; update datasource URL and adjust port mappings 2025-11-20 15:47:48 +01:00
Vincent Guillet
b756c9fa2d feat: implement image carousel in product dialog; enhance image upload handling and preview functionality 2025-11-18 16:32:29 +01:00
Vincent Guillet
d4ffcf0562 feat: add quantity field to product CRUD; implement image upload and condition handling in product dialog 2025-11-18 15:38:24 +01:00
Vincent Guillet
bcc71b965b refactor: reorganize component files and update import paths; add PsItem and PsProduct interfaces 2025-11-12 12:34:58 +01:00
Vincent Guillet
f063a245b9 feat: implement image management functionality; add ImageController, ImageService, ImageMapper, and related DTOs 2025-11-10 16:29:55 +01:00
Vincent Guillet
a849a4dd15 feat: add categories and manufacturers CRUD components; implement form handling and image upload functionality 2025-11-10 16:29:42 +01:00
Vincent Guillet
b84a829a82 feat: implement add product functionality; create add-product component with form validation and styling 2025-11-05 16:44:42 +01:00
Vincent Guillet
1a918a7863 delete test file 2025-11-04 18:44:30 +01:00
Vincent Guillet
df2a5a57ba refactor: update ProductController to return ProductDTO; enhance ProductMapper for ID handling 2025-11-04 18:13:53 +01:00
Vincent Guillet
3bc2a27d76 refactor: rename components and update dialog implementations; add confirm dialog for deletion actions 2025-11-04 18:13:37 +01:00
Vincent Guillet
2fe52830d8 refactor: update controllers and mappers to use DTOs for brands and categories; add ID field to DTOs 2025-11-01 17:56:52 +01:00
Vincent Guillet
55a1d54c70 refactor: rename components and update admin navbar; implement generic list and dialog components for brands, categories, and platforms 2025-11-01 17:56:26 +01:00
Vincent Guillet
e7da8b9b83 add app API endpoints for categories, platforms, and products; update security configuration 2025-11-01 15:44:26 +01:00
Vincent Guillet
7c8f85a500 add Categories management: create CategoriesList component, update admin navbar, and integrate category handling in product forms 2025-11-01 15:43:49 +01:00
Vincent Guillet
4b692806c4 add Platform and Brand DTOs, mappers, and controllers; update security configuration for CORS 2025-10-31 18:33:19 +01:00
Vincent Guillet
7531ea9453 add admin navbar and brand/platform management components 2025-10-31 18:32:24 +01:00
Vincent Guillet
6dc9f4ffea update title in index.html to reflect application name 2025-10-14 14:53:37 +02:00
Vincent Guillet
d2696b16ac update routing to include AddProduct component and adjust guard activations 2025-10-14 14:53:28 +02:00
Vincent Guillet
7fb4f46833 update navbar component for French localization and branding 2025-10-14 14:53:22 +02:00
Vincent Guillet
b0ba0886fd add Brand class and associated unit tests 2025-10-14 14:53:18 +02:00
Vincent Guillet
8c3de85f36 add AddProduct component with form for product creation and associated styles 2025-10-14 14:53:13 +02:00
Vincent Guillet
f04f9fd93f add BrandService and ProductService with basic HTTP methods and tests 2025-10-14 14:53:07 +02:00
Vincent Guillet
f2f855bc70 secure /api/brands/** endpoint and update CORS configuration 2025-10-14 14:50:26 +02:00
Vincent Guillet
611eb685a8 add entities for Brand, Category, Condition, Image, Platform, and Product with relationships 2025-10-14 14:50:21 +02:00
Vincent Guillet
de1df47474 add entities for Brand, Category, Condition, Image, Platform, and Product with relationships 2025-10-14 14:50:17 +02:00
Vincent Guillet
7769cc7ded add BrandRepository and enhance UserRepository with additional retrieval methods 2025-10-14 14:50:13 +02:00
Vincent Guillet
5476651ea3 add BrandService and enhance UserService with additional user retrieval methods 2025-10-14 14:50:06 +02:00
Vincent Guillet
98cf31349b Remove mysql service dependency 2025-09-24 11:58:06 +02:00
Vincent Guillet
4acf024350 Comment .env COPY instruction 2025-09-24 11:56:25 +02:00
Vincent Guillet
2e5b2b3ff5 add Docker configuration and Jenkins pipeline for application deployment 2025-09-24 11:32:43 +02:00
Vincent Guillet
18f0364e26 add initial Angular components, services, and routing setup 2025-09-24 11:31:28 +02:00
Vincent Guillet
dfb4ac302a add .vscode folder to .gitignore 2025-09-24 11:31:04 +02:00
Vincent Guillet
3d2b3f7324 implement authentication and user management features 2025-09-24 11:29:48 +02:00
Vincent Guillet
f2b4f9d3e6 add .gitignore 2025-09-24 11:27:02 +02:00
106 changed files with 21404 additions and 0 deletions

403
.gitignore vendored Normal file
View File

@@ -0,0 +1,403 @@
### Angular ###
## Angular ##
# compiled output
dist/
tmp/
app/**/*.js
app/**/*.js.map
# dependencies
node_modules/
bower_components/
# IDEs and editors
.idea/
# misc
.sass-cache/
connect.lock/
coverage/
libpeerconnection.log/
npm-debug.log
testem.log
typings/
.angular/
# e2e
e2e/*.js
e2e/*.map
# System Files
.DS_Store/
### Java ###
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
### JetBrains ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
target/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### JetBrains Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### macOS Patch ###
# iCloud generated files
*.icloud
### Node ###
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk

9
Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM jenkins/jenkins:lts
USER root
RUN apt update && apt install -y docker.io && apt install -y docker-compose
RUN docker --version
USER jenkins

21
api/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM maven:3.9.6-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
# COPY .env .env
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

34
api/HELP.md Normal file
View File

@@ -0,0 +1,34 @@
# Getting Started
### Reference Documentation
For further reference, please consider the following sections:
* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)
* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.6/maven-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.6/maven-plugin/build-image.html)
* [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.6/reference/data/sql.html#data.sql.jpa-and-spring-data)
* [Spring Boot DevTools](https://docs.spring.io/spring-boot/3.5.6/reference/using/devtools.html)
* [Spring Security](https://docs.spring.io/spring-boot/3.5.6/reference/web/spring-security.html)
* [Validation](https://docs.spring.io/spring-boot/3.5.6/reference/io/validation.html)
* [Spring Web](https://docs.spring.io/spring-boot/3.5.6/reference/web/servlet.html)
### Guides
The following guides illustrate how to use some features concretely:
* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/)
* [Securing a Web Application](https://spring.io/guides/gs/securing-web/)
* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/)
* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/)
* [Validation](https://spring.io/guides/gs/validating-form-input/)
* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
### Maven Parent overrides
Due to Maven's design, elements are inherited from the parent POM to the project POM.
While most of the inheritance is fine, it also inherits unwanted elements like `<license>` and `<developers>` from the parent.
To prevent this, the project POM contains empty overrides for these elements.
If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.

295
api/mvnw vendored Normal file
View File

@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
api/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

119
api/pom.xml Normal file
View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>fr.gameovergne</groupId>
<artifactId>api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>api</name>
<description>api</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.4</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<maven.compiler.release>21</maven.compiler.release>
<sonar.organization>vincentguillet</sonar.organization>
</properties>
<dependencies>
<!-- Web REST -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL 8.4 driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- OpenAPI / Swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.9</version>
</dependency>
<!-- Spring Dotenv -->
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>4.0.0</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>${maven.compiler.release}</release>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,13 @@
package fr.gameovergne.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GameOvergneApplication {
public static void main(String[] args) {
SpringApplication.run(GameOvergneApplication.class, args);
}
}

View File

@@ -0,0 +1,106 @@
package fr.gameovergne.api.config;
import fr.gameovergne.api.service.security.JpaUserDetailsService;
import fr.gameovergne.api.service.security.filter.JwtAuthFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final JpaUserDetailsService userDetailsService;
public SecurityConfig(JwtAuthFilter jwtAuthFilter, JpaUserDetailsService userDetailsService) {
this.jwtAuthFilter = jwtAuthFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authz -> authz
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // autoriser les preflight
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/users/**").authenticated()
.requestMatchers("/api/app/**").authenticated()
.anyRequest().permitAll()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// IMPORTANT : origins explicites, sans path
config.setAllowedOrigins(Arrays.asList(
"http://localhost:4200",
"http://127.0.0.1:4200",
"https://dev.vincent-guillet.fr",
"https://projets.vincent-guillet.fr"
));
config.setAllowCredentials(true);
// Autoriser tous les headers côté requête (plus robuste)
config.setAllowedHeaders(Arrays.asList("*"));
// Autoriser les méthodes classiques
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// Headers que le client *voit* dans la réponse
config.setExposedHeaders(Arrays.asList("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@@ -0,0 +1,13 @@
package fr.gameovergne.api.config.prestashop;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "prestashop")
@Getter
@Setter
public class PrestashopProperties {
private String baseUrl;
private String apiKey;
}

View File

@@ -0,0 +1,141 @@
package fr.gameovergne.api.controller.auth;
import fr.gameovergne.api.dto.auth.AuthRequest;
import fr.gameovergne.api.dto.auth.AuthResponse;
import fr.gameovergne.api.dto.user.UserDTO;
import fr.gameovergne.api.mapper.user.UserMapper;
import fr.gameovergne.api.service.auth.AuthService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
// ===================== REGISTER =====================
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@RequestBody UserDTO dto,
HttpServletResponse response) {
return authService.register(UserMapper.fromDto(dto))
.map(authResponse -> createAuthResponse(authResponse, response))
.orElse(ResponseEntity.badRequest().build());
}
// ===================== LOGIN =====================
@PostMapping("/login")
public ResponseEntity<AuthResponse> authenticate(@RequestBody AuthRequest request,
HttpServletResponse response) {
return authService.authenticate(request)
.map(authResponse -> createAuthResponse(authResponse, response))
.orElse(ResponseEntity.badRequest().build());
}
// ===================== LOGOUT =====================
@GetMapping("/logout")
public ResponseEntity<Void> logout(HttpServletResponse response) {
// Supprime le cookie de refresh token
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(false) // true en prod derrière HTTPS
.path("/")
.maxAge(0) // expire immédiatement
.sameSite("Lax")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok().build();
}
// ===================== ME =====================
@GetMapping("/me")
public ResponseEntity<UserDTO> getCurrentUser(HttpServletRequest request) {
String username = request.getUserPrincipal() != null
? request.getUserPrincipal().getName()
: null;
if (username == null) {
return ResponseEntity.status(401).build();
}
return authService.getCurrentUser(username)
.map(user -> ResponseEntity.ok(UserMapper.toDto(user)))
.orElse(ResponseEntity.notFound().build());
}
// ===================== REFRESH =====================
// Accepte POST et GET pour être robuste aux proxies / redirects bizarres
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refreshPost(HttpServletRequest request,
HttpServletResponse response) {
return handleRefresh(request, response);
}
@GetMapping("/refresh")
public ResponseEntity<AuthResponse> refreshGet(HttpServletRequest request,
HttpServletResponse response) {
return handleRefresh(request, response);
}
private ResponseEntity<AuthResponse> handleRefresh(HttpServletRequest request,
HttpServletResponse response) {
String refreshToken = null;
if (request.getCookies() != null) {
refreshToken = Arrays.stream(request.getCookies())
.filter(c -> "refreshToken".equals(c.getName()))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
}
if (refreshToken == null) {
// Pas de cookie -> on ne casse pas le front, juste 204
return ResponseEntity.noContent().build();
}
return authService.refresh(refreshToken)
.map(authResponse -> createAuthResponse(authResponse, response))
// token expiré / invalide -> 204 aussi (pas derreur réseau)
.orElse(ResponseEntity.noContent().build());
}
// ===================== UTILITAIRE =====================
private ResponseEntity<AuthResponse> createAuthResponse(AuthResponse authResponse,
HttpServletResponse response) {
// Cookie HTTP-only pour le refresh
ResponseCookie cookie = ResponseCookie.from("refreshToken", authResponse.refreshToken())
.httpOnly(true)
.secure(false) // true en prod
.path("/")
.maxAge(60 * 60 * 24 * 7) // 7 jours
.sameSite("Lax")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
// On ne renvoie pas le refresh token dans le body
return ResponseEntity.ok(new AuthResponse(
authResponse.username(),
authResponse.accessToken(),
null
));
}
}

View File

@@ -0,0 +1,143 @@
package fr.gameovergne.api.controller.prestashop;
import fr.gameovergne.api.service.prestashop.PrestashopClient;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.HandlerMapping;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/ps")
public class PrestashopProxyController {
Logger log = LoggerFactory.getLogger(PrestashopProxyController.class);
private final PrestashopClient prestashopClient;
public PrestashopProxyController(PrestashopClient prestashopClient) {
this.prestashopClient = prestashopClient;
}
// --- Helpers communs ---
private String extractPath(HttpServletRequest request) {
String fullPath = (String) request.getAttribute(
HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE
);
String bestMatchPattern = (String) request.getAttribute(
HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE
);
String relativePath = new AntPathMatcher()
.extractPathWithinPattern(bestMatchPattern, fullPath);
// On renvoie toujours avec un "/" devant
return "/" + relativePath;
}
private String extractDecodedQuery(HttpServletRequest request) {
String rawQuery = request.getQueryString();
if (rawQuery != null) {
rawQuery = URLDecoder.decode(rawQuery, StandardCharsets.UTF_8);
}
return rawQuery;
}
// ---------- GET ----------
@GetMapping("/**")
public ResponseEntity<String> proxyGet(HttpServletRequest request) {
String path = extractPath(request);
String rawQuery = extractDecodedQuery(request);
ResponseEntity<String> prestaResponse =
prestashopClient.getWithRawQuery(path, rawQuery);
return ResponseEntity
.status(prestaResponse.getStatusCode())
.contentType(MediaType.APPLICATION_JSON)
.body(prestaResponse.getBody());
}
// ---------- POST ----------
@PostMapping("/**")
public ResponseEntity<String> proxyPost(HttpServletRequest request,
@RequestBody String xmlBody) {
String path = extractPath(request);
String rawQuery = extractDecodedQuery(request);
log.info("XML envoyé à Presta:\n{}", xmlBody);
ResponseEntity<String> prestaResponse =
prestashopClient.postWithRawQuery(path, rawQuery, xmlBody);
return ResponseEntity
.status(prestaResponse.getStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(prestaResponse.getBody());
}
// ---------- PUT ----------
@PutMapping("/**")
public ResponseEntity<String> proxyPut(HttpServletRequest request,
@RequestBody String xmlBody) {
String path = extractPath(request);
String rawQuery = extractDecodedQuery(request);
log.info("XML envoyé à Presta:\n{}", xmlBody);
ResponseEntity<String> prestaResponse =
prestashopClient.putWithRawQuery(path, rawQuery, xmlBody);
return ResponseEntity
.status(prestaResponse.getStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(prestaResponse.getBody());
}
// ---------- DELETE ----------
@DeleteMapping("/**")
public ResponseEntity<String> proxyDelete(HttpServletRequest request) {
String path = extractPath(request);
String rawQuery = extractDecodedQuery(request);
ResponseEntity<String> prestaResponse =
prestashopClient.deleteWithRawQuery(path, rawQuery);
return ResponseEntity
.status(prestaResponse.getStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(prestaResponse.getBody());
}
/**
* Upload d'une image produit :
* Front → (multipart/form-data) → /api/ps/images/products/{productId}
* Backend → (bytes) → https://shop.gameovergne.fr/api/images/products/{productId}
*/
@PostMapping(
path = "/images/products/{productId}",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_XML_VALUE
)
public ResponseEntity<String> uploadProductImage(
jakarta.servlet.http.HttpServletRequest request,
@PathVariable("productId") String productId,
@RequestPart("image") MultipartFile imageFile
) {
String rawQuery = request.getQueryString(); // si jamais tu ajoutes des options côté Presta
log.info("[Proxy] Upload image produit {} (size={} bytes, ct={})",
productId, imageFile.getSize(), imageFile.getContentType());
return prestashopClient.uploadProductImage(productId, rawQuery, imageFile);
}
}

View File

@@ -0,0 +1,54 @@
package fr.gameovergne.api.controller.user;
import fr.gameovergne.api.model.user.User;
import fr.gameovergne.api.service.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<User> getAllUsers() {
return userService.getAllUsers();
}
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
return userService.getUserById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public void saveUser(@RequestBody User user) {
userService.saveUser(user);
}
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) {
return userService.getUserById(id)
.map(existingUser -> userService.updateUser(user)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()))
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<User> deleteUserById(@PathVariable Long id) {
return userService.deleteUserById(id)
.map(user -> ResponseEntity.ok().body(user))
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,6 @@
package fr.gameovergne.api.dto.auth;
public record AuthRequest(
String username,
String password
) {}

View File

@@ -0,0 +1,7 @@
package fr.gameovergne.api.dto.auth;
public record AuthResponse(
String username,
String accessToken,
String refreshToken
) {}

View File

@@ -0,0 +1,17 @@
package fr.gameovergne.api.dto.user;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDTO {
private String firstName;
private String lastName;
private String username;
private String email;
private String password;
private String role;
}

View File

@@ -0,0 +1,30 @@
package fr.gameovergne.api.mapper.user;
import fr.gameovergne.api.dto.user.UserDTO;
import fr.gameovergne.api.model.user.Role;
import fr.gameovergne.api.model.user.User;
public class UserMapper {
public static User fromDto(UserDTO userDTO) {
User user = new User();
user.setFirstName(userDTO.getFirstName());
user.setLastName(userDTO.getLastName());
user.setUsername(userDTO.getUsername());
user.setEmail(userDTO.getEmail());
user.setPassword(userDTO.getPassword());
user.setRole(Role.valueOf(userDTO.getRole() != null ? userDTO.getRole() : "USER"));
return user;
}
public static UserDTO toDto(User user) {
UserDTO userDTO = new UserDTO();
userDTO.setFirstName(user.getFirstName());
userDTO.setLastName(user.getLastName());
userDTO.setUsername(user.getUsername());
userDTO.setEmail(user.getEmail());
userDTO.setPassword(user.getPassword());
userDTO.setRole(user.getRole() != null ? user.getRole().getDisplayName() : null);
return userDTO;
}
}

View File

@@ -0,0 +1,33 @@
package fr.gameovergne.api.model.security;
import com.fasterxml.jackson.annotation.JsonBackReference;
import fr.gameovergne.api.model.user.User;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "tokens")
public class Token {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
private String value;
private Date expirationDate;
@OneToOne
@JoinColumn(name = "user_id", referencedColumnName = "id")
@JsonBackReference
private User user;
}

View File

@@ -0,0 +1,15 @@
package fr.gameovergne.api.model.user;
import lombok.Getter;
@Getter
public enum Role {
USER("User"),
ADMIN("Administrator");
private final String displayName;
Role(String displayName) {
this.displayName = displayName;
}
}

View File

@@ -0,0 +1,62 @@
package fr.gameovergne.api.model.user;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import fr.gameovergne.api.model.security.Token;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Column(length = 30, nullable = false)
private String firstName;
@NotBlank
@Column(length = 30, nullable = false)
private String lastName;
@Column(length = 0)
private String username;
@Email
@NotBlank
@Column(length = 120, unique = true, nullable = false)
private String email;
@NotBlank
@Column(length = 120, nullable = false)
private String password;
@NotNull
@Enumerated(EnumType.STRING)
private Role role = Role.USER;
@OneToOne(mappedBy = "user")
@JsonManagedReference
private Token token;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(Role.USER.name()));
}
}

View File

@@ -0,0 +1,12 @@
package fr.gameovergne.api.repository.security;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import fr.gameovergne.api.model.security.Token;
import java.util.Optional;
@Repository
public interface TokenRepository extends JpaRepository<Token, Long> {
Optional<Token> findByValue(String value);
}

View File

@@ -0,0 +1,14 @@
package fr.gameovergne.api.repository.user;
import fr.gameovergne.api.model.user.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findById(Long id);
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
}

View File

@@ -0,0 +1,131 @@
package fr.gameovergne.api.service.auth;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import fr.gameovergne.api.dto.auth.AuthRequest;
import fr.gameovergne.api.dto.auth.AuthResponse;
import fr.gameovergne.api.model.user.User;
import fr.gameovergne.api.model.security.Token;
import fr.gameovergne.api.repository.user.UserRepository;
import fr.gameovergne.api.repository.security.TokenRepository;
import fr.gameovergne.api.service.security.JpaUserDetailsService;
import fr.gameovergne.api.service.security.JwtService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final JpaUserDetailsService userDetailsService;
private final TokenRepository tokenRepository;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final int REFRESH_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 7; // 7 jours
public Optional<AuthResponse> register(User user) {
String username = user.getUsername();
if (userRepository.findByUsername(username).isPresent()) {
System.err.println("User already exists: " + username);
return Optional.empty();
}
user.setPassword(passwordEncoder.encode(user.getPassword()));
userDetailsService.registerNewUser(user);
return Optional.of(new AuthResponse(username, null, null));
}
@Transactional
public Optional<AuthResponse> authenticate(AuthRequest request) {
String accessToken = jwtService.generateToken(request.username());
UserDetails userDetails = userDetailsService.loadUserByUsername(request.username());
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new RuntimeException("User not found: " + userDetails.getUsername()));
Token token = user.getToken() != null
? tokenRepository.findById(user.getToken().getId()).orElse(null)
: null;
if (token == null || isRefreshTokenExpired(token)) {
System.out.println("[Authenticate] Refresh token absent ou expiré pour user: " + user.getUsername());
token = generateNewRefreshToken(user);
}
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
return Optional.of(new AuthResponse(
user.getUsername(),
accessToken,
token.getValue()
));
}
@Transactional
public Optional<User> getCurrentUser(String username) {
return userRepository.findByUsername(username)
.map(user -> {
if (user.getToken() != null && isRefreshTokenExpired(user.getToken())) {
System.out.println("[AuthService] Refresh token expired for user: " + user.getUsername());
user.setToken(null);
userRepository.save(user);
}
return user;
});
}
@Transactional
public Optional<AuthResponse> refresh(String refreshTokenValue) {
Token savedToken = tokenRepository.findByValue(refreshTokenValue)
.orElseThrow(() -> new RuntimeException("Refresh token not found: " + refreshTokenValue));
User user = savedToken.getUser();
if (isRefreshTokenExpired(savedToken)) {
return Optional.empty();
}
String newAccessToken = jwtService.generateToken(user.getUsername());
return Optional.of(new AuthResponse(
user.getUsername(),
newAccessToken,
refreshTokenValue
));
}
private Token generateNewRefreshToken(User user) {
if (user.getToken() != null && tokenRepository.findById(user.getToken().getId()).isPresent()) {
System.out.println("[AuthService] Deleting existing refresh token for user: " + user.getUsername());
tokenRepository.delete(user.getToken());
user.setToken(null);
userRepository.save(user);
tokenRepository.flush();
}
Token newToken = new Token();
newToken.setUser(user);
newToken.setValue(jwtService.generateToken(user.getUsername()));
newToken.setExpirationDate(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION_TIME));
tokenRepository.save(newToken);
user.setToken(newToken);
userRepository.save(user);
return newToken;
}
private boolean isRefreshTokenExpired(Token token) {
return token.getExpirationDate().getTime() < System.currentTimeMillis();
}
}

View File

@@ -0,0 +1,366 @@
package fr.gameovergne.api.service.prestashop;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@Service
@Slf4j
public class PrestashopClient {
private static final MediaType APPLICATION_XML_UTF8 =
new MediaType("application", "xml", StandardCharsets.UTF_8);
private final RestClient client;
private final String baseUrl;
private final String basicAuthHeader;
public PrestashopClient(
@Value("${prestashop.base-url}") String baseUrl,
@Value("${prestashop.api-key}") String apiKey
) {
this.baseUrl = baseUrl;
String basicAuth = Base64.getEncoder()
.encodeToString((apiKey + ":").getBytes(StandardCharsets.UTF_8));
this.basicAuthHeader = "Basic " + basicAuth; // <--- mémorisé pour HttpURLConnection
this.client = RestClient.builder()
.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuthHeader)
.build();
log.info("[PrestaShop] Base URL = {}", baseUrl);
log.info("[PrestaShop] API key length = {}", apiKey.length());
}
private String buildUri(String path, MultiValueMap<String, String> params) {
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(baseUrl + path);
if (params != null && !params.isEmpty()) {
builder.queryParams(params);
}
return builder.build(true).toUriString();
}
// -------- Méthodes "typed" JSON / XML utilisées par ps-admin --------
public String getJson(String path, MultiValueMap<String, String> params) {
String uri = buildUri(path, params);
log.info("[PrestaShop] GET JSON {}", uri);
return client.get()
.uri(uri)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(String.class);
}
public String getXml(String path, MultiValueMap<String, String> params) {
String uri = buildUri(path, params);
log.info("[PrestaShop] GET XML {}", uri);
return client.get()
.uri(uri)
.accept(MediaType.APPLICATION_XML)
.retrieve()
.body(String.class);
}
public String postXml(String path, MultiValueMap<String, String> params, String xmlBody) {
String uri = buildUri(path, params);
log.info("[PrestaShop] POST XML {}", uri);
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
return client.post()
.uri(uri)
.contentType(APPLICATION_XML_UTF8)
.body(bodyBytes)
.retrieve()
.body(String.class);
}
public String putXml(String path, MultiValueMap<String, String> params, String xmlBody) {
String uri = buildUri(path, params);
log.info("[PrestaShop] PUT XML {}", uri);
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
return client.put()
.uri(uri)
.contentType(APPLICATION_XML_UTF8)
.body(bodyBytes)
.retrieve()
.body(String.class);
}
public void delete(String path, MultiValueMap<String, String> params) {
String uri = buildUri(path, params);
log.info("[PrestaShop] DELETE {}", uri);
client.delete()
.uri(uri)
.retrieve()
.toBodilessEntity();
}
// -------- Méthodes génériques utilisées par le proxy /api/ps/** --------
public ResponseEntity<String> getWithRawQuery(String path, String rawQuery) {
String uri = baseUrl + path;
if (rawQuery != null && !rawQuery.isBlank()) {
uri = uri + "?" + rawQuery;
}
log.info("[PrestaShop] GET (proxy) {}", uri);
return client.get()
.uri(uri)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.toEntity(String.class);
}
public ResponseEntity<String> postWithRawQuery(String path, String rawQuery, String xmlBody) {
String uri = baseUrl + path;
if (rawQuery != null && !rawQuery.isBlank()) {
uri = uri + "?" + rawQuery;
}
log.info("[PrestaShop] POST (proxy) {}", uri);
log.info("[PrestaShop] XML envoyé (proxy POST):\n{}", xmlBody);
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
try {
return client.post()
.uri(uri)
.contentType(APPLICATION_XML_UTF8)
.body(bodyBytes)
.retrieve()
.toEntity(String.class);
} catch (RestClientResponseException ex) {
// On propage tel quel le status + le body XML renvoyé par Presta
log.error("[PrestaShop] POST error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
return ResponseEntity
.status(ex.getRawStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(ex.getResponseBodyAsString());
} catch (RestClientException ex) {
// Cas réseau, timeout, etc.
log.error("[PrestaShop] POST technical error", ex);
return ResponseEntity
.status(502)
.contentType(MediaType.TEXT_PLAIN)
.body("Error while calling PrestaShop WebService");
}
}
public ResponseEntity<String> putWithRawQuery(String path, String rawQuery, String xmlBody) {
String uri = baseUrl + path;
if (rawQuery != null && !rawQuery.isBlank()) {
uri = uri + "?" + rawQuery;
}
log.info("[PrestaShop] PUT (proxy) {}", uri);
log.info("[PrestaShop] XML envoyé (proxy PUT):\n{}", xmlBody);
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
try {
return client.put()
.uri(uri)
.contentType(APPLICATION_XML_UTF8)
.body(bodyBytes)
.retrieve()
.toEntity(String.class);
} catch (RestClientResponseException ex) {
// On propage tel quel le status + le body XML renvoyé par Presta
log.error("[PrestaShop] PUT error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
return ResponseEntity
.status(ex.getRawStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(ex.getResponseBodyAsString());
} catch (RestClientException ex) {
// Cas réseau, timeout, etc.
log.error("[PrestaShop] PUT technical error", ex);
return ResponseEntity
.status(502)
.contentType(MediaType.TEXT_PLAIN)
.body("Error while calling PrestaShop WebService");
}
}
public ResponseEntity<String> deleteWithRawQuery(String path, String rawQuery) {
String uri = baseUrl + path;
if (rawQuery != null && !rawQuery.isBlank()) {
uri = uri + "?" + rawQuery;
}
log.info("[PrestaShop] DELETE (proxy) {}", uri);
try {
return client.delete()
.uri(uri)
.retrieve()
.toEntity(String.class);
} catch (RestClientResponseException ex) {
// On propage tel quel le status + le body XML renvoyé par Presta
log.error("[PrestaShop] DELETE error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
return ResponseEntity
.status(ex.getRawStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(ex.getResponseBodyAsString());
} catch (RestClientException ex) {
// Cas réseau, timeout, etc.
log.error("[PrestaShop] DELETE technical error", ex);
return ResponseEntity
.status(502)
.contentType(MediaType.TEXT_PLAIN)
.body("Error while calling PrestaShop WebService");
}
}
/**
* Upload d'une image produit vers PrestaShop :
* POST /api/images/products/{productId}
*/
public ResponseEntity<String> uploadProductImage(
String productId,
String rawQuery,
MultipartFile imageFile
) {
try {
// Construire lURL Presta (comme avant)
StringBuilder urlBuilder = new StringBuilder(baseUrl)
.append("/images/products/")
.append(productId);
if (rawQuery != null && !rawQuery.isBlank()) {
urlBuilder.append('?').append(rawQuery);
}
String url = urlBuilder.toString();
byte[] fileBytes = imageFile.getBytes();
String originalFilename = imageFile.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "image.jpg";
}
String contentType = imageFile.getContentType();
if (contentType == null || contentType.isBlank()) {
contentType = "application/octet-stream";
}
log.info(
"[PrestaShop] POST (image multipart - manual) {} (size={} bytes, contentType={}, filename={})",
url, fileBytes.length, contentType, originalFilename
);
// -------- Construction du multipart "à la main" --------
String boundary = "----PrestashopBoundary" + System.currentTimeMillis();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(baos, StandardCharsets.UTF_8);
PrintWriter writer = new PrintWriter(osw, true);
// Début de la part "image"
writer.append("--").append(boundary).append("\r\n");
writer.append("Content-Disposition: form-data; name=\"image\"; filename=\"")
.append(originalFilename)
.append("\"\r\n");
writer.append("Content-Type: ").append(contentType).append("\r\n");
writer.append("\r\n");
writer.flush();
// Données binaires du fichier
baos.write(fileBytes);
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
// Fin du multipart
writer.append("--").append(boundary).append("--").append("\r\n");
writer.flush();
byte[] multipartBytes = baos.toByteArray();
// -------- Envoi via HttpURLConnection --------
URL targetUrl = URI.create(url).toURL();
HttpURLConnection conn = (HttpURLConnection) targetUrl.openConnection();
conn.setDoOutput(true);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
conn.setRequestProperty("Accept", "application/xml, text/xml, */*;q=0.1");
conn.setRequestProperty("Authorization", basicAuthHeader);
// Important : pas de chunked, on envoie une taille fixe
conn.setFixedLengthStreamingMode(multipartBytes.length);
try (OutputStream os = conn.getOutputStream()) {
os.write(multipartBytes);
}
int status = conn.getResponseCode();
InputStream is = (status >= 200 && status < 300)
? conn.getInputStream()
: conn.getErrorStream();
String responseBody;
if (is != null) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
responseBody = sb.toString();
}
} else {
responseBody = "";
}
log.info("[PrestaShop] Image upload response status={}, body={}", status, responseBody);
// ---------- Mapping vers une réponse propre pour le front ----------
if (status >= 200 && status < 300) {
// Succès : on renvoie un petit JSON que Angular sait lire
return ResponseEntity
.ok()
.contentType(MediaType.APPLICATION_JSON)
.body("{\"success\":true}");
}
HttpStatus springStatus = HttpStatus.resolve(status);
if (springStatus == null) {
springStatus = HttpStatus.INTERNAL_SERVER_ERROR;
}
// En cas derreur Presta, on propage lXML pour debug
return ResponseEntity
.status(springStatus)
.contentType(MediaType.APPLICATION_XML)
.body(responseBody);
} catch (IOException e) {
log.error("[PrestaShop] Erreur lors de l'upload d'image", e);
return ResponseEntity
.status(HttpStatus.BAD_GATEWAY)
.contentType(MediaType.TEXT_PLAIN)
.body("Erreur lors de l'upload d'image vers PrestaShop");
}
}
}

View File

@@ -0,0 +1,31 @@
package fr.gameovergne.api.service.security;
import fr.gameovergne.api.model.user.User;
import fr.gameovergne.api.repository.user.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public JpaUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
public void registerNewUser(User user) {
if (userRepository.findByUsername(user.getUsername()).isPresent()) {
throw new IllegalArgumentException("Username already exists: " + user.getUsername());
}
userRepository.save(user);
}
}

View File

@@ -0,0 +1,65 @@
package fr.gameovergne.api.service.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
import java.util.function.Function;
@Service
public class JwtService {
private static final long EXPIRATION_TIME = 1000 * 60 * 10; // 10min
private final Key signingKey;
public JwtService(@Value("${jwt.secret}") String secret) {
this.signingKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret));
}
public String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(signingKey, SignatureAlgorithm.HS256)
.compact();
}
public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token)
.getBody();
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}

View File

@@ -0,0 +1,51 @@
package fr.gameovergne.api.service.security;
import fr.gameovergne.api.model.security.Token;
import fr.gameovergne.api.repository.security.TokenRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class TokenService {
private final TokenRepository tokenRepository;
@Autowired
public TokenService(TokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
}
@Transactional
public void saveToken(Token token) {
if (token.getId() == null) {
tokenRepository.save(token);
}
}
public Optional<Token> getTokenById(Long id) {
return tokenRepository.findById(id);
}
public List<Token> getAllTokens() {
return tokenRepository.findAll();
}
@Transactional
public Optional<Token> updateToken(Token token) {
return tokenRepository.findById(token.getId()).map(existingToken -> {
existingToken.setValue(token.getValue());
return tokenRepository.save(existingToken);
});
}
@Transactional
public Optional<Token> deleteTokenById(Long id) {
Optional<Token> token = tokenRepository.findById(id);
token.ifPresent(tokenRepository::delete);
return token;
}
}

View File

@@ -0,0 +1,65 @@
package fr.gameovergne.api.service.security.filter;
import fr.gameovergne.api.service.security.JpaUserDetailsService;
import fr.gameovergne.api.service.security.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final JpaUserDetailsService userDetailsService;
public JwtAuthFilter(JwtService jwtService, JpaUserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,61 @@
package fr.gameovergne.api.service.user;
import fr.gameovergne.api.model.user.User;
import fr.gameovergne.api.repository.user.UserRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UserService {
private final UserRepository userRepository;
@Autowired
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
public void saveUser(User user) {
if (user.getId() == null) {
userRepository.save(user);
}
}
public List<User> getAllUsers() {
return userRepository.findAll();
}
public Optional<User> getUserById(Long id) {
return userRepository.findById(id);
}
public Optional<User> getUserByUsername(String username) {
return userRepository.findByUsername(username);
}
public Optional<User> getUserByEmail(String email) {
return userRepository.findByEmail(email);
}
@Transactional
public Optional<User> updateUser(User user) {
return userRepository.findById(user.getId()).map(existingUser -> {
existingUser.setEmail(user.getEmail());
existingUser.setUsername(user.getUsername());
existingUser.setPassword(user.getPassword());
return userRepository.save(existingUser);
});
}
@Transactional
public Optional<User> deleteUserById(Long id) {
Optional<User> user = userRepository.findById(id);
user.ifPresent(userRepository::delete);
return user;
}
}

View File

@@ -0,0 +1,19 @@
spring.application.name=api
server.port=3000
spring.datasource.url=jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username=gameovergne
spring.datasource.password=gameovergne
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.show-sql=true
spring.servlet.multipart.max-file-size=15MB
spring.servlet.multipart.max-request-size=15MB
jwt.secret=a23ac96ce968bf13099d99410b951dd498118851bdfc996a3f844bd68b1b2afd
prestashop.base-url=https://shop.gameovergne.fr/api
prestashop.api-key=${PRESTASHOP_API_KEY}

View File

@@ -0,0 +1,13 @@
package fr.gameovergne.api;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class GameOvergneApplicationTests {
@Test
void contextLoads() {
}
}

17
client/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

31
client/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# ===== STAGE 1 : build Angular =====
FROM node:20-alpine AS build
WORKDIR /app
# Dépendances
COPY package*.json ./
RUN npm ci
# Code source
COPY . .
# Build Angular en mode prod
RUN npm run build -- --configuration production
# ===== STAGE 2 : Nginx pour servir le build =====
FROM nginx:1.27-alpine
# On nettoie la racine Nginx
RUN rm -rf /usr/share/nginx/html/*
# ⚠ On copie TOUT dist/client dans l'image
# => ça crée /usr/share/nginx/html/browser/index.html
COPY --from=build /app/dist/client/ /usr/share/nginx/html/
# Conf Nginx spécifique à l'app
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

27
client/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Client
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.21.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

122
client/angular.json Normal file
View File

@@ -0,0 +1,122 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"client": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/client",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"host": "0.0.0.0",
"port": 4200,
"allowedHosts": [
"dev.vincent-guillet.fr"
],
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "client:build:production"
},
"development": {
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.css"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

12
client/nginx.conf Normal file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html/browser;
index index.html;
# Angular SPA
location / {
try_files $uri $uri/ /index.html;
}
}

14196
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
client/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/cdk": "^18.2.14",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/material": "^18.2.14",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.10"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.21",
"@angular/cli": "^18.2.21",
"@angular/compiler-cli": "^18.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.5.2"
}
}

14
client/proxy.conf.json Normal file
View File

@@ -0,0 +1,14 @@
{
"/ps": {
"target": "https://shop.gameovergne.fr",
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/ps": "/api"
},
"headers": {
"Authorization": "Basic MkFRUEcxM01KOFgxMTdVNkZKNU5HSFBTOTNIRTM0QUI="
}
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

View File

@@ -0,0 +1,2 @@
<app-main-navbar></app-main-navbar>
<router-outlet></router-outlet>

View File

@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import {MainNavbarComponent} from './components/main-navbar/main-navbar.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, MainNavbarComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'client';
}

View File

@@ -0,0 +1,41 @@
import {
APP_INITIALIZER,
ApplicationConfig,
inject,
provideZoneChangeDetection,
importProvidersFrom
} from '@angular/core';
import {provideRouter} from '@angular/router';
import {BrowserModule} from '@angular/platform-browser';
import {APP_BASE_HREF} from '@angular/common';
import {routes} from './app.routes';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {authTokenInterceptor} from './interceptors/auth-token.interceptor';
import {AuthService} from './services/auth.service';
import {catchError, firstValueFrom, of} from 'rxjs';
import {environment} from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
importProvidersFrom(BrowserModule),
{provide: APP_BASE_HREF, useValue: environment.hrefBase},
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(withInterceptors([authTokenInterceptor])),
{
provide: APP_INITIALIZER,
multi: true,
useFactory: () => {
const auth = inject(AuthService);
return () =>
firstValueFrom(
auth.bootstrapSession().pipe(
catchError(() => of(null))
)
);
}
}
]
};

View File

@@ -0,0 +1,61 @@
import {Routes} from '@angular/router';
import {HomeComponent} from './pages/home/home.component';
import {RegisterComponent} from './pages/auth/register/register.component';
import {LoginComponent} from './pages/auth/login/login.component';
import {ProfileComponent} from './pages/profile/profile.component';
import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard';
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
import {ProductsComponent} from './pages/products/products.component';
export const routes: Routes = [
{
path: '',
children: [
{
path: '',
component: HomeComponent
},
{
path: 'home',
component: HomeComponent
}
]
},
{
path: 'register',
component: RegisterComponent,
canMatch: [guestOnlyCanMatch],
canActivate: [guestOnlyCanActivate]
},
{
path: 'login',
component: LoginComponent,
canMatch: [guestOnlyCanMatch],
canActivate: [guestOnlyCanActivate]
},
{
path: 'profile',
component: ProfileComponent,
canMatch: [authOnlyCanMatch],
canActivate: [authOnlyCanActivate]
},
{
path: 'products',
component: ProductsComponent,
canMatch: [adminOnlyCanMatch],
canActivate: [adminOnlyCanActivate]
},
{
path: 'admin',
component: PsAdminComponent,
canMatch: [adminOnlyCanMatch],
canActivate: [adminOnlyCanActivate]
},
{
path: '**',
redirectTo: ''
}
];

View File

@@ -0,0 +1,124 @@
/* src/app/components/main-navbar/main-navbar.component.css */
/* Ajout prise en charge safe-area et meilleure gestion des overflow */
.mat-toolbar {
/* protège contre les zones sensibles (notch / status bar) */
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
position: relative;
z-index: 1000;
box-sizing: border-box;
}
/* wrapper principal */
.container {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 12px;
padding: 0 12px;
box-sizing: border-box;
min-height: 56px; /* assure une hauteur minimale utile sur mobile */
}
/* marque / titre */
.brand {
font-weight: bold;
font-size: 1.2rem;
cursor: pointer;
min-width: 0; /* autorise le shrink */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
}
/* actions (boutons, menu utilisateur) */
.nav-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex: 0 1 auto;
justify-content: flex-end;
flex-wrap: nowrap;
min-width: 0; /* important pour permettre la réduction des enfants */
overflow: visible;
}
/* icône dans mat-menu */
.mat-menu-item mat-icon {
margin-right: 8px;
}
/* Empêcher les boutons de dépasser et couper le texte avec ellipsis */
.nav-actions button {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Angular Material place le texte dans .mat-button-wrapper — on le tronque proprement */
.nav-actions button .mat-button-wrapper {
display: inline-block;
max-width: calc(100% - 56px); /* espace pour icônes + padding */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
/* Ajustements spécifiques pour petits écrans */
@media (max-width: 720px) {
.mat-toolbar {
padding-top: env(safe-area-inset-top);
}
.container {
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 8px;
}
.brand {
order: 1;
width: 100%;
font-size: 1.05rem;
margin-bottom: 4px;
}
.nav-actions {
order: 2;
width: 100%;
justify-content: flex-end;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px; /* espace pour le scroll horizontal */
}
.nav-actions button {
padding: 6px 8px;
font-size: 0.95rem;
min-width: 0;
white-space: nowrap;
max-width: 100%;
}
.nav-actions button .mat-button-wrapper {
max-width: calc(100% - 40px);
}
.nav-actions mat-icon {
font-size: 20px;
line-height: 1;
}
}

View File

@@ -0,0 +1,33 @@
<mat-toolbar color="primary">
<div class="container">
<div class="brand" [routerLink]="'/'">Game Over'gne App</div>
<div class="nav-actions">
@if (getUser(); as user) {
<button mat-button [matMenuTriggerFor]="userMenu">
<mat-icon>account_circle</mat-icon>
Connecté en tant que {{ user.username }}
<mat-icon>expand_more</mat-icon>
</button>
<mat-menu #userMenu="matMenu">
@if (authService.hasRole('Administrator')) {
<button mat-menu-item [routerLink]="'/admin'">
<mat-icon>admin_panel_settings</mat-icon>
Administration
</button>
}
<button mat-menu-item [routerLink]="'/profile'">
<mat-icon>person</mat-icon>
Profil
</button>
<button mat-menu-item (click)="logout()">
<mat-icon>logout</mat-icon>
Se déconnecter
</button>
</mat-menu>
} @else {
<button mat-button [routerLink]="'/login'">Se connecter</button>
<button mat-button [routerLink]="'/register'">S'inscrire</button>
}
</div>
</div>
</mat-toolbar>

View File

@@ -0,0 +1,43 @@
import {Component, inject} from '@angular/core';
import {MatToolbar} from '@angular/material/toolbar';
import {MatButton} from '@angular/material/button';
import {Router, RouterLink} from '@angular/router';
import {AuthService} from '../../services/auth.service';
import {MatMenu, MatMenuItem, MatMenuTrigger} from '@angular/material/menu';
import {MatIcon} from '@angular/material/icon';
@Component({
selector: 'app-main-navbar',
standalone: true,
imports: [
MatToolbar,
MatButton,
RouterLink,
MatMenuTrigger,
MatIcon,
MatMenu,
MatMenuItem
],
templateUrl: './main-navbar.component.html',
styleUrl: './main-navbar.component.css'
})
export class MainNavbarComponent {
protected readonly authService = inject(AuthService);
private readonly router: Router = inject(Router);
logout() {
this.authService.logout().subscribe({
next: () => {
this.router.navigate(['/login'], {queryParams: {redirect: '/profile'}}).then();
},
error: (err) => {
console.error('Logout failed:', err);
}
});
}
getUser() {
return this.authService.user();
}
}

View File

@@ -0,0 +1,18 @@
.crud {
display: grid;
gap: 16px;
#createBtn {
margin-top: 16px;
}
.row {
display: flex;
gap: 12px;
align-items: flex-end;
}
}
table {
width: 100%;
}

View File

@@ -0,0 +1,48 @@
<section class="crud">
<div class="row">
<button id="createBtn" mat-raised-button color="primary" (click)="createNew()">Ajouter {{ label.toLowerCase() }}</button>
</div>
<mat-form-field appearance="outline">
<mat-label>Filtrer</mat-label>
<input matInput (keyup)="applyFilter($any($event.target).value)" placeholder="Rechercher par ID ou nom…">
</mat-form-field>
<div class="mat-elevation-z2">
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
<td mat-cell *matCellDef="let el">{{ el.id }}</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Nom</th>
<td mat-cell *matCellDef="let el">{{ el.name }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let el">
<button mat-icon-button (click)="startEdit(el)" aria-label="edit">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
Aucune donnée ne correspond au filtre.
</td>
</tr>
</table>
<mat-paginator [pageSizeOptions]="[5,10,25,100]" [pageSize]="10" aria-label="Pagination"></mat-paginator>
</div>
</section>

View File

@@ -0,0 +1,164 @@
import {CommonModule} from '@angular/common';
import {AfterViewInit, Component, inject, Input, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow, MatHeaderRowDef, MatNoDataRow, MatRow, MatRowDef,
MatTable, MatTableDataSource
} from '@angular/material/table';
import {MatSort, MatSortModule} from '@angular/material/sort';
import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator';
import {MatFormField, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {MatButton, MatIconButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {PrestashopService} from '../../services/prestashop.serivce';
import {debounceTime, Observable, Subject, takeUntil} from 'rxjs';
import {PsItem} from '../../interfaces/ps-item';
import {PsItemDialogComponent} from '../ps-item-dialog/ps-item-dialog.component';
import {MatDialog} from '@angular/material/dialog';
type Resource = 'categories' | 'manufacturers' | 'suppliers';
@Component({
selector: 'app-ps-admin-crud',
standalone: true,
templateUrl: './ps-admin-crud.component.html',
styleUrls: ['./ps-admin-crud.component.css'],
imports: [
CommonModule,
ReactiveFormsModule,
MatTable, MatColumnDef, MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef,
MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
MatSortModule, MatPaginatorModule,
MatFormField, MatLabel, MatInput,
MatButton, MatIconButton, MatIcon, MatNoDataRow
]
})
export class PsAdminCrudComponent implements OnInit, AfterViewInit, OnDestroy {
@Input({required: true}) resource!: Resource;
@Input({required: true}) label!: string;
private readonly fb = inject(FormBuilder);
private readonly ps = inject(PrestashopService);
private readonly dialog = inject(MatDialog);
private readonly destroy$ = new Subject<void>();
dataSource = new MatTableDataSource<PsItem>([]);
displayedColumns: string[] = ['id', 'name', 'actions'];
form = this.fb.group({name: ['', Validators.required]});
editId: number | null = null;
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
@ViewChild(MatTable) table!: MatTable<PsItem>;
private readonly filter$: Subject<string> = new Subject<string>();
ngOnInit(): void {
this.dataSource.filterPredicate = (row, filter) => {
const f = filter.trim().toLowerCase();
return (
String(row.id).toLowerCase().includes(f) ||
String(row.name ?? '').toLowerCase().includes(f)
);
};
this.filter$.pipe(debounceTime(150), takeUntil(this.destroy$))
.subscribe(v => {
this.dataSource.filter = (v ?? '').trim().toLowerCase();
if (this.paginator) this.paginator.firstPage();
});
this.reload();
}
ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
reload(): void {
this.ps.list(this.resource).subscribe({
next: items => {
this.dataSource.data = items;
// rafraîchir le rendu si nécessaire
this.table?.renderRows?.();
},
error: e => alert('Erreur de chargement: ' + (e?.message || e))
});
}
createNew() {
const ref = this.dialog.open(PsItemDialogComponent, {
width: '400px',
data: {label: this.label, title: `Créer ${this.label}`}
});
ref.afterClosed().subscribe((name: string | null) => {
if (!name) return;
this.ps.create(this.resource, name).subscribe({
next: () => this.reload(),
error: e => alert('Erreur: ' + (e?.message || e))
});
});
}
startEdit(row: PsItem) {
const ref = this.dialog.open(PsItemDialogComponent, {
width: '400px',
data: {label: this.label, name: row.name, title: `Modifier ${this.label} #${row.id}`}
});
ref.afterClosed().subscribe((name: string | null) => {
if (!name) return;
this.ps.update(this.resource, row.id, name).subscribe({
next: () => this.reload(),
error: e => alert('Erreur: ' + (e?.message || e))
});
});
}
cancelEdit() {
this.editId = null;
this.form.reset({name: ''});
}
onSubmit() {
const name = (this.form.value.name ?? '').trim();
if (!name) return;
const req$: Observable<unknown> = this.editId
? this.ps.update(this.resource, this.editId, name) as Observable<unknown>
: this.ps.create(this.resource, name) as Observable<unknown>;
req$.subscribe({
next: () => {
this.cancelEdit();
this.reload();
},
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
});
}
remove(row: PsItem) {
if (!confirm(`Supprimer ${this.label.toLowerCase()} "#${row.id} ${row.name} ?`)) return;
this.ps.delete(this.resource, row.id).subscribe({
next: () => this.reload(),
error: e => alert('Erreur: ' + (e?.message || e))
});
}
applyFilter(value: string) {
this.filter$.next(value);
}
}

View File

@@ -0,0 +1,3 @@
.full {
width: 100%;
}

View File

@@ -0,0 +1,11 @@
<h2 mat-dialog-title>{{ data.title || 'Élément' }}</h2>
<form [formGroup]="form" (ngSubmit)="confirm()" mat-dialog-content>
<mat-form-field class="full">
<mat-label>Nom de {{ data.label.toLowerCase() || '' }}</mat-label>
<input matInput formControlName="name" autocomplete="off"/>
</mat-form-field>
<div mat-dialog-actions>
<button mat-button type="button" (click)="cancel()">Annuler</button>
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid">OK</button>
</div>
</form>

View File

@@ -0,0 +1,54 @@
import {CommonModule} from '@angular/common';
import {Component, inject, Inject} from '@angular/core';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatFormField, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {MatButton} from '@angular/material/button';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions, MatDialogModule
} from '@angular/material/dialog';
@Component({
selector: 'app-ps-item-dialog',
standalone: true,
templateUrl: './ps-item-dialog.component.html',
styleUrls: ['./ps-item-dialog.component.css'],
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatDialogTitle,
MatDialogContent,
MatFormField,
MatLabel,
MatInput,
MatDialogActions,
MatButton
]
})
export class PsItemDialogComponent {
private readonly fb = inject(FormBuilder);
constructor(
private readonly dialogRef: MatDialogRef<PsItemDialogComponent, string | null>,
@Inject(MAT_DIALOG_DATA) public data: { label: string; name?: string; title?: string }
) {
this.form = this.fb.group({name: [data?.name ?? '', Validators.required]});
}
form = this.fb.group({name: ['', Validators.required]});
confirm() {
const name = (this.form.value.name ?? '').trim();
if (!name) return;
this.dialogRef.close(name);
}
cancel() {
this.dialogRef.close(null);
}
}

View File

@@ -0,0 +1,95 @@
.crud {
display: grid;
gap: 16px;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.toolbar .filter {
margin-left: auto;
min-width: 360px;
}
.mat-elevation-z2 {
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
min-width: 800px;
border-collapse: collapse;
}
th, td {
white-space: nowrap;
}
.prod-cell {
display: flex;
align-items: center;
gap: 8px;
}
.prod-thumb {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.product-list-root {
position: relative;
}
.product-list-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: all;
}
mat-paginator {
width: 100%;
overflow: hidden;
}
@media (max-width: 720px) {
.toolbar {
gap: 8px;
}
.toolbar button {
flex: 0 0 auto;
order: 1;
}
.toolbar .filter {
order: 2;
margin-left: 0;
min-width: 0;
width: 100%;
}
.prod-thumb {
width: 24px;
height: 24px;
}
table {
min-width: 720px;
}
}

View File

@@ -0,0 +1,98 @@
<section class="crud">
<div class="toolbar">
<button mat-raised-button
color="primary"
(click)="create()"
[disabled]="isLoading">
<mat-icon>add</mat-icon>&nbsp;Nouveau produit
</button>
<mat-form-field appearance="outline" class="filter">
<mat-label>Filtrer</mat-label>
<input matInput
[formControl]="filterCtrl"
placeholder="Nom, ID, catégorie, marque, fournisseur…"
[disabled]="isLoading">
</mat-form-field>
</div>
<div class="mat-elevation-z2 product-list-root">
<!-- Overlay de chargement -->
<div class="product-list-loading-overlay" *ngIf="isLoading">
<mat-spinner diameter="48"></mat-spinner>
</div>
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
<td mat-cell *matCellDef="let el">{{ el.id }}</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Nom</th>
<td mat-cell *matCellDef="let el">{{ el.name }}</td>
</ng-container>
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Catégorie</th>
<td mat-cell *matCellDef="let el">{{ el.categoryName }}</td>
</ng-container>
<ng-container matColumnDef="manufacturer">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Marque</th>
<td mat-cell *matCellDef="let el">{{ el.manufacturerName }}</td>
</ng-container>
<ng-container matColumnDef="supplier">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Fournisseur</th>
<td mat-cell *matCellDef="let el">{{ el.supplierName }}</td>
</ng-container>
<ng-container matColumnDef="priceTtc">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Prix TTC (€)</th>
<td mat-cell *matCellDef="let el">{{ el.priceTtc | number:'1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantité</th>
<td mat-cell *matCellDef="let el">{{ el.quantity }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let el">
<button mat-icon-button
aria-label="edit"
(click)="edit(el)"
[disabled]="isLoading">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button
color="warn"
aria-label="delete"
(click)="remove(el)"
[disabled]="isLoading">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayed"></tr>
<tr mat-row *matRowDef="let row; columns: displayed;"></tr>
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayed.length">
Aucune donnée.
</td>
</tr>
</table>
<mat-paginator
[pageSizeOptions]="[5,10,25,100]"
[pageSize]="10"
aria-label="Pagination"
[disabled]="isLoading">
</mat-paginator>
</div>
</section>

View File

@@ -0,0 +1,206 @@
import {Component, inject, OnInit, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common';
import {
MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef,
MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
MatNoDataRow, MatTable, MatTableDataSource
} from '@angular/material/table';
import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator';
import {MatSort, MatSortModule} from '@angular/material/sort';
import {MatFormField, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {MatButton, MatIconButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {forkJoin, finalize} from 'rxjs';
import {PsItem} from '../../interfaces/ps-item';
import {ProductListItem} from '../../interfaces/product-list-item';
import {PrestashopService} from '../../services/prestashop.serivce';
import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/ps-product-dialog.component';
@Component({
selector: 'app-ps-product-crud',
standalone: true,
templateUrl: './ps-product-crud.component.html',
styleUrls: ['./ps-product-crud.component.css'],
imports: [
CommonModule, ReactiveFormsModule,
MatTable, MatColumnDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, MatNoDataRow,
MatSortModule, MatPaginatorModule,
MatFormField, MatLabel, MatInput,
MatButton, MatIconButton, MatIcon,
MatDialogModule,
MatProgressSpinnerModule
]
})
export class PsProductCrudComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly ps = inject(PrestashopService);
private readonly dialog = inject(MatDialog);
categories: PsItem[] = [];
manufacturers: PsItem[] = [];
suppliers: PsItem[] = [];
private catMap = new Map<number, string>();
private manMap = new Map<number, string>();
private supMap = new Map<number, string>();
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
dataSource = new MatTableDataSource<any>([]);
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
@ViewChild(MatTable) table!: MatTable<any>;
filterCtrl = this.fb.control<string>('');
isLoading = false;
ngOnInit(): void {
forkJoin({
cats: this.ps.list('categories'),
mans: this.ps.list('manufacturers'),
sups: this.ps.list('suppliers')
}).subscribe({
next: ({cats, mans, sups}) => {
this.categories = cats ?? [];
this.catMap = new Map(this.categories.map(x => [x.id, x.name]));
this.manufacturers = mans ?? [];
this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name]));
this.suppliers = sups ?? [];
this.supMap = new Map(this.suppliers.map(x => [x.id, x.name]));
this.reload();
},
error: err => {
console.error('Erreur lors du chargement des référentiels', err);
}
});
this.filterCtrl.valueChanges.subscribe(v => {
this.dataSource.filter = (v ?? '').toString().trim().toLowerCase();
if (this.paginator) this.paginator.firstPage();
});
this.dataSource.filterPredicate = (row: any, f: string) =>
row.name?.toLowerCase().includes(f) ||
String(row.id).includes(f) ||
(row.categoryName?.toLowerCase().includes(f)) ||
(row.manufacturerName?.toLowerCase().includes(f)) ||
(row.supplierName?.toLowerCase().includes(f)) ||
String(row.quantity ?? '').includes(f);
}
private toTtc(ht: number, vat: number) {
return Math.round(((ht * (1 + vat)) + Number.EPSILON) * 100) / 100;
}
private attachSortingAccessors() {
this.dataSource.sortingDataAccessor = (item: any, property: string) => {
switch (property) {
case 'category':
return (item.categoryName ?? '').toLowerCase();
case 'manufacturer':
return (item.manufacturerName ?? '').toLowerCase();
case 'supplier':
return (item.supplierName ?? '').toLowerCase();
case 'priceTtc':
return Number(item.priceTtc ?? 0);
case 'name':
return (item.name ?? '').toLowerCase();
default:
return item[property];
}
};
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
private bindProducts(p: (ProductListItem & { priceHt?: number })[]) {
const vat = 0.2;
this.dataSource.data = p.map(x => ({
...x,
categoryName: x.id_category_default ? (this.catMap.get(x.id_category_default) ?? '') : '',
manufacturerName: x.id_manufacturer ? (this.manMap.get(x.id_manufacturer) ?? '') : '',
supplierName: x.id_supplier ? (this.supMap.get(x.id_supplier) ?? '') : '',
priceTtc: this.toTtc(x.priceHt ?? 0, vat)
}));
this.attachSortingAccessors();
this.table?.renderRows?.();
}
reload() {
this.isLoading = true;
this.ps.listProducts()
.pipe(
finalize(() => {
this.isLoading = false;
})
)
.subscribe({
next: p => this.bindProducts(p),
error: err => {
console.error('Erreur lors du chargement des produits', err);
}
});
}
create() {
if (this.isLoading) return;
const data: ProductDialogData = {
mode: 'create',
refs: {
categories: this.categories,
manufacturers: this.manufacturers,
suppliers: this.suppliers
}
};
this.dialog.open(PsProductDialogComponent, {width: '900px', data})
.afterClosed()
.subscribe(ok => {
if (ok) this.reload();
});
}
edit(row: ProductListItem & { priceHt?: number }) {
if (this.isLoading) return;
const data: ProductDialogData = {
mode: 'edit',
productRow: row,
refs: {
categories: this.categories,
manufacturers: this.manufacturers,
suppliers: this.suppliers
}
};
this.dialog.open(PsProductDialogComponent, {width: '900px', data})
.afterClosed()
.subscribe(ok => {
if (ok) this.reload();
});
}
remove(row: ProductListItem) {
if (this.isLoading) return;
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
this.isLoading = true;
this.ps.deleteProduct(row.id)
.pipe(
finalize(() => {
})
)
.subscribe({
next: () => this.reload(),
error: (e: unknown) => {
this.isLoading = false;
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)));
}
});
}
}

View File

@@ -0,0 +1,181 @@
.grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(12, 1fr);
align-items: start;
}
.col-12 {
grid-column: span 12;
}
.col-6 {
grid-column: span 6;
}
.col-4 {
grid-column: span 4;
}
.flags {
display: flex;
gap: 16px;
align-items: center;
}
/* ===== Nouveau : carrousel ===== */
.carousel {
grid-column: span 12;
display: grid;
gap: 8px;
}
.carousel-main {
position: relative;
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
}
.carousel-main img {
max-width: 100%;
max-height: 280px;
object-fit: contain;
}
.carousel-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.carousel-nav-btn.left {
left: 4px;
}
.carousel-nav-btn.right {
right: 4px;
}
.carousel-placeholder {
text-align: center;
color: #757575;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
}
.carousel-placeholder mat-icon {
font-size: 40px;
}
/* Bouton de suppression (croix rouge) */
.carousel-delete-btn {
position: absolute;
top: 6px;
right: 6px;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
padding: 2px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
.carousel-delete-btn mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #e53935;
}
/* Bandeau de vignettes */
.carousel-thumbs {
display: flex;
gap: 8px;
overflow-x: auto;
}
.thumb-item {
position: relative;
width: 64px;
height: 64px;
border-radius: 4px;
overflow: hidden; /* tu peux laisser comme ça */
border: 2px solid transparent;
flex: 0 0 auto;
cursor: pointer;
}
/* Bouton de suppression sur les vignettes */
.thumb-delete-btn {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
min-width: 18px;
padding: 0;
line-height: 18px;
background: transparent;
border-radius: 0;
box-shadow: none;
}
.thumb-delete-btn mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #e53935; /* rouge discret mais lisible */
}
.thumb-item.active {
border-color: #1976d2;
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #eeeeee;
color: #575656;
border: 1px dashed #bdbdbd;
}
.thumb-placeholder mat-icon {
font-size: 28px;
}
.dialog-root {
position: relative;
}
/* Overlay plein écran dans le dialog pendant la sauvegarde */
.dialog-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
pointer-events: all;
}

View File

@@ -0,0 +1,183 @@
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
<div class="dialog-root">
<!-- Overlay de chargement -->
@if (isSaving) {
<div class="dialog-loading-overlay">
<mat-spinner diameter="48"></mat-spinner>
</div>
}
<div mat-dialog-content class="grid" [formGroup]="form">
<!-- CARROUSEL IMAGES -->
<div class="col-12 carousel">
<div class="carousel-main">
<!-- Bouton précédent -->
<button mat-icon-button
class="carousel-nav-btn left"
(click)="prev()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_left</mat-icon>
</button>
<!-- Image principale ou placeholder -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<img [src]="carouselItems[currentIndex].src" alt="Produit">
} @else {
<div class="carousel-placeholder" (click)="fileInput.click()">
<mat-icon>add_photo_alternate</mat-icon>
<span>Ajouter des images</span>
</div>
}
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Bouton de suppression (croix rouge) -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<button mat-icon-button
class="carousel-delete-btn"
(click)="onDeleteCurrentImage()">
<mat-icon>delete</mat-icon>
</button>
}
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<!-- Bandeau de vignettes -->
<div class="carousel-thumbs">
@for (item of carouselItems; let i = $index; track item) {
<div class="thumb-item"
[class.active]="i === currentIndex"
(click)="onThumbClick(i)">
@if (!item.isPlaceholder) {
<!-- Bouton suppression vignette -->
<button mat-icon-button
class="thumb-delete-btn"
(click)="onDeleteThumb(i, $event)">
<mat-icon>close</mat-icon>
</button>
<img class="thumb-img" [src]="item.src" alt="Vignette produit">
} @else {
<div class="thumb-placeholder" (click)="fileInput.click()">
<mat-icon>add</mat-icon>
</div>
}
</div>
}
</div>
<!-- Input réel, caché -->
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
</div>
<!-- Input pour le nom du produit -->
<mat-form-field class="col-12">
<mat-label>Nom du produit</mat-label>
<input matInput formControlName="name" autocomplete="off">
</mat-form-field>
<!-- Textarea pour la description -->
<mat-form-field class="col-12">
<mat-label>Description</mat-label>
<textarea matInput rows="4" formControlName="description"></textarea>
</mat-form-field>
<!-- Sélecteur pour la catégorie -->
<mat-form-field class="col-6">
<mat-label>Catégorie</mat-label>
<mat-select formControlName="categoryId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (c of categories; track c.id) {
<mat-option [value]="c.id">{{ c.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour l'état du produit -->
<mat-form-field class="col-6">
<mat-label>État</mat-label>
<mat-select formControlName="conditionLabel">
@for (opt of conditionOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la marque -->
<mat-form-field class="col-6">
<mat-label>Marque</mat-label>
<mat-select formControlName="manufacturerId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (m of manufacturers; track m.id) {
<mat-option [value]="m.id">{{ m.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la plateforme (Fournisseur) -->
<mat-form-field class="col-6">
<mat-label>Plateforme</mat-label>
<mat-select formControlName="supplierId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (s of suppliers; track s.id) {
<mat-option [value]="s.id">{{ s.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Checkboxes pour Complet/Notice -->
<div class="col-12 flags">
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
</div>
<!-- Inputs pour le prix -->
<mat-form-field class="col-4">
<mat-label>Prix TTC (€)</mat-label>
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
</mat-form-field>
<!-- Input pour la quantité -->
<mat-form-field class="col-4">
<mat-label>Quantité</mat-label>
<input matInput type="number" step="1" min="0" formControlName="quantity">
</mat-form-field>
</div>
</div>
<!-- Actions -->
<mat-dialog-actions align="end">
<button mat-button
(click)="close()"
[disabled]="isSaving">
Annuler
</button>
<button mat-raised-button
color="primary"
(click)="save()"
[disabled]="form.invalid || isSaving">
@if (!isSaving) {
Enregistrer
} @else {
Enregistrement...
}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,396 @@
import {Component, Inject, OnInit, inject, OnDestroy} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatFormField, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {MatSelectModule} from '@angular/material/select';
import {MatCheckbox} from '@angular/material/checkbox';
import {MatButton, MatIconButton} from '@angular/material/button';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import {MatIcon} from '@angular/material/icon';
import {catchError, forkJoin, of, Observable, finalize} from 'rxjs';
import {PsItem} from '../../interfaces/ps-item';
import {ProductListItem} from '../../interfaces/product-list-item';
import {PrestashopService} from '../../services/prestashop.serivce';
import {MatProgressSpinner} from '@angular/material/progress-spinner';
export type ProductDialogData = {
mode: 'create' | 'edit';
refs: { categories: PsItem[]; manufacturers: PsItem[]; suppliers: PsItem[]; };
productRow?: ProductListItem & { priceHt?: number };
};
type CarouselItem = { src: string; isPlaceholder: boolean };
@Component({
selector: 'app-ps-product-dialog',
standalone: true,
templateUrl: './ps-product-dialog.component.html',
styleUrls: ['./ps-product-dialog.component.css'],
imports: [
CommonModule, ReactiveFormsModule,
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
MatIcon, MatIconButton, MatProgressSpinner
]
})
export class PsProductDialogComponent implements OnInit, OnDestroy {
private readonly fb = inject(FormBuilder);
private readonly ps = inject(PrestashopService);
constructor(
@Inject(MAT_DIALOG_DATA) public data: ProductDialogData,
private readonly dialogRef: MatDialogRef<PsProductDialogComponent>
) {
}
isSaving = false;
mode!: 'create' | 'edit';
categories: PsItem[] = [];
manufacturers: PsItem[] = [];
suppliers: PsItem[] = [];
productRow?: ProductListItem & { priceHt?: number };
images: File[] = [];
existingImageUrls: string[] = [];
// Previews des fichiers nouvellement sélectionnés
previewUrls: string[] = [];
// items du carrousel (existants + nouveaux + placeholder)
carouselItems: CarouselItem[] = [];
currentIndex = 0;
// options possibles pour l'état (Neuf, Très bon état, etc.)
conditionOptions: string[] = [];
// on conserve la dernière description chargée pour éviter lécrasement à vide
private lastLoadedDescription = '';
form = this.fb.group({
name: ['', Validators.required],
description: [''],
categoryId: [null as number | null, Validators.required],
manufacturerId: [null as number | null, Validators.required],
supplierId: [null as number | null, Validators.required],
complete: [true],
hasManual: [true],
conditionLabel: ['', Validators.required],
priceTtc: [0, [Validators.required, Validators.min(0)]],
quantity: [0, [Validators.required, Validators.min(0)]],
});
// ---------- Helpers locaux ----------
private toTtc(ht: number) {
return Math.round(((ht * 1.2) + Number.EPSILON) * 100) / 100;
}
/** normalisation simple pour comparaison de labels (insensible à casse/accents) */
private normalizeLabel(s: string): string {
return String(s ?? '')
.normalize('NFD')
.replaceAll(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
/** enlève <![CDATA[ ... ]]> si présent */
private stripCdata(s: string): string {
if (!s) return '';
return s.startsWith('<![CDATA[') && s.endsWith(']]>')
? s.slice(9, -3)
: s;
}
/** convertit du HTML en texte (pour le textarea) */
private htmlToText(html: string): string {
if (!html) return '';
const div = document.createElement('div');
div.innerHTML = html;
return (div.textContent || div.innerText || '').trim();
}
/** nettoyage CDATA+HTML -> texte simple */
private cleanForTextarea(src: string): string {
return this.htmlToText(this.stripCdata(src ?? ''));
}
ngOnInit(): void {
this.mode = this.data.mode;
this.productRow = this.data.productRow;
// Les refs viennent déjà triées du service
const rawCategories = this.data.refs.categories ?? [];
const rawManufacturers = this.data.refs.manufacturers ?? [];
const rawSuppliers = this.data.refs.suppliers ?? [];
const forbiddenCats = new Set(['racine', 'root', 'accueil', 'home']);
// on filtre seulement ici, tri déjà fait côté service
this.categories = rawCategories.filter(
c => !forbiddenCats.has(this.normalizeLabel(c.name))
);
this.manufacturers = rawManufacturers;
this.suppliers = rawSuppliers;
// charger les valeurs possibles pour létat (Neuf, Très bon état, etc.)
this.ps.getConditionValues()
.pipe(catchError(() => of<string[]>([])))
.subscribe((opts: string[]) => this.conditionOptions = opts);
// ---- Mode édition : pré-remplissage ----
if (this.mode === 'edit' && this.productRow) {
const r = this.productRow;
const immediateTtc = r.priceHt == null ? 0 : this.toTtc(r.priceHt);
this.form.patchValue({
name: r.name,
categoryId: r.id_category_default ?? null,
manufacturerId: r.id_manufacturer ?? null,
supplierId: r.id_supplier ?? null,
priceTtc: immediateTtc
});
const details$ = this.ps.getProductDetails(r.id).pipe(
catchError(() => of({
id: r.id, name: r.name, description: '',
id_manufacturer: r.id_manufacturer, id_supplier: r.id_supplier,
id_category_default: r.id_category_default, priceHt: r.priceHt ?? 0
}))
);
const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0)));
const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of<string[]>([])));
const flags$ = this.ps.getProductFlags(r.id).pipe(
catchError(() => of({complete: false, hasManual: false, conditionLabel: undefined}))
);
forkJoin({details: details$, qty: qty$, imgs: imgs$, flags: flags$})
.subscribe(({details, qty, imgs, flags}) => {
const ttc = this.toTtc(details.priceHt ?? 0);
const baseDesc = this.cleanForTextarea(details.description ?? '');
this.lastLoadedDescription = baseDesc;
this.form.patchValue({
description: baseDesc,
complete: flags.complete,
hasManual: flags.hasManual,
conditionLabel: flags.conditionLabel || '',
priceTtc: (ttc || this.form.value.priceTtc || 0),
quantity: qty,
categoryId: (details.id_category_default ?? this.form.value.categoryId) ?? null,
manufacturerId: (details.id_manufacturer ?? this.form.value.manufacturerId) ?? null,
supplierId: (details.id_supplier ?? this.form.value.supplierId) ?? null
});
this.existingImageUrls = imgs;
this.buildCarousel();
});
} else {
// mode création : uniquement placeholder au début
this.buildCarousel();
}
}
// -------- Carrousel / gestion des fichiers --------
onFiles(ev: Event) {
const fl = (ev.target as HTMLInputElement).files;
// Nettoyage des anciens objectURL
for (let url of this.previewUrls) {
URL.revokeObjectURL(url);
}
this.previewUrls = [];
this.images = [];
if (fl) {
this.images = Array.from(fl);
this.previewUrls = this.images.map(f => URL.createObjectURL(f));
}
this.buildCarousel();
// si on a ajouté des images, se placer sur la première nouvelle
if (this.images.length && this.existingImageUrls.length + this.previewUrls.length > 0) {
this.currentIndex = this.existingImageUrls.length; // première nouvelle
}
}
private buildCarousel() {
const items: CarouselItem[] = [
...this.existingImageUrls.map(u => ({src: u, isPlaceholder: false})),
...this.previewUrls.map(u => ({src: u, isPlaceholder: false}))
];
// placeholder en dernier
items.push({src: '', isPlaceholder: true});
this.carouselItems = items;
if (!this.carouselItems.length) {
this.currentIndex = 0;
} else if (this.currentIndex >= this.carouselItems.length) {
this.currentIndex = 0;
}
}
prev() {
if (!this.carouselItems.length) return;
this.currentIndex = (this.currentIndex - 1 + this.carouselItems.length) % this.carouselItems.length;
}
next() {
if (!this.carouselItems.length) return;
this.currentIndex = (this.currentIndex + 1) % this.carouselItems.length;
}
onThumbClick(index: number) {
if (index < 0 || index >= this.carouselItems.length) return;
this.currentIndex = index;
}
ngOnDestroy() {
// Nettoyage des objectURL
for (let url of this.previewUrls) {
URL.revokeObjectURL(url);
}
}
// -------- Save / close --------
save() {
if (this.form.invalid || this.isSaving) return;
this.isSaving = true;
this.dialogRef.disableClose = true;
const v = this.form.getRawValue();
const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription;
const dto = {
name: v.name!,
description: effectiveDescription,
categoryId: +v.categoryId!,
manufacturerId: +v.manufacturerId!,
supplierId: +v.supplierId!,
images: this.images,
complete: !!v.complete,
hasManual: !!v.hasManual,
conditionLabel: v.conditionLabel || undefined,
priceTtc: Number(v.priceTtc ?? 0),
vatRate: 0.2,
quantity: Math.max(0, Number(v.quantity ?? 0))
};
let op$: Observable<unknown>;
if (this.mode === 'create' || !this.productRow) {
op$ = this.ps.createProduct(dto) as Observable<unknown>;
} else {
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
}
op$
.pipe(
finalize(() => {
// si la boîte de dialogue est encore ouverte, on réactive tout
this.isSaving = false;
this.dialogRef.disableClose = false;
})
)
.subscribe({
next: () => this.dialogRef.close(true),
error: (e: unknown) =>
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
});
}
/** Extrait l'id_image depuis une URL FO Presta (.../img/p/.../<id>.jpg) */
private extractImageIdFromUrl(url: string): number | null {
const m = /\/(\d+)\.(?:jpg|jpeg|png|gif)$/i.exec(url);
if (!m) return null;
const id = Number(m[1]);
return Number.isFinite(id) ? id : null;
}
/** Suppression générique d'une image à l'index donné (carrousel + vignettes) */
private deleteImageAtIndex(idx: number) {
if (!this.carouselItems.length) return;
const item = this.carouselItems[idx];
if (!item || item.isPlaceholder) return;
const existingCount = this.existingImageUrls.length;
// --- Cas 1 : image existante (déjà chez Presta) ---
if (idx < existingCount) {
if (!this.productRow) return; // sécurité
const url = this.existingImageUrls[idx];
const imageId = this.extractImageIdFromUrl(url);
if (!imageId) {
alert('Impossible de déterminer lID de limage à supprimer.');
return;
}
if (!confirm('Supprimer cette image du produit ?')) return;
this.ps.deleteProductImage(this.productRow.id, imageId).subscribe({
next: () => {
// On la retire du tableau local et on reconstruit le carrousel
this.existingImageUrls.splice(idx, 1);
this.buildCarousel();
// Repositionnement de lindex si nécessaire
if (this.currentIndex >= this.carouselItems.length - 1) {
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
}
},
error: (e: unknown) => {
alert('Erreur lors de la suppression de limage : ' + (e instanceof Error ? e.message : String(e)));
}
});
return;
}
// --- Cas 2 : image locale (nouvelle) ---
const localIdx = idx - existingCount;
if (localIdx >= 0 && localIdx < this.previewUrls.length) {
if (!confirm('Retirer cette image de la sélection ?')) return;
this.previewUrls.splice(localIdx, 1);
this.images.splice(localIdx, 1);
this.buildCarousel();
if (this.currentIndex >= this.carouselItems.length - 1) {
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
}
}
}
// utilisée par la grande image
onDeleteCurrentImage() {
if (!this.carouselItems.length) return;
this.deleteImageAtIndex(this.currentIndex);
}
// utilisée par la croix sur une vignette
onDeleteThumb(index: number, event: MouseEvent) {
event.stopPropagation();
this.deleteImageAtIndex(index);
}
close() {
if (this.isSaving) return;
this.dialogRef.close(false);
}
}

View File

@@ -0,0 +1,27 @@
import { inject } from '@angular/core';
import { CanActivateFn, CanMatchFn, Router, UrlTree, ActivatedRouteSnapshot, Route } from '@angular/router';
import { AuthService } from '../services/auth.service';
function requireAdmin(url?: string): boolean | UrlTree {
const authService: AuthService = inject(AuthService);
const router: Router = inject(Router);
// 1) pas connecté -> envoie vers /login (avec redirect)
if (!authService.isLoggedIn()) {
return router.createUrlTree(['/login'], { queryParams: { redirect: url ?? router.url } });
}
// 2) connecté mais pas ADMIN -> redirige vers /home (ou /403 si tu crées une page)
if (!authService.hasRole('Administrator')) {
return router.parseUrl('/home');
}
// 3) ok
return true;
}
export const adminOnlyCanActivate: CanActivateFn = (route: ActivatedRouteSnapshot): boolean | UrlTree =>
requireAdmin(route?.url?.map(u => u.path).join('/') ?? '/admin');
export const adminOnlyCanMatch: CanMatchFn = (route: Route): boolean | UrlTree =>
requireAdmin(route?.path ?? '/admin');

View File

@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, CanMatchFn, Router, UrlTree, ActivatedRouteSnapshot, Route } from '@angular/router';
import { AuthService } from '../services/auth.service';
function requireAuth(url?: string): boolean | UrlTree {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isLoggedIn()) {
return true;
}
// redirige vers /login avec un param "redirect"
return router.createUrlTree(['/login'], { queryParams: { redirect: url ?? router.url } });
}
export const authOnlyCanActivate: CanActivateFn = (route: ActivatedRouteSnapshot): boolean | UrlTree =>
requireAuth(route?.url?.map(urlSegment => urlSegment.path).join('/') ?? '/login');
export const authOnlyCanMatch: CanMatchFn = (route: Route): boolean | UrlTree =>
requireAuth(route?.path ?? '/login');

View File

@@ -0,0 +1,13 @@
import { inject } from '@angular/core';
import { Router, UrlTree, CanActivateFn, CanMatchFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
function redirectIfLoggedIn(): boolean | UrlTree {
const authService = inject(AuthService);
const router = inject(Router);
// Si déjà connecté -> redirige vers /home
return authService.isLoggedIn() ? router.parseUrl('/home') : true;
}
export const guestOnlyCanMatch: CanMatchFn = () => redirectIfLoggedIn();
export const guestOnlyCanActivate: CanActivateFn = () => redirectIfLoggedIn();

View File

@@ -0,0 +1,45 @@
import {HttpErrorResponse, HttpInterceptorFn} from '@angular/common/http';
import {inject} from '@angular/core';
import {AuthService} from '../services/auth.service';
import {catchError, switchMap, throwError} from 'rxjs';
let isRefreshing = false;
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const authService: AuthService = inject(AuthService);
const token = authService.getAccessToken();
// Ajout de lAuthorization si on a un access token en mémoire
const authReq = token
? req.clone({setHeaders: {Authorization: `Bearer ${token}`}, withCredentials: true})
: req.clone({withCredentials: true});
return next(authReq).pipe(
catchError((error: any) => {
const is401 = error instanceof HttpErrorResponse && error.status === 401;
// si 401 et pas déjà en refresh, tente un refresh puis rejoue la requête une fois
if (is401 && !isRefreshing) {
isRefreshing = true;
return inject(AuthService).refresh().pipe(
switchMap(newToken => {
isRefreshing = false;
if (!newToken) return throwError(() => error);
const retryReq = req.clone({
setHeaders: {Authorization: `Bearer ${newToken}`},
withCredentials: true
});
return next(retryReq);
}),
catchError(err => {
isRefreshing = false;
return throwError(() => err);
})
);
}
return throwError(() => error);
})
);
};

View File

@@ -0,0 +1,4 @@
export interface Credentials {
username: string;
password: string;
}

View File

@@ -0,0 +1,7 @@
export interface ProductListItem {
id: number;
name: string;
id_manufacturer?: number;
id_supplier?: number;
id_category_default?: number;
}

View File

@@ -0,0 +1,5 @@
export interface PsItem {
id: number;
name: string;
active?: boolean;
}

View File

@@ -0,0 +1,16 @@
export interface PsProduct {
name: string;
description?: string; // texte saisi libre
categoryId: number; // id_category_default + associations
manufacturerId: number;
supplierId: number;
images?: File[]; // optionnel
// Champs “hors Presta” injectés dans la description :
conditionLabel?: string; // ex. "Occasion"
complete?: boolean; // Complet
hasManual?: boolean; // Notice
priceTtc: number; // saisi côté UI (TTC)
vatRate?: number; // ex: 0.20 (20%). Défaut: 0.20 si non fourni
quantity: number; // stock souhaité (pour id_product_attribute = 0)
thumbUrl?: string | null;
}

View File

@@ -0,0 +1,7 @@
export interface User {
firstName: string;
lastName: string;
username: string;
email: string ;
role: string;
}

View File

@@ -0,0 +1,5 @@
.wrap {
padding: 16px;
max-width: 900px;
margin: auto
}

View File

@@ -0,0 +1,14 @@
<div class="wrap">
<h2>Administration Prestashop</h2>
<mat-tab-group>
<mat-tab label="Catégories">
<app-ps-admin-crud [resource]="'categories'" [label]="'Catégorie'"></app-ps-admin-crud>
</mat-tab>
<mat-tab label="Marques">
<app-ps-admin-crud [resource]="'manufacturers'" [label]="'Marque'"></app-ps-admin-crud>
</mat-tab>
<mat-tab label="Fournisseurs">
<app-ps-admin-crud [resource]="'suppliers'" [label]="'Fournisseur'"></app-ps-admin-crud>
</mat-tab>
</mat-tab-group>
</div>

View File

@@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import {MatTab, MatTabGroup} from '@angular/material/tabs';
import {PsAdminCrudComponent} from '../../../components/ps-generic-crud/ps-admin-crud.component';
@Component({
standalone: true,
selector: 'app-ps-admin',
templateUrl: './ps-admin.component.html',
imports: [
MatTabGroup,
MatTab,
PsAdminCrudComponent
],
styleUrls: ['./ps-admin.component.css']
})
export class PsAdminComponent {}

View File

@@ -0,0 +1,20 @@
#container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
form {
background-color: white;
padding: 20px;
border-radius: 10px;
display: flex;
flex-direction: column;
width: 400px;
}
mat-error {
text-align: center;
}

View File

@@ -0,0 +1,17 @@
<div id="container">
<form (submit)="login()" [formGroup]="loginFormGroup">
<h3>Se connecter</h3>
<mat-form-field>
<mat-label>Nom d'utilisateur</mat-label>
<input matInput formControlName="username">
</mat-form-field>
<mat-form-field>
<mat-label>Mot de passe</mat-label>
<input matInput type="password" formControlName="password">
</mat-form-field>
<button mat-flat-button [disabled]="loginFormGroup.invalid">Se connecter</button>
@if (invalidCredentials) {
<mat-error>Nom d'utilisateur ou mot de passe invalide</mat-error>
}
</form>
</div>

View File

@@ -0,0 +1,66 @@
import {Component, inject, OnDestroy} from '@angular/core';
import {MatError, MatFormField, MatLabel} from '@angular/material/form-field';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {AuthService} from '../../../services/auth.service';
import {Router} from '@angular/router';
import {Subscription} from 'rxjs';
import {Credentials} from '../../../interfaces/credentials';
import {User} from '../../../interfaces/user';
import {MatInput} from '@angular/material/input';
import {MatButton} from '@angular/material/button';
@Component({
selector: 'app-login',
standalone: true,
imports: [
MatFormField,
MatLabel,
MatError,
ReactiveFormsModule,
MatInput,
MatButton
],
templateUrl: './login.component.html',
styleUrl: './login.component.css'
})
export class LoginComponent implements OnDestroy {
private readonly formBuilder: FormBuilder = inject(FormBuilder);
private readonly authService: AuthService = inject(AuthService);
private readonly router: Router = inject(Router);
private loginSubscription: Subscription | null = null;
loginFormGroup = this.formBuilder.group({
username: ['', [
Validators.required
]],
password: ['', [
Validators.required
]]
});
invalidCredentials = false;
ngOnDestroy() {
this.loginSubscription?.unsubscribe();
}
login() {
this.loginSubscription = this.authService.login(
this.loginFormGroup.value as Credentials).subscribe({
next: (result: User | null | undefined) => {
console.log(result);
this.navigateHome();
},
error: (error) => {
console.log(error);
this.invalidCredentials = true;
}
});
}
navigateHome() {
this.router.navigate(['/home']).then();
}
}

View File

@@ -0,0 +1,32 @@
.auth-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 16px;
}
.auth-card {
width: 100%;
max-width: 520px;
}
.form-grid {
display: grid;
gap: 16px;
margin-top: 16px;
}
.actions {
display: flex;
margin: 8px;
button {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.ml-8 {
margin-left: 8px;
}

View File

@@ -0,0 +1,137 @@
<section class="auth-wrap">
<mat-card class="auth-card">
<mat-card-header>
<mat-card-title>Inscription</mat-card-title>
<mat-card-subtitle>Créer un nouveau compte</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="registerForm" (ngSubmit)="onRegister()" class="form-grid">
<!-- First Name -->
<mat-form-field appearance="outline">
<mat-label>Prénom</mat-label>
<input matInput
id="firstName"
name="firstName"
formControlName="firstName"
type="text"
autocomplete="given-name"
required>
@if (isFieldInvalid('firstName')) {
<mat-error>{{ getFieldError('firstName') }}</mat-error>
}
</mat-form-field>
<!-- Last Name -->
<mat-form-field appearance="outline">
<mat-label>Nom</mat-label>
<input matInput
id="lastName"
name="lastName"
formControlName="lastName"
type="text"
autocomplete="family-name"
required>
@if (isFieldInvalid('lastName')) {
<mat-error>{{ getFieldError('lastName') }}</mat-error>
}
</mat-form-field>
<!-- Username -->
<mat-form-field appearance="outline">
<mat-label>Nom d'utilisateur</mat-label>
<input matInput
id="username"
name="username"
formControlName="username"
type="text"
autocomplete="username"
required>
@if (isFieldInvalid('username')) {
<mat-error>{{ getFieldError('username') }}</mat-error>
}
</mat-form-field>
<!-- Email -->
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput
id="email"
name="email"
formControlName="email"
type="email"
autocomplete="email"
required>
@if (isFieldInvalid('email')) {
<mat-error>{{ getFieldError('email') }}</mat-error>
}
</mat-form-field>
<!-- Password -->
<mat-form-field appearance="outline">
<mat-label>Mot de passe</mat-label>
<input matInput
id="password"
name="password"
formControlName="password"
type="password"
autocomplete="new-password"
required>
@if (isFieldInvalid('password')) {
<mat-error>{{ getFieldError('password') }}</mat-error>
}
</mat-form-field>
<!-- Password confirmation -->
<mat-form-field appearance="outline">
<mat-label>Confirmer le mot de passe</mat-label>
<input matInput
id="confirmPassword"
name="confirmPassword"
formControlName="confirmPassword"
type="password"
autocomplete="new-password"
required>
@if (isFieldInvalid('confirmPassword')) {
<mat-error>{{ getFieldError('confirmPassword') }}</mat-error>
}
</mat-form-field>
@if (registerForm.hasError('passwordMismatch') && (registerForm.dirty || registerForm.touched || isSubmitted)) {
<mat-error>Les mots de passe ne correspondent pas</mat-error>
}
<!-- Terms and Conditions -->
<mat-checkbox formControlName="termsAndConditions" id="iAgree">
J'accepte les <a href="#" target="_blank" rel="noopener">conditions générales d'utilisation</a>
</mat-checkbox>
@if (isFieldInvalid('termsAndConditions')) {
<div class="mat-caption mat-error">{{ getFieldError('termsAndConditions') }}</div>
}
<!-- Submit Button -->
<div class="actions">
<button mat-raised-button color="primary"
type="submit"
[disabled]="isLoading || registerForm.invalid">
@if (isLoading) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
<span class="ml-8">Inscription…</span>
} @else {
S'inscrire
}
</button>
</div>
</form>
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-actions align="end">
<span class="mat-body-small">
Vous avez déjà un compte ?
<a [routerLink]="'/login'">Se connecter</a>
</span>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,162 @@
import {Component, inject, OnDestroy} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import {Router, RouterLink} from '@angular/router';
import {MatError, MatFormField, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {
MatCard,
MatCardActions,
MatCardContent,
MatCardHeader,
MatCardSubtitle,
MatCardTitle
} from '@angular/material/card';
import {MatProgressSpinner} from '@angular/material/progress-spinner';
import {MatDivider} from '@angular/material/divider';
import {AuthService} from '../../../services/auth.service';
import {MatCheckbox} from '@angular/material/checkbox';
import {MatButton} from '@angular/material/button';
import {Subscription} from 'rxjs';
@Component({
selector: 'app-register',
standalone: true,
imports: [
MatError,
MatFormField,
MatLabel,
MatInput,
MatCard,
MatCardHeader,
MatCardTitle,
MatCardSubtitle,
MatCardContent,
ReactiveFormsModule,
MatProgressSpinner,
MatDivider,
MatCardActions,
RouterLink,
MatCheckbox,
MatButton
],
templateUrl: './register.component.html',
styleUrl: './register.component.css'
})
export class RegisterComponent implements OnDestroy {
registerForm: FormGroup;
isSubmitted = false;
isLoading = false;
private readonly passwordPattern: RegExp = new RegExp(
'^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[\\p{P}\\p{S}]).{8,}$',
'u'
);
private readonly router: Router = inject(Router);
private readonly authService: AuthService = inject(AuthService);
private registerSubscription: Subscription | null = null;
constructor(private readonly formBuilder: FormBuilder) {
this.registerForm = this.formBuilder.group({
firstName: ['', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(50),
Validators.pattern('^[a-zA-Z]+$')
]],
lastName: ['', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(50),
Validators.pattern('^[a-zA-Z]+$')
]],
username: ['', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(20),
Validators.pattern('^[a-zA-Z0-9_]+$')
]],
email: ['', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(120),
Validators.email
]],
password: ['', [
Validators.required,
Validators.minLength(8),
Validators.maxLength(50),
Validators.pattern(this.passwordPattern)
]],
confirmPassword: ['', [
Validators.required,
Validators.minLength(8),
Validators.maxLength(50),
Validators.pattern(this.passwordPattern)
]],
termsAndConditions: [false, Validators.requiredTrue]
}, {validators: this.passwordMatchValidator});
}
ngOnDestroy(): void {
this.registerSubscription?.unsubscribe();
}
private readonly passwordMatchValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
return password === confirmPassword ? null : {passwordMismatch: true};
};
onRegister() {
this.isSubmitted = true;
if (this.registerForm.valid) {
this.isLoading = true;
const registrationData = this.registerForm.value;
delete registrationData.confirmPassword;
this.registerSubscription = this.authService.register(registrationData).subscribe({
next: (response) => {
this.isLoading = false;
this.registerForm.reset();
this.isSubmitted = false;
alert("Registration successful!");
this.router.navigate(['/']).then();
},
error: (error) => {
console.error('Erreur HTTP:', error);
this.isLoading = false;
alert("An error occurred during registration. Please try again.");
}
});
}
}
isFieldInvalid(fieldName: string): boolean {
const field = this.registerForm.get(fieldName);
return Boolean(field && field.invalid && (field.dirty || field.touched || this.isSubmitted));
}
getFieldError(fieldName: string): string {
const field = this.registerForm.get(fieldName);
if (field && field.errors) {
if (field.errors['required']) return `Ce champ est obligatoire`;
if (field.errors['email']) return `Format d'email invalide`;
if (field.errors['minlength']) return `Minimum ${field.errors['minlength'].requiredLength} caractères`;
if (field.errors['maxlength']) return `Maximum ${field.errors['maxlength'].requiredLength} caractères`;
}
return '';
}
}

View File

@@ -0,0 +1 @@
<p>not-found works!</p>

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-not-found',
standalone: true,
imports: [],
templateUrl: './not-found.component.html',
styleUrl: './not-found.component.css'
})
export class NotFoundComponent {
}

View File

@@ -0,0 +1,13 @@
.home-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
text-align: center;
}
.home-actions {
margin-top: 2rem;
display: flex;
justify-content: center;
gap: 1rem;
}

View File

@@ -0,0 +1,17 @@
<div class="home-container">
@if (getUser(); as user) {
<h1>Bonjour, {{ user.firstName }}!</h1>
<p>Que souhaitez-vous faire ?</p>
<br>
<div class="home-actions">
<button mat-flat-button [routerLink]="'/products'">Voir la liste des produits</button>
<button mat-raised-button [routerLink]="'/admin'">Gérer la base de données</button>
</div>
} @else {
<h2>Gestion des produits</h2>
<div class="home-actions">
<button mat-flat-button [routerLink]="'/login'">Se connecter</button>
<button mat-raised-button [routerLink]="'/register'">S'inscrire</button>
</div>
}
</div>

View File

@@ -0,0 +1,24 @@
import {Component, inject} from '@angular/core';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../../services/auth.service';
import {Router, RouterLink} from '@angular/router';
@Component({
selector: 'app-home',
standalone: true,
imports: [
MatButton,
RouterLink
],
templateUrl: './home.component.html',
styleUrl: './home.component.css'
})
export class HomeComponent {
protected readonly authService: AuthService = inject(AuthService);
protected readonly router: Router = inject(Router);
getUser() {
return this.authService.user();
}
}

View File

@@ -0,0 +1,5 @@
.wrap {
padding: 16px;
max-width: 1100px;
margin: auto
}

View File

@@ -0,0 +1,4 @@
<section class="wrap">
<h2>Gestion des produits</h2>
<app-ps-product-crud></app-ps-product-crud>
</section>

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import {PsProductCrudComponent} from '../../components/ps-product-crud/ps-product-crud.component';
@Component({
selector: 'app-products',
standalone: true,
imports: [
PsProductCrudComponent
],
templateUrl: './products.component.html',
styleUrl: './products.component.css'
})
export class ProductsComponent {
}

View File

@@ -0,0 +1,72 @@
:host {
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
}
.profile-card {
max-width: 380px;
width: 100%;
margin: 0 auto;
padding: 2rem 1.5rem 1.5rem 1.5rem;
border-radius: 18px;
box-shadow: 0 4px 24px rgba(25, 118, 210, 0.08);
background: #fff;
}
.profile-avatar {
background: #1976d2;
color: #fff;
width: 64px;
height: 64px;
font-size: 48px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(25, 118, 210, 0.15);
}
.profile-actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
mat-card-header {
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 1rem;
}
mat-card-title {
font-size: 1.3rem;
font-weight: 600;
margin-top: 0.5rem;
}
mat-card-subtitle {
color: #888;
font-size: 1rem;
margin-bottom: 0.5rem;
}
mat-card-content p {
display: flex;
align-items: center;
gap: 10px;
margin: 1rem 0 0 0;
font-size: 1.05rem;
color: #444;
justify-content: center;
}
mat-card-actions {
display: flex;
justify-content: center;
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,31 @@
@if (getUser(); as user) {
<mat-card class="profile-card">
<mat-card-header>
<div mat-card-avatar class="profile-avatar">
<mat-icon>account_circle</mat-icon>
</div>
<mat-card-title>{{ user.firstName }} {{ user.lastName }}</mat-card-title>
@if (user.role == "Administrator") {
<mat-card-subtitle>{{ user.username }} ({{ user.role }})</mat-card-subtitle>
} @else {
<mat-card-subtitle>{{ user.username }}</mat-card-subtitle>
}
</mat-card-header>
<mat-card-content>
<p>
<mat-icon>email</mat-icon>
{{ user.email }}
</p>
</mat-card-content>
<mat-card-actions class="profile-actions">
<button mat-raised-button color="primary">
<mat-icon>edit</mat-icon>
Edit profile
</button>
<button mat-raised-button color="warn" (click)="logout()">
<mat-icon>logout</mat-icon>
Logout
</button>
</mat-card-actions>
</mat-card>
}

View File

@@ -0,0 +1,51 @@
import {Component, inject} from '@angular/core';
import {
MatCard,
MatCardActions,
MatCardContent,
MatCardHeader,
MatCardSubtitle,
MatCardTitle
} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../../services/auth.service';
import {User} from '../../interfaces/user';
import {Router} from '@angular/router';
@Component({
selector: 'app-profile',
standalone: true,
imports: [
MatCard,
MatCardHeader,
MatIcon,
MatCardTitle,
MatCardSubtitle,
MatCardContent,
MatCardActions,
MatButton
],
templateUrl: './profile.component.html',
styleUrl: './profile.component.css'
})
export class ProfileComponent {
private readonly authService: AuthService = inject(AuthService);
private readonly router: Router = inject(Router);
logout() {
this.authService.logout().subscribe({
next: () => {
this.router.navigate(['/login'], {queryParams: {redirect: '/profile'}}).then();
},
error: (err) => {
console.error('Logout failed:', err);
}
});
}
getUser(): User | null {
return this.authService.user();
}
}

View File

@@ -0,0 +1,89 @@
import {inject, Injectable, signal} from '@angular/core';
import {catchError, map, of, switchMap, tap} from 'rxjs';
import {Credentials} from '../interfaces/credentials';
import {HttpClient} from '@angular/common/http';
import {User} from '../interfaces/user';
import {environment} from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly http = inject(HttpClient);
private readonly BASE_URL = `${environment.apiUrl}/auth`;
readonly user = signal<User | null>(null);
private readonly accessToken = signal<string | null>(null);
register(user: User) {
return this.http.post(this.BASE_URL + '/register', user, {withCredentials: true});
}
login(credentials: Credentials) {
return this.http.post<any>(this.BASE_URL + '/login', credentials, {withCredentials: true}).pipe(
tap(res => this.accessToken.set(res?.accessToken ?? null)),
switchMap(() => this.me())
);
}
logout() {
return this.http.get(this.BASE_URL + '/logout', {withCredentials: true}).pipe(
tap(() => {
this.accessToken.set(null);
this.user.set(null);
}),
map(() => null)
);
}
// Charge l'utilisateur courant (protégé)
me() {
return this.http.get<User>(this.BASE_URL + '/me', {withCredentials: true}).pipe(
tap(u => this.user.set(u)),
map(() => this.user()),
catchError(() => {
this.user.set(null);
return of(null);
})
);
}
// Demande un nouvel access token via le cookie HttpOnly
refresh() {
return this.http.post<any>(this.BASE_URL + '/refresh', {}, {
withCredentials: true,
observe: 'response'
}).pipe(
map(res => {
const token = (res.body as any)?.accessToken ?? null;
this.accessToken.set(token);
return token;
}),
catchError(() => of(null))
);
}
// Au démarrage de l'app, tenter transparence session -> doit TOUJOURS compléter
bootstrapSession() {
return this.refresh().pipe(
switchMap(token => token ? this.me() : of(null)),
catchError(() => of(null)) // <- sécurité supplémentaire
);
}
isLoggedIn(): boolean {
return this.user() !== null;
}
hasRole(role: string): boolean {
const user: User | null = this.user();
if (!user) return false;
const roles = Array.isArray((user as any).roles) ? (user as any).roles : [(user as any).role];
return roles?.includes(role) ?? false;
}
getAccessToken(): string | null {
return this.accessToken();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
export function resizeImage(
file: File,
maxWidth = 1600,
maxHeight = 1600,
quality = 0.8
): Promise<File> {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
reader.onload = e => {
if (!e.target?.result) return reject('Cannot load file');
img.onload = () => {
let { width, height } = img;
const ratio = Math.min(maxWidth / width, maxHeight / height, 1);
const newW = Math.round(width * ratio);
const newH = Math.round(height * ratio);
const canvas = document.createElement('canvas');
canvas.width = newW;
canvas.height = newH;
const ctx = canvas.getContext('2d');
if (!ctx) return reject('Cannot get canvas context');
ctx.drawImage(img, 0, 0, newW, newH);
canvas.toBlob(
blob => {
if (!blob) return reject('Compression failed');
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
},
'image/jpeg',
quality
);
};
img.src = e.target.result as string;
};
reader.readAsDataURL(file);
});
}

View File

@@ -0,0 +1,6 @@
export const environment = {
production: true,
apiUrl: '/gameovergne-api/api',
psUrl: '/gameovergne-api/api/ps',
hrefBase: '/gameovergne/',
};

View File

@@ -0,0 +1,6 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
psUrl: '/ps',
hrefBase: '/',
};

15
client/src/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Game Over'gne App</title>
<base href="/gameovergne/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

6
client/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

14
client/src/styles.css Normal file
View File

@@ -0,0 +1,14 @@
body, html {
font-family: Roboto, sans-serif;
height: 100%;
background: linear-gradient(
135deg,
rgb(245, 245, 245),
rgb(230, 230, 230)
);
}
body {
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
}

15
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

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