Compare commits
253 Commits
4f3c6d7f02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00208f08c9 | ||
| b79068623f | |||
|
|
fd538f376f | ||
|
|
3eed3d251f | ||
|
|
cefb3c54c3 | ||
| 7dcc85ac95 | |||
| 79bd33fe41 | |||
| ec9eb0dc7d | |||
| 01cafd5904 | |||
| 321e2fd546 | |||
| 696e0ac817 | |||
| 888ddc1362 | |||
| 3026f0a13f | |||
| 52d17e5ad8 | |||
| 2803e910bd | |||
| 653ce83c33 | |||
| ce618deecf | |||
| 5331ce7866 | |||
|
|
6f6d033be3 | ||
|
|
ff8536b448 | ||
|
|
60593f6c11 | ||
|
|
1708c1bead | ||
|
|
dc33d762a1 | ||
|
|
e04cac3345 | ||
|
|
00f45ae6c7 | ||
|
|
1a5d3a570a | ||
|
|
9763289c2f | ||
|
|
fd6c730ae3 | ||
|
|
eb94697955 | ||
|
|
a72957648e | ||
|
|
b15c331295 | ||
|
|
503cbee641 | ||
|
|
078cef0585 | ||
|
|
48f7e84ef9 | ||
|
|
f975e57110 | ||
|
|
389beca604 | ||
|
|
fa7a1c2f26 | ||
|
|
df98dfe38e | ||
|
|
65559bbb48 | ||
|
|
f317d15ac5 | ||
|
|
68ccb164e2 | ||
|
|
a8f9c5f49a | ||
|
|
ff331e1630 | ||
|
|
d076286728 | ||
|
|
de942e0d96 | ||
|
|
ce3389f2e6 | ||
|
|
8680b2fc92 | ||
|
|
5068390a14 | ||
|
|
4fe16b0cb1 | ||
|
|
42c1e655f1 | ||
|
|
fdb6c40bb9 | ||
|
|
72f3791616 | ||
|
|
db8085c0aa | ||
|
|
177eb2eb5c | ||
|
|
bceedc8620 | ||
|
|
14a6f66742 | ||
|
|
28faf2ed2b | ||
|
|
02387e9a50 | ||
|
|
c09316189e | ||
|
|
ad441b8dbc | ||
|
|
9759f2cf8e | ||
|
|
16bd098954 | ||
|
|
7e5f75e482 | ||
|
|
d866924130 | ||
|
|
9eb8256fd7 | ||
|
|
e5411f2fdc | ||
|
|
d64fed8157 | ||
|
|
007cb34c81 | ||
|
|
aead87b1bc | ||
|
|
e30fb83043 | ||
|
|
44764a5f14 | ||
|
|
4c16e356a3 | ||
|
|
8a750b94d0 | ||
|
|
9ae60a087a | ||
|
|
9d2f89f805 | ||
|
|
d802418c29 | ||
| 504fb4fe8e | |||
|
|
5c42db7540 | ||
|
|
14e19ac2ea | ||
|
|
136f9c1732 | ||
|
|
60ce19f72f | ||
| 659b16f700 | |||
| 664123cc22 | |||
| fec615db26 | |||
| 6388db1026 | |||
| 35a5f0d755 | |||
| e6efbdeafe | |||
| dba9e19c6c | |||
| 9b2a4b55a2 | |||
| ad66b1bf31 | |||
| 785c482057 | |||
| bbd1b94524 | |||
| 9d07e4d14e | |||
| 8088d3efb7 | |||
|
|
e5adb9356f | ||
|
|
b8aa3e61ed | ||
|
|
411c407a40 | ||
| 9e350ec2b5 | |||
| 6427f08ed8 | |||
| 40eb58aa4d | |||
| c9d8186f21 | |||
| 8e5819db38 | |||
| 1659ac6ad2 | |||
| 0d68975f41 | |||
| 0d71596e70 | |||
| 03661021cc | |||
| 82de928f3e | |||
| bfa90c5b31 | |||
| 136532947f | |||
| 7028d1094b | |||
| 00fd7e2069 | |||
| d388ce2d1d | |||
| 939bb6159c | |||
| 3a6b26ac38 | |||
| dabfd03d0c | |||
| d8410b7463 | |||
| d526b8ab39 | |||
| 687400ebd9 | |||
| 44abcda2e8 | |||
| 6e47c4b4d9 | |||
| 6cc3423451 | |||
| 1cda6f4660 | |||
| 2a4b71f52f | |||
| edd8011efc | |||
| f2ba047fc4 | |||
| 9b8c8b05b3 | |||
| 30e740e70f | |||
| 669980ea93 | |||
| 1a670ae930 | |||
| b42b4fd015 | |||
| d6dba27b16 | |||
| 9a27cd3789 | |||
| b9c4a7fdb4 | |||
| b888089e22 | |||
|
|
bfe7176388 | ||
| facfd5d32b | |||
| 97f97450f4 | |||
| dd0478970b | |||
|
|
f44ca08f6a | ||
| dc21f3820c | |||
| 9a8e59e07e | |||
|
|
669a4cbe00 | ||
| b98995b7ae | |||
| a40fd2c7ac | |||
|
|
d73275572f | ||
|
|
ad6567efa2 | ||
| f8358594d5 | |||
| 1261e90fd7 | |||
| 60f6ac4823 | |||
| 734320a405 | |||
|
|
6f05f66ea6 | ||
|
|
e5cce7668f | ||
|
|
b16eff2e76 | ||
|
|
8cdfab9596 | ||
|
|
f9a9e81713 | ||
|
|
f4696b5f5b | ||
|
|
d94ce06d95 | ||
|
|
103e4c055d | ||
|
|
005bbcc678 | ||
|
|
b9320c7383 | ||
|
|
1efe158631 | ||
|
|
7c82cf0d3f | ||
|
|
4b29e116cf | ||
|
|
21cdb3dc30 | ||
|
|
64f00d5b30 | ||
|
|
8317fc24d9 | ||
|
|
4a16aa715f | ||
| 61e164732c | |||
| d3f47549f9 | |||
| b289a53154 | |||
| 47edec0d33 | |||
|
|
c7b9b68d42 | ||
|
|
e839aae4dd | ||
| 019b8f4c01 | |||
| 844c132915 | |||
| c9ceb46746 | |||
| e4dacdd61a | |||
| 7229364a59 | |||
| e6769bb1d5 | |||
| 1b876d2d6c | |||
| 2b9b80b031 | |||
| 1ba498e862 | |||
| 9241cbd4e2 | |||
|
|
20573d73b3 | ||
|
|
3c700348df | ||
|
|
cb38b80134 | ||
| d84a2ad122 | |||
| 93d575f7c5 | |||
| 60c3c4a3fa | |||
| 6b6ef1cbab | |||
| 05c998be1f | |||
| 9fef690989 | |||
| 6eeda7c4ba | |||
| 716a73c7a9 | |||
| c6b95ef9c7 | |||
| 5c99a2eada | |||
| 03c0179f62 | |||
| bfaca86a29 | |||
| f1d2c9b33b | |||
|
|
e38c7f8241 | ||
|
|
ccd73cd478 | ||
| 0db746afd3 | |||
|
|
8eccdc75d5 | ||
| adc83b2d70 | |||
| 714706532c | |||
|
|
3f16faceb0 | ||
| 368a7f3227 | |||
| df179c7791 | |||
| 877af6ada3 | |||
| 42906cc1d9 | |||
| b1c1aae383 | |||
|
|
7d20b396ad | ||
| 47c6693d1d | |||
| 19dc42f0a5 | |||
| 1cb7057911 | |||
| 910f283f8a | |||
| bf3a7c33c0 | |||
|
|
f83a8cc26b | ||
|
|
028bd80669 | ||
|
|
6ef5b998a4 | ||
|
|
b756c9fa2d | ||
|
|
d4ffcf0562 | ||
|
|
bcc71b965b | ||
|
|
f063a245b9 | ||
|
|
a849a4dd15 | ||
|
|
b84a829a82 | ||
|
|
1a918a7863 | ||
|
|
df2a5a57ba | ||
|
|
3bc2a27d76 | ||
|
|
2fe52830d8 | ||
|
|
55a1d54c70 | ||
|
|
e7da8b9b83 | ||
|
|
7c8f85a500 | ||
|
|
4b692806c4 | ||
|
|
7531ea9453 | ||
|
|
6dc9f4ffea | ||
|
|
d2696b16ac | ||
|
|
7fb4f46833 | ||
|
|
b0ba0886fd | ||
|
|
8c3de85f36 | ||
|
|
f04f9fd93f | ||
|
|
f2f855bc70 | ||
|
|
611eb685a8 | ||
|
|
de1df47474 | ||
|
|
7769cc7ded | ||
|
|
5476651ea3 | ||
|
|
98cf31349b | ||
|
|
4acf024350 | ||
|
|
2e5b2b3ff5 | ||
|
|
18f0364e26 | ||
|
|
dfb4ac302a | ||
|
|
3d2b3f7324 | ||
|
|
f2b4f9d3e6 |
403
.gitignore
vendored
Normal file
403
.gitignore
vendored
Normal 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
9
Dockerfile
Normal 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
21
api/Dockerfile
Normal 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
34
api/HELP.md
Normal 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
295
api/mvnw
vendored
Normal 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
189
api/mvnw.cmd
vendored
Normal 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
119
api/pom.xml
Normal 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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
106
api/src/main/java/fr/gameovergne/api/config/SecurityConfig.java
Normal file
106
api/src/main/java/fr/gameovergne/api/config/SecurityConfig.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 d’erreur 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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package fr.gameovergne.api.dto.auth;
|
||||||
|
|
||||||
|
public record AuthRequest(
|
||||||
|
String username,
|
||||||
|
String password
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package fr.gameovergne.api.dto.auth;
|
||||||
|
|
||||||
|
public record AuthResponse(
|
||||||
|
String username,
|
||||||
|
String accessToken,
|
||||||
|
String refreshToken
|
||||||
|
) {}
|
||||||
17
api/src/main/java/fr/gameovergne/api/dto/user/UserDTO.java
Normal file
17
api/src/main/java/fr/gameovergne/api/dto/user/UserDTO.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
15
api/src/main/java/fr/gameovergne/api/model/user/Role.java
Normal file
15
api/src/main/java/fr/gameovergne/api/model/user/Role.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
api/src/main/java/fr/gameovergne/api/model/user/User.java
Normal file
62
api/src/main/java/fr/gameovergne/api/model/user/User.java
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 l’URL 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 d’erreur Presta, on propage l’XML 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
api/src/main/resources/application.properties
Normal file
19
api/src/main/resources/application.properties
Normal 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}
|
||||||
@@ -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
17
client/.editorconfig
Normal 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
42
client/.gitignore
vendored
Normal 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
31
client/Dockerfile
Normal 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
27
client/README.md
Normal 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
122
client/angular.json
Normal 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
12
client/nginx.conf
Normal 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
14196
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
client/package.json
Normal file
40
client/package.json
Normal 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
14
client/proxy.conf.json
Normal 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
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
0
client/src/app/app.component.css
Normal file
0
client/src/app/app.component.css
Normal file
2
client/src/app/app.component.html
Normal file
2
client/src/app/app.component.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<app-main-navbar></app-main-navbar>
|
||||||
|
<router-outlet></router-outlet>
|
||||||
14
client/src/app/app.component.ts
Normal file
14
client/src/app/app.component.ts
Normal 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';
|
||||||
|
}
|
||||||
41
client/src/app/app.config.ts
Normal file
41
client/src/app/app.config.ts
Normal 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))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
61
client/src/app/app.routes.ts
Normal file
61
client/src/app/app.routes.ts
Normal 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: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
124
client/src/app/components/main-navbar/main-navbar.component.css
Normal file
124
client/src/app/components/main-navbar/main-navbar.component.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> 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>
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 l’ID de l’image à 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 l’index 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 l’image : ' + (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
client/src/app/guards/admin-only.guard.ts
Normal file
27
client/src/app/guards/admin-only.guard.ts
Normal 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');
|
||||||
21
client/src/app/guards/auth-only.guard.ts
Normal file
21
client/src/app/guards/auth-only.guard.ts
Normal 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');
|
||||||
13
client/src/app/guards/guest-only.guard.ts
Normal file
13
client/src/app/guards/guest-only.guard.ts
Normal 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();
|
||||||
45
client/src/app/interceptors/auth-token.interceptor.ts
Normal file
45
client/src/app/interceptors/auth-token.interceptor.ts
Normal 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 l’Authorization 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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
4
client/src/app/interfaces/credentials.ts
Normal file
4
client/src/app/interfaces/credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Credentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
7
client/src/app/interfaces/product-list-item.ts
Normal file
7
client/src/app/interfaces/product-list-item.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ProductListItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
id_manufacturer?: number;
|
||||||
|
id_supplier?: number;
|
||||||
|
id_category_default?: number;
|
||||||
|
}
|
||||||
5
client/src/app/interfaces/ps-item.ts
Normal file
5
client/src/app/interfaces/ps-item.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface PsItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
16
client/src/app/interfaces/ps-product.ts
Normal file
16
client/src/app/interfaces/ps-product.ts
Normal 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;
|
||||||
|
}
|
||||||
7
client/src/app/interfaces/user.ts
Normal file
7
client/src/app/interfaces/user.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface User {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
username: string;
|
||||||
|
email: string ;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.wrap {
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: auto
|
||||||
|
}
|
||||||
14
client/src/app/pages/admin/ps-admin/ps-admin.component.html
Normal file
14
client/src/app/pages/admin/ps-admin/ps-admin.component.html
Normal 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>
|
||||||
16
client/src/app/pages/admin/ps-admin/ps-admin.component.ts
Normal file
16
client/src/app/pages/admin/ps-admin/ps-admin.component.ts
Normal 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 {}
|
||||||
20
client/src/app/pages/auth/login/login.component.css
Normal file
20
client/src/app/pages/auth/login/login.component.css
Normal 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;
|
||||||
|
}
|
||||||
17
client/src/app/pages/auth/login/login.component.html
Normal file
17
client/src/app/pages/auth/login/login.component.html
Normal 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>
|
||||||
66
client/src/app/pages/auth/login/login.component.ts
Normal file
66
client/src/app/pages/auth/login/login.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
client/src/app/pages/auth/register/register.component.css
Normal file
32
client/src/app/pages/auth/register/register.component.css
Normal 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;
|
||||||
|
}
|
||||||
137
client/src/app/pages/auth/register/register.component.html
Normal file
137
client/src/app/pages/auth/register/register.component.html
Normal 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>
|
||||||
162
client/src/app/pages/auth/register/register.component.ts
Normal file
162
client/src/app/pages/auth/register/register.component.ts
Normal 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>not-found works!</p>
|
||||||
12
client/src/app/pages/errors/not-found/not-found.component.ts
Normal file
12
client/src/app/pages/errors/not-found/not-found.component.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
13
client/src/app/pages/home/home.component.css
Normal file
13
client/src/app/pages/home/home.component.css
Normal 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;
|
||||||
|
}
|
||||||
17
client/src/app/pages/home/home.component.html
Normal file
17
client/src/app/pages/home/home.component.html
Normal 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>
|
||||||
24
client/src/app/pages/home/home.component.ts
Normal file
24
client/src/app/pages/home/home.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
5
client/src/app/pages/products/products.component.css
Normal file
5
client/src/app/pages/products/products.component.css
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.wrap {
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: auto
|
||||||
|
}
|
||||||
4
client/src/app/pages/products/products.component.html
Normal file
4
client/src/app/pages/products/products.component.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<section class="wrap">
|
||||||
|
<h2>Gestion des produits</h2>
|
||||||
|
<app-ps-product-crud></app-ps-product-crud>
|
||||||
|
</section>
|
||||||
15
client/src/app/pages/products/products.component.ts
Normal file
15
client/src/app/pages/products/products.component.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
72
client/src/app/pages/profile/profile.component.css
Normal file
72
client/src/app/pages/profile/profile.component.css
Normal 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;
|
||||||
|
}
|
||||||
31
client/src/app/pages/profile/profile.component.html
Normal file
31
client/src/app/pages/profile/profile.component.html
Normal 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>
|
||||||
|
}
|
||||||
51
client/src/app/pages/profile/profile.component.ts
Normal file
51
client/src/app/pages/profile/profile.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
89
client/src/app/services/auth.service.ts
Normal file
89
client/src/app/services/auth.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1109
client/src/app/services/prestashop.serivce.ts
Normal file
1109
client/src/app/services/prestashop.serivce.ts
Normal file
File diff suppressed because it is too large
Load Diff
45
client/src/app/utils/image-utils.ts
Normal file
45
client/src/app/utils/image-utils.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
6
client/src/environments/environment.prod.ts
Normal file
6
client/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: '/gameovergne-api/api',
|
||||||
|
psUrl: '/gameovergne-api/api/ps',
|
||||||
|
hrefBase: '/gameovergne/',
|
||||||
|
};
|
||||||
6
client/src/environments/environment.ts
Normal file
6
client/src/environments/environment.ts
Normal 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
15
client/src/index.html
Normal 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
6
client/src/main.ts
Normal 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
14
client/src/styles.css
Normal 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
15
client/tsconfig.app.json
Normal 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
Reference in New Issue
Block a user