From 716015a43fc31d7d54d169945e78fbb930e72cde Mon Sep 17 00:00:00 2001 From: Deepu Date: Tue, 5 Sep 2023 20:45:32 +0200 Subject: [PATCH] automatic project update for v8.0.0-beta.3 --- .devcontainer/Dockerfile | 4 +- .devcontainer/devcontainer.json | 46 +- .eslintignore | 1 + .eslintrc.json | 99 ++ .gitignore | 8 - .jhipster/BankAccount.json | 2 +- .jhipster/Label.json | 2 +- .jhipster/Operation.json | 2 +- .mvn/wrapper/maven-wrapper.jar | Bin 59925 -> 62547 bytes .mvn/wrapper/maven-wrapper.properties | 6 +- .prettierignore | 0 .prettierrc | 4 + README.md | 112 +- angular.json | 109 ++ cypress-audits.config.ts | 2 +- cypress.config.ts | 8 +- jest.conf.js | 29 + mvnw | 218 ++- mvnw.cmd | 31 +- ngsw-config.json | 21 + npmw.cmd | 2 +- package.json | 149 +- pom.xml | 1504 ++++++++--------- sonar-project.properties | 58 +- src/main/docker/app.yml | 41 +- src/main/docker/jhipster-control-center.yml | 3 +- src/main/docker/jib/entrypoint.sh | 0 src/main/docker/monitoring.yml | 10 +- src/main/docker/postgresql.yml | 11 +- src/main/docker/services.yml | 7 + src/main/docker/sonar.yml | 14 +- .../jhipster/sample/GeneratedByJHipster.java | 2 +- .../sample/JhipsterSampleApplicationApp.java | 18 +- .../sample/aop/logging/package-info.java | 4 + .../sample/config/CRLFLogConverter.java | 17 +- .../sample/config/JacksonConfiguration.java | 24 +- .../sample/config/LiquibaseConfiguration.java | 4 +- .../sample/config/SecurityConfiguration.java | 147 +- .../config/SecurityJwtConfiguration.java | 68 + .../jhipster/sample/config/WebConfigurer.java | 5 +- .../jhipster/sample/config/package-info.java | 2 +- .../sample/domain/AbstractAuditingEntity.java | 6 +- .../jhipster/sample/domain/Authority.java | 12 +- .../jhipster/sample/domain/BankAccount.java | 8 +- .../github/jhipster/sample/domain/Label.java | 6 +- .../jhipster/sample/domain/Operation.java | 8 +- .../github/jhipster/sample/domain/User.java | 10 +- .../jhipster/sample/domain/package-info.java | 2 +- .../sample/management/package-info.java | 4 + .../github/jhipster/sample/package-info.java | 4 + .../repository/BankAccountRepository.java | 6 +- .../repository/OperationRepository.java | 6 +- ...ionRepositoryWithBagRelationshipsImpl.java | 13 +- .../sample/repository/package-info.java | 2 +- .../security/DomainUserDetailsService.java | 6 +- .../sample/security/SecurityUtils.java | 15 +- .../sample/security/jwt/JWTConfigurer.java | 21 - .../sample/security/jwt/JWTFilter.java | 47 - .../sample/security/jwt/TokenProvider.java | 126 -- .../sample/security/package-info.java | 2 +- .../jhipster/sample/service/MailService.java | 6 +- .../jhipster/sample/service/UserService.java | 4 +- .../sample/service/dto/AdminUserDTO.java | 2 +- .../sample/service/dto/package-info.java | 2 +- .../sample/service/mapper/UserMapper.java | 6 +- .../sample/service/mapper/package-info.java | 2 +- .../jhipster/sample/service/package-info.java | 2 +- .../sample/web/filter/SpaWebFilter.java | 34 + .../sample/web/filter/package-info.java | 4 + .../sample/web/rest/AccountResource.java | 19 +- .../web/rest/AuthenticateController.java | 124 ++ .../sample/web/rest/BankAccountResource.java | 4 +- .../web/rest/ClientForwardController.java | 17 - .../sample/web/rest/LabelResource.java | 4 +- .../sample/web/rest/OperationResource.java | 7 +- .../sample/web/rest/PublicUserResource.java | 2 +- .../sample/web/rest/UserJWTController.java | 68 - .../sample/web/rest/UserResource.java | 10 +- .../rest/errors/BadRequestAlertException.java | 30 +- .../web/rest/errors/ExceptionTranslator.java | 331 ++-- .../rest/errors/InvalidPasswordException.java | 18 +- .../sample/web/rest/errors/package-info.java | 4 +- .../sample/web/rest/package-info.java | 2 +- .../jhipster/sample/web/rest/vm/LoginVM.java | 4 +- .../sample/web/rest/vm/ManagedUserVM.java | 2 +- .../sample/web/rest/vm/package-info.java | 2 +- src/main/resources/banner.txt | 2 +- src/main/resources/config/application-dev.yml | 5 +- .../resources/config/application-prod.yml | 12 +- src/main/resources/config/application.yml | 45 +- .../00000000000000_initial_schema.xml | 16 +- ...8_added_entity_constraints_BankAccount.xml | 3 +- ...054_added_entity_constraints_Operation.xml | 9 +- .../liquibase/fake-data/bank_account.csv | 20 +- .../config/liquibase/fake-data/label.csv | 20 +- .../config/liquibase/fake-data/operation.csv | 20 +- .../resources/config/liquibase/master.xml | 6 +- src/main/resources/config/tls/keystore.p12 | Bin 2768 -> 2752 bytes src/main/resources/logback-spring.xml | 49 +- src/main/resources/templates/error.html | 6 +- .../templates/mail/activationEmail.html | 2 +- .../templates/mail/creationEmail.html | 2 +- .../templates/mail/passwordResetEmail.html | 2 +- src/main/webapp/404.html | 2 +- src/main/webapp/app/account/account.route.ts | 19 + .../account/activate/activate.component.html | 16 + .../activate/activate.component.spec.ts | 68 + .../account/activate/activate.component.ts | 29 + .../app/account/activate/activate.route.ts | 11 + .../account/activate/activate.service.spec.ts | 47 + .../app/account/activate/activate.service.ts | 19 + .../password-reset-finish.component.html | 137 ++ .../password-reset-finish.component.spec.ts | 97 ++ .../finish/password-reset-finish.component.ts | 71 + .../finish/password-reset-finish.route.ts | 11 + .../password-reset-finish.service.spec.ts | 44 + .../finish/password-reset-finish.service.ts | 17 + .../init/password-reset-init.component.html | 81 + .../password-reset-init.component.spec.ts | 62 + .../init/password-reset-init.component.ts | 36 + .../init/password-reset-init.route.ts | 11 + .../init/password-reset-init.service.spec.ts | 43 + .../init/password-reset-init.service.ts | 17 + .../password-strength-bar.component.html | 10 + .../password-strength-bar.component.scss | 23 + .../password-strength-bar.component.spec.ts | 46 + .../password-strength-bar.component.ts | 79 + .../account/password/password.component.html | 152 ++ .../password/password.component.spec.ts | 103 ++ .../account/password/password.component.ts | 58 + .../app/account/password/password.route.ts | 13 + .../account/password/password.service.spec.ts | 44 + .../app/account/password/password.service.ts | 17 + .../account/register/register.component.html | 234 +++ .../register/register.component.spec.ts | 134 ++ .../account/register/register.component.ts | 89 + .../app/account/register/register.model.ts | 8 + .../app/account/register/register.route.ts | 11 + .../account/register/register.service.spec.ts | 48 + .../app/account/register/register.service.ts | 18 + .../account/settings/settings.component.html | 166 ++ .../settings/settings.component.spec.ts | 90 + .../account/settings/settings.component.ts | 70 + .../app/account/settings/settings.route.ts | 13 + .../webapp/app/admin/admin-routing.module.ts | 43 + .../configuration.component.html | 57 + .../configuration.component.spec.ts | 66 + .../configuration/configuration.component.ts | 40 + .../configuration/configuration.model.ts | 40 + .../configuration.service.spec.ts | 71 + .../configuration/configuration.service.ts | 31 + .../webapp/app/admin/docs/docs.component.html | 10 + .../webapp/app/admin/docs/docs.component.scss | 6 + .../webapp/app/admin/docs/docs.component.ts | 9 + .../app/admin/health/health.component.html | 48 + .../app/admin/health/health.component.spec.ts | 65 + .../app/admin/health/health.component.ts | 50 + .../webapp/app/admin/health/health.model.ts | 15 + .../app/admin/health/health.service.spec.ts | 48 + .../webapp/app/admin/health/health.service.ts | 18 + .../health/modal/health-modal.component.html | 36 + .../modal/health-modal.component.spec.ts | 111 ++ .../health/modal/health-modal.component.ts | 37 + src/main/webapp/app/admin/logs/log.model.ts | 18 + .../webapp/app/admin/logs/logs.component.html | 78 + .../app/admin/logs/logs.component.spec.ts | 82 + .../webapp/app/admin/logs/logs.component.ts | 65 + .../app/admin/logs/logs.service.spec.ts | 31 + .../webapp/app/admin/logs/logs.service.ts | 22 + .../jvm-memory/jvm-memory.component.html | 28 + .../blocks/jvm-memory/jvm-memory.component.ts | 22 + .../jvm-threads/jvm-threads.component.html | 55 + .../jvm-threads/jvm-threads.component.ts | 58 + .../metrics-cache.component.html | 42 + .../metrics-cache/metrics-cache.component.ts | 26 + .../metrics-datasource.component.html | 57 + .../metrics-datasource.component.ts | 26 + .../metrics-endpoints-requests.component.html | 24 + .../metrics-endpoints-requests.component.ts | 22 + .../metrics-garbagecollector.component.html | 92 + .../metrics-garbagecollector.component.ts | 22 + .../metrics-modal-threads.component.html | 90 + .../metrics-modal-threads.component.ts | 62 + .../metrics-request.component.html | 26 + .../metrics-request.component.ts | 26 + .../metrics-system.component.html | 51 + .../metrics-system.component.ts | 46 + .../app/admin/metrics/metrics.component.html | 51 + .../admin/metrics/metrics.component.spec.ts | 40 + .../app/admin/metrics/metrics.component.ts | 66 + .../webapp/app/admin/metrics/metrics.model.ts | 159 ++ .../app/admin/metrics/metrics.service.spec.ts | 81 + .../app/admin/metrics/metrics.service.ts | 22 + ...er-management-delete-dialog.component.html | 25 + ...management-delete-dialog.component.spec.ts | 51 + ...user-management-delete-dialog.component.ts | 32 + .../user-management-detail.component.html | 56 + .../user-management-detail.component.spec.ts | 56 + .../user-management-detail.component.ts | 23 + .../list/user-management.component.html | 124 ++ .../list/user-management.component.spec.ts | 103 ++ .../list/user-management.component.ts | 116 ++ .../service/user-management.service.spec.ts | 67 + .../service/user-management.service.ts | 43 + .../user-management-update.component.html | 142 ++ .../user-management-update.component.spec.ts | 94 ++ .../user-management-update.component.ts | 94 ++ .../user-management/user-management.model.ts | 31 + .../user-management/user-management.route.ts | 50 + .../webapp/app/app-page-title-strategy.ts | 20 + src/main/webapp/app/app-routing.module.ts | 55 + src/main/webapp/app/app.constants.ts | 9 + src/main/webapp/app/app.module.ts | 51 + .../webapp/app/config/authority.constants.ts | 4 + .../webapp/app/config/datepicker-adapter.ts | 20 + src/main/webapp/app/config/dayjs.ts | 12 + src/main/webapp/app/config/error.constants.ts | 3 + .../webapp/app/config/font-awesome-icons.ts | 83 + src/main/webapp/app/config/input.constants.ts | 2 + .../webapp/app/config/language.constants.ts | 8 + .../webapp/app/config/navigation.constants.ts | 5 + .../webapp/app/config/pagination.constants.ts | 3 + .../webapp/app/config/translation.config.ts | 20 + .../app/config/uib-pagination.config.ts | 14 + .../webapp/app/core/auth/account.model.ts | 12 + .../app/core/auth/account.service.spec.ts | 248 +++ .../webapp/app/core/auth/account.service.ts | 90 + .../app/core/auth/auth-jwt.service.spec.ts | 80 + .../webapp/app/core/auth/auth-jwt.service.ts | 42 + .../app/core/auth/state-storage.service.ts | 53 + .../core/auth/user-route-access.service.ts | 33 + .../config/application-config.service.spec.ts | 40 + .../core/config/application-config.service.ts | 28 + .../interceptor/auth-expired.interceptor.ts | 33 + .../app/core/interceptor/auth.interceptor.ts | 31 + .../interceptor/error-handler.interceptor.ts | 23 + src/main/webapp/app/core/interceptor/index.ts | 29 + .../interceptor/notification.interceptor.ts | 38 + .../webapp/app/core/request/request-util.ts | 23 + .../webapp/app/core/request/request.model.ts | 11 + .../app/core/util/alert.service.spec.ts | 285 ++++ .../webapp/app/core/util/alert.service.ts | 93 + .../app/core/util/data-util.service.spec.ts | 34 + .../webapp/app/core/util/data-util.service.ts | 130 ++ .../core/util/event-manager.service.spec.ts | 84 + .../app/core/util/event-manager.service.ts | 66 + .../webapp/app/core/util/operators.spec.ts | 18 + src/main/webapp/app/core/util/operators.ts | 9 + .../app/core/util/parse-links.service.spec.ts | 36 + .../app/core/util/parse-links.service.ts | 47 + .../bank-account/bank-account.model.ts | 10 + .../bank-account/bank-account.routes.ts | 45 + .../bank-account/bank-account.test-samples.ts | 30 + .../bank-account-delete-dialog.component.html | 28 + ...nk-account-delete-dialog.component.spec.ts | 62 + .../bank-account-delete-dialog.component.ts | 32 + .../detail/bank-account-detail.component.html | 42 + .../bank-account-detail.component.spec.ts | 38 + .../detail/bank-account-detail.component.ts | 22 + .../list/bank-account.component.html | 104 ++ .../list/bank-account.component.spec.ts | 79 + .../list/bank-account.component.ts | 134 ++ ...nk-account-routing-resolve.service.spec.ts | 99 ++ .../bank-account-routing-resolve.service.ts | 29 + .../service/bank-account.service.spec.ts | 204 +++ .../service/bank-account.service.ts | 82 + .../update/bank-account-form.service.spec.ts | 90 + .../update/bank-account-form.service.ts | 72 + .../update/bank-account-update.component.html | 77 + .../bank-account-update.component.spec.ts | 166 ++ .../update/bank-account-update.component.ts | 97 ++ .../app/entities/entity-navbar-items.ts | 19 + .../app/entities/entity-routing.module.ts | 26 + .../delete/label-delete-dialog.component.html | 24 + .../label-delete-dialog.component.spec.ts | 62 + .../delete/label-delete-dialog.component.ts | 32 + .../label/detail/label-detail.component.html | 32 + .../detail/label-detail.component.spec.ts | 38 + .../label/detail/label-detail.component.ts | 22 + .../webapp/app/entities/label/label.model.ts | 9 + .../webapp/app/entities/label/label.routes.ts | 45 + .../app/entities/label/label.test-samples.ts | 26 + .../entities/label/list/label.component.html | 78 + .../label/list/label.component.spec.ts | 75 + .../entities/label/list/label.component.ts | 133 ++ .../label-routing-resolve.service.spec.ts | 99 ++ .../route/label-routing-resolve.service.ts | 29 + .../label/service/label.service.spec.ts | 204 +++ .../entities/label/service/label.service.ts | 76 + .../label/update/label-form.service.spec.ts | 88 + .../label/update/label-form.service.ts | 69 + .../label/update/label-update.component.html | 56 + .../update/label-update.component.spec.ts | 124 ++ .../label/update/label-update.component.ts | 78 + .../operation-delete-dialog.component.html | 28 + .../operation-delete-dialog.component.spec.ts | 62 + .../operation-delete-dialog.component.ts | 32 + .../detail/operation-detail.component.html | 53 + .../detail/operation-detail.component.spec.ts | 38 + .../detail/operation-detail.component.ts | 22 + .../operation/list/operation.component.html | 118 ++ .../list/operation.component.spec.ts | 127 ++ .../operation/list/operation.component.ts | 185 ++ .../app/entities/operation/operation.model.ts | 14 + .../entities/operation/operation.routes.ts | 45 + .../operation/operation.test-samples.ts | 34 + .../operation-routing-resolve.service.spec.ts | 99 ++ .../operation-routing-resolve.service.ts | 29 + .../service/operation.service.spec.ts | 205 +++ .../operation/service/operation.service.ts | 129 ++ .../update/operation-form.service.spec.ts | 94 ++ .../update/operation-form.service.ts | 110 ++ .../update/operation-update.component.html | 126 ++ .../update/operation-update.component.spec.ts | 204 +++ .../update/operation-update.component.ts | 120 ++ .../webapp/app/entities/user/user.model.ts | 15 + .../app/entities/user/user.service.spec.ts | 109 ++ .../webapp/app/entities/user/user.service.ts | 48 + src/main/webapp/app/home/home.component.html | 78 + src/main/webapp/app/home/home.component.scss | 23 + .../webapp/app/home/home.component.spec.ts | 111 ++ src/main/webapp/app/home/home.component.ts | 42 + .../app/layouts/error/error.component.html | 15 + .../app/layouts/error/error.component.ts | 47 + .../webapp/app/layouts/error/error.route.ts | 31 + .../app/layouts/footer/footer.component.html | 3 + .../app/layouts/footer/footer.component.ts | 8 + .../app/layouts/main/main.component.html | 13 + .../app/layouts/main/main.component.spec.ts | 230 +++ .../webapp/app/layouts/main/main.component.ts | 37 + .../webapp/app/layouts/main/main.module.ts | 13 + .../layouts/navbar/active-menu.directive.ts | 32 + .../app/layouts/navbar/navbar-item.model.d.ts | 7 + .../app/layouts/navbar/navbar.component.html | 234 +++ .../app/layouts/navbar/navbar.component.scss | 36 + .../layouts/navbar/navbar.component.spec.ts | 96 ++ .../app/layouts/navbar/navbar.component.ts | 81 + .../profiles/page-ribbon.component.scss | 25 + .../profiles/page-ribbon.component.spec.ts | 39 + .../layouts/profiles/page-ribbon.component.ts | 27 + .../layouts/profiles/profile-info.model.ts | 15 + .../app/layouts/profiles/profile.service.ts | 44 + .../webapp/app/login/login.component.html | 57 + .../webapp/app/login/login.component.spec.ts | 152 ++ src/main/webapp/app/login/login.component.ts | 58 + src/main/webapp/app/login/login.model.ts | 7 + src/main/webapp/app/login/login.service.ts | 24 + .../shared/alert/alert-error.component.html | 7 + .../alert/alert-error.component.spec.ts | 159 ++ .../app/shared/alert/alert-error.component.ts | 121 ++ .../app/shared/alert/alert-error.model.ts | 7 + .../app/shared/alert/alert.component.html | 7 + .../app/shared/alert/alert.component.spec.ts | 44 + .../app/shared/alert/alert.component.ts | 37 + .../auth/has-any-authority.directive.spec.ts | 131 ++ .../auth/has-any-authority.directive.ts | 58 + .../webapp/app/shared/date/duration.pipe.ts | 16 + .../date/format-medium-date.pipe.spec.ts | 19 + .../shared/date/format-medium-date.pipe.ts | 13 + .../date/format-medium-datetime.pipe.spec.ts | 19 + .../date/format-medium-datetime.pipe.ts | 13 + src/main/webapp/app/shared/date/index.ts | 3 + .../app/shared/filter/filter.component.html | 12 + .../app/shared/filter/filter.component.ts | 21 + .../app/shared/filter/filter.model.spec.ts | 242 +++ .../webapp/app/shared/filter/filter.model.ts | 159 ++ src/main/webapp/app/shared/filter/index.ts | 2 + .../language/find-language-from-key.pipe.ts | 16 + src/main/webapp/app/shared/language/index.ts | 2 + .../language/translate.directive.spec.ts | 35 + .../shared/language/translate.directive.ts | 55 + .../app/shared/language/translation.module.ts | 32 + .../webapp/app/shared/pagination/index.ts | 1 + .../pagination/item-count.component.spec.ts | 67 + .../shared/pagination/item-count.component.ts | 34 + src/main/webapp/app/shared/shared.module.ts | 28 + src/main/webapp/app/shared/sort/index.ts | 2 + .../app/shared/sort/sort-by.directive.spec.ts | 140 ++ .../app/shared/sort/sort-by.directive.ts | 56 + .../app/shared/sort/sort.directive.spec.ts | 87 + .../webapp/app/shared/sort/sort.directive.ts | 40 + .../webapp/app/shared/sort/sort.service.ts | 13 + src/main/webapp/bootstrap.ts | 16 + .../content/scss/_bootstrap-variables.scss | 45 + src/main/webapp/content/scss/global.scss | 239 +++ src/main/webapp/content/scss/vendor.scss | 12 + src/main/webapp/declarations.d.ts | 2 + src/main/webapp/i18n/en/global.json | 4 +- src/main/webapp/i18n/en/health.json | 1 + src/main/webapp/i18n/en/reset.json | 2 +- src/main/webapp/index.html | 34 +- src/main/webapp/main.ts | 1 + src/main/webapp/swagger-ui/index.html | 15 +- .../simulations/BankAccountGatlingTest.scala | 97 -- .../simulations/LabelGatlingTest.scala | 96 -- .../simulations/OperationGatlingTest.scala | 98 -- .../simulations/BankAccountGatlingTest.java | 96 ++ .../gatling/simulations/LabelGatlingTest.java | 96 ++ .../simulations/OperationGatlingTest.java | 104 ++ .../jhipster/sample/IntegrationTest.java | 1 - .../sample/TechnicalStructureTest.java | 3 +- .../sample/config/AsyncSyncConfiguration.java | 1 - .../config/PostgreSqlTestContainer.java | 2 +- ...tainersSpringContextCustomizerFactory.java | 52 + ...tainersSpringContextCustomizerFactory.java | 57 - .../sample/config/WebConfigurerTest.java | 3 +- .../config/timezone/HibernateTimeZoneIT.java | 64 +- .../repository/timezone/DateTimeWrapper.java | 2 +- .../jwt/AuthenticationIntegrationTest.java | 35 + .../sample/security/jwt/JWTFilterTest.java | 117 -- .../jwt/JwtAuthenticationTestUtils.java | 106 ++ .../security/jwt/TokenAuthenticationIT.java | 53 + .../TokenAuthenticationSecurityMetersIT.java | 87 + .../jwt/TokenProviderSecurityMetersTests.java | 158 -- .../security/jwt/TokenProviderTest.java | 141 -- .../sample/service/MailServiceIT.java | 14 +- .../sample/service/UserServiceIT.java | 4 - .../sample/web/filter/SpaWebFilterIT.java | 88 + .../sample/web/rest/AccountResourceIT.java | 36 +- ...rIT.java => AuthenticateControllerIT.java} | 4 +- .../web/rest/BankAccountResourceIT.java | 11 +- .../web/rest/ClientForwardControllerTest.java | 68 - .../sample/web/rest/LabelResourceIT.java | 4 +- .../sample/web/rest/OperationResourceIT.java | 9 +- .../sample/web/rest/PublicUserResourceIT.java | 4 +- .../jhipster/sample/web/rest/TestUtil.java | 20 +- .../sample/web/rest/UserResourceIT.java | 249 ++- .../rest/errors/ExceptionTranslatorIT.java | 3 +- .../ExceptionTranslatorTestController.java | 4 +- src/test/javascript/cypress/.eslintrc.json | 3 +- .../cypress/e2e/account/password-page.cy.ts | 19 +- .../cypress/e2e/account/register-page.cy.ts | 47 +- .../e2e/account/reset-password-page.cy.ts | 3 +- .../cypress/e2e/account/settings-page.cy.ts | 79 +- .../cypress/e2e/entity/bank-account.cy.ts | 10 +- .../javascript/cypress/e2e/entity/label.cy.ts | 7 +- .../cypress/e2e/entity/operation.cy.ts | 14 +- src/test/javascript/cypress/plugins/index.ts | 18 +- .../javascript/cypress/support/account.ts | 27 + .../javascript/cypress/support/commands.ts | 26 +- src/test/javascript/cypress/support/entity.ts | 3 +- src/test/javascript/cypress/support/index.ts | 1 + src/test/javascript/cypress/support/navbar.ts | 21 +- src/test/javascript/cypress/tsconfig.json | 3 +- src/test/resources/META-INF/spring.factories | 3 +- .../resources/config/application-testdev.yml | 9 +- .../resources/config/application-testprod.yml | 7 +- src/test/resources/config/application.yml | 6 +- .../resources/i18n/messages_en.properties | 0 src/test/resources/junit-platform.properties | 0 src/test/resources/logback.xml | 16 +- .../templates/mail/activationEmail.html | 2 +- .../templates/mail/creationEmail.html | 2 +- .../templates/mail/passwordResetEmail.html | 2 +- .../resources/templates/mail/testEmail.html | 0 src/test/resources/testcontainers.properties | 1 - tsconfig.app.json | 9 + tsconfig.json | 35 + tsconfig.spec.json | 9 + webpack/proxy.conf.js | 3 +- webpack/webpack.custom.js | 16 +- 461 files changed, 19650 insertions(+), 3038 deletions(-) create mode 100644 .eslintrc.json mode change 100644 => 100755 .prettierignore create mode 100644 angular.json create mode 100644 jest.conf.js create mode 100644 ngsw-config.json mode change 100644 => 100755 src/main/docker/jib/entrypoint.sh create mode 100644 src/main/docker/services.yml create mode 100644 src/main/java/io/github/jhipster/sample/aop/logging/package-info.java create mode 100644 src/main/java/io/github/jhipster/sample/config/SecurityJwtConfiguration.java create mode 100644 src/main/java/io/github/jhipster/sample/management/package-info.java create mode 100644 src/main/java/io/github/jhipster/sample/package-info.java delete mode 100644 src/main/java/io/github/jhipster/sample/security/jwt/JWTConfigurer.java delete mode 100644 src/main/java/io/github/jhipster/sample/security/jwt/JWTFilter.java delete mode 100644 src/main/java/io/github/jhipster/sample/security/jwt/TokenProvider.java create mode 100644 src/main/java/io/github/jhipster/sample/web/filter/SpaWebFilter.java create mode 100644 src/main/java/io/github/jhipster/sample/web/filter/package-info.java create mode 100644 src/main/java/io/github/jhipster/sample/web/rest/AuthenticateController.java delete mode 100644 src/main/java/io/github/jhipster/sample/web/rest/ClientForwardController.java delete mode 100644 src/main/java/io/github/jhipster/sample/web/rest/UserJWTController.java mode change 100644 => 100755 src/main/resources/templates/mail/activationEmail.html mode change 100644 => 100755 src/main/resources/templates/mail/creationEmail.html mode change 100644 => 100755 src/main/resources/templates/mail/passwordResetEmail.html create mode 100644 src/main/webapp/app/account/account.route.ts create mode 100644 src/main/webapp/app/account/activate/activate.component.html create mode 100644 src/main/webapp/app/account/activate/activate.component.spec.ts create mode 100644 src/main/webapp/app/account/activate/activate.component.ts create mode 100644 src/main/webapp/app/account/activate/activate.route.ts create mode 100644 src/main/webapp/app/account/activate/activate.service.spec.ts create mode 100644 src/main/webapp/app/account/activate/activate.service.ts create mode 100644 src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.html create mode 100644 src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.spec.ts create mode 100644 src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.ts create mode 100644 src/main/webapp/app/account/password-reset/finish/password-reset-finish.route.ts create mode 100644 src/main/webapp/app/account/password-reset/finish/password-reset-finish.service.spec.ts create mode 100644 src/main/webapp/app/account/password-reset/finish/password-reset-finish.service.ts create mode 100644 src/main/webapp/app/account/password-reset/init/password-reset-init.component.html create mode 100644 src/main/webapp/app/account/password-reset/init/password-reset-init.component.spec.ts create mode 100644 src/main/webapp/app/account/password-reset/init/password-reset-init.component.ts create mode 100644 src/main/webapp/app/account/password-reset/init/password-reset-init.route.ts create mode 100644 src/main/webapp/app/account/password-reset/init/password-reset-init.service.spec.ts create mode 100644 src/main/webapp/app/account/password-reset/init/password-reset-init.service.ts create mode 100644 src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.html create mode 100644 src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.scss create mode 100644 src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.spec.ts create mode 100644 src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.ts create mode 100644 src/main/webapp/app/account/password/password.component.html create mode 100644 src/main/webapp/app/account/password/password.component.spec.ts create mode 100644 src/main/webapp/app/account/password/password.component.ts create mode 100644 src/main/webapp/app/account/password/password.route.ts create mode 100644 src/main/webapp/app/account/password/password.service.spec.ts create mode 100644 src/main/webapp/app/account/password/password.service.ts create mode 100644 src/main/webapp/app/account/register/register.component.html create mode 100644 src/main/webapp/app/account/register/register.component.spec.ts create mode 100644 src/main/webapp/app/account/register/register.component.ts create mode 100644 src/main/webapp/app/account/register/register.model.ts create mode 100644 src/main/webapp/app/account/register/register.route.ts create mode 100644 src/main/webapp/app/account/register/register.service.spec.ts create mode 100644 src/main/webapp/app/account/register/register.service.ts create mode 100644 src/main/webapp/app/account/settings/settings.component.html create mode 100644 src/main/webapp/app/account/settings/settings.component.spec.ts create mode 100644 src/main/webapp/app/account/settings/settings.component.ts create mode 100644 src/main/webapp/app/account/settings/settings.route.ts create mode 100644 src/main/webapp/app/admin/admin-routing.module.ts create mode 100644 src/main/webapp/app/admin/configuration/configuration.component.html create mode 100644 src/main/webapp/app/admin/configuration/configuration.component.spec.ts create mode 100644 src/main/webapp/app/admin/configuration/configuration.component.ts create mode 100644 src/main/webapp/app/admin/configuration/configuration.model.ts create mode 100644 src/main/webapp/app/admin/configuration/configuration.service.spec.ts create mode 100644 src/main/webapp/app/admin/configuration/configuration.service.ts create mode 100644 src/main/webapp/app/admin/docs/docs.component.html create mode 100644 src/main/webapp/app/admin/docs/docs.component.scss create mode 100644 src/main/webapp/app/admin/docs/docs.component.ts create mode 100644 src/main/webapp/app/admin/health/health.component.html create mode 100644 src/main/webapp/app/admin/health/health.component.spec.ts create mode 100644 src/main/webapp/app/admin/health/health.component.ts create mode 100644 src/main/webapp/app/admin/health/health.model.ts create mode 100644 src/main/webapp/app/admin/health/health.service.spec.ts create mode 100644 src/main/webapp/app/admin/health/health.service.ts create mode 100644 src/main/webapp/app/admin/health/modal/health-modal.component.html create mode 100644 src/main/webapp/app/admin/health/modal/health-modal.component.spec.ts create mode 100644 src/main/webapp/app/admin/health/modal/health-modal.component.ts create mode 100644 src/main/webapp/app/admin/logs/log.model.ts create mode 100644 src/main/webapp/app/admin/logs/logs.component.html create mode 100644 src/main/webapp/app/admin/logs/logs.component.spec.ts create mode 100644 src/main/webapp/app/admin/logs/logs.component.ts create mode 100644 src/main/webapp/app/admin/logs/logs.service.spec.ts create mode 100644 src/main/webapp/app/admin/logs/logs.service.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.ts create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.html create mode 100644 src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics.component.html create mode 100644 src/main/webapp/app/admin/metrics/metrics.component.spec.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics.component.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics.model.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics.service.spec.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics.service.ts create mode 100644 src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.html create mode 100644 src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.spec.ts create mode 100644 src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.ts create mode 100644 src/main/webapp/app/admin/user-management/detail/user-management-detail.component.html create mode 100644 src/main/webapp/app/admin/user-management/detail/user-management-detail.component.spec.ts create mode 100644 src/main/webapp/app/admin/user-management/detail/user-management-detail.component.ts create mode 100644 src/main/webapp/app/admin/user-management/list/user-management.component.html create mode 100644 src/main/webapp/app/admin/user-management/list/user-management.component.spec.ts create mode 100644 src/main/webapp/app/admin/user-management/list/user-management.component.ts create mode 100644 src/main/webapp/app/admin/user-management/service/user-management.service.spec.ts create mode 100644 src/main/webapp/app/admin/user-management/service/user-management.service.ts create mode 100644 src/main/webapp/app/admin/user-management/update/user-management-update.component.html create mode 100644 src/main/webapp/app/admin/user-management/update/user-management-update.component.spec.ts create mode 100644 src/main/webapp/app/admin/user-management/update/user-management-update.component.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management.model.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management.route.ts create mode 100644 src/main/webapp/app/app-page-title-strategy.ts create mode 100644 src/main/webapp/app/app-routing.module.ts create mode 100644 src/main/webapp/app/app.constants.ts create mode 100644 src/main/webapp/app/app.module.ts create mode 100644 src/main/webapp/app/config/authority.constants.ts create mode 100644 src/main/webapp/app/config/datepicker-adapter.ts create mode 100644 src/main/webapp/app/config/dayjs.ts create mode 100644 src/main/webapp/app/config/error.constants.ts create mode 100644 src/main/webapp/app/config/font-awesome-icons.ts create mode 100644 src/main/webapp/app/config/input.constants.ts create mode 100644 src/main/webapp/app/config/language.constants.ts create mode 100644 src/main/webapp/app/config/navigation.constants.ts create mode 100644 src/main/webapp/app/config/pagination.constants.ts create mode 100644 src/main/webapp/app/config/translation.config.ts create mode 100644 src/main/webapp/app/config/uib-pagination.config.ts create mode 100644 src/main/webapp/app/core/auth/account.model.ts create mode 100644 src/main/webapp/app/core/auth/account.service.spec.ts create mode 100644 src/main/webapp/app/core/auth/account.service.ts create mode 100644 src/main/webapp/app/core/auth/auth-jwt.service.spec.ts create mode 100644 src/main/webapp/app/core/auth/auth-jwt.service.ts create mode 100644 src/main/webapp/app/core/auth/state-storage.service.ts create mode 100644 src/main/webapp/app/core/auth/user-route-access.service.ts create mode 100644 src/main/webapp/app/core/config/application-config.service.spec.ts create mode 100644 src/main/webapp/app/core/config/application-config.service.ts create mode 100644 src/main/webapp/app/core/interceptor/auth-expired.interceptor.ts create mode 100644 src/main/webapp/app/core/interceptor/auth.interceptor.ts create mode 100644 src/main/webapp/app/core/interceptor/error-handler.interceptor.ts create mode 100644 src/main/webapp/app/core/interceptor/index.ts create mode 100644 src/main/webapp/app/core/interceptor/notification.interceptor.ts create mode 100644 src/main/webapp/app/core/request/request-util.ts create mode 100644 src/main/webapp/app/core/request/request.model.ts create mode 100644 src/main/webapp/app/core/util/alert.service.spec.ts create mode 100644 src/main/webapp/app/core/util/alert.service.ts create mode 100644 src/main/webapp/app/core/util/data-util.service.spec.ts create mode 100644 src/main/webapp/app/core/util/data-util.service.ts create mode 100644 src/main/webapp/app/core/util/event-manager.service.spec.ts create mode 100644 src/main/webapp/app/core/util/event-manager.service.ts create mode 100644 src/main/webapp/app/core/util/operators.spec.ts create mode 100644 src/main/webapp/app/core/util/operators.ts create mode 100644 src/main/webapp/app/core/util/parse-links.service.spec.ts create mode 100644 src/main/webapp/app/core/util/parse-links.service.ts create mode 100644 src/main/webapp/app/entities/bank-account/bank-account.model.ts create mode 100644 src/main/webapp/app/entities/bank-account/bank-account.routes.ts create mode 100644 src/main/webapp/app/entities/bank-account/bank-account.test-samples.ts create mode 100644 src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.html create mode 100644 src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.spec.ts create mode 100644 src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.ts create mode 100644 src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.html create mode 100644 src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.spec.ts create mode 100644 src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.ts create mode 100644 src/main/webapp/app/entities/bank-account/list/bank-account.component.html create mode 100644 src/main/webapp/app/entities/bank-account/list/bank-account.component.spec.ts create mode 100644 src/main/webapp/app/entities/bank-account/list/bank-account.component.ts create mode 100644 src/main/webapp/app/entities/bank-account/route/bank-account-routing-resolve.service.spec.ts create mode 100644 src/main/webapp/app/entities/bank-account/route/bank-account-routing-resolve.service.ts create mode 100644 src/main/webapp/app/entities/bank-account/service/bank-account.service.spec.ts create mode 100644 src/main/webapp/app/entities/bank-account/service/bank-account.service.ts create mode 100644 src/main/webapp/app/entities/bank-account/update/bank-account-form.service.spec.ts create mode 100644 src/main/webapp/app/entities/bank-account/update/bank-account-form.service.ts create mode 100644 src/main/webapp/app/entities/bank-account/update/bank-account-update.component.html create mode 100644 src/main/webapp/app/entities/bank-account/update/bank-account-update.component.spec.ts create mode 100644 src/main/webapp/app/entities/bank-account/update/bank-account-update.component.ts create mode 100644 src/main/webapp/app/entities/entity-navbar-items.ts create mode 100644 src/main/webapp/app/entities/entity-routing.module.ts create mode 100644 src/main/webapp/app/entities/label/delete/label-delete-dialog.component.html create mode 100644 src/main/webapp/app/entities/label/delete/label-delete-dialog.component.spec.ts create mode 100644 src/main/webapp/app/entities/label/delete/label-delete-dialog.component.ts create mode 100644 src/main/webapp/app/entities/label/detail/label-detail.component.html create mode 100644 src/main/webapp/app/entities/label/detail/label-detail.component.spec.ts create mode 100644 src/main/webapp/app/entities/label/detail/label-detail.component.ts create mode 100644 src/main/webapp/app/entities/label/label.model.ts create mode 100644 src/main/webapp/app/entities/label/label.routes.ts create mode 100644 src/main/webapp/app/entities/label/label.test-samples.ts create mode 100644 src/main/webapp/app/entities/label/list/label.component.html create mode 100644 src/main/webapp/app/entities/label/list/label.component.spec.ts create mode 100644 src/main/webapp/app/entities/label/list/label.component.ts create mode 100644 src/main/webapp/app/entities/label/route/label-routing-resolve.service.spec.ts create mode 100644 src/main/webapp/app/entities/label/route/label-routing-resolve.service.ts create mode 100644 src/main/webapp/app/entities/label/service/label.service.spec.ts create mode 100644 src/main/webapp/app/entities/label/service/label.service.ts create mode 100644 src/main/webapp/app/entities/label/update/label-form.service.spec.ts create mode 100644 src/main/webapp/app/entities/label/update/label-form.service.ts create mode 100644 src/main/webapp/app/entities/label/update/label-update.component.html create mode 100644 src/main/webapp/app/entities/label/update/label-update.component.spec.ts create mode 100644 src/main/webapp/app/entities/label/update/label-update.component.ts create mode 100644 src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.html create mode 100644 src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.spec.ts create mode 100644 src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.ts create mode 100644 src/main/webapp/app/entities/operation/detail/operation-detail.component.html create mode 100644 src/main/webapp/app/entities/operation/detail/operation-detail.component.spec.ts create mode 100644 src/main/webapp/app/entities/operation/detail/operation-detail.component.ts create mode 100644 src/main/webapp/app/entities/operation/list/operation.component.html create mode 100644 src/main/webapp/app/entities/operation/list/operation.component.spec.ts create mode 100644 src/main/webapp/app/entities/operation/list/operation.component.ts create mode 100644 src/main/webapp/app/entities/operation/operation.model.ts create mode 100644 src/main/webapp/app/entities/operation/operation.routes.ts create mode 100644 src/main/webapp/app/entities/operation/operation.test-samples.ts create mode 100644 src/main/webapp/app/entities/operation/route/operation-routing-resolve.service.spec.ts create mode 100644 src/main/webapp/app/entities/operation/route/operation-routing-resolve.service.ts create mode 100644 src/main/webapp/app/entities/operation/service/operation.service.spec.ts create mode 100644 src/main/webapp/app/entities/operation/service/operation.service.ts create mode 100644 src/main/webapp/app/entities/operation/update/operation-form.service.spec.ts create mode 100644 src/main/webapp/app/entities/operation/update/operation-form.service.ts create mode 100644 src/main/webapp/app/entities/operation/update/operation-update.component.html create mode 100644 src/main/webapp/app/entities/operation/update/operation-update.component.spec.ts create mode 100644 src/main/webapp/app/entities/operation/update/operation-update.component.ts create mode 100644 src/main/webapp/app/entities/user/user.model.ts create mode 100644 src/main/webapp/app/entities/user/user.service.spec.ts create mode 100644 src/main/webapp/app/entities/user/user.service.ts create mode 100644 src/main/webapp/app/home/home.component.html create mode 100644 src/main/webapp/app/home/home.component.scss create mode 100644 src/main/webapp/app/home/home.component.spec.ts create mode 100644 src/main/webapp/app/home/home.component.ts create mode 100644 src/main/webapp/app/layouts/error/error.component.html create mode 100644 src/main/webapp/app/layouts/error/error.component.ts create mode 100644 src/main/webapp/app/layouts/error/error.route.ts create mode 100644 src/main/webapp/app/layouts/footer/footer.component.html create mode 100644 src/main/webapp/app/layouts/footer/footer.component.ts create mode 100644 src/main/webapp/app/layouts/main/main.component.html create mode 100644 src/main/webapp/app/layouts/main/main.component.spec.ts create mode 100644 src/main/webapp/app/layouts/main/main.component.ts create mode 100644 src/main/webapp/app/layouts/main/main.module.ts create mode 100644 src/main/webapp/app/layouts/navbar/active-menu.directive.ts create mode 100644 src/main/webapp/app/layouts/navbar/navbar-item.model.d.ts create mode 100644 src/main/webapp/app/layouts/navbar/navbar.component.html create mode 100644 src/main/webapp/app/layouts/navbar/navbar.component.scss create mode 100644 src/main/webapp/app/layouts/navbar/navbar.component.spec.ts create mode 100644 src/main/webapp/app/layouts/navbar/navbar.component.ts create mode 100644 src/main/webapp/app/layouts/profiles/page-ribbon.component.scss create mode 100644 src/main/webapp/app/layouts/profiles/page-ribbon.component.spec.ts create mode 100644 src/main/webapp/app/layouts/profiles/page-ribbon.component.ts create mode 100644 src/main/webapp/app/layouts/profiles/profile-info.model.ts create mode 100644 src/main/webapp/app/layouts/profiles/profile.service.ts create mode 100644 src/main/webapp/app/login/login.component.html create mode 100644 src/main/webapp/app/login/login.component.spec.ts create mode 100644 src/main/webapp/app/login/login.component.ts create mode 100644 src/main/webapp/app/login/login.model.ts create mode 100644 src/main/webapp/app/login/login.service.ts create mode 100644 src/main/webapp/app/shared/alert/alert-error.component.html create mode 100644 src/main/webapp/app/shared/alert/alert-error.component.spec.ts create mode 100644 src/main/webapp/app/shared/alert/alert-error.component.ts create mode 100644 src/main/webapp/app/shared/alert/alert-error.model.ts create mode 100644 src/main/webapp/app/shared/alert/alert.component.html create mode 100644 src/main/webapp/app/shared/alert/alert.component.spec.ts create mode 100644 src/main/webapp/app/shared/alert/alert.component.ts create mode 100644 src/main/webapp/app/shared/auth/has-any-authority.directive.spec.ts create mode 100644 src/main/webapp/app/shared/auth/has-any-authority.directive.ts create mode 100644 src/main/webapp/app/shared/date/duration.pipe.ts create mode 100644 src/main/webapp/app/shared/date/format-medium-date.pipe.spec.ts create mode 100644 src/main/webapp/app/shared/date/format-medium-date.pipe.ts create mode 100644 src/main/webapp/app/shared/date/format-medium-datetime.pipe.spec.ts create mode 100644 src/main/webapp/app/shared/date/format-medium-datetime.pipe.ts create mode 100644 src/main/webapp/app/shared/date/index.ts create mode 100644 src/main/webapp/app/shared/filter/filter.component.html create mode 100644 src/main/webapp/app/shared/filter/filter.component.ts create mode 100644 src/main/webapp/app/shared/filter/filter.model.spec.ts create mode 100644 src/main/webapp/app/shared/filter/filter.model.ts create mode 100644 src/main/webapp/app/shared/filter/index.ts create mode 100644 src/main/webapp/app/shared/language/find-language-from-key.pipe.ts create mode 100644 src/main/webapp/app/shared/language/index.ts create mode 100644 src/main/webapp/app/shared/language/translate.directive.spec.ts create mode 100644 src/main/webapp/app/shared/language/translate.directive.ts create mode 100644 src/main/webapp/app/shared/language/translation.module.ts create mode 100644 src/main/webapp/app/shared/pagination/index.ts create mode 100644 src/main/webapp/app/shared/pagination/item-count.component.spec.ts create mode 100644 src/main/webapp/app/shared/pagination/item-count.component.ts create mode 100644 src/main/webapp/app/shared/shared.module.ts create mode 100644 src/main/webapp/app/shared/sort/index.ts create mode 100644 src/main/webapp/app/shared/sort/sort-by.directive.spec.ts create mode 100644 src/main/webapp/app/shared/sort/sort-by.directive.ts create mode 100644 src/main/webapp/app/shared/sort/sort.directive.spec.ts create mode 100644 src/main/webapp/app/shared/sort/sort.directive.ts create mode 100644 src/main/webapp/app/shared/sort/sort.service.ts create mode 100644 src/main/webapp/bootstrap.ts create mode 100644 src/main/webapp/content/scss/_bootstrap-variables.scss create mode 100644 src/main/webapp/content/scss/global.scss create mode 100644 src/main/webapp/content/scss/vendor.scss create mode 100644 src/main/webapp/declarations.d.ts create mode 100644 src/main/webapp/main.ts delete mode 100644 src/test/gatling/user-files/simulations/BankAccountGatlingTest.scala delete mode 100644 src/test/gatling/user-files/simulations/LabelGatlingTest.scala delete mode 100644 src/test/gatling/user-files/simulations/OperationGatlingTest.scala create mode 100644 src/test/java/gatling/simulations/BankAccountGatlingTest.java create mode 100644 src/test/java/gatling/simulations/LabelGatlingTest.java create mode 100644 src/test/java/gatling/simulations/OperationGatlingTest.java create mode 100644 src/test/java/io/github/jhipster/sample/config/SqlTestContainersSpringContextCustomizerFactory.java delete mode 100644 src/test/java/io/github/jhipster/sample/config/TestContainersSpringContextCustomizerFactory.java create mode 100644 src/test/java/io/github/jhipster/sample/security/jwt/AuthenticationIntegrationTest.java delete mode 100644 src/test/java/io/github/jhipster/sample/security/jwt/JWTFilterTest.java create mode 100644 src/test/java/io/github/jhipster/sample/security/jwt/JwtAuthenticationTestUtils.java create mode 100644 src/test/java/io/github/jhipster/sample/security/jwt/TokenAuthenticationIT.java create mode 100644 src/test/java/io/github/jhipster/sample/security/jwt/TokenAuthenticationSecurityMetersIT.java delete mode 100644 src/test/java/io/github/jhipster/sample/security/jwt/TokenProviderSecurityMetersTests.java delete mode 100644 src/test/java/io/github/jhipster/sample/security/jwt/TokenProviderTest.java create mode 100644 src/test/java/io/github/jhipster/sample/web/filter/SpaWebFilterIT.java rename src/test/java/io/github/jhipster/sample/web/rest/{UserJWTControllerIT.java => AuthenticateControllerIT.java} (97%) delete mode 100644 src/test/java/io/github/jhipster/sample/web/rest/ClientForwardControllerTest.java create mode 100644 src/test/javascript/cypress/support/account.ts mode change 100644 => 100755 src/test/resources/i18n/messages_en.properties mode change 100644 => 100755 src/test/resources/junit-platform.properties mode change 100644 => 100755 src/test/resources/templates/mail/activationEmail.html mode change 100644 => 100755 src/test/resources/templates/mail/creationEmail.html mode change 100644 => 100755 src/test/resources/templates/mail/passwordResetEmail.html mode change 100644 => 100755 src/test/resources/templates/mail/testEmail.html delete mode 100644 src/test/resources/testcontainers.properties create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.spec.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 98a7ee768..a914fd258 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/java/.devcontainer/base.Dockerfile -# [Choice] Java version (use -bullseye variants on local arm64/Apple Silicon): 11, 17, 11-bullseye, 17-bullseye, 11-buster, 17-buster -ARG VARIANT="11" +# [Choice] Java version (use -bullseye variants on local arm64/Apple Silicon): 17, 17-bullseye, 17-buster +ARG VARIANT="17" FROM mcr.microsoft.com/vscode/devcontainers/java:0-${VARIANT} # [Option] Install Maven diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8a6c221d7..68d3199e0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,35 +5,41 @@ "build": { "dockerfile": "Dockerfile", "args": { - // Update the VARIANT arg to pick a Java version: 11, 17 + // Update the VARIANT arg to pick a Java version: 17, 19 // Append -bullseye or -buster to pin to an OS version. // Use the -bullseye variants on local arm64/Apple Silicon. - "VARIANT": "11", + "VARIANT": "17-bullseye", // Options - "INSTALL_MAVEN": "true", - "INSTALL_GRADLE": "false", - "NODE_VERSION": "lts/*" + // maven and gradle wrappers are used by default, we don't need them installed globally + // "INSTALL_MAVEN": "true", + // "INSTALL_GRADLE": "false", + "NODE_VERSION": "18.17.1" } }, - // Set *default* container specific settings.json values on container create. - "settings": { - "java.home": "/docker-java-home" - }, + "customizations": { + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "java.jdt.ls.java.home": "/docker-java-home" + }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "christian-kohler.npm-intellisense", - "firsttris.vscode-jest-runner", - "ms-vscode.vscode-typescript-tslint-plugin", - "dbaeumer.vscode-eslint", - "vscjava.vscode-java-pack", - "pivotal.vscode-boot-dev-pack", - "esbenp.prettier-vscode" - ], + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "angular.ng-template", + "christian-kohler.npm-intellisense", + "firsttris.vscode-jest-runner", + "ms-vscode.vscode-typescript-tslint-plugin", + "dbaeumer.vscode-eslint", + "vscjava.vscode-java-pack", + "pivotal.vscode-boot-dev-pack", + "esbenp.prettier-vscode" + ] + } + }, // Use 'forwardPorts' to make a list of ports inside the container available locally. - "forwardPorts": [3001, 9000, 8080], + "forwardPorts": [4200, 3001, 9000, 8080], // Use 'postCreateCommand' to run commands after the container is created. // "postCreateCommand": "java -version", diff --git a/.eslintignore b/.eslintignore index 0f4bda1ad..d2162abac 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,4 +5,5 @@ webpack/ target/ build/ node/ +coverage/ postcss.config.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..9f1260c5c --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,99 @@ +{ + "parser": "@typescript-eslint/parser", + "plugins": ["@angular-eslint/eslint-plugin", "@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "plugin:@angular-eslint/recommended", + "prettier", + "eslint-config-prettier" + ], + "env": { + "browser": true, + "es6": true, + "commonjs": true + }, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module", + "project": ["./tsconfig.app.json", "./src/test/javascript/cypress/tsconfig.json", "./tsconfig.spec.json"] + }, + "rules": { + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "jhi", + "style": "kebab-case" + } + ], + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "jhi", + "style": "camelCase" + } + ], + "@angular-eslint/relative-url-prefix": "error", + "@typescript-eslint/ban-types": [ + "error", + { + "extendDefaults": true, + "types": { + "{}": false + } + } + ], + "@typescript-eslint/explicit-function-return-type": ["error", { "allowExpressions": true }], + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/member-ordering": [ + "error", + { + "default": [ + "public-static-field", + "protected-static-field", + "private-static-field", + "public-instance-field", + "protected-instance-field", + "private-instance-field", + "constructor", + "public-static-method", + "protected-static-method", + "private-static-method", + "public-instance-method", + "protected-instance-method", + "private-instance-method" + ] + } + ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-shadow": ["error"], + "@typescript-eslint/no-unnecessary-condition": "error", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/prefer-nullish-coalescing": "error", + "@typescript-eslint/prefer-optional-chain": "error", + "@typescript-eslint/unbound-method": "off", + "arrow-body-style": "error", + "curly": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "guard-for-in": "error", + "no-bitwise": "error", + "no-caller": "error", + "no-console": ["error", { "allow": ["warn", "error"] }], + "no-eval": "error", + "no-labels": "error", + "no-new": "error", + "no-new-wrappers": "error", + "object-shorthand": ["error", "always", { "avoidExplicitReturnArrows": true }], + "radix": "error", + "spaced-comment": ["warn", "always"] + } +} diff --git a/.gitignore b/.gitignore index 50f7684bb..d543a07cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,3 @@ -###################### -# Project Specific -###################### -/src/main/webapp/content/css/main.css -/target/classes/static/** -/src/test/javascript/coverage/ - ###################### # Node ###################### @@ -37,7 +30,6 @@ local.properties .settings/ .loadpath .factorypath -/src/main/resources/rebel.xml # External tool builders .externalToolBuilders/** diff --git a/.jhipster/BankAccount.json b/.jhipster/BankAccount.json index 7526e5d64..104f5a62d 100644 --- a/.jhipster/BankAccount.json +++ b/.jhipster/BankAccount.json @@ -36,5 +36,5 @@ "relationshipType": "one-to-many" } ], - "searchEngine": false + "searchEngine": "no" } diff --git a/.jhipster/Label.json b/.jhipster/Label.json index 9d886990b..b87f70b38 100644 --- a/.jhipster/Label.json +++ b/.jhipster/Label.json @@ -24,5 +24,5 @@ "relationshipType": "many-to-many" } ], - "searchEngine": false + "searchEngine": "no" } diff --git a/.jhipster/Operation.json b/.jhipster/Operation.json index 750770814..0c3a562a5 100644 --- a/.jhipster/Operation.json +++ b/.jhipster/Operation.json @@ -42,5 +42,5 @@ "relationshipType": "many-to-many" } ], - "searchEngine": false + "searchEngine": "no" } diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar index bf82ff01c6cdae4a1bb754a6e062954d77ac5c11..cb28b0e37c7d206feb564310fdeec0927af4123a 100644 GIT binary patch delta 52729 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%0DIoqqGCqH%97HM??=#psgoIz5zcKJ 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 delta 50109 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 - if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('./service-worker.js').then(function () { - console.log('Service Worker Registered'); - }); - } - +```typescript +ServiceWorkerModule.register('ngsw-worker.js', { enabled: false }), ``` -Note: [Workbox](https://developers.google.com/web/tools/workbox/) powers JHipster's service worker. It dynamically generates the `service-worker.js` file. - ### Managing dependencies For example, to add [Leaflet][] library as a runtime dependency of your application, you would run following command: @@ -83,16 +75,46 @@ npm install --save-dev --save-exact @types/leaflet ``` Then you would import the JS and CSS files specified in library's installation instructions so that [Webpack][] knows about them: +Edit [src/main/webapp/app/app.module.ts](src/main/webapp/app/app.module.ts) file: + +``` +import 'leaflet/dist/leaflet.js'; +``` + +Edit [src/main/webapp/content/scss/vendor.scss](src/main/webapp/content/scss/vendor.scss) file: + +``` +@import 'leaflet/dist/leaflet.css'; +``` + Note: There are still a few other things remaining to do for Leaflet that we won't detail here. For further instructions on how to develop with JHipster, have a look at [Using JHipster in development][]. +### Using Angular CLI + +You can also use [Angular CLI][] to generate some custom client code. + +For example, the following command: + +``` +ng generate component my-component +``` + +will generate few files: + +``` +create src/main/webapp/app/my-component/my-component.component.html +create src/main/webapp/app/my-component/my-component.component.ts +update src/main/webapp/app/app.module.ts +``` + ### JHipster Control Center JHipster Control Center can help you manage and control your application(s). You can start a local control center server (accessible on http://localhost:7419) with: ``` -docker-compose -f src/main/docker/jhipster-control-center.yml up +docker compose -f src/main/docker/jhipster-control-center.yml up ``` ## Building for production @@ -151,9 +173,13 @@ The lighthouse report is created in `target/cypress/lhreport.html` ### Other tests -Performance tests are run by [Gatling][] and written in Scala. They're located in [src/test/gatling](src/test/gatling). +Performance tests are run by [Gatling][] and written in Scala. They're located in [src/test/java/gatling/simulations](src/test/java/gatling/simulations). -To use those tests, you must install Gatling from [https://gatling.io/](https://gatling.io/). +You can execute all Gatling tests with + +``` +./mvnw gatling:test +``` For more information, refer to the [Running tests page][]. @@ -162,23 +188,30 @@ For more information, refer to the [Running tests page][]. Sonar is used to analyse code quality. You can start a local Sonar server (accessible on http://localhost:9001) with: ``` -docker-compose -f src/main/docker/sonar.yml up -d +docker compose -f src/main/docker/sonar.yml up -d ``` -Note: we have turned off authentication in [src/main/docker/sonar.yml](src/main/docker/sonar.yml) for out of the box experience while trying out SonarQube, for real use cases turn it back on. +Note: we have turned off forced authentication redirect for UI in [src/main/docker/sonar.yml](src/main/docker/sonar.yml) for out of the box experience while trying out SonarQube, for real use cases turn it back on. You can run a Sonar analysis with using the [sonar-scanner](https://docs.sonarqube.org/display/SCAN/Analyzing+with+SonarQube+Scanner) or by using the maven plugin. Then, run a Sonar analysis: ``` -./mvnw -Pprod clean verify sonar:sonar +./mvnw -Pprod clean verify sonar:sonar -Dsonar.login=admin -Dsonar.password=admin ``` If you need to re-run the Sonar phase, please be sure to specify at least the `initialize` phase since Sonar properties are loaded from the sonar-project.properties file. ``` -./mvnw initialize sonar:sonar +./mvnw initialize sonar:sonar -Dsonar.login=admin -Dsonar.password=admin +``` + +Additionally, Instead of passing `sonar.password` and `sonar.login` as CLI arguments, these parameters can be configured from [sonar-project.properties](sonar-project.properties) as shown below: + +``` +sonar.login=admin +sonar.password=admin ``` For more information, refer to the [Code quality page][]. @@ -190,13 +223,13 @@ You can use Docker to improve your JHipster development experience. A number of For example, to start a postgresql database in a docker container, run: ``` -docker-compose -f src/main/docker/postgresql.yml up -d +docker compose -f src/main/docker/postgresql.yml up -d ``` To stop it and remove the container, run: ``` -docker-compose -f src/main/docker/postgresql.yml down +docker compose -f src/main/docker/postgresql.yml down ``` You can also fully dockerize your application and all the services that it depends on. @@ -215,7 +248,7 @@ npm run java:docker:arm64 Then run: ``` -docker-compose -f src/main/docker/app.yml up -d +docker compose -f src/main/docker/app.yml up -d ``` When running Docker Desktop on MacOS Big Sur or later, consider enabling experimental `Use the new Virtualization framework` for better processing performance ([disk access performance is worse](https://github.com/docker/roadmap/issues/7)). @@ -226,20 +259,21 @@ For more information refer to [Using Docker and Docker-Compose][], this page als To configure CI for your project, run the ci-cd sub-generator (`jhipster ci-cd`), this will let you generate configuration files for a number of Continuous Integration systems. Consult the [Setting up Continuous Integration][] page for more information. -[jhipster homepage and latest documentation]: https://www.jhipster.tech -[jhipster 7.9.4 archive]: https://www.jhipster.tech/documentation-archive/v7.9.4 -[using jhipster in development]: https://www.jhipster.tech/documentation-archive/v7.9.4/development/ -[using docker and docker-compose]: https://www.jhipster.tech/documentation-archive/v7.9.4/docker-compose -[using jhipster in production]: https://www.jhipster.tech/documentation-archive/v7.9.4/production/ -[running tests page]: https://www.jhipster.tech/documentation-archive/v7.9.4/running-tests/ -[code quality page]: https://www.jhipster.tech/documentation-archive/v7.9.4/code-quality/ -[setting up continuous integration]: https://www.jhipster.tech/documentation-archive/v7.9.4/setting-up-ci/ -[node.js]: https://nodejs.org/ -[npm]: https://www.npmjs.com/ -[webpack]: https://webpack.github.io/ -[browsersync]: https://www.browsersync.io/ -[jest]: https://facebook.github.io/jest/ -[cypress]: https://www.cypress.io/ -[leaflet]: https://leafletjs.com/ -[definitelytyped]: https://definitelytyped.org/ -[gatling]: https://gatling.io/ +[JHipster Homepage and latest documentation]: https://www.jhipster.tech +[JHipster 7.9.4 archive]: https://www.jhipster.tech/documentation-archive/v7.9.4 +[Using JHipster in development]: https://www.jhipster.tech/documentation-archive/v7.9.4/development/ +[Using Docker and Docker-Compose]: https://www.jhipster.tech/documentation-archive/v7.9.4/docker-compose +[Using JHipster in production]: https://www.jhipster.tech/documentation-archive/v7.9.4/production/ +[Running tests page]: https://www.jhipster.tech/documentation-archive/v7.9.4/running-tests/ +[Code quality page]: https://www.jhipster.tech/documentation-archive/v7.9.4/code-quality/ +[Setting up Continuous Integration]: https://www.jhipster.tech/documentation-archive/v7.9.4/setting-up-ci/ +[Node.js]: https://nodejs.org/ +[NPM]: https://www.npmjs.com/ +[Webpack]: https://webpack.github.io/ +[BrowserSync]: https://www.browsersync.io/ +[Jest]: https://facebook.github.io/jest/ +[Cypress]: https://www.cypress.io/ +[Leaflet]: https://leafletjs.com/ +[DefinitelyTyped]: https://definitelytyped.org/ +[Angular CLI]: https://cli.angular.io/ +[Gatling]: https://gatling.io/ diff --git a/angular.json b/angular.json new file mode 100644 index 000000000..ad64747b4 --- /dev/null +++ b/angular.json @@ -0,0 +1,109 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "jhipster-sample-application": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + }, + "@schematics/angular:application": { + "strict": true + } + }, + "root": "", + "sourceRoot": "src/main/webapp", + "prefix": "jhi", + "architect": { + "build": { + "builder": "@angular-builders/custom-webpack:browser", + "options": { + "customWebpackConfig": { + "path": "./webpack/webpack.custom.js" + }, + "outputPath": "target/classes/static/", + "index": "src/main/webapp/index.html", + "main": "src/main/webapp/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/main/webapp/content", + "src/main/webapp/favicon.ico", + "src/main/webapp/manifest.webapp", + "src/main/webapp/robots.txt" + ], + "styles": ["src/main/webapp/content/scss/vendor.scss", "src/main/webapp/content/scss/global.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "serviceWorker": true, + "ngswConfigPath": "ngsw-config.json", + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ] + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-builders/custom-webpack:dev-server", + "options": { + "browserTarget": "jhipster-sample-application:build:development", + "port": 9060 + }, + "configurations": { + "production": { + "browserTarget": "jhipster-sample-application:build:production" + }, + "development": { + "browserTarget": "jhipster-sample-application:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular-builders/jest:run", + "options": { + "configPath": "jest.conf.js" + } + } + } + } + }, + "cli": { + "cache": { + "enabled": true, + "path": "./target/angular/", + "environment": "all" + }, + "packageManager": "npm" + } +} diff --git a/cypress-audits.config.ts b/cypress-audits.config.ts index 2664df2a6..c84b1785b 100644 --- a/cypress-audits.config.ts +++ b/cypress-audits.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'cypress'; -import { defaultConfig } from './cypress.config'; +import defaultConfig from './cypress.config'; export default defineConfig({ ...defaultConfig, diff --git a/cypress.config.ts b/cypress.config.ts index a521c1eaf..46b2d43b6 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'cypress'; -export const defaultConfig = { +export default defineConfig({ video: false, fixturesFolder: 'src/test/javascript/cypress/fixtures', screenshotsFolder: 'target/cypress/screenshots', @@ -23,8 +23,6 @@ export const defaultConfig = { baseUrl: 'http://localhost:8080/', specPattern: 'src/test/javascript/cypress/e2e/**/*.cy.ts', supportFile: 'src/test/javascript/cypress/support/index.ts', - experimentalSessionAndOrigin: true, + experimentalRunAllSpecs: true, }, -}; - -export default defineConfig(defaultConfig); +}); diff --git a/jest.conf.js b/jest.conf.js new file mode 100644 index 000000000..969b41fb8 --- /dev/null +++ b/jest.conf.js @@ -0,0 +1,29 @@ +const { pathsToModuleNameMapper } = require('ts-jest'); + +const { + compilerOptions: { paths = {}, baseUrl = './' }, +} = require('./tsconfig.json'); +const environment = require('./webpack/environment'); + +module.exports = { + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$|dayjs/esm)'], + resolver: 'jest-preset-angular/build/resolvers/ng-jest-resolver.js', + globals: { + ...environment, + }, + roots: ['', `/${baseUrl}`], + modulePaths: [`/${baseUrl}`], + setupFiles: ['jest-date-mock'], + cacheDirectory: '/target/jest-cache', + coverageDirectory: '/target/test-results/', + moduleNameMapper: pathsToModuleNameMapper(paths, { prefix: `/${baseUrl}/` }), + reporters: [ + 'default', + ['jest-junit', { outputDirectory: '/target/test-results/', outputName: 'TESTS-results-jest.xml' }], + ['jest-sonar', { outputDirectory: './target/test-results/jest', outputName: 'TESTS-results-sonar.xml' }], + ], + testMatch: ['/src/main/webapp/app/**/@(*.)@(spec.ts)'], + testEnvironmentOptions: { + url: 'https://jhipster.tech', + }, +}; diff --git a/mvnw b/mvnw index 5643201c7..8d937f4c1 100755 --- a/mvnw +++ b/mvnw @@ -19,7 +19,7 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Maven Start Up Batch script +# Apache Maven Wrapper startup batch script, version 3.2.0 # # Required ENV vars: # ------------------ @@ -27,7 +27,6 @@ # # Optional ENV vars # ----------------- -# M2_HOME - location of maven2's installed home dir # MAVEN_OPTS - parameters passed to the Java VM when running Maven # e.g. to debug Maven itself, use # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 @@ -54,7 +53,7 @@ fi cygwin=false; darwin=false; mingw=false -case "`uname`" in +case "$(uname)" in CYGWIN*) cygwin=true ;; MINGW*) mingw=true;; Darwin*) darwin=true @@ -62,9 +61,9 @@ case "`uname`" in # See https://developer.apple.com/library/mac/qa/qa1170/_index.html if [ -z "$JAVA_HOME" ]; then if [ -x "/usr/libexec/java_home" ]; then - export JAVA_HOME="`/usr/libexec/java_home`" + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME else - export JAVA_HOME="/Library/Java/Home" + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME fi fi ;; @@ -72,68 +71,38 @@ esac if [ -z "$JAVA_HOME" ] ; then if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=`java-config --jre-home` + JAVA_HOME=$(java-config --jre-home) fi fi -if [ -z "$M2_HOME" ] ; then - ## resolve links - $0 may be a link to maven's home - PRG="$0" - - # need this for relative symlinks - while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG="`dirname "$PRG"`/$link" - fi - done - - saveddir=`pwd` - - M2_HOME=`dirname "$PRG"`/.. - - # make it fully qualified - M2_HOME=`cd "$M2_HOME" && pwd` - - cd "$saveddir" - # echo Using m2 at $M2_HOME -fi - # For Cygwin, ensure paths are in UNIX format before anything is touched if $cygwin ; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --unix "$M2_HOME"` [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --unix "$CLASSPATH"` + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") fi # For Mingw, ensure paths are in UNIX format before anything is touched if $mingw ; then - [ -n "$M2_HOME" ] && - M2_HOME="`(cd "$M2_HOME"; pwd)`" - [ -n "$JAVA_HOME" ] && - JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" fi if [ -z "$JAVA_HOME" ]; then - javaExecutable="`which javac`" - if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then # readlink(1) is not available as standard on Solaris 10. - readLink=`which readlink` - if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then if $darwin ; then - javaHome="`dirname \"$javaExecutable\"`" - javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" else - javaExecutable="`readlink -f \"$javaExecutable\"`" + javaExecutable="$(readlink -f "\"$javaExecutable\"")" fi - javaHome="`dirname \"$javaExecutable\"`" - javaHome=`expr "$javaHome" : '\(.*\)/bin'` + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') JAVA_HOME="$javaHome" export JAVA_HOME fi @@ -149,7 +118,7 @@ if [ -z "$JAVACMD" ] ; then JAVACMD="$JAVA_HOME/bin/java" fi else - JAVACMD="`\\unset -f command; \\command -v java`" + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" fi fi @@ -163,12 +132,9 @@ if [ -z "$JAVA_HOME" ] ; then echo "Warning: JAVA_HOME environment variable is not set." fi -CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher - # traverses directory structure from process work directory to filesystem root # first directory with .mvn subdirectory is considered project base directory find_maven_basedir() { - if [ -z "$1" ] then echo "Path not specified to find_maven_basedir" @@ -184,96 +150,99 @@ find_maven_basedir() { fi # workaround for JBEAP-8937 (on Solaris 10/Sparc) if [ -d "${wdir}" ]; then - wdir=`cd "$wdir/.."; pwd` + wdir=$(cd "$wdir/.." || exit 1; pwd) fi # end of workaround done - echo "${basedir}" + printf '%s' "$(cd "$basedir" || exit 1; pwd)" } # concatenates all lines of a file concat_lines() { if [ -f "$1" ]; then - echo "$(tr -s '\n' ' ' < "$1")" + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" fi } -BASE_DIR=`find_maven_basedir "$(pwd)"` +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") if [ -z "$BASE_DIR" ]; then exit 1; fi +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + ########################################################################################## # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central # This allows using the maven wrapper in projects that prohibit checking in binary data. ########################################################################################## -if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found .mvn/wrapper/maven-wrapper.jar" - fi +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" else - if [ "$MVNW_VERBOSE" = true ]; then - echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." - fi + log "Couldn't find $wrapperJarPath, downloading it ..." + if [ -n "$MVNW_REPOURL" ]; then - jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" else - jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" fi - while IFS="=" read key value; do - case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; esac - done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" - if [ "$MVNW_VERBOSE" = true ]; then - echo "Downloading from: $jarUrl" - fi - wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + if $cygwin; then - wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") fi if command -v wget > /dev/null; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found wget ... using wget" - fi + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" else - wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" fi elif command -v curl > /dev/null; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found curl ... using curl" - fi + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl -o "$wrapperJarPath" "$jarUrl" -f + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" else - curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" fi - else - if [ "$MVNW_VERBOSE" = true ]; then - echo "Falling back to using Java to download" - fi - javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" # For Cygwin, switch paths to Windows format before running javac if $cygwin; then - javaClass=`cygpath --path --windows "$javaClass"` + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") fi - if [ -e "$javaClass" ]; then - if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then - if [ "$MVNW_VERBOSE" = true ]; then - echo " - Compiling MavenWrapperDownloader.java ..." - fi - # Compiling the Java class - ("$JAVA_HOME/bin/javac" "$javaClass") + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") fi - if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then - # Running the downloader - if [ "$MVNW_VERBOSE" = true ]; then - echo " - Running MavenWrapperDownloader.java ..." - fi - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" fi fi fi @@ -282,35 +251,58 @@ fi # End of extension ########################################################################################## -export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} -if [ "$MVNW_VERBOSE" = true ]; then - echo $MAVEN_PROJECTBASEDIR +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi fi + MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" # For Cygwin, switch paths to Windows format before running java if $cygwin; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --path --windows "$M2_HOME"` [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") fi # Provide a "standardized" way to retrieve the CLI args that will # work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" export MAVEN_CMD_LINE_ARGS WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain +# shellcheck disable=SC2086 # safe args exec "$JAVACMD" \ $MAVEN_OPTS \ $MAVEN_DEBUG_OPTS \ -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.home=${M2_HOME}" \ "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd index 8a15b7f31..c4586b564 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -18,13 +18,12 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Maven Start Up Batch script +@REM Apache Maven Wrapper startup batch script, version 3.2.0 @REM @REM Required ENV vars: @REM JAVA_HOME - location of a JDK home dir @REM @REM Optional ENV vars -@REM M2_HOME - location of maven2's installed home dir @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven @@ -120,10 +119,10 @@ SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain -set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B ) @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central @@ -134,11 +133,11 @@ if exist %WRAPPER_JAR% ( ) ) else ( if not "%MVNW_REPOURL%" == "" ( - SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" ) if "%MVNW_VERBOSE%" == "true" ( echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %DOWNLOAD_URL% + echo Downloading from: %WRAPPER_URL% ) powershell -Command "&{"^ @@ -146,7 +145,7 @@ if exist %WRAPPER_JAR% ( "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ "}" if "%MVNW_VERBOSE%" == "true" ( echo Finished downloading %WRAPPER_JAR% @@ -154,6 +153,24 @@ if exist %WRAPPER_JAR% ( ) @REM End of extension +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + @REM Provide a "standardized" way to retrieve the CLI args that will @REM work with both Windows and non-Windows executions. set MAVEN_CMD_LINE_ARGS=%* diff --git a/ngsw-config.json b/ngsw-config.json new file mode 100644 index 000000000..8d5760288 --- /dev/null +++ b/ngsw-config.json @@ -0,0 +1,21 @@ +{ + "$schema": "./node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": ["/favicon.ico", "/index.html", "/manifest.webapp", "/*.css", "/*.js"] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": ["/content/**", "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"] + } + } + ] +} diff --git a/npmw.cmd b/npmw.cmd index e45f868db..b6e798095 100644 --- a/npmw.cmd +++ b/npmw.cmd @@ -17,7 +17,7 @@ if exist "%NPMW_DIR%mvnw.cmd" ( ) if not exist %NPM_EXE% ( - call %INSTALL_NPM_COMMAND% + call %INSTALL_NPM_COMMAND% ) if exist %NODE_EXE% ( diff --git a/package.json b/package.json index a159f610e..c65f10def 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,49 @@ { + "name": "jhipster-sample-application", + "version": "0.0.1-SNAPSHOT", + "private": true, + "description": "Description for Jhipster Sample Application", + "license": "UNLICENSED", "scripts": { "app:start": "./mvnw", + "app:up": "docker compose -f src/main/docker/app.yml up --wait", "backend:build-cache": "./mvnw dependency:go-offline", "backend:debug": "./mvnw -Dspring-boot.run.jvmArguments=\"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000\"", "backend:doc:test": "./mvnw -ntp javadoc:javadoc --batch-mode", "backend:info": "./mvnw -ntp enforcer:display-info --batch-mode", "backend:nohttp:test": "./mvnw -ntp checkstyle:check --batch-mode", "backend:start": "./mvnw -Dskip.installnodenpm -Dskip.npm", - "backend:unit:test": "./mvnw -ntp -Dskip.installnodenpm -Dskip.npm verify --batch-mode -Dlogging.level.ROOT=OFF -Dlogging.level.org.zalando=OFF -Dlogging.level.tech.jhipster=OFF -Dlogging.level.io.github.jhipster.sample=OFF -Dlogging.level.org.springframework=OFF -Dlogging.level.org.springframework.web=OFF -Dlogging.level.org.springframework.security=OFF", + "backend:unit:test": "./mvnw -ntp -Dskip.installnodenpm -Dskip.npm verify --batch-mode -Dlogging.level.ROOT=OFF -Dlogging.level.tech.jhipster=OFF -Dlogging.level.io.github.jhipster.sample=OFF -Dlogging.level.org.springframework=OFF -Dlogging.level.org.springframework.web=OFF -Dlogging.level.org.springframework.security=OFF", + "build": "npm run webapp:prod --", + "build-watch": "concurrently 'npm run webapp:build:dev -- --watch' npm:backend:start", "ci:backend:test": "npm run backend:info && npm run backend:doc:test && npm run backend:nohttp:test && npm run backend:unit:test -- -P$npm_package_config_default_environment", "ci:e2e:package": "npm run java:$npm_package_config_packaging:$npm_package_config_default_environment -- -Pe2e -Denforcer.skip=true", "ci:e2e:prepare": "npm run ci:e2e:prepare:docker", - "ci:e2e:prepare:docker": "npm run docker:db:up && npm run docker:others:up && docker ps -a", - "preci:e2e:server:start": "npm run docker:db:await --if-present && npm run docker:others:await --if-present", - "ci:e2e:server:start": "java -jar target/e2e.$npm_package_config_packaging --spring.profiles.active=e2e,$npm_package_config_default_environment -Dlogging.level.ROOT=OFF -Dlogging.level.org.zalando=OFF -Dlogging.level.tech.jhipster=OFF -Dlogging.level.io.github.jhipster.sample=OFF -Dlogging.level.org.springframework=OFF -Dlogging.level.org.springframework.web=OFF -Dlogging.level.org.springframework.security=OFF --logging.level.org.springframework.web=ERROR", - "ci:e2e:teardown": "npm run ci:e2e:teardown:docker", - "ci:e2e:teardown:docker": "npm run docker:db:down --if-present && npm run docker:others:down && docker ps -a", + "ci:e2e:prepare:docker": "npm run services:up --if-present && docker ps -a", + "ci:e2e:run": "concurrently -k -s first \"npm run ci:e2e:server:start\" \"npm run e2e:headless\"", + "preci:e2e:server:start": "npm run services:db:await --if-present && npm run services:others:await --if-present", + "ci:e2e:server:start": "java -jar target/e2e.$npm_package_config_packaging --spring.profiles.active=e2e,$npm_package_config_default_environment -Dlogging.level.ROOT=OFF -Dlogging.level.tech.jhipster=OFF -Dlogging.level.io.github.jhipster.sample=OFF -Dlogging.level.org.springframework=OFF -Dlogging.level.org.springframework.web=OFF -Dlogging.level.org.springframework.security=OFF --logging.level.org.springframework.web=ERROR", + "ci:e2e:teardown": "npm run ci:e2e:teardown:docker --if-present", + "ci:e2e:teardown:docker": "docker compose -f src/main/docker/services.yml down -v && docker ps -a", "ci:frontend:build": "npm run webapp:build:$npm_package_config_default_environment", "ci:frontend:test": "npm run ci:frontend:build && npm test", + "ci:server:await": "echo \"Waiting for server at port $npm_package_config_backend_port to start\" && wait-on -t 180000 http-get://localhost:$npm_package_config_backend_port/management/health && echo \"Server at port $npm_package_config_backend_port started\"", + "clean-www": "rimraf target/classes/static/app/{src,target/}", + "cleanup": "rimraf target/classes/static/", + "cypress": "cypress open --e2e", "cypress:audits": "cypress open --e2e --config-file cypress-audits.config.js", - "docker:app:up": "docker-compose -f src/main/docker/app.yml up -d", - "docker:db:down": "docker-compose -f src/main/docker/postgresql.yml down -v", - "docker:db:up": "docker-compose -f src/main/docker/postgresql.yml up -d", - "docker:others:await": "", - "docker:others:down": "", - "predocker:others:up": "", - "docker:others:up": "", - "e2e:cypress:audits": "cypress run --e2e --browser chrome --record ${CYPRESS_ENABLE_RECORD:-false} --config-file cypress-audits.config.js", + "docker:db:down": "docker compose -f src/main/docker/postgresql.yml down -v", + "docker:db:up": "docker compose -f src/main/docker/postgresql.yml up --wait", + "e2e": "npm run e2e:cypress:headed --", + "e2e:cypress": "cypress run --e2e --browser chrome", + "e2e:cypress:audits": "cypress run --e2e --browser chrome --config-file cypress-audits.config.js", "e2e:cypress:audits:headless": "npm run e2e:cypress -- --config-file cypress-audits.config.js", + "e2e:cypress:headed": "npm run e2e:cypress -- --headed", + "e2e:cypress:record": "npm run e2e:cypress -- --record", + "e2e:dev": "concurrently -k -s first \"./mvnw\" \"npm run e2e\"", + "e2e:devserver": "concurrently -k -s first \"npm run backend:start\" \"npm start\" \"wait-on -t 180000 http-get://localhost:9000 && npm run e2e:headless -- -c baseUrl=http://localhost:9000\"", + "pree2e:headless": "npm run ci:server:await", + "e2e:headless": "npm run e2e:cypress --", "java:docker": "./mvnw -ntp verify -DskipTests -Pprod jib:dockerBuild", "java:docker:arm64": "npm run java:docker -- -Djib-maven-plugin.architecture=arm64", "java:docker:dev": "npm run java:docker -- -Pdev,webapp", @@ -38,20 +54,111 @@ "java:war": "./mvnw -ntp verify -DskipTests --batch-mode -Pwar", "java:war:dev": "npm run java:war -- -Pdev,webapp", "java:war:prod": "npm run java:war -- -Pprod", - "prepare": "husky install" + "jest": "jest --coverage --logHeapUsage --maxWorkers=2 --config jest.conf.js", + "lint": "eslint . --ext .js,.ts", + "lint:fix": "npm run lint -- --fix", + "prepare": "husky install", + "prettier:check": "prettier --check \"{,src/**/,webpack/,.blueprint/**/}*.{md,json,yml,html,cjs,mjs,js,ts,tsx,css,scss,java}\"", + "prettier:format": "prettier --write \"{,src/**/,webpack/,.blueprint/**/}*.{md,json,yml,html,cjs,mjs,js,ts,tsx,css,scss,java}\"", + "serve": "npm run start --", + "services:up": "docker compose -f src/main/docker/services.yml up --wait", + "start": "ng serve --hmr", + "start-tls": "npm run webapp:dev-ssl", + "pretest": "npm run lint", + "test": "ng test --coverage --log-heap-usage -w=2", + "test:watch": "npm run test -- --watch", + "watch": "concurrently npm:start npm:backend:start", + "webapp:build": "npm run clean-www && npm run webapp:build:dev", + "webapp:build:dev": "ng build --configuration development", + "webapp:build:prod": "ng build --configuration production", + "webapp:dev": "ng serve", + "webapp:dev-ssl": "ng serve --ssl", + "webapp:dev-verbose": "ng serve --verbose", + "webapp:prod": "npm run clean-www && npm run webapp:build:prod", + "webapp:test": "npm run test --" }, "config": { "backend_port": 8080, "default_environment": "prod", "packaging": "jar" }, + "dependencies": { + "@angular/common": "16.2.3", + "@angular/compiler": "16.2.3", + "@angular/core": "16.2.3", + "@angular/forms": "16.2.3", + "@angular/localize": "16.2.3", + "@angular/platform-browser": "16.2.3", + "@angular/platform-browser-dynamic": "16.2.3", + "@angular/router": "16.2.3", + "@fortawesome/angular-fontawesome": "0.13.0", + "@fortawesome/fontawesome-svg-core": "6.4.2", + "@fortawesome/free-solid-svg-icons": "6.4.2", + "@ng-bootstrap/ng-bootstrap": "15.1.1", + "@ngx-translate/core": "15.0.0", + "@ngx-translate/http-loader": "8.0.0", + "@popperjs/core": "2.11.8", + "bootstrap": "5.3.1", + "dayjs": "1.11.9", + "ngx-infinite-scroll": "16.0.0", + "rxjs": "7.8.1", + "tslib": "2.6.2", + "zone.js": "0.13.1" + }, "devDependencies": { - "concurrently": "7.3.0", + "@angular-builders/custom-webpack": "16.0.1", + "@angular-builders/jest": "16.0.1", + "@angular-devkit/build-angular": "16.2.1", + "@angular-eslint/eslint-plugin": "16.1.1", + "@angular/cli": "16.2.1", + "@angular/compiler-cli": "16.2.3", + "@angular/service-worker": "16.2.3", + "@types/jest": "29.5.4", + "@types/node": "18.17.14", + "@typescript-eslint/eslint-plugin": "6.6.0", + "@typescript-eslint/parser": "6.6.0", + "browser-sync": "2.29.3", + "browser-sync-webpack-plugin": "2.3.0", + "buffer": "6.0.3", + "concurrently": "8.2.1", + "copy-webpack-plugin": "11.0.0", + "cypress": "13.1.0", "cypress-audit": "1.1.0", - "eslint-plugin-cypress": "2.12.1", - "husky": "7.0.4", - "lighthouse": "9.6.6", - "lint-staged": "13.0.3", - "wait-on": "6.0.1" + "eslint": "8.48.0", + "eslint-config-prettier": "9.0.0", + "eslint-plugin-cypress": "2.14.0", + "eslint-webpack-plugin": "4.0.1", + "folder-hash": "4.0.4", + "generator-jhipster": "7.9.4", + "husky": "8.0.3", + "jest": "29.6.4", + "jest-date-mock": "1.0.8", + "jest-environment-jsdom": "29.6.4", + "jest-junit": "16.0.0", + "jest-preset-angular": "13.1.1", + "jest-sonar": "0.2.16", + "lighthouse": "11.0.0", + "lint-staged": "14.0.1", + "merge-jsons-webpack-plugin": "2.0.1", + "prettier": "3.0.3", + "prettier-plugin-java": "2.3.1", + "prettier-plugin-packagejson": "2.4.5", + "rimraf": "5.0.1", + "swagger-ui-dist": "5.5.0", + "ts-jest": "29.1.1", + "typescript": "5.1.6", + "wait-on": "7.0.1", + "webpack-bundle-analyzer": "4.9.1", + "webpack-merge": "5.9.0", + "webpack-notifier": "1.15.0" + }, + "engines": { + "node": ">=18.17.1" + }, + "cacheDirectories": [ + "node_modules" + ], + "overrides": { + "webpack": "5.88.2" } } diff --git a/pom.xml b/pom.xml index 40ccfc18b..e4887f1a3 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,5 @@ - 4.0.0 @@ -10,25 +8,17 @@ 0.0.1-SNAPSHOT jar Jhipster Sample Application - Description for jhipsterSampleApplication - - - - - - - - - - + Description for Jhipster Sample Application - + 3.2.5 - 11 - v16.17.0 - 8.19.1 - + 17 + v18.17.1 + 10.0.0 UTF-8 UTF-8 yyyyMMddHHmmss @@ -38,58 +28,55 @@ -Djava.security.egd=file:/dev/./urandom -Xmx1G jdt_apt false - - - - - - - - 7.9.3 - - 2.7.3 - - 5.6.10.Final - - 4.15.0 - 4.15.0 - 2.0.1.Final - 4.0.0 - 0.22.0 - 1.5.2.Final - - 3.2.0 - 3.12.1 - 3.10.1 - 3.4.1 - 2.10 - 3.1.0 - 3.0.0-M7 - 3.2.2 - 2.2.1 - 3.3.0 - 3.0.0-M7 - 3.3.2 - 3.1.2 - 10.3.2 - 0.0.10 - 1.12.1 + 8.0.0-beta.3 + 3.1.3 + 1.1.0 + 10.12.3 1.11 - 3.1.0 - 5.0.0 - 2.4.0 - 0.8.8 - 3.2.1 - eclipse-temurin:11-jre-focal + 1.13.4 + 4.5.0 + 3.9.5 + 6.0.0 + 2.2.222 + 6.2.7.Final + 0.8.10 + 4.0.3 amd64 + eclipse-temurin:17-jre-focal + 3.3.2 1.0.0 - 1.1.0 + + + + + + 4.23.1 + 1.5.5.Final + 3.1.0 + 3.3.0 + 3.3.1 + 3.11.0 + 2.1 + 3.4.0 + 3.1.2 + 2.2.1 + 3.3.0 + 3.5.0 + 3.3.1 + 3.12.1 + 3.1.2 + 3.4.0 + 2.6.0 + 0.0.11 + + + + + + 1.2.0 3.9.1.2184 - - + 2.39.0 + 3.0.2 @@ -101,7 +88,6 @@ pom import - @@ -110,199 +96,208 @@ tech.jhipster jhipster-framework - - javax.annotation - javax.annotation-api - org.springframework.boot - spring-boot-starter-cache + spring-boot-configuration-processor + provided - com.fasterxml.jackson.module - jackson-module-jaxb-annotations + org.springframework.boot + spring-boot-loader-tools - com.fasterxml.jackson.datatype - jackson-datatype-hibernate5 + org.springframework.boot + spring-boot-starter-actuator - com.fasterxml.jackson.datatype - jackson-datatype-hppc + org.springframework.boot + spring-boot-starter-cache - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 + org.springframework.boot + spring-boot-starter-data-jpa - org.springdoc - springdoc-openapi-webmvc-core + org.springframework.boot + spring-boot-starter-logging - com.zaxxer - HikariCP + org.springframework.boot + spring-boot-starter-mail - org.apache.commons - commons-lang3 + org.springframework.boot + spring-boot-starter-oauth2-resource-server - javax.cache - cache-api + org.springframework.boot + spring-boot-starter-security - org.ehcache - ehcache + org.springframework.boot + spring-boot-starter-test + test - org.hibernate - hibernate-jcache + org.springframework.boot + spring-boot-starter-thymeleaf - org.hibernate - hibernate-jpamodelgen - provided + org.springframework.boot + spring-boot-starter-undertow - org.hibernate - hibernate-core + org.springframework.boot + spring-boot-starter-web - org.hibernate.validator - hibernate-validator + org.springframework.boot + spring-boot-test + test - org.liquibase - liquibase-core - - ${liquibase.version} + org.springframework.security + spring-security-data - org.mapstruct - mapstruct - ${mapstruct.version} + org.springframework.security + spring-security-test + test - org.mapstruct - mapstruct-processor - ${mapstruct.version} - provided + org.springdoc + springdoc-openapi-starter-webmvc-api - org.springframework.boot - spring-boot-configuration-processor - provided + com.fasterxml.jackson.datatype + jackson-datatype-hibernate6 - org.springframework.boot - spring-boot-loader-tools + com.fasterxml.jackson.datatype + jackson-datatype-hppc - org.springframework.boot - spring-boot-starter-actuator + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 - org.springframework.boot - spring-boot-starter-data-jpa + com.fasterxml.jackson.module + jackson-module-jaxb-annotations - org.testcontainers - jdbc + com.tngtech.archunit + archunit-junit5-api + ${archunit-junit5.version} test - org.springframework.boot - spring-boot-starter-logging + + + com.tngtech.archunit + archunit-junit5-engine + ${archunit-junit5.version} + test - org.springframework.boot - spring-boot-starter-mail + com.zaxxer + HikariCP - org.springframework.boot - spring-boot-starter-security + io.dropwizard.metrics + metrics-core - org.springframework.boot - spring-boot-starter-thymeleaf + io.gatling.highcharts + gatling-charts-highcharts + ${gatling.version} + test - org.springframework.boot - spring-boot-starter-web + io.micrometer + micrometer-registry-prometheus - org.springframework.boot - spring-boot-starter-test - test + jakarta.annotation + jakarta.annotation-api - org.springframework.boot - spring-boot-test - test + javax.cache + cache-api - org.springframework.security - spring-security-test - test + org.apache.commons + commons-lang3 - com.tngtech.archunit - archunit-junit5-api - ${archunit-junit5.version} - test + org.ehcache + ehcache + jakarta - - - com.tngtech.archunit - archunit-junit5-engine - ${archunit-junit5.version} - test + org.hibernate.orm + hibernate-core + ${hibernate.version} - org.zalando - problem-spring-web + org.hibernate.orm + hibernate-jcache + ${hibernate.version} - org.springframework.boot - spring-boot-starter-undertow + org.hibernate.orm + hibernate-jpamodelgen + provided - io.jsonwebtoken - jjwt-api + org.hibernate.validator + hibernate-validator - io.jsonwebtoken - jjwt-impl - runtime + org.liquibase + liquibase-core + ${liquibase.version} - io.jsonwebtoken - jjwt-jackson - runtime + org.mapstruct + mapstruct + ${mapstruct.version} - - org.springframework.security - spring-security-data + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided - io.micrometer - micrometer-registry-prometheus + org.testcontainers + jdbc + test - io.dropwizard.metrics - metrics-core + org.testcontainers + postgresql + test - spring-boot:run - org.apache.maven.plugins - maven-compiler-plugin + org.springframework.boot + spring-boot-maven-plugin + + + com.diffplug.spotless + spotless-maven-plugin + + + com.google.cloud.tools + jib-maven-plugin + + + io.gatling + gatling-maven-plugin org.apache.maven.plugins @@ -310,7 +305,7 @@ org.apache.maven.plugins - maven-javadoc-plugin + maven-compiler-plugin org.apache.maven.plugins @@ -330,27 +325,15 @@ org.apache.maven.plugins - maven-resources-plugin + maven-javadoc-plugin org.apache.maven.plugins - maven-surefire-plugin - - - org.jacoco - jacoco-maven-plugin - - - org.sonarsource.scanner.maven - sonar-maven-plugin - - - org.springframework.boot - spring-boot-maven-plugin + maven-resources-plugin - com.google.cloud.tools - jib-maven-plugin + org.apache.maven.plugins + maven-surefire-plugin org.codehaus.mojo @@ -360,107 +343,54 @@ org.gaul modernizer-maven-plugin - + + org.jacoco + jacoco-maven-plugin + + + org.sonarsource.scanner.maven + sonar-maven-plugin + - org.apache.maven.plugins - maven-checkstyle-plugin - ${maven-checkstyle-plugin.version} - - - com.puppycrawl.tools - checkstyle - ${checkstyle.version} - - - io.spring.nohttp - nohttp-checkstyle - ${nohttp-checkstyle.version} - - - - checkstyle.xml - pom.xml,README.md - .git/**/*,target/**/*,node_modules/**/*,node/**/* - ./ - + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} - check + repackage + + ${start-class} + + - org.apache.maven.plugins - maven-compiler-plugin - ${maven-compiler-plugin.version} + com.diffplug.spotless + spotless-maven-plugin + ${spotless-maven-plugin.version} - ${java.version} - ${java.version} - - - org.springframework.boot - spring-boot-configuration-processor - ${spring-boot.version} - - - org.mapstruct - mapstruct-processor - ${mapstruct.version} - - - - org.hibernate - hibernate-jpamodelgen - ${hibernate.version} - - - org.glassfish.jaxb - jaxb-runtime - ${jaxb-runtime.version} - - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - ${maven-javadoc-plugin.version} - - ${maven.compiler.source} + + + - - - org.apache.maven.plugins - maven-war-plugin - ${maven-war-plugin.version} - default-war + spotless + process-sources - war + apply - package - - WEB-INF/**,META-INF/** - false - target/classes/static/ - - - src/main/webapp - - WEB-INF/** - - - - com.github.eirslett @@ -468,100 +398,10 @@ ${frontend-maven-plugin.version} target + ${node.version} + ${npm.version} - - org.codehaus.mojo - properties-maven-plugin - ${properties-maven-plugin.version} - - - initialize - - read-project-properties - - - - sonar-project.properties - - - - - - - io.github.git-commit-id - git-commit-id-maven-plugin - ${git-commit-id-plugin.version} - - - - revision - - - - - false - false - true - - ^git.commit.id.abbrev$ - ^git.commit.id.describe$ - ^git.branch$ - - - - - org.gaul - modernizer-maven-plugin - ${modernizer-maven-plugin.version} - - - modernizer - package - - modernizer - - - - - ${java.version} - - - - org.jacoco - jacoco-maven-plugin - ${jacoco-maven-plugin.version} - - - pre-unit-tests - - prepare-agent - - - - - post-unit-test - test - - report - - - - pre-integration-tests - - prepare-agent-integration - - - - - post-integration-tests - post-integration-test - - report-integration - - - - com.google.cloud.tools jib-maven-plugin @@ -607,12 +447,101 @@ + io.gatling + gatling-maven-plugin + ${gatling-maven-plugin.version} + + true + ${project.basedir}/src/test/gatling/conf + + + + io.github.git-commit-id + git-commit-id-maven-plugin + ${git-commit-id-maven-plugin.version} + + + + revision + + + + + false + false + true + + ^git.commit.id.abbrev$ + ^git.commit.id.describe$ + ^git.branch$ + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${maven-checkstyle-plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + io.spring.nohttp + nohttp-checkstyle + ${nohttp-checkstyle.version} + + + + checkstyle.xml + pom.xml,README.md + .git/**/*,target/**/*,node_modules/**/*,node/**/* + ./ + + + + + check + + + + + + org.apache.maven.plugins maven-clean-plugin ${maven-clean-plugin.version} - maven-site-plugin - ${maven-site-plugin.version} + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${java.version} + ${java.version} + + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.glassfish.jaxb + jaxb-runtime + ${jaxb-runtime.version} + + + org.hibernate.orm + hibernate-jpamodelgen + ${hibernate.version} + + + org.apache.maven.plugins @@ -638,7 +567,7 @@ enforce-dependencyConvergence - + false @@ -654,12 +583,43 @@ [${maven.version},) - You are running an incompatible version of Java. JHipster supports JDK 11 to 18. - [11,12),[12,13),[13,14),[14,15),[15,16),[16,17),[17,18),[18,19) + You are running an incompatible version of Java. JHipster supports JDK 17 to 21. + [17,18),[18,19),[19,20),[20,21),[21,22) + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-failsafe-plugin.version} + + + + ${project.build.outputDirectory} + alphabetical + + **/*IT* + **/*IntTest* + + @{argLine} -Dspring.profiles.active=${profile.test} + + + + integration-test + + integration-test + + + + verify + + verify + + + + org.apache.maven.plugins maven-idea-plugin @@ -668,6 +628,19 @@ node_modules + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + ${maven.compiler.source} + + org.apache.maven.plugins maven-resources-plugin @@ -707,8 +680,13 @@ org.apache.maven.plugins - maven-surefire-plugin - ${maven-surefire-plugin.version} + maven-site-plugin + ${maven-site-plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} alphabetical @@ -720,46 +698,156 @@ org.apache.maven.plugins - maven-jar-plugin - ${maven-jar-plugin.version} - + maven-war-plugin + ${maven-war-plugin.version} + + + default-war + + war + + package + + + + WEB-INF/**,META-INF/** + false + target/classes/static/ + + + src/main/webapp + + WEB-INF/** + + + + + - org.sonarsource.scanner.maven - sonar-maven-plugin - ${sonar-maven-plugin.version} + org.codehaus.mojo + properties-maven-plugin + ${properties-maven-plugin.version} + + + initialize + + read-project-properties + + + + sonar-project.properties + + + + - org.springframework.boot - spring-boot-maven-plugin - ${spring-boot.version} + org.gaul + modernizer-maven-plugin + ${modernizer-maven-plugin.version} + modernizer + package - repackage + modernizer - ${start-class} - true - + ${java.version} - - + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + pre-unit-tests + + prepare-agent + + + + + post-unit-test + test + + report + + + + pre-integration-tests + + prepare-agent-integration + + + + + post-integration-tests + post-integration-test + + report-integration + + + + + + org.liquibase + liquibase-maven-plugin + ${liquibase.version} + + config/liquibase/master.xml + ${project.basedir}/src/main/resources/config/liquibase/changelog/${maven.build.timestamp}_changelog.xml + ${liquibase-plugin.driver} + ${liquibase-plugin.url} + + ${liquibase-plugin.username} + ${liquibase-plugin.password} + hibernate:spring:io.github.jhipster.sample.domain?dialect=${liquibase-plugin.hibernate-dialect}&hibernate.physical_naming_strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy&hibernate.implicit_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy + true + debug + !test + + + + org.liquibase + liquibase-core + ${liquibase.version} + + + org.liquibase.ext + liquibase-hibernate6 + ${liquibase.version} + + + org.springframework.boot + spring-boot-starter-data-jpa + ${spring-boot.version} + + + jakarta.validation + jakarta.validation-api + ${validation-api.version} + + + com.h2database + h2 + ${h2.version} + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + ${sonar-maven-plugin.version} + + - - no-liquibase - - ,no-liquibase - - api-docs @@ -767,143 +855,20 @@ - tls - - ,tls - - - - webapp + dev true - - - - net.nicoulaj.maven.plugins - checksum-maven-plugin - ${checksum-maven-plugin.version} - - - create-pre-compiled-webapp-checksum - - files - - generate-resources - - - create-compiled-webapp-checksum - - files - - compile - - checksums.csv.old - - - - - - - ${project.basedir} - - src/main/webapp/**/*.* - target/classes/static/**/*.* - package-lock.json - package.json - webpack/*.* - tsconfig.json - - - **/app/**/service-worker.js - **/app/**/vendor.css - - - - false - false - false - - SHA-1 - - true - true - - - - org.apache.maven.plugins - maven-antrun-plugin - ${maven-antrun-plugin.version} - - - eval-frontend-checksum - generate-resources - - run - - - - - - - - - - - - true - - - - - - com.github.eirslett - frontend-maven-plugin - - - install-node-and-npm - - install-node-and-npm - - - ${node.version} - ${npm.version} - - - - npm install - - npm - - - - webapp build dev - - npm - - generate-resources - - run webapp:build - - ${project.version} - - false - - - - - - - dev${profile.no-liquibase} + dev${profile.tls}${profile.no-liquibase} + testdev + org.hibernate.dialect.H2Dialect + org.h2.Driver + jdbc:h2:tcp://localhost:18080/mem:jhipstersampleapplication + jhipsterSampleApplication + - - - dev - - true - org.springframework.boot @@ -926,185 +891,140 @@ - + + + + e2e + + ,e2e + + + e2e - org.apache.maven.plugins - maven-failsafe-plugin - ${maven-failsafe-plugin.version} - - - ${project.build.outputDirectory} - - alphabetical - - **/*IT* - **/*IntTest* - - @{argLine} -Dspring.profiles.active=testdev - + org.springframework.boot + spring-boot-maven-plugin - integration-test - - integration-test - - - - verify + repackage - verify + repackage - - org.liquibase - liquibase-maven-plugin - ${liquibase.version} - - ${project.basedir}/src/main/resources/config/liquibase/master.xml - ${project.basedir}/src/main/resources/config/liquibase/changelog/${maven.build.timestamp}_changelog.xml - - - - jhipsterSampleApplication - - hibernate:spring:io.github.jhipster.sample.domain?dialect=&hibernate.physical_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy&hibernate.implicit_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy - true - debug - !test - - - - org.liquibase - liquibase-core - ${liquibase.version} - - - org.liquibase.ext - liquibase-hibernate5 - ${liquibase-hibernate5.version} - - - org.springframework.boot - spring-boot-starter-data-jpa - ${spring-boot.version} - - - javax.validation - validation-api - ${validation-api.version} - - - - - - dev${profile.tls}${profile.no-liquibase} - - prod + + eclipse + + + m2e.version + + - org.testcontainers - postgresql - test - - - org.postgresql - postgresql + + org.springframework.boot + spring-boot-starter-undertow + - org.apache.maven.plugins - maven-failsafe-plugin - ${maven-failsafe-plugin.version} - - - ${project.build.outputDirectory} - - alphabetical - - **/*IT* - **/*IntTest* - - @{argLine} -Dspring.profiles.active=testprod - - - - integration-test - - integration-test - - - - verify - - verify - - - - - - org.liquibase - liquibase-maven-plugin - ${liquibase.version} + org.eclipse.m2e + lifecycle-mapping + ${lifecycle-mapping.version} - ${project.basedir}/src/main/resources/config/liquibase/master.xml - ${project.basedir}/src/main/resources/config/liquibase/changelog/${maven.build.timestamp}_changelog.xml - org.postgresql.Driver - jdbc:postgresql://localhost:5432/jhipsterSampleApplication - - jhipsterSampleApplication - - hibernate:spring:io.github.jhipster.sample.domain?dialect=tech.jhipster.domain.util.FixedPostgreSQL10Dialect&hibernate.physical_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy&hibernate.implicit_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy - true - debug - !test + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + prepare-agent + + + + + + + + + com.github.eirslett + frontend-maven-plugin + ${frontend-maven-plugin.version} + + install-node-and-npm + npm + + + + + + + + - - - org.liquibase - liquibase-core - ${liquibase.version} - - - org.liquibase.ext - liquibase-hibernate5 - ${liquibase-hibernate5.version} - - - org.springframework.boot - spring-boot-starter-data-jpa - ${spring-boot.version} - - - javax.validation - validation-api - ${validation-api.version} - - - tech.jhipster - jhipster-framework - ${jhipster-dependencies.version} - - + + + + + IDE + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.hibernate.orm + hibernate-jpamodelgen + + + + + no-liquibase + + ,no-liquibase + + + + prod + + + prod${profile.api-docs}${profile.tls}${profile.e2e}${profile.no-liquibase} + testprod + org.hibernate.dialect.PostgreSQLDialect + org.postgresql.Driver + jdbc:postgresql://localhost:5432/jhipsterSampleApplication + jhipsterSampleApplication + + + + org.apache.maven.plugins maven-clean-plugin @@ -1134,10 +1054,6 @@ install-node-and-npm - - ${node.version} - ${npm.version} - npm install @@ -1178,9 +1094,17 @@ + + + org.postgresql + postgresql + + + + + tls - - prod${profile.api-docs}${profile.tls}${profile.e2e}${profile.no-liquibase} + ,tls @@ -1195,118 +1119,128 @@ - - IDE - - - org.mapstruct - mapstruct-processor - ${mapstruct.version} - - - org.hibernate - hibernate-jpamodelgen - - - - - - eclipse + webapp - - m2e.version - + true - - - - org.springframework.boot - spring-boot-starter-undertow - - - - - - - org.eclipse.m2e - lifecycle-mapping - ${lifecycle-mapping.version} - - - - - - org.jacoco - - jacoco-maven-plugin - - - ${jacoco-maven-plugin.version} - - - prepare-agent - - - - - - - - - com.github.eirslett - frontend-maven-plugin - ${frontend-maven-plugin.version} - - install-node-and-npm - npm - - - - - - - - - - - - + + + net.nicoulaj.maven.plugins + checksum-maven-plugin + ${checksum-maven-plugin.version} + + + create-pre-compiled-webapp-checksum + + files + + generate-resources + + + create-compiled-webapp-checksum + + files + + compile + + checksums.csv.old + + + + + + + ${project.basedir} + + src/main/webapp/**/*.* + target/classes/static/**/*.* + package-lock.json + package.json + webpack/*.* + tsconfig.json + tsconfig.app.json + + + **/app/**/service-worker.js + **/app/**/vendor.css + + + + false + false + false + + SHA-1 + + true + true + + + + org.apache.maven.plugins + maven-antrun-plugin + ${maven-antrun-plugin.version} + + + eval-frontend-checksum + generate-resources + + run + + + + + + + + + + + + true + + + + + + com.github.eirslett + frontend-maven-plugin + + + install-node-and-npm + + install-node-and-npm + + + + npm install + + npm + + + + webapp build dev + + npm + + generate-resources + + run webapp:build + + ${project.version} + + false + + + + + - - - e2e - ,e2e + + dev${profile.no-liquibase} - - e2e - - - - org.springframework.boot - spring-boot-maven-plugin - - - repackage - - repackage - - - - - - - - diff --git a/sonar-project.properties b/sonar-project.properties index 148d4a173..5b8f5be59 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,34 +1,40 @@ -sonar.projectKey=jhipsterSampleApplication -sonar.projectName=jhipsterSampleApplication generated by jhipster +sonar.projectKey = jhipsterSampleApplication +sonar.projectName = jhipsterSampleApplication generated by jhipster -# Typescript tests files must be inside sources and tests, othewise `INFO: Test execution data ignored for 80 unknown files, including:` is shown. -sonar.sources=src -sonar.tests=src -sonar.host.url=http://localhost:9001 +# Typescript tests files must be inside sources and tests, othewise `INFO: Test execution data ignored for 80 unknown files, including:` is +# shown. +sonar.sources = src +sonar.tests = src +sonar.host.url = http://localhost:9001 -sonar.test.inclusions=src/test/**/*.* -sonar.coverage.jacoco.xmlReportPaths=target/site/**/jacoco*.xml -sonar.java.codeCoveragePlugin=jacoco -sonar.junit.reportPaths=target/surefire-reports,target/failsafe-reports -sonar.testExecutionReportPaths=target/test-results/jest/TESTS-results-sonar.xml -sonar.javascript.lcov.reportPaths=target/test-results/lcov.info +sonar.test.inclusions = src/test/**/*.*, src/main/webapp/app/**/*.spec.ts +sonar.coverage.jacoco.xmlReportPaths = target/site/**/jacoco*.xml +sonar.java.codeCoveragePlugin = jacoco +sonar.junit.reportPaths = target/surefire-reports,target/failsafe-reports +sonar.testExecutionReportPaths = target/test-results/jest/TESTS-results-sonar.xml +sonar.javascript.lcov.reportPaths = target/test-results/lcov.info -sonar.sourceEncoding=UTF-8 -sonar.exclusions=src/main/webapp/content/**/*.*, src/main/webapp/i18n/*.js, target/classes/static/**/*.* +sonar.sourceEncoding = UTF-8 +sonar.exclusions = src/main/webapp/content/**/*.*, src/main/webapp/i18n/*.js, target/classes/static/**/*.* -sonar.issue.ignore.multicriteria=S3437,S4502,S4684,S5145,UndocumentedApi +sonar.issue.ignore.multicriteria = S2187,S3437,S4502,S4684,S5145,UndocumentedApi + +# Rule https://rules.sonarsource.com/java/RSPEC-2187 is ignored, gatling tests are not supported by sonar +sonar.issue.ignore.multicriteria.S2187.resourceKey = src/test/java/gatling/**/* +sonar.issue.ignore.multicriteria.S2187.ruleKey = java:S2187 # Rule https://rules.sonarsource.com/java/RSPEC-3437 is ignored, as a JPA-managed field cannot be transient -sonar.issue.ignore.multicriteria.S3437.resourceKey=src/main/java/**/* -sonar.issue.ignore.multicriteria.S3437.ruleKey=squid:S3437 +sonar.issue.ignore.multicriteria.S3437.resourceKey = src/main/java/**/* +sonar.issue.ignore.multicriteria.S3437.ruleKey = squid:S3437 # Rule https://rules.sonarsource.com/java/RSPEC-4502 is ignored, as for JWT tokens we are not subject to CSRF attack -sonar.issue.ignore.multicriteria.S4502.resourceKey=src/main/java/**/* -sonar.issue.ignore.multicriteria.S4502.ruleKey=java:S4502 +sonar.issue.ignore.multicriteria.S4502.resourceKey = src/main/java/**/* +sonar.issue.ignore.multicriteria.S4502.ruleKey = java:S4502 # Rule https://rules.sonarsource.com/java/RSPEC-4684 -sonar.issue.ignore.multicriteria.S4684.resourceKey=src/main/java/**/* -sonar.issue.ignore.multicriteria.S4684.ruleKey=java:S4684 +sonar.issue.ignore.multicriteria.S4684.resourceKey = src/main/java/**/* +sonar.issue.ignore.multicriteria.S4684.ruleKey = java:S4684 # Rule https://rules.sonarsource.com/java/RSPEC-5145 log filter is applied -sonar.issue.ignore.multicriteria.S5145.resourceKey=src/main/java/**/* -sonar.issue.ignore.multicriteria.S5145.ruleKey=javasecurity:S5145 -# Rule https://rules.sonarsource.com/java/RSPEC-1176 is ignored, as we want to follow "clean code" guidelines and classes, methods and arguments names should be self-explanatory -sonar.issue.ignore.multicriteria.UndocumentedApi.resourceKey=src/main/java/**/* -sonar.issue.ignore.multicriteria.UndocumentedApi.ruleKey=squid:UndocumentedApi +sonar.issue.ignore.multicriteria.S5145.resourceKey = src/main/java/**/* +sonar.issue.ignore.multicriteria.S5145.ruleKey = javasecurity:S5145 +# Rule https://rules.sonarsource.com/java/RSPEC-1176 is ignored, as we want to follow "clean code" guidelines and classes, methods and +# arguments names should be self-explanatory +sonar.issue.ignore.multicriteria.UndocumentedApi.resourceKey = src/main/java/**/* +sonar.issue.ignore.multicriteria.UndocumentedApi.ruleKey = squid:UndocumentedApi diff --git a/src/main/docker/app.yml b/src/main/docker/app.yml index 4e10698cb..95e5e1c8b 100644 --- a/src/main/docker/app.yml +++ b/src/main/docker/app.yml @@ -1,28 +1,29 @@ # This configuration is intended for development purpose, it's **your** responsibility to harden it for production -version: '3.8' +name: jhipstersampleapplication services: - jhipstersampleapplication-app: + app: image: jhipstersampleapplication environment: - _JAVA_OPTIONS=-Xmx512m -Xms256m - SPRING_PROFILES_ACTIVE=prod,api-docs - - MANAGEMENT_METRICS_EXPORT_PROMETHEUS_ENABLED=true - - SPRING_DATASOURCE_URL=jdbc:postgresql://jhipstersampleapplication-postgresql:5432/jhipsterSampleApplication - - SPRING_LIQUIBASE_URL=jdbc:postgresql://jhipstersampleapplication-postgresql:5432/jhipsterSampleApplication - - JHIPSTER_SLEEP=30 # gives time for other services to boot before the application - # If you want to expose these ports outside your dev PC, - # remove the "127.0.0.1:" prefix + - MANAGEMENT_PROMETHEUS_METRICS_EXPORT_ENABLED=true + - SPRING_DATASOURCE_URL=jdbc:postgresql://postgresql:5432/jhipsterSampleApplication + - SPRING_LIQUIBASE_URL=jdbc:postgresql://postgresql:5432/jhipsterSampleApplication ports: - 127.0.0.1:8080:8080 - jhipstersampleapplication-postgresql: - image: postgres:14.5 - # volumes: - # - ~/volumes/jhipster/jhipsterSampleApplication/postgresql/:/var/lib/postgresql/data/ - environment: - - POSTGRES_USER=jhipsterSampleApplication - - POSTGRES_PASSWORD= - - POSTGRES_HOST_AUTH_METHOD=trust - # If you want to expose these ports outside your dev PC, - # remove the "127.0.0.1:" prefix - ports: - - 127.0.0.1:5432:5432 + healthcheck: + test: + - CMD + - curl + - -f + - http://localhost:8080/management/health + interval: 5s + timeout: 5s + retries: 40 + depends_on: + postgresql: + condition: service_healthy + postgresql: + extends: + file: ./postgresql.yml + service: postgresql diff --git a/src/main/docker/jhipster-control-center.yml b/src/main/docker/jhipster-control-center.yml index 6e966e558..e5aaacb0c 100644 --- a/src/main/docker/jhipster-control-center.yml +++ b/src/main/docker/jhipster-control-center.yml @@ -28,7 +28,7 @@ # - In Consul mode, the ports are in the consul.yml file. # - In Eureka mode, the ports are in the jhipster-registry.yml file. -version: '3.8' +name: jhipstersampleapplication services: jhipster-control-center: image: 'jhipster/jhipster-control-center:v0.5.0' @@ -40,7 +40,6 @@ services: environment: - _JAVA_OPTIONS=-Xmx512m -Xms256m - SPRING_PROFILES_ACTIVE=prod,api-docs,static - - JHIPSTER_SLEEP=30 # gives time for other services to boot before the application - SPRING_SECURITY_USER_PASSWORD=admin # The token should have the same value than the one declared in you Spring configuration under the jhipster.security.authentication.jwt.base64-secret configuration's entry - JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64_SECRET=bXktc2VjcmV0LWtleS13aGljaC1zaG91bGQtYmUtY2hhbmdlZC1pbi1wcm9kdWN0aW9uLWFuZC1iZS1iYXNlNjQtZW5jb2RlZAo= diff --git a/src/main/docker/jib/entrypoint.sh b/src/main/docker/jib/entrypoint.sh old mode 100644 new mode 100755 diff --git a/src/main/docker/monitoring.yml b/src/main/docker/monitoring.yml index 7bec8d866..49851e25a 100644 --- a/src/main/docker/monitoring.yml +++ b/src/main/docker/monitoring.yml @@ -1,8 +1,8 @@ # This configuration is intended for development purpose, it's **your** responsibility to harden it for production -version: '3.8' +name: jhipstersampleapplication services: - jhipstersampleapplication-prometheus: - image: prom/prometheus:v2.38.0 + prometheus: + image: prom/prometheus:v2.46.0 volumes: - ./prometheus/:/etc/prometheus/ command: @@ -14,8 +14,8 @@ services: # On MacOS, remove next line and replace localhost by host.docker.internal in prometheus/prometheus.yml and # grafana/provisioning/datasources/datasource.yml network_mode: 'host' # to test locally running service - jhipstersampleapplication-grafana: - image: grafana/grafana:9.1.0 + grafana: + image: grafana/grafana:10.1.1 volumes: - ./grafana/provisioning/:/etc/grafana/provisioning/ environment: diff --git a/src/main/docker/postgresql.yml b/src/main/docker/postgresql.yml index bd209c23d..3f604861b 100644 --- a/src/main/docker/postgresql.yml +++ b/src/main/docker/postgresql.yml @@ -1,14 +1,19 @@ # This configuration is intended for development purpose, it's **your** responsibility to harden it for production -version: '3.8' +name: jhipstersampleapplication services: - jhipstersampleapplication-postgresql: - image: postgres:14.5 + postgresql: + image: postgres:15.4 # volumes: # - ~/volumes/jhipster/jhipsterSampleApplication/postgresql/:/var/lib/postgresql/data/ environment: - POSTGRES_USER=jhipsterSampleApplication - POSTGRES_PASSWORD= - POSTGRES_HOST_AUTH_METHOD=trust + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER}'] + interval: 5s + timeout: 5s + retries: 10 # If you want to expose these ports outside your dev PC, # remove the "127.0.0.1:" prefix ports: diff --git a/src/main/docker/services.yml b/src/main/docker/services.yml new file mode 100644 index 000000000..a1c65b45e --- /dev/null +++ b/src/main/docker/services.yml @@ -0,0 +1,7 @@ +# This configuration is intended for development purpose, it's **your** responsibility to harden it for production +name: jhipstersampleapplication +services: + postgresql: + extends: + file: ./postgresql.yml + service: postgresql diff --git a/src/main/docker/sonar.yml b/src/main/docker/sonar.yml index 14d80429b..1e5e1faf3 100644 --- a/src/main/docker/sonar.yml +++ b/src/main/docker/sonar.yml @@ -1,13 +1,15 @@ # This configuration is intended for development purpose, it's **your** responsibility to harden it for production -version: '3.8' +name: jhipstersampleapplication services: - jhipstersampleapplication-sonar: - image: sonarqube:9.6.0-community - # Authentication is turned off for out of the box experience while trying out SonarQube - # For real use cases delete sonar.forceAuthentication variable or set sonar.forceAuthentication=true + sonar: + container_name: sonarqube + image: sonarqube:10.2.0-community + # Forced authentication redirect for UI is turned off for out of the box experience while trying out SonarQube + # For real use cases delete SONAR_FORCEAUTHENTICATION variable or set SONAR_FORCEAUTHENTICATION=true environment: - - sonar.forceAuthentication=false + - SONAR_FORCEAUTHENTICATION=false # If you want to expose these ports outside your dev PC, # remove the "127.0.0.1:" prefix ports: - 127.0.0.1:9001:9000 + - 127.0.0.1:9000:9000 diff --git a/src/main/java/io/github/jhipster/sample/GeneratedByJHipster.java b/src/main/java/io/github/jhipster/sample/GeneratedByJHipster.java index 37db78956..bf1e2603c 100644 --- a/src/main/java/io/github/jhipster/sample/GeneratedByJHipster.java +++ b/src/main/java/io/github/jhipster/sample/GeneratedByJHipster.java @@ -1,10 +1,10 @@ package io.github.jhipster.sample; +import jakarta.annotation.Generated; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import javax.annotation.Generated; @Generated(value = "JHipster", comments = "Generated by JHipster 7.9.4") @Retention(RetentionPolicy.SOURCE) diff --git a/src/main/java/io/github/jhipster/sample/JhipsterSampleApplicationApp.java b/src/main/java/io/github/jhipster/sample/JhipsterSampleApplicationApp.java index 6e283669c..7592887cb 100644 --- a/src/main/java/io/github/jhipster/sample/JhipsterSampleApplicationApp.java +++ b/src/main/java/io/github/jhipster/sample/JhipsterSampleApplicationApp.java @@ -2,12 +2,12 @@ import io.github.jhipster.sample.config.ApplicationProperties; import io.github.jhipster.sample.config.CRLFLogConverter; +import jakarta.annotation.PostConstruct; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.Arrays; import java.util.Collection; import java.util.Optional; -import javax.annotation.PostConstruct; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,6 +73,7 @@ public static void main(String[] args) { private static void logApplicationStartup(Environment env) { String protocol = Optional.ofNullable(env.getProperty("server.ssl.key-store")).map(key -> "https").orElse("http"); + String applicationName = env.getProperty("spring.application.name"); String serverPort = env.getProperty("server.port"); String contextPath = Optional .ofNullable(env.getProperty("server.servlet.context-path")) @@ -86,12 +87,15 @@ private static void logApplicationStartup(Environment env) { } log.info( CRLFLogConverter.CRLF_SAFE_MARKER, - "\n----------------------------------------------------------\n\t" + - "Application '{}' is running! Access URLs:\n\t" + - "Local: \t\t{}://localhost:{}{}\n\t" + - "External: \t{}://{}:{}{}\n\t" + - "Profile(s): \t{}\n----------------------------------------------------------", - env.getProperty("spring.application.name"), + """ + + ---------------------------------------------------------- + \tApplication '{}' is running! Access URLs: + \tLocal: \t\t{}://localhost:{}{} + \tExternal: \t{}://{}:{}{} + \tProfile(s): \t{} + ----------------------------------------------------------""", + applicationName, protocol, serverPort, contextPath, diff --git a/src/main/java/io/github/jhipster/sample/aop/logging/package-info.java b/src/main/java/io/github/jhipster/sample/aop/logging/package-info.java new file mode 100644 index 000000000..84f774c6c --- /dev/null +++ b/src/main/java/io/github/jhipster/sample/aop/logging/package-info.java @@ -0,0 +1,4 @@ +/** + * Logging aspect. + */ +package io.github.jhipster.sample.aop.logging; diff --git a/src/main/java/io/github/jhipster/sample/config/CRLFLogConverter.java b/src/main/java/io/github/jhipster/sample/config/CRLFLogConverter.java index 89e5be71e..655e2ccf1 100644 --- a/src/main/java/io/github/jhipster/sample/config/CRLFLogConverter.java +++ b/src/main/java/io/github/jhipster/sample/config/CRLFLogConverter.java @@ -4,6 +4,7 @@ import ch.qos.logback.core.pattern.CompositeConverter; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.slf4j.Marker; import org.slf4j.MarkerFactory; @@ -12,11 +13,22 @@ import org.springframework.boot.ansi.AnsiOutput; import org.springframework.boot.ansi.AnsiStyle; +/** + * Log filter to prevent attackers from forging log entries by submitting input containing CRLF characters. + * CRLF characters are replaced with a red colored _ character. + * + * @see Log Forging Description + * @see JHipster issue + */ public class CRLFLogConverter extends CompositeConverter { public static final Marker CRLF_SAFE_MARKER = MarkerFactory.getMarker("CRLF_SAFE"); - private static final String[] SAFE_LOGGERS = { "org.hibernate" }; + private static final String[] SAFE_LOGGERS = { + "org.hibernate", + "org.springframework.boot.autoconfigure", + "org.springframework.boot.diagnostics", + }; private static final Map ELEMENTS; static { @@ -34,7 +46,8 @@ public class CRLFLogConverter extends CompositeConverter { @Override protected String transform(ILoggingEvent event, String in) { AnsiElement element = ELEMENTS.get(getFirstOption()); - if ((event.getMarker() != null && event.getMarker().contains(CRLF_SAFE_MARKER)) || isLoggerSafe(event)) { + List markers = event.getMarkerList(); + if ((markers != null && !markers.isEmpty() && markers.get(0).contains(CRLF_SAFE_MARKER)) || isLoggerSafe(event)) { return in; } String replacement = element == null ? "_" : toAnsiString("_", element); diff --git a/src/main/java/io/github/jhipster/sample/config/JacksonConfiguration.java b/src/main/java/io/github/jhipster/sample/config/JacksonConfiguration.java index 0c5eeda24..1941d23f1 100644 --- a/src/main/java/io/github/jhipster/sample/config/JacksonConfiguration.java +++ b/src/main/java/io/github/jhipster/sample/config/JacksonConfiguration.java @@ -1,12 +1,10 @@ package io.github.jhipster.sample.config; -import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module; +import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.zalando.problem.jackson.ProblemModule; -import org.zalando.problem.violations.ConstraintViolationProblemModule; @Configuration public class JacksonConfiguration { @@ -29,23 +27,7 @@ public Jdk8Module jdk8TimeModule() { * Support for Hibernate types in Jackson. */ @Bean - public Hibernate5Module hibernate5Module() { - return new Hibernate5Module(); - } - - /* - * Module for serialization/deserialization of RFC7807 Problem. - */ - @Bean - public ProblemModule problemModule() { - return new ProblemModule(); - } - - /* - * Module for serialization/deserialization of ConstraintViolationProblem. - */ - @Bean - public ConstraintViolationProblemModule constraintViolationProblemModule() { - return new ConstraintViolationProblemModule(); + public Hibernate6Module hibernate6Module() { + return new Hibernate6Module(); } } diff --git a/src/main/java/io/github/jhipster/sample/config/LiquibaseConfiguration.java b/src/main/java/io/github/jhipster/sample/config/LiquibaseConfiguration.java index bbf72f6a2..98873154f 100644 --- a/src/main/java/io/github/jhipster/sample/config/LiquibaseConfiguration.java +++ b/src/main/java/io/github/jhipster/sample/config/LiquibaseConfiguration.java @@ -31,8 +31,8 @@ public LiquibaseConfiguration(Environment env) { @Bean public SpringLiquibase liquibase( @Qualifier("taskExecutor") Executor executor, - @LiquibaseDataSource ObjectProvider liquibaseDataSource, LiquibaseProperties liquibaseProperties, + @LiquibaseDataSource ObjectProvider liquibaseDataSource, ObjectProvider dataSource, DataSourceProperties dataSourceProperties ) { @@ -54,7 +54,7 @@ public SpringLiquibase liquibase( liquibase.setDatabaseChangeLogLockTable(liquibaseProperties.getDatabaseChangeLogLockTable()); liquibase.setDatabaseChangeLogTable(liquibaseProperties.getDatabaseChangeLogTable()); liquibase.setDropFirst(liquibaseProperties.isDropFirst()); - liquibase.setLabels(liquibaseProperties.getLabels()); + liquibase.setLabelFilter(liquibaseProperties.getLabelFilter()); liquibase.setChangeLogParameters(liquibaseProperties.getParameters()); liquibase.setRollbackFile(liquibaseProperties.getRollbackFile()); liquibase.setTestRollbackOnUpdate(liquibaseProperties.isTestRollbackOnUpdate()); diff --git a/src/main/java/io/github/jhipster/sample/config/SecurityConfiguration.java b/src/main/java/io/github/jhipster/sample/config/SecurityConfiguration.java index 09b36fa1a..ff68c84a8 100644 --- a/src/main/java/io/github/jhipster/sample/config/SecurityConfiguration.java +++ b/src/main/java/io/github/jhipster/sample/config/SecurityConfiguration.java @@ -1,44 +1,42 @@ package io.github.jhipster.sample.config; +import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; + import io.github.jhipster.sample.security.*; -import io.github.jhipster.sample.security.jwt.*; +import io.github.jhipster.sample.web.filter.SpaWebFilter; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.Customizer; +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.HeadersConfigurer.FrameOptionsConfig; 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.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; -import org.springframework.web.filter.CorsFilter; -import org.zalando.problem.spring.web.advice.security.SecurityProblemSupport; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; +import tech.jhipster.config.JHipsterConstants; import tech.jhipster.config.JHipsterProperties; -@EnableWebSecurity -@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) -@Import(SecurityProblemSupport.class) +@Configuration +@EnableMethodSecurity(securedEnabled = true) public class SecurityConfiguration { - private final JHipsterProperties jHipsterProperties; - - private final TokenProvider tokenProvider; + private final Environment env; - private final CorsFilter corsFilter; - private final SecurityProblemSupport problemSupport; + private final JHipsterProperties jHipsterProperties; - public SecurityConfiguration( - TokenProvider tokenProvider, - CorsFilter corsFilter, - JHipsterProperties jHipsterProperties, - SecurityProblemSupport problemSupport - ) { - this.tokenProvider = tokenProvider; - this.corsFilter = corsFilter; - this.problemSupport = problemSupport; + public SecurityConfiguration(Environment env, JHipsterProperties jHipsterProperties) { + this.env = env; this.jHipsterProperties = jHipsterProperties; } @@ -48,58 +46,61 @@ public PasswordEncoder passwordEncoder() { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - // @formatter:off + public SecurityFilterChain filterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception { http - .csrf() - .ignoringAntMatchers("/h2-console/**") - .disable() - .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) - .exceptionHandling() - .authenticationEntryPoint(problemSupport) - .accessDeniedHandler(problemSupport) - .and() - .headers() - .contentSecurityPolicy(jHipsterProperties.getSecurity().getContentSecurityPolicy()) - .and() - .referrerPolicy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN) - .and() - .permissionsPolicy().policy("camera=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), sync-xhr=()") - .and() - .frameOptions().sameOrigin() - .and() - .sessionManagement() - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .and() - .authorizeRequests() - .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .antMatchers("/app/**/*.{js,html}").permitAll() - .antMatchers("/i18n/**").permitAll() - .antMatchers("/content/**").permitAll() - .antMatchers("/swagger-ui/**").permitAll() - .antMatchers("/test/**").permitAll() - .antMatchers("/h2-console/**").permitAll() - .antMatchers("/api/authenticate").permitAll() - .antMatchers("/api/register").permitAll() - .antMatchers("/api/activate").permitAll() - .antMatchers("/api/account/reset-password/init").permitAll() - .antMatchers("/api/account/reset-password/finish").permitAll() - .antMatchers("/api/admin/**").hasAuthority(AuthoritiesConstants.ADMIN) - .antMatchers("/api/**").authenticated() - .antMatchers("/management/health").permitAll() - .antMatchers("/management/health/**").permitAll() - .antMatchers("/management/info").permitAll() - .antMatchers("/management/prometheus").permitAll() - .antMatchers("/management/**").hasAuthority(AuthoritiesConstants.ADMIN) - .and() - .httpBasic() - .and() - .apply(securityConfigurerAdapter()); + .cors(withDefaults()) + .csrf(csrf -> csrf.disable()) + .addFilterAfter(new SpaWebFilter(), BasicAuthenticationFilter.class) + .headers(headers -> + headers + .contentSecurityPolicy(csp -> csp.policyDirectives(jHipsterProperties.getSecurity().getContentSecurityPolicy())) + .frameOptions(FrameOptionsConfig::sameOrigin) + .referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + .permissionsPolicy(permissions -> + permissions.policy( + "camera=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), sync-xhr=()" + ) + ) + ) + .authorizeHttpRequests(authz -> + // prettier-ignore + authz + .requestMatchers(mvc.pattern("/index.html"), mvc.pattern("/*.js"), mvc.pattern("/*.map"), mvc.pattern("/*.css")).permitAll() + .requestMatchers(mvc.pattern("/*.ico"), mvc.pattern("/*.png"), mvc.pattern("/*.svg"), mvc.pattern("/*.webapp")).permitAll() + .requestMatchers(mvc.pattern("/app/**")).permitAll() + .requestMatchers(mvc.pattern("/i18n/**")).permitAll() + .requestMatchers(mvc.pattern("/content/**")).permitAll() + .requestMatchers(mvc.pattern("/swagger-ui/**")).permitAll() + .requestMatchers(mvc.pattern(HttpMethod.POST, "/api/authenticate")).permitAll() + .requestMatchers(mvc.pattern(HttpMethod.GET, "/api/authenticate")).permitAll() + .requestMatchers(mvc.pattern("/api/register")).permitAll() + .requestMatchers(mvc.pattern("/api/activate")).permitAll() + .requestMatchers(mvc.pattern("/api/account/reset-password/init")).permitAll() + .requestMatchers(mvc.pattern("/api/account/reset-password/finish")).permitAll() + .requestMatchers(mvc.pattern("/api/admin/**")).hasAuthority(AuthoritiesConstants.ADMIN) + .requestMatchers(mvc.pattern("/api/**")).authenticated() + .requestMatchers(mvc.pattern("/v3/api-docs/**")).hasAuthority(AuthoritiesConstants.ADMIN) + .requestMatchers(mvc.pattern("/management/health")).permitAll() + .requestMatchers(mvc.pattern("/management/health/**")).permitAll() + .requestMatchers(mvc.pattern("/management/info")).permitAll() + .requestMatchers(mvc.pattern("/management/prometheus")).permitAll() + .requestMatchers(mvc.pattern("/management/**")).hasAuthority(AuthoritiesConstants.ADMIN) + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptions -> + exceptions + .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + if (env.acceptsProfiles(Profiles.of(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT))) { + http.authorizeHttpRequests(authz -> authz.requestMatchers(antMatcher("/h2-console/**")).permitAll()); + } return http.build(); - // @formatter:on } - private JWTConfigurer securityConfigurerAdapter() { - return new JWTConfigurer(tokenProvider); + @Bean + MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) { + return new MvcRequestMatcher.Builder(introspector); } } diff --git a/src/main/java/io/github/jhipster/sample/config/SecurityJwtConfiguration.java b/src/main/java/io/github/jhipster/sample/config/SecurityJwtConfiguration.java new file mode 100644 index 000000000..9e36acf0a --- /dev/null +++ b/src/main/java/io/github/jhipster/sample/config/SecurityJwtConfiguration.java @@ -0,0 +1,68 @@ +package io.github.jhipster.sample.config; + +import static io.github.jhipster.sample.security.SecurityUtils.AUTHORITIES_KEY; +import static io.github.jhipster.sample.security.SecurityUtils.JWT_ALGORITHM; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.util.Base64; +import io.github.jhipster.sample.management.SecurityMetersService; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; + +@Configuration +public class SecurityJwtConfiguration { + + @Value("${jhipster.security.authentication.jwt.base64-secret}") + private String jwtKey; + + @Bean + public JwtDecoder jwtDecoder(SecurityMetersService metersService) { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(getSecretKey()).macAlgorithm(JWT_ALGORITHM).build(); + return token -> { + try { + return jwtDecoder.decode(token); + } catch (Exception e) { + if (e.getMessage().contains("Invalid signature")) { + metersService.trackTokenInvalidSignature(); + } else if (e.getMessage().contains("Jwt expired at")) { + metersService.trackTokenExpired(); + } else if (e.getMessage().contains("Invalid JWT serialization")) { + metersService.trackTokenMalformed(); + } else if (e.getMessage().contains("Invalid unsecured/JWS/JWE")) { + metersService.trackTokenMalformed(); + } + throw e; + } + }; + } + + @Bean + public JwtEncoder jwtEncoder() { + return new NimbusJwtEncoder(new ImmutableSecret<>(getSecretKey())); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + grantedAuthoritiesConverter.setAuthorityPrefix(""); + grantedAuthoritiesConverter.setAuthoritiesClaimName(AUTHORITIES_KEY); + + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + return jwtAuthenticationConverter; + } + + private SecretKey getSecretKey() { + byte[] keyBytes = Base64.from(jwtKey).decode(); + return new SecretKeySpec(keyBytes, 0, keyBytes.length, JWT_ALGORITHM.getName()); + } +} diff --git a/src/main/java/io/github/jhipster/sample/config/WebConfigurer.java b/src/main/java/io/github/jhipster/sample/config/WebConfigurer.java index 75d7d6cd2..f15994e5b 100644 --- a/src/main/java/io/github/jhipster/sample/config/WebConfigurer.java +++ b/src/main/java/io/github/jhipster/sample/config/WebConfigurer.java @@ -2,10 +2,10 @@ import static java.net.URLDecoder.decode; +import jakarta.servlet.*; import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Paths; -import javax.servlet.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.web.server.*; @@ -62,8 +62,7 @@ public void customize(WebServerFactory server) { } private void setLocationForStaticAssets(WebServerFactory server) { - if (server instanceof ConfigurableServletWebServerFactory) { - ConfigurableServletWebServerFactory servletWebServer = (ConfigurableServletWebServerFactory) server; + if (server instanceof ConfigurableServletWebServerFactory servletWebServer) { File root; String prefixPath = resolvePathPrefix(); root = new File(prefixPath + "target/classes/static/"); diff --git a/src/main/java/io/github/jhipster/sample/config/package-info.java b/src/main/java/io/github/jhipster/sample/config/package-info.java index 7a623f3fd..ad869b14f 100644 --- a/src/main/java/io/github/jhipster/sample/config/package-info.java +++ b/src/main/java/io/github/jhipster/sample/config/package-info.java @@ -1,4 +1,4 @@ /** - * Spring Framework configuration files. + * Application configuration. */ package io.github.jhipster.sample.config; diff --git a/src/main/java/io/github/jhipster/sample/domain/AbstractAuditingEntity.java b/src/main/java/io/github/jhipster/sample/domain/AbstractAuditingEntity.java index ffef4f599..55f172427 100644 --- a/src/main/java/io/github/jhipster/sample/domain/AbstractAuditingEntity.java +++ b/src/main/java/io/github/jhipster/sample/domain/AbstractAuditingEntity.java @@ -1,11 +1,11 @@ package io.github.jhipster.sample.domain; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; import java.io.Serializable; import java.time.Instant; -import javax.persistence.Column; -import javax.persistence.EntityListeners; -import javax.persistence.MappedSuperclass; import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedBy; diff --git a/src/main/java/io/github/jhipster/sample/domain/Authority.java b/src/main/java/io/github/jhipster/sample/domain/Authority.java index ecc01e6ad..bdaf7281f 100644 --- a/src/main/java/io/github/jhipster/sample/domain/Authority.java +++ b/src/main/java/io/github/jhipster/sample/domain/Authority.java @@ -1,13 +1,13 @@ package io.github.jhipster.sample.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.io.Serializable; import java.util.Objects; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; diff --git a/src/main/java/io/github/jhipster/sample/domain/BankAccount.java b/src/main/java/io/github/jhipster/sample/domain/BankAccount.java index dde2660df..da0a4be57 100644 --- a/src/main/java/io/github/jhipster/sample/domain/BankAccount.java +++ b/src/main/java/io/github/jhipster/sample/domain/BankAccount.java @@ -1,12 +1,12 @@ package io.github.jhipster.sample.domain; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import jakarta.validation.constraints.*; import java.io.Serializable; import java.math.BigDecimal; import java.util.HashSet; import java.util.Set; -import javax.persistence.*; -import javax.validation.constraints.*; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; @@ -35,10 +35,10 @@ public class BankAccount implements Serializable { @Column(name = "balance", precision = 21, scale = 2, nullable = false) private BigDecimal balance; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) private User user; - @OneToMany(mappedBy = "bankAccount") + @OneToMany(fetch = FetchType.LAZY, mappedBy = "bankAccount") @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) @JsonIgnoreProperties(value = { "bankAccount", "labels" }, allowSetters = true) private Set operations = new HashSet<>(); diff --git a/src/main/java/io/github/jhipster/sample/domain/Label.java b/src/main/java/io/github/jhipster/sample/domain/Label.java index b613aabaf..3d573d8c2 100644 --- a/src/main/java/io/github/jhipster/sample/domain/Label.java +++ b/src/main/java/io/github/jhipster/sample/domain/Label.java @@ -1,11 +1,11 @@ package io.github.jhipster.sample.domain; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import jakarta.validation.constraints.*; import java.io.Serializable; import java.util.HashSet; import java.util.Set; -import javax.persistence.*; -import javax.validation.constraints.*; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; @@ -31,7 +31,7 @@ public class Label implements Serializable { @Column(name = "label", nullable = false) private String label; - @ManyToMany(mappedBy = "labels") + @ManyToMany(fetch = FetchType.LAZY, mappedBy = "labels") @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) @JsonIgnoreProperties(value = { "bankAccount", "labels" }, allowSetters = true) private Set operations = new HashSet<>(); diff --git a/src/main/java/io/github/jhipster/sample/domain/Operation.java b/src/main/java/io/github/jhipster/sample/domain/Operation.java index acf20cf04..3a454d6e1 100644 --- a/src/main/java/io/github/jhipster/sample/domain/Operation.java +++ b/src/main/java/io/github/jhipster/sample/domain/Operation.java @@ -1,13 +1,13 @@ package io.github.jhipster.sample.domain; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import jakarta.validation.constraints.*; import java.io.Serializable; import java.math.BigDecimal; import java.time.Instant; import java.util.HashSet; import java.util.Set; -import javax.persistence.*; -import javax.validation.constraints.*; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; @@ -39,11 +39,11 @@ public class Operation implements Serializable { @Column(name = "amount", precision = 21, scale = 2, nullable = false) private BigDecimal amount; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JsonIgnoreProperties(value = { "user", "operations" }, allowSetters = true) private BankAccount bankAccount; - @ManyToMany + @ManyToMany(fetch = FetchType.LAZY) @JoinTable( name = "rel_operation__label", joinColumns = @JoinColumn(name = "operation_id"), diff --git a/src/main/java/io/github/jhipster/sample/domain/User.java b/src/main/java/io/github/jhipster/sample/domain/User.java index 4ce67bf5a..81460224e 100644 --- a/src/main/java/io/github/jhipster/sample/domain/User.java +++ b/src/main/java/io/github/jhipster/sample/domain/User.java @@ -2,16 +2,16 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import io.github.jhipster.sample.config.Constants; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import java.io.Serializable; import java.time.Instant; import java.util.HashSet; import java.util.Locale; import java.util.Set; -import javax.persistence.*; -import javax.validation.constraints.Email; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; import org.apache.commons.lang3.StringUtils; import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.Cache; diff --git a/src/main/java/io/github/jhipster/sample/domain/package-info.java b/src/main/java/io/github/jhipster/sample/domain/package-info.java index b5eb9b2eb..cab748aff 100644 --- a/src/main/java/io/github/jhipster/sample/domain/package-info.java +++ b/src/main/java/io/github/jhipster/sample/domain/package-info.java @@ -1,4 +1,4 @@ /** - * JPA domain objects. + * Domain objects. */ package io.github.jhipster.sample.domain; diff --git a/src/main/java/io/github/jhipster/sample/management/package-info.java b/src/main/java/io/github/jhipster/sample/management/package-info.java new file mode 100644 index 000000000..a0a6fef96 --- /dev/null +++ b/src/main/java/io/github/jhipster/sample/management/package-info.java @@ -0,0 +1,4 @@ +/** + * Application management. + */ +package io.github.jhipster.sample.management; diff --git a/src/main/java/io/github/jhipster/sample/package-info.java b/src/main/java/io/github/jhipster/sample/package-info.java new file mode 100644 index 000000000..e3ed9e153 --- /dev/null +++ b/src/main/java/io/github/jhipster/sample/package-info.java @@ -0,0 +1,4 @@ +/** + * Application root. + */ +package io.github.jhipster.sample; diff --git a/src/main/java/io/github/jhipster/sample/repository/BankAccountRepository.java b/src/main/java/io/github/jhipster/sample/repository/BankAccountRepository.java index f7048995b..09a9a3da3 100644 --- a/src/main/java/io/github/jhipster/sample/repository/BankAccountRepository.java +++ b/src/main/java/io/github/jhipster/sample/repository/BankAccountRepository.java @@ -30,12 +30,12 @@ default Page findAllWithEagerRelationships(Pageable pageable) { } @Query( - value = "select distinct bankAccount from BankAccount bankAccount left join fetch bankAccount.user", - countQuery = "select count(distinct bankAccount) from BankAccount bankAccount" + value = "select bankAccount from BankAccount bankAccount left join fetch bankAccount.user", + countQuery = "select count(bankAccount) from BankAccount bankAccount" ) Page findAllWithToOneRelationships(Pageable pageable); - @Query("select distinct bankAccount from BankAccount bankAccount left join fetch bankAccount.user") + @Query("select bankAccount from BankAccount bankAccount left join fetch bankAccount.user") List findAllWithToOneRelationships(); @Query("select bankAccount from BankAccount bankAccount left join fetch bankAccount.user where bankAccount.id =:id") diff --git a/src/main/java/io/github/jhipster/sample/repository/OperationRepository.java b/src/main/java/io/github/jhipster/sample/repository/OperationRepository.java index a81c14758..8be1aca3d 100644 --- a/src/main/java/io/github/jhipster/sample/repository/OperationRepository.java +++ b/src/main/java/io/github/jhipster/sample/repository/OperationRepository.java @@ -30,12 +30,12 @@ default Page findAllWithEagerRelationships(Pageable pageable) { } @Query( - value = "select distinct operation from Operation operation left join fetch operation.bankAccount", - countQuery = "select count(distinct operation) from Operation operation" + value = "select operation from Operation operation left join fetch operation.bankAccount", + countQuery = "select count(operation) from Operation operation" ) Page findAllWithToOneRelationships(Pageable pageable); - @Query("select distinct operation from Operation operation left join fetch operation.bankAccount") + @Query("select operation from Operation operation left join fetch operation.bankAccount") List findAllWithToOneRelationships(); @Query("select operation from Operation operation left join fetch operation.bankAccount where operation.id =:id") diff --git a/src/main/java/io/github/jhipster/sample/repository/OperationRepositoryWithBagRelationshipsImpl.java b/src/main/java/io/github/jhipster/sample/repository/OperationRepositoryWithBagRelationshipsImpl.java index e88dbdcd6..b316f42aa 100644 --- a/src/main/java/io/github/jhipster/sample/repository/OperationRepositoryWithBagRelationshipsImpl.java +++ b/src/main/java/io/github/jhipster/sample/repository/OperationRepositoryWithBagRelationshipsImpl.java @@ -1,14 +1,13 @@ package io.github.jhipster.sample.repository; import io.github.jhipster.sample.domain.Operation; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Optional; import java.util.stream.IntStream; -import javax.persistence.EntityManager; -import javax.persistence.PersistenceContext; -import org.hibernate.annotations.QueryHints; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -38,11 +37,10 @@ public List fetchBagRelationships(List operations) { Operation fetchLabels(Operation result) { return entityManager .createQuery( - "select operation from Operation operation left join fetch operation.labels where operation is :operation", + "select operation from Operation operation left join fetch operation.labels where operation.id = :id", Operation.class ) - .setParameter("operation", result) - .setHint(QueryHints.PASS_DISTINCT_THROUGH, false) + .setParameter("id", result.getId()) .getSingleResult(); } @@ -51,11 +49,10 @@ List fetchLabels(List operations) { IntStream.range(0, operations.size()).forEach(index -> order.put(operations.get(index).getId(), index)); List result = entityManager .createQuery( - "select distinct operation from Operation operation left join fetch operation.labels where operation in :operations", + "select operation from Operation operation left join fetch operation.labels where operation in :operations", Operation.class ) .setParameter("operations", operations) - .setHint(QueryHints.PASS_DISTINCT_THROUGH, false) .getResultList(); Collections.sort(result, (o1, o2) -> Integer.compare(order.get(o1.getId()), order.get(o2.getId()))); return result; diff --git a/src/main/java/io/github/jhipster/sample/repository/package-info.java b/src/main/java/io/github/jhipster/sample/repository/package-info.java index d9ff8cf34..2bf83af74 100644 --- a/src/main/java/io/github/jhipster/sample/repository/package-info.java +++ b/src/main/java/io/github/jhipster/sample/repository/package-info.java @@ -1,4 +1,4 @@ /** - * Spring Data JPA repositories. + * Repository layer. */ package io.github.jhipster.sample.repository; diff --git a/src/main/java/io/github/jhipster/sample/security/DomainUserDetailsService.java b/src/main/java/io/github/jhipster/sample/security/DomainUserDetailsService.java index a9d2f3168..d8345e484 100644 --- a/src/main/java/io/github/jhipster/sample/security/DomainUserDetailsService.java +++ b/src/main/java/io/github/jhipster/sample/security/DomainUserDetailsService.java @@ -4,11 +4,9 @@ import io.github.jhipster.sample.domain.User; import io.github.jhipster.sample.repository.UserRepository; import java.util.*; -import java.util.stream.Collectors; import org.hibernate.validator.internal.constraintvalidators.hv.EmailValidator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -53,12 +51,12 @@ private org.springframework.security.core.userdetails.User createSpringSecurityU if (!user.isActivated()) { throw new UserNotActivatedException("User " + lowercaseLogin + " was not activated"); } - List grantedAuthorities = user + List grantedAuthorities = user .getAuthorities() .stream() .map(Authority::getName) .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); + .toList(); return new org.springframework.security.core.userdetails.User(user.getLogin(), user.getPassword(), grantedAuthorities); } } diff --git a/src/main/java/io/github/jhipster/sample/security/SecurityUtils.java b/src/main/java/io/github/jhipster/sample/security/SecurityUtils.java index 7aabaa032..77259f7ff 100644 --- a/src/main/java/io/github/jhipster/sample/security/SecurityUtils.java +++ b/src/main/java/io/github/jhipster/sample/security/SecurityUtils.java @@ -8,12 +8,18 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; /** * Utility class for Spring Security. */ public final class SecurityUtils { + public static final MacAlgorithm JWT_ALGORITHM = MacAlgorithm.HS512; + + public static final String AUTHORITIES_KEY = "auth"; + private SecurityUtils() {} /** @@ -29,11 +35,12 @@ public static Optional getCurrentUserLogin() { private static String extractPrincipal(Authentication authentication) { if (authentication == null) { return null; - } else if (authentication.getPrincipal() instanceof UserDetails) { - UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal(); + } else if (authentication.getPrincipal() instanceof UserDetails springSecurityUser) { return springSecurityUser.getUsername(); - } else if (authentication.getPrincipal() instanceof String) { - return (String) authentication.getPrincipal(); + } else if (authentication.getPrincipal() instanceof Jwt jwt) { + return jwt.getSubject(); + } else if (authentication.getPrincipal() instanceof String s) { + return s; } return null; } diff --git a/src/main/java/io/github/jhipster/sample/security/jwt/JWTConfigurer.java b/src/main/java/io/github/jhipster/sample/security/jwt/JWTConfigurer.java deleted file mode 100644 index 3b75bd1a7..000000000 --- a/src/main/java/io/github/jhipster/sample/security/jwt/JWTConfigurer.java +++ /dev/null @@ -1,21 +0,0 @@ -package io.github.jhipster.sample.security.jwt; - -import org.springframework.security.config.annotation.SecurityConfigurerAdapter; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.DefaultSecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -public class JWTConfigurer extends SecurityConfigurerAdapter { - - private final TokenProvider tokenProvider; - - public JWTConfigurer(TokenProvider tokenProvider) { - this.tokenProvider = tokenProvider; - } - - @Override - public void configure(HttpSecurity http) { - JWTFilter customFilter = new JWTFilter(tokenProvider); - http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); - } -} diff --git a/src/main/java/io/github/jhipster/sample/security/jwt/JWTFilter.java b/src/main/java/io/github/jhipster/sample/security/jwt/JWTFilter.java deleted file mode 100644 index 27faea2be..000000000 --- a/src/main/java/io/github/jhipster/sample/security/jwt/JWTFilter.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.github.jhipster.sample.security.jwt; - -import java.io.IOException; -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; -import javax.servlet.http.HttpServletRequest; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.GenericFilterBean; - -/** - * Filters incoming requests and installs a Spring Security principal if a header corresponding to a valid user is - * found. - */ -public class JWTFilter extends GenericFilterBean { - - public static final String AUTHORIZATION_HEADER = "Authorization"; - - private final TokenProvider tokenProvider; - - public JWTFilter(TokenProvider tokenProvider) { - this.tokenProvider = tokenProvider; - } - - @Override - public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) - throws IOException, ServletException { - HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; - String jwt = resolveToken(httpServletRequest); - if (StringUtils.hasText(jwt) && this.tokenProvider.validateToken(jwt)) { - Authentication authentication = this.tokenProvider.getAuthentication(jwt); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - filterChain.doFilter(servletRequest, servletResponse); - } - - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION_HEADER); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); - } - return null; - } -} diff --git a/src/main/java/io/github/jhipster/sample/security/jwt/TokenProvider.java b/src/main/java/io/github/jhipster/sample/security/jwt/TokenProvider.java deleted file mode 100644 index 203f94423..000000000 --- a/src/main/java/io/github/jhipster/sample/security/jwt/TokenProvider.java +++ /dev/null @@ -1,126 +0,0 @@ -package io.github.jhipster.sample.security.jwt; - -import io.github.jhipster.sample.management.SecurityMetersService; -import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import io.jsonwebtoken.security.SignatureException; -import java.nio.charset.StandardCharsets; -import java.security.Key; -import java.util.*; -import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.User; -import org.springframework.stereotype.Component; -import org.springframework.util.ObjectUtils; -import tech.jhipster.config.JHipsterProperties; - -@Component -public class TokenProvider { - - private final Logger log = LoggerFactory.getLogger(TokenProvider.class); - - private static final String AUTHORITIES_KEY = "auth"; - - private static final String INVALID_JWT_TOKEN = "Invalid JWT token."; - - private final Key key; - - private final JwtParser jwtParser; - - private final long tokenValidityInMilliseconds; - - private final long tokenValidityInMillisecondsForRememberMe; - - private final SecurityMetersService securityMetersService; - - public TokenProvider(JHipsterProperties jHipsterProperties, SecurityMetersService securityMetersService) { - byte[] keyBytes; - String secret = jHipsterProperties.getSecurity().getAuthentication().getJwt().getBase64Secret(); - if (!ObjectUtils.isEmpty(secret)) { - log.debug("Using a Base64-encoded JWT secret key"); - keyBytes = Decoders.BASE64.decode(secret); - } else { - log.warn( - "Warning: the JWT key used is not Base64-encoded. " + - "We recommend using the `jhipster.security.authentication.jwt.base64-secret` key for optimum security." - ); - secret = jHipsterProperties.getSecurity().getAuthentication().getJwt().getSecret(); - keyBytes = secret.getBytes(StandardCharsets.UTF_8); - } - key = Keys.hmacShaKeyFor(keyBytes); - jwtParser = Jwts.parserBuilder().setSigningKey(key).build(); - this.tokenValidityInMilliseconds = 1000 * jHipsterProperties.getSecurity().getAuthentication().getJwt().getTokenValidityInSeconds(); - this.tokenValidityInMillisecondsForRememberMe = - 1000 * jHipsterProperties.getSecurity().getAuthentication().getJwt().getTokenValidityInSecondsForRememberMe(); - - this.securityMetersService = securityMetersService; - } - - public String createToken(Authentication authentication, boolean rememberMe) { - String authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")); - - long now = (new Date()).getTime(); - Date validity; - if (rememberMe) { - validity = new Date(now + this.tokenValidityInMillisecondsForRememberMe); - } else { - validity = new Date(now + this.tokenValidityInMilliseconds); - } - - return Jwts - .builder() - .setSubject(authentication.getName()) - .claim(AUTHORITIES_KEY, authorities) - .signWith(key, SignatureAlgorithm.HS512) - .setExpiration(validity) - .compact(); - } - - public Authentication getAuthentication(String token) { - Claims claims = jwtParser.parseClaimsJws(token).getBody(); - - Collection authorities = Arrays - .stream(claims.get(AUTHORITIES_KEY).toString().split(",")) - .filter(auth -> !auth.trim().isEmpty()) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - - User principal = new User(claims.getSubject(), "", authorities); - - return new UsernamePasswordAuthenticationToken(principal, token, authorities); - } - - public boolean validateToken(String authToken) { - try { - jwtParser.parseClaimsJws(authToken); - - return true; - } catch (ExpiredJwtException e) { - this.securityMetersService.trackTokenExpired(); - - log.trace(INVALID_JWT_TOKEN, e); - } catch (UnsupportedJwtException e) { - this.securityMetersService.trackTokenUnsupported(); - - log.trace(INVALID_JWT_TOKEN, e); - } catch (MalformedJwtException e) { - this.securityMetersService.trackTokenMalformed(); - - log.trace(INVALID_JWT_TOKEN, e); - } catch (SignatureException e) { - this.securityMetersService.trackTokenInvalidSignature(); - - log.trace(INVALID_JWT_TOKEN, e); - } catch (IllegalArgumentException e) { // TODO: should we let it bubble (no catch), to avoid defensive programming and follow the fail-fast principle? - log.error("Token validation error {}", e.getMessage()); - } - - return false; - } -} diff --git a/src/main/java/io/github/jhipster/sample/security/package-info.java b/src/main/java/io/github/jhipster/sample/security/package-info.java index 6c3ea1460..11718c951 100644 --- a/src/main/java/io/github/jhipster/sample/security/package-info.java +++ b/src/main/java/io/github/jhipster/sample/security/package-info.java @@ -1,4 +1,4 @@ /** - * Spring Security configuration. + * Application security utilities. */ package io.github.jhipster.sample.security; diff --git a/src/main/java/io/github/jhipster/sample/service/MailService.java b/src/main/java/io/github/jhipster/sample/service/MailService.java index 73cfe1835..4c90cecf3 100644 --- a/src/main/java/io/github/jhipster/sample/service/MailService.java +++ b/src/main/java/io/github/jhipster/sample/service/MailService.java @@ -1,10 +1,10 @@ package io.github.jhipster.sample.service; import io.github.jhipster.sample.domain.User; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; import java.nio.charset.StandardCharsets; import java.util.Locale; -import javax.mail.MessagingException; -import javax.mail.internet.MimeMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.MessageSource; @@ -14,7 +14,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.thymeleaf.context.Context; -import org.thymeleaf.spring5.SpringTemplateEngine; +import org.thymeleaf.spring6.SpringTemplateEngine; import tech.jhipster.config.JHipsterProperties; /** diff --git a/src/main/java/io/github/jhipster/sample/service/UserService.java b/src/main/java/io/github/jhipster/sample/service/UserService.java index fc7647b4f..6da6914fc 100644 --- a/src/main/java/io/github/jhipster/sample/service/UserService.java +++ b/src/main/java/io/github/jhipster/sample/service/UserService.java @@ -211,6 +211,7 @@ public Optional updateUser(AdminUserDTO userDTO) { .filter(Optional::isPresent) .map(Optional::get) .forEach(managedAuthorities::add); + userRepository.save(user); this.clearUserCaches(user); log.debug("Changed Information for User: {}", user); return user; @@ -249,6 +250,7 @@ public void updateUser(String firstName, String lastName, String email, String l } user.setLangKey(langKey); user.setImageUrl(imageUrl); + userRepository.save(user); this.clearUserCaches(user); log.debug("Changed Information for User: {}", user); }); @@ -313,7 +315,7 @@ public void removeNotActivatedUsers() { */ @Transactional(readOnly = true) public List getAuthorities() { - return authorityRepository.findAll().stream().map(Authority::getName).collect(Collectors.toList()); + return authorityRepository.findAll().stream().map(Authority::getName).toList(); } private void clearUserCaches(User user) { diff --git a/src/main/java/io/github/jhipster/sample/service/dto/AdminUserDTO.java b/src/main/java/io/github/jhipster/sample/service/dto/AdminUserDTO.java index 89f7a0ad4..07a686a3e 100644 --- a/src/main/java/io/github/jhipster/sample/service/dto/AdminUserDTO.java +++ b/src/main/java/io/github/jhipster/sample/service/dto/AdminUserDTO.java @@ -3,11 +3,11 @@ import io.github.jhipster.sample.config.Constants; import io.github.jhipster.sample.domain.Authority; import io.github.jhipster.sample.domain.User; +import jakarta.validation.constraints.*; import java.io.Serializable; import java.time.Instant; import java.util.Set; import java.util.stream.Collectors; -import javax.validation.constraints.*; /** * A DTO representing a user, with his authorities. diff --git a/src/main/java/io/github/jhipster/sample/service/dto/package-info.java b/src/main/java/io/github/jhipster/sample/service/dto/package-info.java index 5df624026..66d446a2d 100644 --- a/src/main/java/io/github/jhipster/sample/service/dto/package-info.java +++ b/src/main/java/io/github/jhipster/sample/service/dto/package-info.java @@ -1,4 +1,4 @@ /** - * Data Transfer Objects. + * Data transfer objects for rest mapping. */ package io.github.jhipster.sample.service.dto; diff --git a/src/main/java/io/github/jhipster/sample/service/mapper/UserMapper.java b/src/main/java/io/github/jhipster/sample/service/mapper/UserMapper.java index 1684667e3..e35a348a1 100644 --- a/src/main/java/io/github/jhipster/sample/service/mapper/UserMapper.java +++ b/src/main/java/io/github/jhipster/sample/service/mapper/UserMapper.java @@ -21,7 +21,7 @@ public class UserMapper { public List usersToUserDTOs(List users) { - return users.stream().filter(Objects::nonNull).map(this::userToUserDTO).collect(Collectors.toList()); + return users.stream().filter(Objects::nonNull).map(this::userToUserDTO).toList(); } public UserDTO userToUserDTO(User user) { @@ -29,7 +29,7 @@ public UserDTO userToUserDTO(User user) { } public List usersToAdminUserDTOs(List users) { - return users.stream().filter(Objects::nonNull).map(this::userToAdminUserDTO).collect(Collectors.toList()); + return users.stream().filter(Objects::nonNull).map(this::userToAdminUserDTO).toList(); } public AdminUserDTO userToAdminUserDTO(User user) { @@ -37,7 +37,7 @@ public AdminUserDTO userToAdminUserDTO(User user) { } public List userDTOsToUsers(List userDTOs) { - return userDTOs.stream().filter(Objects::nonNull).map(this::userDTOToUser).collect(Collectors.toList()); + return userDTOs.stream().filter(Objects::nonNull).map(this::userDTOToUser).toList(); } public User userDTOToUser(AdminUserDTO userDTO) { diff --git a/src/main/java/io/github/jhipster/sample/service/mapper/package-info.java b/src/main/java/io/github/jhipster/sample/service/mapper/package-info.java index fd3db9331..f302380f7 100644 --- a/src/main/java/io/github/jhipster/sample/service/mapper/package-info.java +++ b/src/main/java/io/github/jhipster/sample/service/mapper/package-info.java @@ -1,4 +1,4 @@ /** - * MapStruct mappers for mapping domain objects and Data Transfer Objects. + * Data transfer objects mappers. */ package io.github.jhipster.sample.service.mapper; diff --git a/src/main/java/io/github/jhipster/sample/service/package-info.java b/src/main/java/io/github/jhipster/sample/service/package-info.java index 39ae575ae..a4ea907f0 100644 --- a/src/main/java/io/github/jhipster/sample/service/package-info.java +++ b/src/main/java/io/github/jhipster/sample/service/package-info.java @@ -1,4 +1,4 @@ /** - * Service layer beans. + * Service layer. */ package io.github.jhipster.sample.service; diff --git a/src/main/java/io/github/jhipster/sample/web/filter/SpaWebFilter.java b/src/main/java/io/github/jhipster/sample/web/filter/SpaWebFilter.java new file mode 100644 index 000000000..8f871c690 --- /dev/null +++ b/src/main/java/io/github/jhipster/sample/web/filter/SpaWebFilter.java @@ -0,0 +1,34 @@ +package io.github.jhipster.sample.web.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.web.filter.OncePerRequestFilter; + +public class SpaWebFilter extends OncePerRequestFilter { + + /** + * Forwards any unmapped paths (except those containing a period) to the client {@code index.html}. + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // Request URI includes the contextPath if any, removed it. + String path = request.getRequestURI().substring(request.getContextPath().length()); + if ( + !path.startsWith("/api") && + !path.startsWith("/management") && + !path.startsWith("/v3/api-docs") && + !path.startsWith("/h2-console") && + !path.contains(".") && + path.matches("/(.*)") + ) { + request.getRequestDispatcher("/index.html").forward(request, response); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/io/github/jhipster/sample/web/filter/package-info.java b/src/main/java/io/github/jhipster/sample/web/filter/package-info.java new file mode 100644 index 000000000..2ff72f622 --- /dev/null +++ b/src/main/java/io/github/jhipster/sample/web/filter/package-info.java @@ -0,0 +1,4 @@ +/** + * Request chain filters. + */ +package io.github.jhipster.sample.web.filter; diff --git a/src/main/java/io/github/jhipster/sample/web/rest/AccountResource.java b/src/main/java/io/github/jhipster/sample/web/rest/AccountResource.java index 9ee6857fa..34ea30586 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/AccountResource.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/AccountResource.java @@ -10,9 +10,8 @@ import io.github.jhipster.sample.web.rest.errors.*; import io.github.jhipster.sample.web.rest.vm.KeyAndPasswordVM; import io.github.jhipster.sample.web.rest.vm.ManagedUserVM; +import jakarta.validation.Valid; import java.util.*; -import javax.servlet.http.HttpServletRequest; -import javax.validation.Valid; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -79,18 +78,6 @@ public void activateAccount(@RequestParam(value = "key") String key) { } } - /** - * {@code GET /authenticate} : check if the user is authenticated, and return its login. - * - * @param request the HTTP request. - * @return the login if the user is authenticated. - */ - @GetMapping("/authenticate") - public String isAuthenticated(HttpServletRequest request) { - log.debug("REST request to check if the current user is authenticated"); - return request.getRemoteUser(); - } - /** * {@code GET /account} : get the current user. * @@ -118,7 +105,7 @@ public void saveAccount(@Valid @RequestBody AdminUserDTO userDTO) { .getCurrentUserLogin() .orElseThrow(() -> new AccountResourceException("Current user login not found")); Optional existingUser = userRepository.findOneByEmailIgnoreCase(userDTO.getEmail()); - if (existingUser.isPresent() && (!existingUser.get().getLogin().equalsIgnoreCase(userLogin))) { + if (existingUser.isPresent() && (!existingUser.orElseThrow().getLogin().equalsIgnoreCase(userLogin))) { throw new EmailAlreadyUsedException(); } Optional user = userRepository.findOneByLogin(userLogin); @@ -157,7 +144,7 @@ public void changePassword(@RequestBody PasswordChangeDTO passwordChangeDto) { public void requestPasswordReset(@RequestBody String mail) { Optional user = userService.requestPasswordReset(mail); if (user.isPresent()) { - mailService.sendPasswordResetMail(user.get()); + mailService.sendPasswordResetMail(user.orElseThrow()); } else { // Pretend the request has been successful to prevent checking which emails really exist // but log that an invalid attempt has been made diff --git a/src/main/java/io/github/jhipster/sample/web/rest/AuthenticateController.java b/src/main/java/io/github/jhipster/sample/web/rest/AuthenticateController.java new file mode 100644 index 000000000..6a7d7480c --- /dev/null +++ b/src/main/java/io/github/jhipster/sample/web/rest/AuthenticateController.java @@ -0,0 +1,124 @@ +package io.github.jhipster.sample.web.rest; + +import static io.github.jhipster.sample.security.SecurityUtils.AUTHORITIES_KEY; +import static io.github.jhipster.sample.security.SecurityUtils.JWT_ALGORITHM; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.github.jhipster.sample.web.rest.vm.LoginVM; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.web.bind.annotation.*; + +/** + * Controller to authenticate users. + */ +@RestController +@RequestMapping("/api") +public class AuthenticateController { + + private final Logger log = LoggerFactory.getLogger(AuthenticateController.class); + + private final JwtEncoder jwtEncoder; + + @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds:0}") + private long tokenValidityInSeconds; + + @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me:0}") + private long tokenValidityInSecondsForRememberMe; + + private final AuthenticationManagerBuilder authenticationManagerBuilder; + + public AuthenticateController(JwtEncoder jwtEncoder, AuthenticationManagerBuilder authenticationManagerBuilder) { + this.jwtEncoder = jwtEncoder; + this.authenticationManagerBuilder = authenticationManagerBuilder; + } + + @PostMapping("/authenticate") + public ResponseEntity authorize(@Valid @RequestBody LoginVM loginVM) { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + loginVM.getUsername(), + loginVM.getPassword() + ); + + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + String jwt = this.createToken(authentication, loginVM.isRememberMe()); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setBearerAuth(jwt); + return new ResponseEntity<>(new JWTToken(jwt), httpHeaders, HttpStatus.OK); + } + + /** + * {@code GET /authenticate} : check if the user is authenticated, and return its login. + * + * @param request the HTTP request. + * @return the login if the user is authenticated. + */ + @GetMapping("/authenticate") + public String isAuthenticated(HttpServletRequest request) { + log.debug("REST request to check if the current user is authenticated"); + return request.getRemoteUser(); + } + + public String createToken(Authentication authentication, boolean rememberMe) { + String authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(" ")); + + Instant now = Instant.now(); + Instant validity; + if (rememberMe) { + validity = now.plus(this.tokenValidityInSecondsForRememberMe, ChronoUnit.SECONDS); + } else { + validity = now.plus(this.tokenValidityInSeconds, ChronoUnit.SECONDS); + } + + // @formatter:off + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuedAt(now) + .expiresAt(validity) + .subject(authentication.getName()) + .claim(AUTHORITIES_KEY, authorities) + .build(); + + JwsHeader jwsHeader = JwsHeader.with(JWT_ALGORITHM).build(); + return this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)).getTokenValue(); + } + + /** + * Object to return as body in JWT Authentication. + */ + static class JWTToken { + + private String idToken; + + JWTToken(String idToken) { + this.idToken = idToken; + } + + @JsonProperty("id_token") + String getIdToken() { + return idToken; + } + + void setIdToken(String idToken) { + this.idToken = idToken; + } + } +} diff --git a/src/main/java/io/github/jhipster/sample/web/rest/BankAccountResource.java b/src/main/java/io/github/jhipster/sample/web/rest/BankAccountResource.java index a83565715..2abebfe5b 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/BankAccountResource.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/BankAccountResource.java @@ -3,13 +3,13 @@ import io.github.jhipster.sample.domain.BankAccount; import io.github.jhipster.sample.repository.BankAccountRepository; import io.github.jhipster.sample.web.rest.errors.BadRequestAlertException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import java.net.URI; import java.net.URISyntaxException; import java.util.List; import java.util.Objects; import java.util.Optional; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/io/github/jhipster/sample/web/rest/ClientForwardController.java b/src/main/java/io/github/jhipster/sample/web/rest/ClientForwardController.java deleted file mode 100644 index a8f83973d..000000000 --- a/src/main/java/io/github/jhipster/sample/web/rest/ClientForwardController.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.github.jhipster.sample.web.rest; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; - -@Controller -public class ClientForwardController { - - /** - * Forwards any unmapped paths (except those containing a period) to the client {@code index.html}. - * @return forward to client {@code index.html}. - */ - @GetMapping(value = "/**/{path:[^\\.]*}") - public String forward() { - return "forward:/"; - } -} diff --git a/src/main/java/io/github/jhipster/sample/web/rest/LabelResource.java b/src/main/java/io/github/jhipster/sample/web/rest/LabelResource.java index f7b5518e3..fc761a0fb 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/LabelResource.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/LabelResource.java @@ -3,13 +3,13 @@ import io.github.jhipster.sample.domain.Label; import io.github.jhipster.sample.repository.LabelRepository; import io.github.jhipster.sample.web.rest.errors.BadRequestAlertException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import java.net.URI; import java.net.URISyntaxException; import java.util.List; import java.util.Objects; import java.util.Optional; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/io/github/jhipster/sample/web/rest/OperationResource.java b/src/main/java/io/github/jhipster/sample/web/rest/OperationResource.java index 81d033a2a..e1b47009c 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/OperationResource.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/OperationResource.java @@ -3,20 +3,19 @@ import io.github.jhipster.sample.domain.Operation; import io.github.jhipster.sample.repository.OperationRepository; import io.github.jhipster.sample.web.rest.errors.BadRequestAlertException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import java.net.URI; import java.net.URISyntaxException; import java.util.List; import java.util.Objects; import java.util.Optional; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; @@ -160,7 +159,7 @@ public ResponseEntity partialUpdateOperation( */ @GetMapping("/operations") public ResponseEntity> getAllOperations( - @org.springdoc.api.annotations.ParameterObject Pageable pageable, + @org.springdoc.core.annotations.ParameterObject Pageable pageable, @RequestParam(required = false, defaultValue = "false") boolean eagerload ) { log.debug("REST request to get a page of Operations"); diff --git a/src/main/java/io/github/jhipster/sample/web/rest/PublicUserResource.java b/src/main/java/io/github/jhipster/sample/web/rest/PublicUserResource.java index 1e9956f7b..6db110b7f 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/PublicUserResource.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/PublicUserResource.java @@ -39,7 +39,7 @@ public PublicUserResource(UserService userService) { * @return the {@link ResponseEntity} with status {@code 200 (OK)} and with body all users. */ @GetMapping("/users") - public ResponseEntity> getAllPublicUsers(@org.springdoc.api.annotations.ParameterObject Pageable pageable) { + public ResponseEntity> getAllPublicUsers(@org.springdoc.core.annotations.ParameterObject Pageable pageable) { log.debug("REST request to get all public User names"); if (!onlyContainsAllowedProperties(pageable)) { return ResponseEntity.badRequest().build(); diff --git a/src/main/java/io/github/jhipster/sample/web/rest/UserJWTController.java b/src/main/java/io/github/jhipster/sample/web/rest/UserJWTController.java deleted file mode 100644 index cdc088c2b..000000000 --- a/src/main/java/io/github/jhipster/sample/web/rest/UserJWTController.java +++ /dev/null @@ -1,68 +0,0 @@ -package io.github.jhipster.sample.web.rest; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.github.jhipster.sample.security.jwt.JWTFilter; -import io.github.jhipster.sample.security.jwt.TokenProvider; -import io.github.jhipster.sample.web.rest.vm.LoginVM; -import javax.validation.Valid; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.*; - -/** - * Controller to authenticate users. - */ -@RestController -@RequestMapping("/api") -public class UserJWTController { - - private final TokenProvider tokenProvider; - - private final AuthenticationManagerBuilder authenticationManagerBuilder; - - public UserJWTController(TokenProvider tokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder) { - this.tokenProvider = tokenProvider; - this.authenticationManagerBuilder = authenticationManagerBuilder; - } - - @PostMapping("/authenticate") - public ResponseEntity authorize(@Valid @RequestBody LoginVM loginVM) { - UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( - loginVM.getUsername(), - loginVM.getPassword() - ); - - Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); - SecurityContextHolder.getContext().setAuthentication(authentication); - String jwt = tokenProvider.createToken(authentication, loginVM.isRememberMe()); - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.add(JWTFilter.AUTHORIZATION_HEADER, "Bearer " + jwt); - return new ResponseEntity<>(new JWTToken(jwt), httpHeaders, HttpStatus.OK); - } - - /** - * Object to return as body in JWT Authentication. - */ - static class JWTToken { - - private String idToken; - - JWTToken(String idToken) { - this.idToken = idToken; - } - - @JsonProperty("id_token") - String getIdToken() { - return idToken; - } - - void setIdToken(String idToken) { - this.idToken = idToken; - } - } -} diff --git a/src/main/java/io/github/jhipster/sample/web/rest/UserResource.java b/src/main/java/io/github/jhipster/sample/web/rest/UserResource.java index a6f822d12..c0d3b8187 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/UserResource.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/UserResource.java @@ -10,12 +10,12 @@ import io.github.jhipster.sample.web.rest.errors.BadRequestAlertException; import io.github.jhipster.sample.web.rest.errors.EmailAlreadyUsedException; import io.github.jhipster.sample.web.rest.errors.LoginAlreadyUsedException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; import java.net.URI; import java.net.URISyntaxException; import java.util.*; import java.util.Collections; -import javax.validation.Valid; -import javax.validation.constraints.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -140,11 +140,11 @@ public ResponseEntity createUser(@Valid @RequestBody AdminUserDTO userDTO) public ResponseEntity updateUser(@Valid @RequestBody AdminUserDTO userDTO) { log.debug("REST request to update User : {}", userDTO); Optional existingUser = userRepository.findOneByEmailIgnoreCase(userDTO.getEmail()); - if (existingUser.isPresent() && (!existingUser.get().getId().equals(userDTO.getId()))) { + if (existingUser.isPresent() && (!existingUser.orElseThrow().getId().equals(userDTO.getId()))) { throw new EmailAlreadyUsedException(); } existingUser = userRepository.findOneByLogin(userDTO.getLogin().toLowerCase()); - if (existingUser.isPresent() && (!existingUser.get().getId().equals(userDTO.getId()))) { + if (existingUser.isPresent() && (!existingUser.orElseThrow().getId().equals(userDTO.getId()))) { throw new LoginAlreadyUsedException(); } Optional updatedUser = userService.updateUser(userDTO); @@ -163,7 +163,7 @@ public ResponseEntity updateUser(@Valid @RequestBody AdminUserDTO */ @GetMapping("/users") @PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")") - public ResponseEntity> getAllUsers(@org.springdoc.api.annotations.ParameterObject Pageable pageable) { + public ResponseEntity> getAllUsers(@org.springdoc.core.annotations.ParameterObject Pageable pageable) { log.debug("REST request to get all User for an admin"); if (!onlyContainsAllowedProperties(pageable)) { return ResponseEntity.badRequest().build(); diff --git a/src/main/java/io/github/jhipster/sample/web/rest/errors/BadRequestAlertException.java b/src/main/java/io/github/jhipster/sample/web/rest/errors/BadRequestAlertException.java index 6a4cc7ec3..1ea059995 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/errors/BadRequestAlertException.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/errors/BadRequestAlertException.java @@ -1,13 +1,13 @@ package io.github.jhipster.sample.web.rest.errors; import java.net.URI; -import java.util.HashMap; -import java.util.Map; -import org.zalando.problem.AbstractThrowableProblem; -import org.zalando.problem.Status; +import org.springframework.http.HttpStatus; +import org.springframework.web.ErrorResponseException; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause.ProblemDetailWithCauseBuilder; @SuppressWarnings("java:S110") // Inheritance tree of classes should not be too deep -public class BadRequestAlertException extends AbstractThrowableProblem { +public class BadRequestAlertException extends ErrorResponseException { private static final long serialVersionUID = 1L; @@ -20,7 +20,18 @@ public BadRequestAlertException(String defaultMessage, String entityName, String } public BadRequestAlertException(URI type, String defaultMessage, String entityName, String errorKey) { - super(type, defaultMessage, Status.BAD_REQUEST, null, null, null, getAlertParameters(entityName, errorKey)); + super( + HttpStatus.BAD_REQUEST, + ProblemDetailWithCauseBuilder + .instance() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withType(type) + .withTitle(defaultMessage) + .withProperty("message", "error." + errorKey) + .withProperty("params", entityName) + .build(), + null + ); this.entityName = entityName; this.errorKey = errorKey; } @@ -33,10 +44,7 @@ public String getErrorKey() { return errorKey; } - private static Map getAlertParameters(String entityName, String errorKey) { - Map parameters = new HashMap<>(); - parameters.put("message", "error." + errorKey); - parameters.put("params", entityName); - return parameters; + public ProblemDetailWithCause getProblemDetailWithCause() { + return (ProblemDetailWithCause) this.getBody(); } } diff --git a/src/main/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslator.java b/src/main/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslator.java index 4762f03db..e4456be6f 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslator.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslator.java @@ -1,35 +1,39 @@ package io.github.jhipster.sample.web.rest.errors; +import static org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation; + +import jakarta.servlet.http.HttpServletRequest; import java.net.URI; import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import javax.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.env.Environment; import org.springframework.dao.ConcurrencyFailureException; import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageConversionException; -import org.springframework.validation.BindingResult; +import org.springframework.lang.Nullable; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.ErrorResponse; +import org.springframework.web.ErrorResponseException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.context.request.NativeWebRequest; -import org.zalando.problem.DefaultProblem; -import org.zalando.problem.Problem; -import org.zalando.problem.ProblemBuilder; -import org.zalando.problem.Status; -import org.zalando.problem.StatusType; -import org.zalando.problem.spring.web.advice.ProblemHandling; -import org.zalando.problem.spring.web.advice.security.SecurityAdviceTrait; -import org.zalando.problem.violations.ConstraintViolationProblem; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import tech.jhipster.config.JHipsterConstants; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause.ProblemDetailWithCauseBuilder; import tech.jhipster.web.util.HeaderUtil; /** @@ -37,12 +41,12 @@ * The error response follows RFC7807 - Problem Details for HTTP APIs (https://tools.ietf.org/html/rfc7807). */ @ControllerAdvice -public class ExceptionTranslator implements ProblemHandling, SecurityAdviceTrait { +public class ExceptionTranslator extends ResponseEntityExceptionHandler { private static final String FIELD_ERRORS_KEY = "fieldErrors"; private static final String MESSAGE_KEY = "message"; private static final String PATH_KEY = "path"; - private static final String VIOLATIONS_KEY = "violations"; + private static final boolean CASUAL_CHAIN_ENABLED = false; @Value("${jhipster.clientApp.name}") private String applicationName; @@ -53,46 +57,90 @@ public ExceptionTranslator(Environment env) { this.env = env; } - /** - * Post-process the Problem payload to add the message key for the front-end if needed. - */ + @ExceptionHandler + public ResponseEntity handleAnyException(Throwable ex, NativeWebRequest request) { + ProblemDetailWithCause pdCause = wrapAndCustomizeProblem(ex, request); + return handleExceptionInternal((Exception) ex, pdCause, buildHeaders(ex), HttpStatusCode.valueOf(pdCause.getStatus()), request); + } + + @Nullable @Override - public ResponseEntity process(@Nullable ResponseEntity entity, NativeWebRequest request) { - if (entity == null) { - return null; + protected ResponseEntity handleExceptionInternal( + Exception ex, + @Nullable Object body, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest request + ) { + body = body == null ? wrapAndCustomizeProblem((Throwable) ex, (NativeWebRequest) request) : body; + return super.handleExceptionInternal(ex, body, headers, statusCode, request); + } + + protected ProblemDetailWithCause wrapAndCustomizeProblem(Throwable ex, NativeWebRequest request) { + return customizeProblem(getProblemDetailWithCause(ex), ex, request); + } + + private ProblemDetailWithCause getProblemDetailWithCause(Throwable ex) { + if ( + ex instanceof io.github.jhipster.sample.service.EmailAlreadyUsedException || + ex instanceof io.github.jhipster.sample.service.UsernameAlreadyUsedException + ) { + // return 201 - CREATED on purpose to not reveal information to potential attackers + // see https://github.com/jhipster/generator-jhipster/issues/21731 + return ProblemDetailWithCauseBuilder.instance().withStatus(201).build(); } - Problem problem = entity.getBody(); - if (!(problem instanceof ConstraintViolationProblem || problem instanceof DefaultProblem)) { - return entity; + if ( + ex instanceof io.github.jhipster.sample.service.InvalidPasswordException + ) return (ProblemDetailWithCause) new InvalidPasswordException().getBody(); + + if ( + ex instanceof ErrorResponseException exp && exp.getBody() instanceof ProblemDetailWithCause problemDetailWithCause + ) return problemDetailWithCause; + return ProblemDetailWithCauseBuilder.instance().withStatus(toStatus(ex).value()).build(); + } + + protected ProblemDetailWithCause customizeProblem(ProblemDetailWithCause problem, Throwable err, NativeWebRequest request) { + if (problem.getStatus() <= 0) problem.setStatus(toStatus(err)); + + if (problem.getType() == null || problem.getType().equals(URI.create("about:blank"))) problem.setType(getMappedType(err)); + + // higher precedence to Custom/ResponseStatus types + String title = extractTitle(err, problem.getStatus()); + String problemTitle = problem.getTitle(); + if (problemTitle == null || !problemTitle.equals(title)) { + problem.setTitle(title); } - HttpServletRequest nativeRequest = request.getNativeRequest(HttpServletRequest.class); - String requestUri = nativeRequest != null ? nativeRequest.getRequestURI() : StringUtils.EMPTY; - ProblemBuilder builder = Problem - .builder() - .withType(Problem.DEFAULT_TYPE.equals(problem.getType()) ? ErrorConstants.DEFAULT_TYPE : problem.getType()) - .withStatus(problem.getStatus()) - .withTitle(problem.getTitle()) - .with(PATH_KEY, requestUri); - - if (problem instanceof ConstraintViolationProblem) { - builder - .with(VIOLATIONS_KEY, ((ConstraintViolationProblem) problem).getViolations()) - .with(MESSAGE_KEY, ErrorConstants.ERR_VALIDATION); - } else { - builder.withCause(((DefaultProblem) problem).getCause()).withDetail(problem.getDetail()).withInstance(problem.getInstance()); - problem.getParameters().forEach(builder::with); - if (!problem.getParameters().containsKey(MESSAGE_KEY) && problem.getStatus() != null) { - builder.with(MESSAGE_KEY, "error.http." + problem.getStatus().getStatusCode()); - } + if (problem.getDetail() == null) { + // higher precedence to cause + problem.setDetail(getCustomizedErrorDetails(err)); } - return new ResponseEntity<>(builder.build(), entity.getHeaders(), entity.getStatusCode()); + + Map problemProperties = problem.getProperties(); + if (problemProperties == null || !problemProperties.containsKey(MESSAGE_KEY)) problem.setProperty( + MESSAGE_KEY, + getMappedMessageKey(err) != null ? getMappedMessageKey(err) : "error.http." + problem.getStatus() + ); + + if (problemProperties == null || !problemProperties.containsKey(PATH_KEY)) problem.setProperty(PATH_KEY, getPathValue(request)); + + if ( + (err instanceof MethodArgumentNotValidException fieldException) && + (problemProperties == null || !problemProperties.containsKey(FIELD_ERRORS_KEY)) + ) problem.setProperty(FIELD_ERRORS_KEY, getFieldErrors(fieldException)); + + problem.setCause(buildCause(err.getCause(), request).orElse(null)); + + return problem; } - @Override - public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, @Nonnull NativeWebRequest request) { - BindingResult result = ex.getBindingResult(); - List fieldErrors = result + private String extractTitle(Throwable err, int statusCode) { + return getCustomizedTitle(err) != null ? getCustomizedTitle(err) : extractTitleForResponseStatus(err, statusCode); + } + + private List getFieldErrors(MethodArgumentNotValidException ex) { + return ex + .getBindingResult() .getFieldErrors() .stream() .map(f -> @@ -102,121 +150,118 @@ public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotVal StringUtils.isNotBlank(f.getDefaultMessage()) ? f.getDefaultMessage() : f.getCode() ) ) - .collect(Collectors.toList()); + .toList(); + } - Problem problem = Problem - .builder() - .withType(ErrorConstants.CONSTRAINT_VIOLATION_TYPE) - .withTitle("Method argument not valid") - .withStatus(defaultConstraintViolationStatus()) - .with(MESSAGE_KEY, ErrorConstants.ERR_VALIDATION) - .with(FIELD_ERRORS_KEY, fieldErrors) - .build(); - return create(ex, problem, request); + private String extractTitleForResponseStatus(Throwable err, int statusCode) { + ResponseStatus specialStatus = extractResponseStatus(err); + return specialStatus == null ? HttpStatus.valueOf(statusCode).getReasonPhrase() : specialStatus.reason(); } - @ExceptionHandler - public ResponseEntity handleEmailAlreadyUsedException( - io.github.jhipster.sample.service.EmailAlreadyUsedException ex, - NativeWebRequest request - ) { - EmailAlreadyUsedException problem = new EmailAlreadyUsedException(); - return create( - problem, - request, - HeaderUtil.createFailureAlert(applicationName, true, problem.getEntityName(), problem.getErrorKey(), problem.getMessage()) - ); + private String extractURI(NativeWebRequest request) { + HttpServletRequest nativeRequest = request.getNativeRequest(HttpServletRequest.class); + return nativeRequest != null ? nativeRequest.getRequestURI() : StringUtils.EMPTY; } - @ExceptionHandler - public ResponseEntity handleUsernameAlreadyUsedException( - io.github.jhipster.sample.service.UsernameAlreadyUsedException ex, - NativeWebRequest request - ) { - LoginAlreadyUsedException problem = new LoginAlreadyUsedException(); - return create( - problem, - request, - HeaderUtil.createFailureAlert(applicationName, true, problem.getEntityName(), problem.getErrorKey(), problem.getMessage()) - ); + private HttpStatus toStatus(final Throwable throwable) { + // Let the ErrorResponse take this responsibility + if (throwable instanceof ErrorResponse err) return HttpStatus.valueOf(err.getBody().getStatus()); + + return Optional + .ofNullable(getMappedStatus(throwable)) + .orElse( + Optional.ofNullable(resolveResponseStatus(throwable)).map(ResponseStatus::value).orElse(HttpStatus.INTERNAL_SERVER_ERROR) + ); } - @ExceptionHandler - public ResponseEntity handleInvalidPasswordException( - io.github.jhipster.sample.service.InvalidPasswordException ex, - NativeWebRequest request - ) { - return create(new InvalidPasswordException(), request); + private ResponseStatus extractResponseStatus(final Throwable throwable) { + return Optional.ofNullable(resolveResponseStatus(throwable)).orElse(null); } - @ExceptionHandler - public ResponseEntity handleBadRequestAlertException(BadRequestAlertException ex, NativeWebRequest request) { - return create( - ex, - request, - HeaderUtil.createFailureAlert(applicationName, true, ex.getEntityName(), ex.getErrorKey(), ex.getMessage()) - ); + private ResponseStatus resolveResponseStatus(final Throwable type) { + final ResponseStatus candidate = findMergedAnnotation(type.getClass(), ResponseStatus.class); + return candidate == null && type.getCause() != null ? resolveResponseStatus(type.getCause()) : candidate; } - @ExceptionHandler - public ResponseEntity handleConcurrencyFailure(ConcurrencyFailureException ex, NativeWebRequest request) { - Problem problem = Problem.builder().withStatus(Status.CONFLICT).with(MESSAGE_KEY, ErrorConstants.ERR_CONCURRENCY_FAILURE).build(); - return create(ex, problem, request); + private URI getMappedType(Throwable err) { + if (err instanceof MethodArgumentNotValidException) return ErrorConstants.CONSTRAINT_VIOLATION_TYPE; + return ErrorConstants.DEFAULT_TYPE; } - @Override - public ProblemBuilder prepare(final Throwable throwable, final StatusType status, final URI type) { - Collection activeProfiles = Arrays.asList(env.getActiveProfiles()); + private String getMappedMessageKey(Throwable err) { + if (err instanceof MethodArgumentNotValidException) { + return ErrorConstants.ERR_VALIDATION; + } else if (err instanceof ConcurrencyFailureException || err.getCause() instanceof ConcurrencyFailureException) { + return ErrorConstants.ERR_CONCURRENCY_FAILURE; + } + return null; + } + + private String getCustomizedTitle(Throwable err) { + if (err instanceof MethodArgumentNotValidException) return "Method argument not valid"; + return null; + } + private String getCustomizedErrorDetails(Throwable err) { + Collection activeProfiles = Arrays.asList(env.getActiveProfiles()); if (activeProfiles.contains(JHipsterConstants.SPRING_PROFILE_PRODUCTION)) { - if (throwable instanceof HttpMessageConversionException) { - return Problem - .builder() - .withType(type) - .withTitle(status.getReasonPhrase()) - .withStatus(status) - .withDetail("Unable to convert http message") - .withCause( - Optional.ofNullable(throwable.getCause()).filter(cause -> isCausalChainsEnabled()).map(this::toProblem).orElse(null) - ); - } - if (throwable instanceof DataAccessException) { - return Problem - .builder() - .withType(type) - .withTitle(status.getReasonPhrase()) - .withStatus(status) - .withDetail("Failure during data access") - .withCause( - Optional.ofNullable(throwable.getCause()).filter(cause -> isCausalChainsEnabled()).map(this::toProblem).orElse(null) - ); - } - if (containsPackageName(throwable.getMessage())) { - return Problem - .builder() - .withType(type) - .withTitle(status.getReasonPhrase()) - .withStatus(status) - .withDetail("Unexpected runtime exception") - .withCause( - Optional.ofNullable(throwable.getCause()).filter(cause -> isCausalChainsEnabled()).map(this::toProblem).orElse(null) - ); - } + if (err instanceof HttpMessageConversionException) return "Unable to convert http message"; + if (err instanceof DataAccessException) return "Failure during data access"; + if (containsPackageName(err.getMessage())) return "Unexpected runtime exception"; } + return err.getCause() != null ? err.getCause().getMessage() : err.getMessage(); + } - return Problem - .builder() - .withType(type) - .withTitle(status.getReasonPhrase()) - .withStatus(status) - .withDetail(throwable.getMessage()) - .withCause( - Optional.ofNullable(throwable.getCause()).filter(cause -> isCausalChainsEnabled()).map(this::toProblem).orElse(null) - ); + private HttpStatus getMappedStatus(Throwable err) { + // Where we disagree with Spring defaults + if (err instanceof AccessDeniedException) return HttpStatus.FORBIDDEN; + if (err instanceof ConcurrencyFailureException) return HttpStatus.CONFLICT; + if (err instanceof BadCredentialsException) return HttpStatus.UNAUTHORIZED; + return null; + } + + private URI getPathValue(NativeWebRequest request) { + if (request == null) return URI.create("about:blank"); + return URI.create(extractURI(request)); + } + + private HttpHeaders buildHeaders(Throwable err) { + return err instanceof BadRequestAlertException badRequestAlertException + ? HeaderUtil.createFailureAlert( + applicationName, + true, + badRequestAlertException.getEntityName(), + badRequestAlertException.getErrorKey(), + badRequestAlertException.getMessage() + ) + : null; + } + + public Optional buildCause(final Throwable throwable, NativeWebRequest request) { + if (throwable != null && isCasualChainEnabled()) { + return Optional.of(customizeProblem(getProblemDetailWithCause(throwable), throwable, request)); + } + return Optional.ofNullable(null); + } + + private boolean isCasualChainEnabled() { + // Customize as per the needs + return CASUAL_CHAIN_ENABLED; } private boolean containsPackageName(String message) { // This list is for sure not complete - return StringUtils.containsAny(message, "org.", "java.", "net.", "javax.", "com.", "io.", "de.", "io.github.jhipster.sample"); + return StringUtils.containsAny( + message, + "org.", + "java.", + "net.", + "jakarta.", + "javax.", + "com.", + "io.", + "de.", + "io.github.jhipster.sample" + ); } } diff --git a/src/main/java/io/github/jhipster/sample/web/rest/errors/InvalidPasswordException.java b/src/main/java/io/github/jhipster/sample/web/rest/errors/InvalidPasswordException.java index f1b35e4f6..ee14dcb5e 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/errors/InvalidPasswordException.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/errors/InvalidPasswordException.java @@ -1,14 +1,24 @@ package io.github.jhipster.sample.web.rest.errors; -import org.zalando.problem.AbstractThrowableProblem; -import org.zalando.problem.Status; +import org.springframework.http.HttpStatus; +import org.springframework.web.ErrorResponseException; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause.ProblemDetailWithCauseBuilder; @SuppressWarnings("java:S110") // Inheritance tree of classes should not be too deep -public class InvalidPasswordException extends AbstractThrowableProblem { +public class InvalidPasswordException extends ErrorResponseException { private static final long serialVersionUID = 1L; public InvalidPasswordException() { - super(ErrorConstants.INVALID_PASSWORD_TYPE, "Incorrect password", Status.BAD_REQUEST); + super( + HttpStatus.BAD_REQUEST, + ProblemDetailWithCauseBuilder + .instance() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withType(ErrorConstants.INVALID_PASSWORD_TYPE) + .withTitle("Incorrect password") + .build(), + null + ); } } diff --git a/src/main/java/io/github/jhipster/sample/web/rest/errors/package-info.java b/src/main/java/io/github/jhipster/sample/web/rest/errors/package-info.java index 14d01d335..bd857ca1e 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/errors/package-info.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/errors/package-info.java @@ -1,6 +1,4 @@ /** - * Specific errors used with Zalando's "problem-spring-web" library. - * - * More information on https://github.com/zalando/problem-spring-web + * Rest layer error handling. */ package io.github.jhipster.sample.web.rest.errors; diff --git a/src/main/java/io/github/jhipster/sample/web/rest/package-info.java b/src/main/java/io/github/jhipster/sample/web/rest/package-info.java index 6921f39cc..2183fd0c5 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/package-info.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/package-info.java @@ -1,4 +1,4 @@ /** - * Spring MVC REST controllers. + * Rest layer. */ package io.github.jhipster.sample.web.rest; diff --git a/src/main/java/io/github/jhipster/sample/web/rest/vm/LoginVM.java b/src/main/java/io/github/jhipster/sample/web/rest/vm/LoginVM.java index e92f17339..5aa190ca2 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/vm/LoginVM.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/vm/LoginVM.java @@ -1,7 +1,7 @@ package io.github.jhipster.sample.web.rest.vm; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; /** * View Model object for storing a user's credentials. diff --git a/src/main/java/io/github/jhipster/sample/web/rest/vm/ManagedUserVM.java b/src/main/java/io/github/jhipster/sample/web/rest/vm/ManagedUserVM.java index fb2170ced..94b361358 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/vm/ManagedUserVM.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/vm/ManagedUserVM.java @@ -1,7 +1,7 @@ package io.github.jhipster.sample.web.rest.vm; import io.github.jhipster.sample.service.dto.AdminUserDTO; -import javax.validation.constraints.Size; +import jakarta.validation.constraints.Size; /** * View Model extending the AdminUserDTO, which is meant to be used in the user management UI. diff --git a/src/main/java/io/github/jhipster/sample/web/rest/vm/package-info.java b/src/main/java/io/github/jhipster/sample/web/rest/vm/package-info.java index 258542907..ef70cbc04 100644 --- a/src/main/java/io/github/jhipster/sample/web/rest/vm/package-info.java +++ b/src/main/java/io/github/jhipster/sample/web/rest/vm/package-info.java @@ -1,4 +1,4 @@ /** - * View Models used by Spring MVC REST controllers. + * Rest layer visual models. */ package io.github.jhipster.sample.web.rest.vm; diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt index e0bc55aaf..5be7dbe6f 100644 --- a/src/main/resources/banner.txt +++ b/src/main/resources/banner.txt @@ -6,5 +6,5 @@ ${AnsiColor.GREEN}╚██████╔╝${AnsiColor.RED} ██║ ██║ ████████╗ ██║ ██████╔╝ ██║ ████████╗ ██║ ╚██╗ ${AnsiColor.GREEN} ╚═════╝ ${AnsiColor.RED} ╚═╝ ╚═╝ ╚═══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══════╝ ╚═╝ ╚═╝ -${AnsiColor.BRIGHT_BLUE}:: JHipster 🤓 :: Running Spring Boot ${spring-boot.version} :: +${AnsiColor.BRIGHT_BLUE}:: JHipster 🤓 :: Running Spring Boot ${spring-boot.version} :: Startup profile(s) ${spring.profiles.active} :: :: https://www.jhipster.tech ::${AnsiColor.DEFAULT} diff --git a/src/main/resources/config/application-dev.yml b/src/main/resources/config/application-dev.yml index b36f29562..734c27c16 100644 --- a/src/main/resources/config/application-dev.yml +++ b/src/main/resources/config/application-dev.yml @@ -32,7 +32,7 @@ spring: indent-output: true datasource: type: com.zaxxer.hikari.HikariDataSource - url: jdbc:h2:mem:jhipstersampleapplication;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=LEGACY + url: jdbc:h2:mem:jhipstersampleapplication;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: jhipsterSampleApplication password: hikari: @@ -42,7 +42,6 @@ spring: console: # disable spring boot built-in h2-console since we start it manually with correct configuration enabled: false - jpa: liquibase: # Remove 'faker' if you do not want the sample data to be loaded automatically contexts: dev, faker @@ -97,7 +96,7 @@ jhipster: enabled: false host: localhost port: 5000 - queue-size: 512 + ring-buffer-size: 512 # =================================================================== # Application specific properties # Add your own application properties here, see the ApplicationProperties class diff --git a/src/main/resources/config/application-prod.yml b/src/main/resources/config/application-prod.yml index e074eae65..d61ba8790 100644 --- a/src/main/resources/config/application-prod.yml +++ b/src/main/resources/config/application-prod.yml @@ -20,9 +20,9 @@ logging: io.github.jhipster.sample: INFO management: - metrics: - export: - prometheus: + prometheus: + metrics: + export: enabled: false spring: @@ -39,8 +39,6 @@ spring: hikari: poolName: Hikari auto-commit: false - jpa: - database-platform: tech.jhipster.domain.util.FixedPostgreSQL10Dialect # Replace by 'prod, faker' to add the faker context and have sample data loaded in production liquibase: contexts: prod @@ -98,7 +96,7 @@ jhipster: jwt: # This token must be encoded using Base64 and be at least 256 bits long (you can type `openssl rand -base64 64` on your command line to generate a 512 bits one) # As this is the PRODUCTION configuration, you MUST change the default key, and store it securely: - # - In the JHipster Registry (which includes a Spring Cloud Config server) + # - In the Consul configserver # - In a separate `application-prod.yml` file, in the same folder as your executable JAR file # - In the `JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64_SECRET` environment variable base64-secret: bXktc2VjcmV0LWtleS13aGljaC1zaG91bGQtYmUtY2hhbmdlZC1pbi1wcm9kdWN0aW9uLWFuZC1iZS1iYXNlNjQtZW5jb2RlZAo= @@ -113,7 +111,7 @@ jhipster: enabled: false host: localhost port: 5000 - queue-size: 512 + ring-buffer-size: 512 # =================================================================== # Application specific properties # Add your own application properties here, see the ApplicationProperties class diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index f5b3593f7..2e97f9efb 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -30,20 +30,18 @@ management: base-path: /management exposure: include: - [ - 'configprops', - 'env', - 'health', - 'info', - 'jhimetrics', - 'jhiopenapigroups', - 'logfile', - 'loggers', - 'prometheus', - 'threaddump', - 'caches', - 'liquibase', - ] + - configprops + - env + - health + - info + - jhimetrics + - jhiopenapigroups + - logfile + - loggers + - prometheus + - threaddump + - caches + - liquibase endpoint: health: show-details: when_authorized @@ -65,10 +63,9 @@ management: health: mail: enabled: false # When using the MailService, configure an SMTP server and set this to true - metrics: - export: - # Prometheus is the default metrics backend - prometheus: + prometheus: + metrics: + export: enabled: true step: 60 enable: @@ -114,6 +111,8 @@ spring: open-in-view: false properties: hibernate.jdbc.time_zone: UTC + hibernate.timezone.default_storage: NORMALIZE + hibernate.type.preferred_instant_jdbc_type: TIMESTAMP hibernate.id.new_generator_mappings: true hibernate.connection.provider_disables_autocommit: true hibernate.cache.use_second_level_cache: true @@ -128,15 +127,15 @@ spring: hibernate: ddl-auto: none naming: - physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy messages: basename: i18n/messages main: allow-bean-definition-overriding: true mvc: - pathmatch: - matching-strategy: ant_path_matcher + problemdetails: + enabled: true task: execution: thread-name-prefix: jhipster-sample-application-task- @@ -188,8 +187,8 @@ jhipster: mail: from: jhipsterSampleApplication@localhost api-docs: - default-include-pattern: ${server.servlet.context-path:}/api/** - management-include-pattern: ${server.servlet.context-path:}/management/** + default-include-pattern: /api/** + management-include-pattern: /management/** title: Jhipster Sample Application API description: Jhipster Sample Application API documentation version: 0.0.1 diff --git a/src/main/resources/config/liquibase/changelog/00000000000000_initial_schema.xml b/src/main/resources/config/liquibase/changelog/00000000000000_initial_schema.xml index eaac648ea..5f42c305c 100644 --- a/src/main/resources/config/liquibase/changelog/00000000000000_initial_schema.xml +++ b/src/main/resources/config/liquibase/changelog/00000000000000_initial_schema.xml @@ -78,6 +78,13 @@ + + + - - - - + diff --git a/src/main/resources/config/liquibase/changelog/20150805124838_added_entity_constraints_BankAccount.xml b/src/main/resources/config/liquibase/changelog/20150805124838_added_entity_constraints_BankAccount.xml index 65d974331..e056305c7 100644 --- a/src/main/resources/config/liquibase/changelog/20150805124838_added_entity_constraints_BankAccount.xml +++ b/src/main/resources/config/liquibase/changelog/20150805124838_added_entity_constraints_BankAccount.xml @@ -12,6 +12,7 @@ baseTableName="bank_account" constraintName="fk_bank_account__user_id" referencedColumnNames="id" - referencedTableName="jhi_user"/> + referencedTableName="jhi_user" + /> diff --git a/src/main/resources/config/liquibase/changelog/20150805125054_added_entity_constraints_Operation.xml b/src/main/resources/config/liquibase/changelog/20150805125054_added_entity_constraints_Operation.xml index 118071f7d..10b30f87d 100644 --- a/src/main/resources/config/liquibase/changelog/20150805125054_added_entity_constraints_Operation.xml +++ b/src/main/resources/config/liquibase/changelog/20150805125054_added_entity_constraints_Operation.xml @@ -12,18 +12,21 @@ baseTableName="operation" constraintName="fk_operation__bank_account_id" referencedColumnNames="id" - referencedTableName="bank_account"/> + referencedTableName="bank_account" + /> + referencedTableName="operation" + /> + referencedTableName="label" + /> diff --git a/src/main/resources/config/liquibase/fake-data/bank_account.csv b/src/main/resources/config/liquibase/fake-data/bank_account.csv index 8999823f1..977e03840 100644 --- a/src/main/resources/config/liquibase/fake-data/bank_account.csv +++ b/src/main/resources/config/liquibase/fake-data/bank_account.csv @@ -1,11 +1,11 @@ id;name;balance -1;monitor;17676 -2;TCP;39826 -3;Digitized multi-byte;1602 -4;Fantastic Loan Towels;56737 -5;Movies;98235 -6;Savings Implemented;58341 -7;Gorgeous cross-platform non-volatile;34885 -8;Franc Spurs;16567 -9;Borders Uzbekistan;31332 -10;Montana withdrawal HTTP;3173 +1;pish frantically flow;4359.63 +2;athwart;23158.25 +3;save beyond;17510.99 +4;blah intrigue;14302.93 +5;ah;29896.5 +6;admired as pish;26800.82 +7;miserly;25951.86 +8;obvious;1053.68 +9;the;10696.8 +10;second amongst;5989.02 diff --git a/src/main/resources/config/liquibase/fake-data/label.csv b/src/main/resources/config/liquibase/fake-data/label.csv index d21b7313d..d36df309a 100644 --- a/src/main/resources/config/liquibase/fake-data/label.csv +++ b/src/main/resources/config/liquibase/fake-data/label.csv @@ -1,11 +1,11 @@ id;label -1;deposit -2;repurpose Secured -3;seize invoice -4;Kip -5;encompassing disintermediate CSS -6;XSS -7;Bedfordshire Small -8;connecting User -9;Michigan -10;Loan Strategist +1;elixir waveform +2;barring psst blah +3;brushing +4;postage however excepting +5;yet med +6;provided improbable +7;deceivingly where +8;geez till regulate +9;after regal outlaw +10;smock diff --git a/src/main/resources/config/liquibase/fake-data/operation.csv b/src/main/resources/config/liquibase/fake-data/operation.csv index 194bd648a..2aa2dfef7 100644 --- a/src/main/resources/config/liquibase/fake-data/operation.csv +++ b/src/main/resources/config/liquibase/fake-data/operation.csv @@ -1,11 +1,11 @@ id;date;description;amount -1;2015-08-05T08:48:38;Mississippi Account Associate;13968 -2;2015-08-05T04:22:04;port innovate Persevering;9517 -3;2015-08-04T15:35:56;Hawaii Sausages connecting;52157 -4;2015-08-04T23:59:53;Optimization Rustic Synergistic;14208 -5;2015-08-04T22:10:39;Card Iowa;77596 -6;2015-08-05T03:47:20;mesh Rhode Automotive;8019 -7;2015-08-05T07:24:14;infrastructures Cambridgeshire GB;67835 -8;2015-08-04T19:16:50;Implemented synthesizing program;18655 -9;2015-08-04T21:34:28;Generic RAM;43990 -10;2015-08-05T07:36:05;Bedfordshire partnerships;13274 +1;2015-08-04T18:15:20;yahoo but flippant;26747.54 +2;2015-08-05T11:04:32;lest;4265.22 +3;2015-08-05T00:04:26;reaction;31409.64 +4;2015-08-05T06:17:09;gah breeze thirsty;2603.6 +5;2015-08-05T06:34:29;joshingly;20731.63 +6;2015-08-04T21:53:41;vaguely valiantly necessary;2799.61 +7;2015-08-04T19:08:07;familiar phew gosh;9569.59 +8;2015-08-04T22:47:19;down;24798.72 +9;2015-08-04T19:34:09;ick triumphantly;18487.5 +10;2015-08-05T02:06:09;wan;26751.14 diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index ac3595a55..f8f78bdd4 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -6,13 +6,13 @@ - + - - + + diff --git a/src/main/resources/config/tls/keystore.p12 b/src/main/resources/config/tls/keystore.p12 index abfbe4c35c017415a0a6a94de68f08ce95cfd668..73b95a6b586f0dfbdb2646d9c768ddc2a2fff34e 100644 GIT binary patch delta 2536 zcmVk1&D-iv|lShDe6@4FL=a0Ro_c1u!sz1uT(PAQV8t zG54Ce#?_YzPm;$R>Whh8`zDc!B!4u5d^6ST;Vz}@fF*Q#=RgF41iq+QeiY_5ejX8ipT=SmdQAa$D54J}bla_S~Gd!eJgwCuM2b5j=Loj+1 z)wU24+B49jyzswePg>U8{gdymjOMC!14Fl*0ePj$&8fdO0CF8o zK}6U<2(aiw)Hif|`YP{CL(6p}md~?Txvu$u zu`%KV4zwHxKre^c94HuY)_*4=Mz8alQpxiMj$f>aZ{UJHnTg=cw*qtz$DRCM+b&{G zFLb&`6(eAwyu&)VZ@m6;b^28ADCx(bC-QTc8iwfVJAYspI%2$}b;yil zxIyNi1uJ6FwqO!=qcCvh`A1eG&42!WC!6i@gpML>?~mGq&+?0pFwG%(*BjRuNOels zdr?l?W+NZpxtC27grJg-J;E^d?Tvthk-|mp^*<3VFLRXMNB?w16VS6FKZP&QgP`#p`*XD)Us+IKP_w4!Z`MS%qdi#kC$m0`JI+f{Vi52mY1>kbi1(u9QeS&yicI;P+WP z%+~@iymkOm0Qe3UZR*qxD9T~*E{KF6{x_KisB=Fa`n@s;Ygh-Lm5nqK z3+BjJ1GB-o}B|f!|9Cj;XZFYf5tahQ{(b1d&vN zd3xS^+8lD$_j+YKS`2Uh6p>uzek_AEsGdVReH#jkq+XYG#(uMi#e*fbR;I5!}9!3ud z10)dYk%}aL5Z~jWP3sfH!yI|{8Sxn{fPw@NsVSB8(u|*Tk`U2~!4mu!XNleu_Ycg^ zRh1R3`fOHNjm%F13Na4=EFPAYNsb*HAR4Q{$uY)j25)_@5Na2~^Yx3y_XGj&uoKG( z(}@zAF=+_(cn*`*d{<8}uDq_!KfUwr>X?7G=~yJQ_O05A`PR>{{P zKB!p)SE=%MyZ|e!Bx(*-dAdv<)_8>U46iMxcI4CGAbGi~|D3T?%UmzCZLte)LJ!1R zN`35q@0qzG=g|VJFn8STTYR^`&2Fg6@HR?@axNa|;>tBe$5MO1$Ws zNTeZF8=Uth3_VGP`oB7TJBB*=@5GBURTa2t#$h{gDTvhN!hU!*WyV+KuWo^G>(*sw z_qcMo?~_ObVL`2$Ty_o-lF=;=`_?q}$27cuc}U4AP187zbs&@*QjgRfT8M+=brD7a zj!*AU^qjItpj0j#Z!=`p4Mas_IN6oD?I`S($k5#u))n!R;kEOp%(ZA7eJI`gBVr06 zt#@DRN>w_IYB#3+jEf$y^#P-m6MJqpgt-Tzs-Y_^8Ri6Ti6^N*e#jo4GW&cM-iw)k ziDU86H@tyRO*v!j`f2(4yU8qJ0u%`2%-biuI$pd0GcZISZFs7gM7|8z0LGeL#5(My zkT~pse3e;V5=omm3MM>C>!RP{+p|Zo+sqPVJdfk`mJo$u59jD++67U#U4|3?Et1w` z_W3F-3;@b=`CLk4CcLc-K*!dZ1&+0mV*p&1eo5}wcB#@Gk3V>pnE z0te`Mk&aar%o>B>7~rXxtm8fBp{vDEsBgs*#x3y$YVx1;MMg+%v4OaAt?_byr`&7F zXon77U4%=jU&v2m%=woiaP76dld8|}$!svMHP`V|A5>8yQS)oXTR+;6-|syeZmzvx z6o`aiDMWJ~Ug`YI9-}uGn?_rjSb(ZRNbRfte$Vj^y)XEqm=Gt=`;idhIsJR!m9`+} zN;5pFfNxKT@KOnw(sHuqEZBd4DWl`O-@y-7(Hr&X%P~%?u?9N#&c?IBqRhz_a(Jj- zjlfN2#aGorg0hVv5SB~Ktc0bqF)bSAUXqUL;-vSiqr>e*uwHM$QMf)u$rH)R!onU$ zFM^L}F9Q>>lDB*!e49q?m*H=&E0aK3StW%^=HZ%7xlXrEq((`yR@~hz0ngLZi@?xj zzQmRN?J`@fDp~%0D<{)iewINg*95E8kW`2smIM?7DnP~NlY;oyl5c_H4OASx5~0ol0w)l9!rp@b delta 2570 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 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index e81506284..2bcc7aeec 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -2,25 +2,20 @@ - + - + + - + + - - + + --> - - - - - + + + + + + @@ -53,22 +49,25 @@ + - - + + - - + + + + true diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html index 690e85607..31ee9d7d1 100755 --- a/src/main/resources/templates/error.html +++ b/src/main/resources/templates/error.html @@ -1,4 +1,4 @@ - + + JHipster activation diff --git a/src/main/resources/templates/mail/creationEmail.html b/src/main/resources/templates/mail/creationEmail.html old mode 100644 new mode 100755 index 4e528980e..ab4662166 --- a/src/main/resources/templates/mail/creationEmail.html +++ b/src/main/resources/templates/mail/creationEmail.html @@ -1,4 +1,4 @@ - + JHipster creation diff --git a/src/main/resources/templates/mail/passwordResetEmail.html b/src/main/resources/templates/mail/passwordResetEmail.html old mode 100644 new mode 100755 index 290ca6dcc..eb74196f3 --- a/src/main/resources/templates/mail/passwordResetEmail.html +++ b/src/main/resources/templates/mail/passwordResetEmail.html @@ -1,4 +1,4 @@ - + JHipster password reset diff --git a/src/main/webapp/404.html b/src/main/webapp/404.html index 7569d7e21..3202d9ac8 100644 --- a/src/main/webapp/404.html +++ b/src/main/webapp/404.html @@ -1,4 +1,4 @@ - + diff --git a/src/main/webapp/app/account/account.route.ts b/src/main/webapp/app/account/account.route.ts new file mode 100644 index 000000000..a1a8b4624 --- /dev/null +++ b/src/main/webapp/app/account/account.route.ts @@ -0,0 +1,19 @@ +import { Routes } from '@angular/router'; + +import activateRoute from './activate/activate.route'; +import passwordRoute from './password/password.route'; +import passwordResetFinishRoute from './password-reset/finish/password-reset-finish.route'; +import passwordResetInitRoute from './password-reset/init/password-reset-init.route'; +import registerRoute from './register/register.route'; +import settingsRoute from './settings/settings.route'; + +const accountRoutes: Routes = [ + activateRoute, + passwordRoute, + passwordResetFinishRoute, + passwordResetInitRoute, + registerRoute, + settingsRoute, +]; + +export default accountRoutes; diff --git a/src/main/webapp/app/account/activate/activate.component.html b/src/main/webapp/app/account/activate/activate.component.html new file mode 100644 index 000000000..4cd58373b --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.component.html @@ -0,0 +1,16 @@ +
+
+
+

Activation

+ +
+ Your user account has been activated. Please + sign in. +
+ +
+ Your user could not be activated. Please use the registration form to sign up. +
+
+
+
diff --git a/src/main/webapp/app/account/activate/activate.component.spec.ts b/src/main/webapp/app/account/activate/activate.component.spec.ts new file mode 100644 index 000000000..2a2276753 --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.component.spec.ts @@ -0,0 +1,68 @@ +import { TestBed, waitForAsync, tick, fakeAsync, inject } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { ActivateService } from './activate.service'; +import ActivateComponent from './activate.component'; + +describe('ActivateComponent', () => { + let comp: ActivateComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, ActivateComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { queryParams: of({ key: 'ABC123' }) }, + }, + ], + }) + .overrideTemplate(ActivateComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + const fixture = TestBed.createComponent(ActivateComponent); + comp = fixture.componentInstance; + }); + + it('calls activate.get with the key from params', inject( + [ActivateService], + fakeAsync((service: ActivateService) => { + jest.spyOn(service, 'get').mockReturnValue(of()); + + comp.ngOnInit(); + tick(); + + expect(service.get).toHaveBeenCalledWith('ABC123'); + }), + )); + + it('should set set success to true upon successful activation', inject( + [ActivateService], + fakeAsync((service: ActivateService) => { + jest.spyOn(service, 'get').mockReturnValue(of({})); + + comp.ngOnInit(); + tick(); + + expect(comp.error).toBe(false); + expect(comp.success).toBe(true); + }), + )); + + it('should set set error to true upon activation failure', inject( + [ActivateService], + fakeAsync((service: ActivateService) => { + jest.spyOn(service, 'get').mockReturnValue(throwError('ERROR')); + + comp.ngOnInit(); + tick(); + + expect(comp.error).toBe(true); + expect(comp.success).toBe(false); + }), + )); +}); diff --git a/src/main/webapp/app/account/activate/activate.component.ts b/src/main/webapp/app/account/activate/activate.component.ts new file mode 100644 index 000000000..061c703ea --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.component.ts @@ -0,0 +1,29 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { mergeMap } from 'rxjs/operators'; + +import SharedModule from 'app/shared/shared.module'; +import { ActivateService } from './activate.service'; + +@Component({ + selector: 'jhi-activate', + standalone: true, + imports: [SharedModule, RouterModule], + templateUrl: './activate.component.html', +}) +export default class ActivateComponent implements OnInit { + error = false; + success = false; + + constructor( + private activateService: ActivateService, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + this.route.queryParams.pipe(mergeMap(params => this.activateService.get(params.key))).subscribe({ + next: () => (this.success = true), + error: () => (this.error = true), + }); + } +} diff --git a/src/main/webapp/app/account/activate/activate.route.ts b/src/main/webapp/app/account/activate/activate.route.ts new file mode 100644 index 000000000..5f602552d --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.route.ts @@ -0,0 +1,11 @@ +import { Route } from '@angular/router'; + +import ActivateComponent from './activate.component'; + +const activateRoute: Route = { + path: 'activate', + component: ActivateComponent, + title: 'activate.title', +}; + +export default activateRoute; diff --git a/src/main/webapp/app/account/activate/activate.service.spec.ts b/src/main/webapp/app/account/activate/activate.service.spec.ts new file mode 100644 index 000000000..e33b3bf96 --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.service.spec.ts @@ -0,0 +1,47 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { ActivateService } from './activate.service'; + +describe('ActivateService Service', () => { + let service: ActivateService; + let httpMock: HttpTestingController; + let applicationConfigService: ApplicationConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(ActivateService); + applicationConfigService = TestBed.inject(ApplicationConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should call api/activate endpoint with correct values', () => { + // GIVEN + let expectedResult; + const key = 'key'; + const value = true; + + // WHEN + service.get(key).subscribe(received => { + expectedResult = received; + }); + const testRequest = httpMock.expectOne({ + method: 'GET', + url: applicationConfigService.getEndpointFor(`api/activate?key=${key}`), + }); + testRequest.flush(value); + + // THEN + expect(expectedResult).toEqual(value); + }); + }); +}); diff --git a/src/main/webapp/app/account/activate/activate.service.ts b/src/main/webapp/app/account/activate/activate.service.ts new file mode 100644 index 000000000..9e19475bf --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; + +@Injectable({ providedIn: 'root' }) +export class ActivateService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + get(key: string): Observable<{}> { + return this.http.get(this.applicationConfigService.getEndpointFor('api/activate'), { + params: new HttpParams().set('key', key), + }); + } +} diff --git a/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.html b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.html new file mode 100644 index 000000000..493ef8f9f --- /dev/null +++ b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.html @@ -0,0 +1,137 @@ +
+
+
+

Reset password

+ +
+ The reset key is missing. +
+ +
+ Choose a new password +
+ +
+ Your password couldn't be reset. Remember a password request is only valid for 24 hours. +
+ +
+ Your password has been reset. Please + sign in. +
+ +
+ The password and its confirmation do not match! +
+ +
+
+
+ + + +
+ + Your password is required. + + + + Your password is required to be at least 4 characters. + + + + Your password cannot be longer than 50 characters. + +
+ + +
+ +
+ + + +
+ + Your confirmation password is required. + + + + Your confirmation password is required to be at least 4 characters. + + + + Your confirmation password cannot be longer than 50 characters. + +
+
+ + +
+
+
+
+
diff --git a/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.spec.ts b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.spec.ts new file mode 100644 index 000000000..2ed7ba2df --- /dev/null +++ b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.spec.ts @@ -0,0 +1,97 @@ +import { ElementRef } from '@angular/core'; +import { ComponentFixture, TestBed, inject, tick, fakeAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import PasswordResetFinishComponent from './password-reset-finish.component'; +import { PasswordResetFinishService } from './password-reset-finish.service'; + +describe('PasswordResetFinishComponent', () => { + let fixture: ComponentFixture; + let comp: PasswordResetFinishComponent; + + beforeEach(() => { + fixture = TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, PasswordResetFinishComponent], + providers: [ + FormBuilder, + { + provide: ActivatedRoute, + useValue: { queryParams: of({ key: 'XYZPDQ' }) }, + }, + ], + }) + .overrideTemplate(PasswordResetFinishComponent, '') + .createComponent(PasswordResetFinishComponent); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PasswordResetFinishComponent); + comp = fixture.componentInstance; + comp.ngOnInit(); + }); + + it('should define its initial state', () => { + expect(comp.initialized).toBe(true); + expect(comp.key).toEqual('XYZPDQ'); + }); + + it('sets focus after the view has been initialized', () => { + const node = { + focus: jest.fn(), + }; + comp.newPassword = new ElementRef(node); + + comp.ngAfterViewInit(); + + expect(node.focus).toHaveBeenCalled(); + }); + + it('should ensure the two passwords entered match', () => { + comp.passwordForm.patchValue({ + newPassword: 'password', + confirmPassword: 'non-matching', + }); + + comp.finishReset(); + + expect(comp.doNotMatch).toBe(true); + }); + + it('should update success to true after resetting password', inject( + [PasswordResetFinishService], + fakeAsync((service: PasswordResetFinishService) => { + jest.spyOn(service, 'save').mockReturnValue(of({})); + comp.passwordForm.patchValue({ + newPassword: 'password', + confirmPassword: 'password', + }); + + comp.finishReset(); + tick(); + + expect(service.save).toHaveBeenCalledWith('XYZPDQ', 'password'); + expect(comp.success).toBe(true); + }), + )); + + it('should notify of generic error', inject( + [PasswordResetFinishService], + fakeAsync((service: PasswordResetFinishService) => { + jest.spyOn(service, 'save').mockReturnValue(throwError('ERROR')); + comp.passwordForm.patchValue({ + newPassword: 'password', + confirmPassword: 'password', + }); + + comp.finishReset(); + tick(); + + expect(service.save).toHaveBeenCalledWith('XYZPDQ', 'password'); + expect(comp.success).toBe(false); + expect(comp.error).toBe(true); + }), + )); +}); diff --git a/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.ts b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.ts new file mode 100644 index 000000000..00d5758dc --- /dev/null +++ b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.ts @@ -0,0 +1,71 @@ +import { Component, OnInit, AfterViewInit, ElementRef, ViewChild } from '@angular/core'; +import { FormGroup, FormControl, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import PasswordStrengthBarComponent from 'app/account/password/password-strength-bar/password-strength-bar.component'; +import SharedModule from 'app/shared/shared.module'; + +import { PasswordResetFinishService } from './password-reset-finish.service'; + +@Component({ + selector: 'jhi-password-reset-finish', + standalone: true, + imports: [SharedModule, RouterModule, FormsModule, ReactiveFormsModule, PasswordStrengthBarComponent], + templateUrl: './password-reset-finish.component.html', +}) +export default class PasswordResetFinishComponent implements OnInit, AfterViewInit { + @ViewChild('newPassword', { static: false }) + newPassword?: ElementRef; + + initialized = false; + doNotMatch = false; + error = false; + success = false; + key = ''; + + passwordForm = new FormGroup({ + newPassword: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(4), Validators.maxLength(50)], + }), + confirmPassword: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(4), Validators.maxLength(50)], + }), + }); + + constructor( + private passwordResetFinishService: PasswordResetFinishService, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + this.route.queryParams.subscribe(params => { + if (params['key']) { + this.key = params['key']; + } + this.initialized = true; + }); + } + + ngAfterViewInit(): void { + if (this.newPassword) { + this.newPassword.nativeElement.focus(); + } + } + + finishReset(): void { + this.doNotMatch = false; + this.error = false; + + const { newPassword, confirmPassword } = this.passwordForm.getRawValue(); + + if (newPassword !== confirmPassword) { + this.doNotMatch = true; + } else { + this.passwordResetFinishService.save(this.key, newPassword).subscribe({ + next: () => (this.success = true), + error: () => (this.error = true), + }); + } + } +} diff --git a/src/main/webapp/app/account/password-reset/finish/password-reset-finish.route.ts b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.route.ts new file mode 100644 index 000000000..2963d150d --- /dev/null +++ b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.route.ts @@ -0,0 +1,11 @@ +import { Route } from '@angular/router'; + +import PasswordResetFinishComponent from './password-reset-finish.component'; + +const passwordResetFinishRoute: Route = { + path: 'reset/finish', + component: PasswordResetFinishComponent, + title: 'global.menu.account.password', +}; + +export default passwordResetFinishRoute; diff --git a/src/main/webapp/app/account/password-reset/finish/password-reset-finish.service.spec.ts b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.service.spec.ts new file mode 100644 index 000000000..da6a82537 --- /dev/null +++ b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.service.spec.ts @@ -0,0 +1,44 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { PasswordResetFinishService } from './password-reset-finish.service'; + +describe('PasswordResetFinish Service', () => { + let service: PasswordResetFinishService; + let httpMock: HttpTestingController; + let applicationConfigService: ApplicationConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(PasswordResetFinishService); + applicationConfigService = TestBed.inject(ApplicationConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should call reset-password/finish endpoint with correct values', () => { + // GIVEN + const key = 'abc'; + const newPassword = 'password'; + + // WHEN + service.save(key, newPassword).subscribe(); + + const testRequest = httpMock.expectOne({ + method: 'POST', + url: applicationConfigService.getEndpointFor('api/account/reset-password/finish'), + }); + + // THEN + expect(testRequest.request.body).toEqual({ key, newPassword }); + }); + }); +}); diff --git a/src/main/webapp/app/account/password-reset/finish/password-reset-finish.service.ts b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.service.ts new file mode 100644 index 000000000..3696e10b8 --- /dev/null +++ b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; + +@Injectable({ providedIn: 'root' }) +export class PasswordResetFinishService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + save(key: string, newPassword: string): Observable<{}> { + return this.http.post(this.applicationConfigService.getEndpointFor('api/account/reset-password/finish'), { key, newPassword }); + } +} diff --git a/src/main/webapp/app/account/password-reset/init/password-reset-init.component.html b/src/main/webapp/app/account/password-reset/init/password-reset-init.component.html new file mode 100644 index 000000000..64671c845 --- /dev/null +++ b/src/main/webapp/app/account/password-reset/init/password-reset-init.component.html @@ -0,0 +1,81 @@ +
+
+
+

Reset your password

+ + + +
+ Enter the email address you used to register +
+ +
+ Check your email for details on how to reset your password. +
+ +
+
+ + + +
+ + Your email is required. + + + + Your email is invalid. + + + + Your email is required to be at least 5 characters. + + + + Your email cannot be longer than 50 characters. + +
+
+ + +
+
+
+
diff --git a/src/main/webapp/app/account/password-reset/init/password-reset-init.component.spec.ts b/src/main/webapp/app/account/password-reset/init/password-reset-init.component.spec.ts new file mode 100644 index 000000000..42f896504 --- /dev/null +++ b/src/main/webapp/app/account/password-reset/init/password-reset-init.component.spec.ts @@ -0,0 +1,62 @@ +import { ElementRef } from '@angular/core'; +import { ComponentFixture, TestBed, inject } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { of, throwError } from 'rxjs'; + +import PasswordResetInitComponent from './password-reset-init.component'; +import { PasswordResetInitService } from './password-reset-init.service'; + +describe('PasswordResetInitComponent', () => { + let fixture: ComponentFixture; + let comp: PasswordResetInitComponent; + + beforeEach(() => { + fixture = TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, PasswordResetInitComponent], + providers: [FormBuilder], + }) + .overrideTemplate(PasswordResetInitComponent, '') + .createComponent(PasswordResetInitComponent); + comp = fixture.componentInstance; + }); + + it('sets focus after the view has been initialized', () => { + const node = { + focus: jest.fn(), + }; + comp.email = new ElementRef(node); + + comp.ngAfterViewInit(); + + expect(node.focus).toHaveBeenCalled(); + }); + + it('notifies of success upon successful requestReset', inject([PasswordResetInitService], (service: PasswordResetInitService) => { + jest.spyOn(service, 'save').mockReturnValue(of({})); + comp.resetRequestForm.patchValue({ + email: 'user@domain.com', + }); + + comp.requestReset(); + + expect(service.save).toHaveBeenCalledWith('user@domain.com'); + expect(comp.success).toBe(true); + })); + + it('no notification of success upon error response', inject([PasswordResetInitService], (service: PasswordResetInitService) => { + jest.spyOn(service, 'save').mockReturnValue( + throwError({ + status: 503, + data: 'something else', + }), + ); + comp.resetRequestForm.patchValue({ + email: 'user@domain.com', + }); + comp.requestReset(); + + expect(service.save).toHaveBeenCalledWith('user@domain.com'); + expect(comp.success).toBe(false); + })); +}); diff --git a/src/main/webapp/app/account/password-reset/init/password-reset-init.component.ts b/src/main/webapp/app/account/password-reset/init/password-reset-init.component.ts new file mode 100644 index 000000000..71cc74186 --- /dev/null +++ b/src/main/webapp/app/account/password-reset/init/password-reset-init.component.ts @@ -0,0 +1,36 @@ +import { Component, AfterViewInit, ElementRef, ViewChild } from '@angular/core'; +import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import SharedModule from 'app/shared/shared.module'; + +import { PasswordResetInitService } from './password-reset-init.service'; + +@Component({ + selector: 'jhi-password-reset-init', + standalone: true, + imports: [SharedModule, FormsModule, ReactiveFormsModule], + templateUrl: './password-reset-init.component.html', +}) +export default class PasswordResetInitComponent implements AfterViewInit { + @ViewChild('email', { static: false }) + email?: ElementRef; + + success = false; + resetRequestForm = this.fb.group({ + email: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(254), Validators.email]], + }); + + constructor( + private passwordResetInitService: PasswordResetInitService, + private fb: FormBuilder, + ) {} + + ngAfterViewInit(): void { + if (this.email) { + this.email.nativeElement.focus(); + } + } + + requestReset(): void { + this.passwordResetInitService.save(this.resetRequestForm.get(['email'])!.value).subscribe(() => (this.success = true)); + } +} diff --git a/src/main/webapp/app/account/password-reset/init/password-reset-init.route.ts b/src/main/webapp/app/account/password-reset/init/password-reset-init.route.ts new file mode 100644 index 000000000..654634bec --- /dev/null +++ b/src/main/webapp/app/account/password-reset/init/password-reset-init.route.ts @@ -0,0 +1,11 @@ +import { Route } from '@angular/router'; + +import PasswordResetInitComponent from './password-reset-init.component'; + +const passwordResetInitRoute: Route = { + path: 'reset/request', + component: PasswordResetInitComponent, + title: 'global.menu.account.password', +}; + +export default passwordResetInitRoute; diff --git a/src/main/webapp/app/account/password-reset/init/password-reset-init.service.spec.ts b/src/main/webapp/app/account/password-reset/init/password-reset-init.service.spec.ts new file mode 100644 index 000000000..3a05b2d4b --- /dev/null +++ b/src/main/webapp/app/account/password-reset/init/password-reset-init.service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { PasswordResetInitService } from './password-reset-init.service'; + +describe('PasswordResetInit Service', () => { + let service: PasswordResetInitService; + let httpMock: HttpTestingController; + let applicationConfigService: ApplicationConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(PasswordResetInitService); + applicationConfigService = TestBed.inject(ApplicationConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should call reset-password/init endpoint with correct values', () => { + // GIVEN + const mail = 'test@test.com'; + + // WHEN + service.save(mail).subscribe(); + + const testRequest = httpMock.expectOne({ + method: 'POST', + url: applicationConfigService.getEndpointFor('api/account/reset-password/init'), + }); + + // THEN + expect(testRequest.request.body).toEqual(mail); + }); + }); +}); diff --git a/src/main/webapp/app/account/password-reset/init/password-reset-init.service.ts b/src/main/webapp/app/account/password-reset/init/password-reset-init.service.ts new file mode 100644 index 000000000..31ed7e895 --- /dev/null +++ b/src/main/webapp/app/account/password-reset/init/password-reset-init.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; + +@Injectable({ providedIn: 'root' }) +export class PasswordResetInitService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + save(mail: string): Observable<{}> { + return this.http.post(this.applicationConfigService.getEndpointFor('api/account/reset-password/init'), mail); + } +} diff --git a/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.html b/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.html new file mode 100644 index 000000000..244da088f --- /dev/null +++ b/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.html @@ -0,0 +1,10 @@ +
+ Password strength: +
    +
  • +
  • +
  • +
  • +
  • +
+
diff --git a/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.scss b/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.scss new file mode 100644 index 000000000..67ce4687a --- /dev/null +++ b/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.scss @@ -0,0 +1,23 @@ +/* ========================================================================== +start Password strength bar style +========================================================================== */ +ul#strength { + display: inline; + list-style: none; + margin: 0; + margin-left: 15px; + padding: 0; + vertical-align: 2px; +} + +.point { + background: #ddd; + border-radius: 2px; + display: inline-block; + height: 5px; + margin-right: 1px; + width: 20px; + &:last-child { + margin: 0 !important; + } +} diff --git a/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.spec.ts b/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.spec.ts new file mode 100644 index 000000000..9fd533a7f --- /dev/null +++ b/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import PasswordStrengthBarComponent from './password-strength-bar.component'; + +describe('PasswordStrengthBarComponent', () => { + let comp: PasswordStrengthBarComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [PasswordStrengthBarComponent], + }) + .overrideTemplate(PasswordStrengthBarComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PasswordStrengthBarComponent); + comp = fixture.componentInstance; + }); + + describe('PasswordStrengthBarComponents', () => { + it('should initialize with default values', () => { + expect(comp.measureStrength('')).toBe(0); + expect(comp.colors).toEqual(['#F00', '#F90', '#FF0', '#9F0', '#0F0']); + expect(comp.getColor(0).idx).toBe(1); + expect(comp.getColor(0).color).toBe(comp.colors[0]); + }); + + it('should increase strength upon password value change', () => { + expect(comp.measureStrength('')).toBe(0); + expect(comp.measureStrength('aa')).toBeGreaterThanOrEqual(comp.measureStrength('')); + expect(comp.measureStrength('aa^6')).toBeGreaterThanOrEqual(comp.measureStrength('aa')); + expect(comp.measureStrength('Aa090(**)')).toBeGreaterThanOrEqual(comp.measureStrength('aa^6')); + expect(comp.measureStrength('Aa090(**)+-07365')).toBeGreaterThanOrEqual(comp.measureStrength('Aa090(**)')); + }); + + it('should change the color based on strength', () => { + expect(comp.getColor(0).color).toBe(comp.colors[0]); + expect(comp.getColor(11).color).toBe(comp.colors[1]); + expect(comp.getColor(22).color).toBe(comp.colors[2]); + expect(comp.getColor(33).color).toBe(comp.colors[3]); + expect(comp.getColor(44).color).toBe(comp.colors[4]); + }); + }); +}); diff --git a/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.ts b/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.ts new file mode 100644 index 000000000..7f703fa6e --- /dev/null +++ b/src/main/webapp/app/account/password/password-strength-bar/password-strength-bar.component.ts @@ -0,0 +1,79 @@ +import { Component, ElementRef, Input, Renderer2 } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; + +@Component({ + selector: 'jhi-password-strength-bar', + standalone: true, + imports: [SharedModule], + templateUrl: './password-strength-bar.component.html', + styleUrls: ['./password-strength-bar.component.scss'], +}) +export default class PasswordStrengthBarComponent { + colors = ['#F00', '#F90', '#FF0', '#9F0', '#0F0']; + + constructor( + private renderer: Renderer2, + private elementRef: ElementRef, + ) {} + + measureStrength(p: string): number { + let force = 0; + const regex = /[$-/:-?{-~!"^_`[\]]/g; // " + const lowerLetters = /[a-z]+/.test(p); + const upperLetters = /[A-Z]+/.test(p); + const numbers = /\d+/.test(p); + const symbols = regex.test(p); + + const flags = [lowerLetters, upperLetters, numbers, symbols]; + const passedMatches = flags.filter((isMatchedFlag: boolean) => isMatchedFlag === true).length; + + force += 2 * p.length + (p.length >= 10 ? 1 : 0); + force += passedMatches * 10; + + // penalty (short password) + force = p.length <= 6 ? Math.min(force, 10) : force; + + // penalty (poor variety of characters) + force = passedMatches === 1 ? Math.min(force, 10) : force; + force = passedMatches === 2 ? Math.min(force, 20) : force; + force = passedMatches === 3 ? Math.min(force, 40) : force; + + return force; + } + + getColor(s: number): { idx: number; color: string } { + let idx = 0; + if (s > 10) { + if (s <= 20) { + idx = 1; + } else if (s <= 30) { + idx = 2; + } else if (s <= 40) { + idx = 3; + } else { + idx = 4; + } + } + return { idx: idx + 1, color: this.colors[idx] }; + } + + @Input() + set passwordToCheck(password: string) { + if (password) { + const c = this.getColor(this.measureStrength(password)); + const element = this.elementRef.nativeElement; + if (element.className) { + this.renderer.removeClass(element, element.className); + } + const lis = element.getElementsByTagName('li'); + for (let i = 0; i < lis.length; i++) { + if (i < c.idx) { + this.renderer.setStyle(lis[i], 'backgroundColor', c.color); + } else { + this.renderer.setStyle(lis[i], 'backgroundColor', '#DDD'); + } + } + } + } +} diff --git a/src/main/webapp/app/account/password/password.component.html b/src/main/webapp/app/account/password/password.component.html new file mode 100644 index 000000000..6584df72b --- /dev/null +++ b/src/main/webapp/app/account/password/password.component.html @@ -0,0 +1,152 @@ +
+
+
+

+ Password for [{{ account.login }}] +

+ +
+ Password changed! +
+ +
+ An error has occurred! The password could not be changed. +
+ +
+ The password and its confirmation do not match! +
+ +
+
+ + + +
+ + Your password is required. + +
+
+ +
+ + + +
+ + Your password is required. + + + + Your password is required to be at least 4 characters. + + + + Your password cannot be longer than 50 characters. + +
+ + +
+ +
+ + + +
+ + Your confirmation password is required. + + + + Your confirmation password is required to be at least 4 characters. + + + + Your confirmation password cannot be longer than 50 characters. + +
+
+ + +
+
+
+
diff --git a/src/main/webapp/app/account/password/password.component.spec.ts b/src/main/webapp/app/account/password/password.component.spec.ts new file mode 100644 index 000000000..50cd6c056 --- /dev/null +++ b/src/main/webapp/app/account/password/password.component.spec.ts @@ -0,0 +1,103 @@ +jest.mock('app/core/auth/account.service'); + +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { of, throwError } from 'rxjs'; + +import { AccountService } from 'app/core/auth/account.service'; + +import PasswordComponent from './password.component'; +import { PasswordService } from './password.service'; + +describe('PasswordComponent', () => { + let comp: PasswordComponent; + let fixture: ComponentFixture; + let service: PasswordService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, PasswordComponent], + providers: [FormBuilder, AccountService], + }) + .overrideTemplate(PasswordComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PasswordComponent); + comp = fixture.componentInstance; + service = TestBed.inject(PasswordService); + }); + + it('should show error if passwords do not match', () => { + // GIVEN + comp.passwordForm.patchValue({ + newPassword: 'password1', + confirmPassword: 'password2', + }); + // WHEN + comp.changePassword(); + // THEN + expect(comp.doNotMatch).toBe(true); + expect(comp.error).toBe(false); + expect(comp.success).toBe(false); + }); + + it('should call Auth.changePassword when passwords match', () => { + // GIVEN + const passwordValues = { + currentPassword: 'oldPassword', + newPassword: 'myPassword', + }; + + jest.spyOn(service, 'save').mockReturnValue(of(new HttpResponse({ body: true }))); + + comp.passwordForm.patchValue({ + currentPassword: passwordValues.currentPassword, + newPassword: passwordValues.newPassword, + confirmPassword: passwordValues.newPassword, + }); + + // WHEN + comp.changePassword(); + + // THEN + expect(service.save).toHaveBeenCalledWith(passwordValues.newPassword, passwordValues.currentPassword); + }); + + it('should set success to true upon success', () => { + // GIVEN + jest.spyOn(service, 'save').mockReturnValue(of(new HttpResponse({ body: true }))); + comp.passwordForm.patchValue({ + newPassword: 'myPassword', + confirmPassword: 'myPassword', + }); + + // WHEN + comp.changePassword(); + + // THEN + expect(comp.doNotMatch).toBe(false); + expect(comp.error).toBe(false); + expect(comp.success).toBe(true); + }); + + it('should notify of error if change password fails', () => { + // GIVEN + jest.spyOn(service, 'save').mockReturnValue(throwError('ERROR')); + comp.passwordForm.patchValue({ + newPassword: 'myPassword', + confirmPassword: 'myPassword', + }); + + // WHEN + comp.changePassword(); + + // THEN + expect(comp.doNotMatch).toBe(false); + expect(comp.success).toBe(false); + expect(comp.error).toBe(true); + }); +}); diff --git a/src/main/webapp/app/account/password/password.component.ts b/src/main/webapp/app/account/password/password.component.ts new file mode 100644 index 000000000..7a0864325 --- /dev/null +++ b/src/main/webapp/app/account/password/password.component.ts @@ -0,0 +1,58 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Observable } from 'rxjs'; + +import SharedModule from 'app/shared/shared.module'; +import { AccountService } from 'app/core/auth/account.service'; +import { Account } from 'app/core/auth/account.model'; +import { PasswordService } from './password.service'; +import PasswordStrengthBarComponent from './password-strength-bar/password-strength-bar.component'; + +@Component({ + selector: 'jhi-password', + standalone: true, + imports: [SharedModule, FormsModule, ReactiveFormsModule, PasswordStrengthBarComponent], + templateUrl: './password.component.html', +}) +export default class PasswordComponent implements OnInit { + doNotMatch = false; + error = false; + success = false; + account$?: Observable; + passwordForm = new FormGroup({ + currentPassword: new FormControl('', { nonNullable: true, validators: Validators.required }), + newPassword: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(4), Validators.maxLength(50)], + }), + confirmPassword: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(4), Validators.maxLength(50)], + }), + }); + + constructor( + private passwordService: PasswordService, + private accountService: AccountService, + ) {} + + ngOnInit(): void { + this.account$ = this.accountService.identity(); + } + + changePassword(): void { + this.error = false; + this.success = false; + this.doNotMatch = false; + + const { newPassword, confirmPassword, currentPassword } = this.passwordForm.getRawValue(); + if (newPassword !== confirmPassword) { + this.doNotMatch = true; + } else { + this.passwordService.save(newPassword, currentPassword).subscribe({ + next: () => (this.success = true), + error: () => (this.error = true), + }); + } + } +} diff --git a/src/main/webapp/app/account/password/password.route.ts b/src/main/webapp/app/account/password/password.route.ts new file mode 100644 index 000000000..96faba98e --- /dev/null +++ b/src/main/webapp/app/account/password/password.route.ts @@ -0,0 +1,13 @@ +import { Route } from '@angular/router'; + +import { UserRouteAccessService } from 'app/core/auth/user-route-access.service'; +import PasswordComponent from './password.component'; + +const passwordRoute: Route = { + path: 'password', + component: PasswordComponent, + title: 'global.menu.account.password', + canActivate: [UserRouteAccessService], +}; + +export default passwordRoute; diff --git a/src/main/webapp/app/account/password/password.service.spec.ts b/src/main/webapp/app/account/password/password.service.spec.ts new file mode 100644 index 000000000..b669e5a37 --- /dev/null +++ b/src/main/webapp/app/account/password/password.service.spec.ts @@ -0,0 +1,44 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { PasswordService } from './password.service'; + +describe('Password Service', () => { + let service: PasswordService; + let httpMock: HttpTestingController; + let applicationConfigService: ApplicationConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(PasswordService); + applicationConfigService = TestBed.inject(ApplicationConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should call change-password endpoint with correct values', () => { + // GIVEN + const password1 = 'password1'; + const password2 = 'password2'; + + // WHEN + service.save(password2, password1).subscribe(); + + const testRequest = httpMock.expectOne({ + method: 'POST', + url: applicationConfigService.getEndpointFor('api/account/change-password'), + }); + + // THEN + expect(testRequest.request.body).toEqual({ currentPassword: password1, newPassword: password2 }); + }); + }); +}); diff --git a/src/main/webapp/app/account/password/password.service.ts b/src/main/webapp/app/account/password/password.service.ts new file mode 100644 index 000000000..e29258f51 --- /dev/null +++ b/src/main/webapp/app/account/password/password.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; + +@Injectable({ providedIn: 'root' }) +export class PasswordService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + save(newPassword: string, currentPassword: string): Observable<{}> { + return this.http.post(this.applicationConfigService.getEndpointFor('api/account/change-password'), { currentPassword, newPassword }); + } +} diff --git a/src/main/webapp/app/account/register/register.component.html b/src/main/webapp/app/account/register/register.component.html new file mode 100644 index 000000000..c9eb2af9f --- /dev/null +++ b/src/main/webapp/app/account/register/register.component.html @@ -0,0 +1,234 @@ +
+
+
+

Registration

+ +
+ Registration saved! Please check your email for confirmation. +
+ +
+ Registration failed! Please try again later. +
+ +
+ Login name already registered! Please choose another one. +
+ +
+ Email is already in use! Please choose another one. +
+ +
+ The password and its confirmation do not match! +
+
+
+ +
+
+
+
+ + + +
+ + Your username is required. + + + + Your username is required to be at least 1 character. + + + + Your username cannot be longer than 50 characters. + + + + Your username is invalid. + +
+
+ +
+ + + +
+ + Your email is required. + + + + Your email is invalid. + + + + Your email is required to be at least 5 characters. + + + + Your email cannot be longer than 50 characters. + +
+
+ +
+ + + +
+ + Your password is required. + + + + Your password is required to be at least 4 characters. + + + + Your password cannot be longer than 50 characters. + +
+ + +
+ +
+ + + +
+ + Your confirmation password is required. + + + + Your confirmation password is required to be at least 4 characters. + + + + Your confirmation password cannot be longer than 50 characters. + +
+
+ + +
+ +
+ If you want to + sign in, you can try the default accounts:
- Administrator (login="admin" and password="admin")
- User (login="user" and + password="user").
+
+
+
+
diff --git a/src/main/webapp/app/account/register/register.component.spec.ts b/src/main/webapp/app/account/register/register.component.spec.ts new file mode 100644 index 000000000..1fe62c1ce --- /dev/null +++ b/src/main/webapp/app/account/register/register.component.spec.ts @@ -0,0 +1,134 @@ +import { ComponentFixture, TestBed, waitForAsync, inject, tick, fakeAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { of, throwError } from 'rxjs'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from 'app/config/error.constants'; + +import { RegisterService } from './register.service'; +import RegisterComponent from './register.component'; + +describe('RegisterComponent', () => { + let fixture: ComponentFixture; + let comp: RegisterComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), HttpClientTestingModule, RegisterComponent], + providers: [FormBuilder], + }) + .overrideTemplate(RegisterComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(RegisterComponent); + comp = fixture.componentInstance; + }); + + it('should ensure the two passwords entered match', () => { + comp.registerForm.patchValue({ + password: 'password', + confirmPassword: 'non-matching', + }); + + comp.register(); + + expect(comp.doNotMatch).toBe(true); + }); + + it('should update success to true after creating an account', inject( + [RegisterService, TranslateService], + fakeAsync((service: RegisterService, mockTranslateService: TranslateService) => { + jest.spyOn(service, 'save').mockReturnValue(of({})); + mockTranslateService.currentLang = 'en'; + comp.registerForm.patchValue({ + password: 'password', + confirmPassword: 'password', + }); + + comp.register(); + tick(); + + expect(service.save).toHaveBeenCalledWith({ + email: '', + password: 'password', + login: '', + langKey: 'en', + }); + expect(comp.success).toBe(true); + expect(comp.errorUserExists).toBe(false); + expect(comp.errorEmailExists).toBe(false); + expect(comp.error).toBe(false); + }), + )); + + it('should notify of user existence upon 400/login already in use', inject( + [RegisterService], + fakeAsync((service: RegisterService) => { + jest.spyOn(service, 'save').mockReturnValue( + throwError({ + status: 400, + error: { type: LOGIN_ALREADY_USED_TYPE }, + }), + ); + comp.registerForm.patchValue({ + password: 'password', + confirmPassword: 'password', + }); + + comp.register(); + tick(); + + expect(comp.errorUserExists).toBe(true); + expect(comp.errorEmailExists).toBe(false); + expect(comp.error).toBe(false); + }), + )); + + it('should notify of email existence upon 400/email address already in use', inject( + [RegisterService], + fakeAsync((service: RegisterService) => { + jest.spyOn(service, 'save').mockReturnValue( + throwError({ + status: 400, + error: { type: EMAIL_ALREADY_USED_TYPE }, + }), + ); + comp.registerForm.patchValue({ + password: 'password', + confirmPassword: 'password', + }); + + comp.register(); + tick(); + + expect(comp.errorEmailExists).toBe(true); + expect(comp.errorUserExists).toBe(false); + expect(comp.error).toBe(false); + }), + )); + + it('should notify of generic error', inject( + [RegisterService], + fakeAsync((service: RegisterService) => { + jest.spyOn(service, 'save').mockReturnValue( + throwError({ + status: 503, + }), + ); + comp.registerForm.patchValue({ + password: 'password', + confirmPassword: 'password', + }); + + comp.register(); + tick(); + + expect(comp.errorUserExists).toBe(false); + expect(comp.errorEmailExists).toBe(false); + expect(comp.error).toBe(true); + }), + )); +}); diff --git a/src/main/webapp/app/account/register/register.component.ts b/src/main/webapp/app/account/register/register.component.ts new file mode 100644 index 000000000..1210bbc2e --- /dev/null +++ b/src/main/webapp/app/account/register/register.component.ts @@ -0,0 +1,89 @@ +import { Component, AfterViewInit, ElementRef, ViewChild } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { RouterModule } from '@angular/router'; +import { FormGroup, FormControl, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; + +import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from 'app/config/error.constants'; +import SharedModule from 'app/shared/shared.module'; +import PasswordStrengthBarComponent from '../password/password-strength-bar/password-strength-bar.component'; +import { RegisterService } from './register.service'; + +@Component({ + selector: 'jhi-register', + standalone: true, + imports: [SharedModule, RouterModule, FormsModule, ReactiveFormsModule, PasswordStrengthBarComponent], + templateUrl: './register.component.html', +}) +export default class RegisterComponent implements AfterViewInit { + @ViewChild('login', { static: false }) + login?: ElementRef; + + doNotMatch = false; + error = false; + errorEmailExists = false; + errorUserExists = false; + success = false; + + registerForm = new FormGroup({ + login: new FormControl('', { + nonNullable: true, + validators: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(50), + Validators.pattern('^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$'), + ], + }), + email: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(5), Validators.maxLength(254), Validators.email], + }), + password: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(4), Validators.maxLength(50)], + }), + confirmPassword: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(4), Validators.maxLength(50)], + }), + }); + + constructor( + private translateService: TranslateService, + private registerService: RegisterService, + ) {} + + ngAfterViewInit(): void { + if (this.login) { + this.login.nativeElement.focus(); + } + } + + register(): void { + this.doNotMatch = false; + this.error = false; + this.errorEmailExists = false; + this.errorUserExists = false; + + const { password, confirmPassword } = this.registerForm.getRawValue(); + if (password !== confirmPassword) { + this.doNotMatch = true; + } else { + const { login, email } = this.registerForm.getRawValue(); + this.registerService + .save({ login, email, password, langKey: this.translateService.currentLang }) + .subscribe({ next: () => (this.success = true), error: response => this.processError(response) }); + } + } + + private processError(response: HttpErrorResponse): void { + if (response.status === 400 && response.error.type === LOGIN_ALREADY_USED_TYPE) { + this.errorUserExists = true; + } else if (response.status === 400 && response.error.type === EMAIL_ALREADY_USED_TYPE) { + this.errorEmailExists = true; + } else { + this.error = true; + } + } +} diff --git a/src/main/webapp/app/account/register/register.model.ts b/src/main/webapp/app/account/register/register.model.ts new file mode 100644 index 000000000..02e04a140 --- /dev/null +++ b/src/main/webapp/app/account/register/register.model.ts @@ -0,0 +1,8 @@ +export class Registration { + constructor( + public login: string, + public email: string, + public password: string, + public langKey: string, + ) {} +} diff --git a/src/main/webapp/app/account/register/register.route.ts b/src/main/webapp/app/account/register/register.route.ts new file mode 100644 index 000000000..81323c3cb --- /dev/null +++ b/src/main/webapp/app/account/register/register.route.ts @@ -0,0 +1,11 @@ +import { Route } from '@angular/router'; + +import RegisterComponent from './register.component'; + +const registerRoute: Route = { + path: 'register', + component: RegisterComponent, + title: 'register.title', +}; + +export default registerRoute; diff --git a/src/main/webapp/app/account/register/register.service.spec.ts b/src/main/webapp/app/account/register/register.service.spec.ts new file mode 100644 index 000000000..fb9d8a738 --- /dev/null +++ b/src/main/webapp/app/account/register/register.service.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { RegisterService } from './register.service'; +import { Registration } from './register.model'; + +describe('RegisterService Service', () => { + let service: RegisterService; + let httpMock: HttpTestingController; + let applicationConfigService: ApplicationConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(RegisterService); + applicationConfigService = TestBed.inject(ApplicationConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should call register endpoint with correct values', () => { + // GIVEN + const login = 'abc'; + const email = 'test@test.com'; + const password = 'pass'; + const langKey = 'FR'; + const registration = new Registration(login, email, password, langKey); + + // WHEN + service.save(registration).subscribe(); + + const testRequest = httpMock.expectOne({ + method: 'POST', + url: applicationConfigService.getEndpointFor('api/register'), + }); + + // THEN + expect(testRequest.request.body).toEqual({ email, langKey, login, password }); + }); + }); +}); diff --git a/src/main/webapp/app/account/register/register.service.ts b/src/main/webapp/app/account/register/register.service.ts new file mode 100644 index 000000000..29c183303 --- /dev/null +++ b/src/main/webapp/app/account/register/register.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { Registration } from './register.model'; + +@Injectable({ providedIn: 'root' }) +export class RegisterService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + save(registration: Registration): Observable<{}> { + return this.http.post(this.applicationConfigService.getEndpointFor('api/register'), registration); + } +} diff --git a/src/main/webapp/app/account/settings/settings.component.html b/src/main/webapp/app/account/settings/settings.component.html new file mode 100644 index 000000000..4fb7967e7 --- /dev/null +++ b/src/main/webapp/app/account/settings/settings.component.html @@ -0,0 +1,166 @@ +
+
+
+

+ User settings for [{{ settingsForm.value.login }}] +

+ +
+ Settings saved! +
+ + + +
+
+ + + +
+ + Your first name is required. + + + + Your first name is required to be at least 1 character + + + + Your first name cannot be longer than 50 characters + +
+
+ +
+ + + +
+ + Your last name is required. + + + + Your last name is required to be at least 1 character + + + + Your last name cannot be longer than 50 characters + +
+
+ +
+ + + +
+ + Your email is required. + + + + Your email is invalid. + + + + Your email is required to be at least 5 characters. + + + + Your email cannot be longer than 50 characters. + +
+
+ +
+ + +
+ + +
+
+
+
diff --git a/src/main/webapp/app/account/settings/settings.component.spec.ts b/src/main/webapp/app/account/settings/settings.component.spec.ts new file mode 100644 index 000000000..cad67277e --- /dev/null +++ b/src/main/webapp/app/account/settings/settings.component.spec.ts @@ -0,0 +1,90 @@ +jest.mock('app/core/auth/account.service'); + +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { throwError, of } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AccountService } from 'app/core/auth/account.service'; +import { Account } from 'app/core/auth/account.model'; + +import SettingsComponent from './settings.component'; + +describe('SettingsComponent', () => { + let comp: SettingsComponent; + let fixture: ComponentFixture; + let mockAccountService: AccountService; + const account: Account = { + firstName: 'John', + lastName: 'Doe', + activated: true, + email: 'john.doe@mail.com', + langKey: 'en', + login: 'john', + authorities: [], + imageUrl: '', + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), HttpClientTestingModule, SettingsComponent], + providers: [FormBuilder, AccountService], + }) + .overrideTemplate(SettingsComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SettingsComponent); + comp = fixture.componentInstance; + mockAccountService = TestBed.inject(AccountService); + mockAccountService.identity = jest.fn(() => of(account)); + mockAccountService.getAuthenticationState = jest.fn(() => of(account)); + }); + + it('should send the current identity upon save', () => { + // GIVEN + mockAccountService.save = jest.fn(() => of({})); + const settingsFormValues = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@mail.com', + langKey: 'en', + }; + + // WHEN + comp.ngOnInit(); + comp.save(); + + // THEN + expect(mockAccountService.identity).toHaveBeenCalled(); + expect(mockAccountService.save).toHaveBeenCalledWith(account); + expect(mockAccountService.authenticate).toHaveBeenCalledWith(account); + expect(comp.settingsForm.value).toMatchObject(expect.objectContaining(settingsFormValues)); + }); + + it('should notify of success upon successful save', () => { + // GIVEN + mockAccountService.save = jest.fn(() => of({})); + + // WHEN + comp.ngOnInit(); + comp.save(); + + // THEN + expect(comp.success).toBe(true); + }); + + it('should notify of error upon failed save', () => { + // GIVEN + mockAccountService.save = jest.fn(() => throwError('ERROR')); + + // WHEN + comp.ngOnInit(); + comp.save(); + + // THEN + expect(comp.success).toBe(false); + }); +}); diff --git a/src/main/webapp/app/account/settings/settings.component.ts b/src/main/webapp/app/account/settings/settings.component.ts new file mode 100644 index 000000000..1a60d9fe6 --- /dev/null +++ b/src/main/webapp/app/account/settings/settings.component.ts @@ -0,0 +1,70 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; + +import SharedModule from 'app/shared/shared.module'; +import { AccountService } from 'app/core/auth/account.service'; +import { Account } from 'app/core/auth/account.model'; +import { LANGUAGES } from 'app/config/language.constants'; + +const initialAccount: Account = {} as Account; + +@Component({ + selector: 'jhi-settings', + standalone: true, + imports: [SharedModule, FormsModule, ReactiveFormsModule], + templateUrl: './settings.component.html', +}) +export default class SettingsComponent implements OnInit { + success = false; + languages = LANGUAGES; + + settingsForm = new FormGroup({ + firstName: new FormControl(initialAccount.firstName, { + nonNullable: true, + validators: [Validators.required, Validators.minLength(1), Validators.maxLength(50)], + }), + lastName: new FormControl(initialAccount.lastName, { + nonNullable: true, + validators: [Validators.required, Validators.minLength(1), Validators.maxLength(50)], + }), + email: new FormControl(initialAccount.email, { + nonNullable: true, + validators: [Validators.required, Validators.minLength(5), Validators.maxLength(254), Validators.email], + }), + langKey: new FormControl(initialAccount.langKey, { nonNullable: true }), + + activated: new FormControl(initialAccount.activated, { nonNullable: true }), + authorities: new FormControl(initialAccount.authorities, { nonNullable: true }), + imageUrl: new FormControl(initialAccount.imageUrl, { nonNullable: true }), + login: new FormControl(initialAccount.login, { nonNullable: true }), + }); + + constructor( + private accountService: AccountService, + private translateService: TranslateService, + ) {} + + ngOnInit(): void { + this.accountService.identity().subscribe(account => { + if (account) { + this.settingsForm.patchValue(account); + } + }); + } + + save(): void { + this.success = false; + + const account = this.settingsForm.getRawValue(); + this.accountService.save(account).subscribe(() => { + this.success = true; + + this.accountService.authenticate(account); + + if (account.langKey !== this.translateService.currentLang) { + this.translateService.use(account.langKey); + } + }); + } +} diff --git a/src/main/webapp/app/account/settings/settings.route.ts b/src/main/webapp/app/account/settings/settings.route.ts new file mode 100644 index 000000000..3c9306611 --- /dev/null +++ b/src/main/webapp/app/account/settings/settings.route.ts @@ -0,0 +1,13 @@ +import { Route } from '@angular/router'; + +import { UserRouteAccessService } from 'app/core/auth/user-route-access.service'; +import SettingsComponent from './settings.component'; + +const settingsRoute: Route = { + path: 'settings', + component: SettingsComponent, + title: 'global.menu.account.settings', + canActivate: [UserRouteAccessService], +}; + +export default settingsRoute; diff --git a/src/main/webapp/app/admin/admin-routing.module.ts b/src/main/webapp/app/admin/admin-routing.module.ts new file mode 100644 index 000000000..34450cfe6 --- /dev/null +++ b/src/main/webapp/app/admin/admin-routing.module.ts @@ -0,0 +1,43 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +/* jhipster-needle-add-admin-module-import - JHipster will add admin modules imports here */ + +@NgModule({ + imports: [ + /* jhipster-needle-add-admin-module - JHipster will add admin modules here */ + RouterModule.forChild([ + { + path: 'user-management', + loadChildren: () => import('./user-management/user-management.route'), + title: 'userManagement.home.title', + }, + { + path: 'docs', + loadComponent: () => import('./docs/docs.component'), + title: 'global.menu.admin.apidocs', + }, + { + path: 'configuration', + loadComponent: () => import('./configuration/configuration.component'), + title: 'configuration.title', + }, + { + path: 'health', + loadComponent: () => import('./health/health.component'), + title: 'health.title', + }, + { + path: 'logs', + loadComponent: () => import('./logs/logs.component'), + title: 'logs.title', + }, + { + path: 'metrics', + loadComponent: () => import('./metrics/metrics.component'), + title: 'metrics.title', + }, + /* jhipster-needle-add-admin-route - JHipster will add admin routes here */ + ]), + ], +}) +export default class AdminRoutingModule {} diff --git a/src/main/webapp/app/admin/configuration/configuration.component.html b/src/main/webapp/app/admin/configuration/configuration.component.html new file mode 100644 index 000000000..e76d02c30 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.component.html @@ -0,0 +1,57 @@ +
+

Configuration

+ + Filter (by prefix) + + +

Spring configuration

+ + + + + + + + + + + + + + +
+ Prefix + Properties
+ {{ bean.prefix }} + +
+
{{ property.key }}
+
+ {{ property.value | json }} +
+
+
+ +
+

+ {{ propertySource.name }} +

+ + + + + + + + + + + + + + +
PropertyValue
{{ property.key }} + {{ property.value.value }} +
+
+
diff --git a/src/main/webapp/app/admin/configuration/configuration.component.spec.ts b/src/main/webapp/app/admin/configuration/configuration.component.spec.ts new file mode 100644 index 000000000..cebc27935 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.component.spec.ts @@ -0,0 +1,66 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + +import ConfigurationComponent from './configuration.component'; +import { ConfigurationService } from './configuration.service'; +import { Bean, PropertySource } from './configuration.model'; + +describe('ConfigurationComponent', () => { + let comp: ConfigurationComponent; + let fixture: ComponentFixture; + let service: ConfigurationService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, ConfigurationComponent], + providers: [ConfigurationService], + }) + .overrideTemplate(ConfigurationComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfigurationComponent); + comp = fixture.componentInstance; + service = TestBed.inject(ConfigurationService); + }); + + describe('OnInit', () => { + it('Should call load all on init', () => { + // GIVEN + const beans: Bean[] = [ + { + prefix: 'jhipster', + properties: { + clientApp: { + name: 'jhipsterApp', + }, + }, + }, + ]; + const propertySources: PropertySource[] = [ + { + name: 'server.ports', + properties: { + 'local.server.port': { + value: '8080', + }, + }, + }, + ]; + jest.spyOn(service, 'getBeans').mockReturnValue(of(beans)); + jest.spyOn(service, 'getPropertySources').mockReturnValue(of(propertySources)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.getBeans).toHaveBeenCalled(); + expect(service.getPropertySources).toHaveBeenCalled(); + expect(comp.allBeans).toEqual(beans); + expect(comp.beans).toEqual(beans); + expect(comp.propertySources).toEqual(propertySources); + }); + }); +}); diff --git a/src/main/webapp/app/admin/configuration/configuration.component.ts b/src/main/webapp/app/admin/configuration/configuration.component.ts new file mode 100644 index 000000000..ee97f6bc4 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; +import { FormsModule } from '@angular/forms'; +import { SortDirective, SortByDirective } from 'app/shared/sort'; +import { ConfigurationService } from './configuration.service'; +import { Bean, PropertySource } from './configuration.model'; + +@Component({ + standalone: true, + selector: 'jhi-configuration', + templateUrl: './configuration.component.html', + imports: [SharedModule, FormsModule, SortDirective, SortByDirective], +}) +export default class ConfigurationComponent implements OnInit { + allBeans!: Bean[]; + beans: Bean[] = []; + beansFilter = ''; + beansAscending = true; + propertySources: PropertySource[] = []; + + constructor(private configurationService: ConfigurationService) {} + + ngOnInit(): void { + this.configurationService.getBeans().subscribe(beans => { + this.allBeans = beans; + this.filterAndSortBeans(); + }); + + this.configurationService.getPropertySources().subscribe(propertySources => (this.propertySources = propertySources)); + } + + filterAndSortBeans(): void { + const beansAscendingValue = this.beansAscending ? -1 : 1; + const beansAscendingValueReverse = this.beansAscending ? 1 : -1; + this.beans = this.allBeans + .filter(bean => !this.beansFilter || bean.prefix.toLowerCase().includes(this.beansFilter.toLowerCase())) + .sort((a, b) => (a.prefix < b.prefix ? beansAscendingValue : beansAscendingValueReverse)); + } +} diff --git a/src/main/webapp/app/admin/configuration/configuration.model.ts b/src/main/webapp/app/admin/configuration/configuration.model.ts new file mode 100644 index 000000000..6a671e0a9 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.model.ts @@ -0,0 +1,40 @@ +export interface ConfigProps { + contexts: Contexts; +} + +export interface Contexts { + [key: string]: Context; +} + +export interface Context { + beans: Beans; + parentId?: any; +} + +export interface Beans { + [key: string]: Bean; +} + +export interface Bean { + prefix: string; + properties: any; +} + +export interface Env { + activeProfiles?: string[]; + propertySources: PropertySource[]; +} + +export interface PropertySource { + name: string; + properties: Properties; +} + +export interface Properties { + [key: string]: Property; +} + +export interface Property { + value: string; + origin?: string; +} diff --git a/src/main/webapp/app/admin/configuration/configuration.service.spec.ts b/src/main/webapp/app/admin/configuration/configuration.service.spec.ts new file mode 100644 index 000000000..6e6ff7f49 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.service.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ConfigurationService } from './configuration.service'; +import { Bean, ConfigProps, Env, PropertySource } from './configuration.model'; + +describe('Logs Service', () => { + let service: ConfigurationService; + let httpMock: HttpTestingController; + let expectedResult: Bean[] | PropertySource[] | null; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + expectedResult = null; + service = TestBed.inject(ConfigurationService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should get the config', () => { + const bean: Bean = { + prefix: 'jhipster', + properties: { + clientApp: { + name: 'jhipsterApp', + }, + }, + }; + const configProps: ConfigProps = { + contexts: { + jhipster: { + beans: { + 'tech.jhipster.config.JHipsterProperties': bean, + }, + }, + }, + }; + service.getBeans().subscribe(received => (expectedResult = received)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(configProps); + expect(expectedResult).toEqual([bean]); + }); + + it('should get the env', () => { + const propertySources: PropertySource[] = [ + { + name: 'server.ports', + properties: { + 'local.server.port': { + value: '8080', + }, + }, + }, + ]; + const env: Env = { propertySources }; + service.getPropertySources().subscribe(received => (expectedResult = received)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(env); + expect(expectedResult).toEqual(propertySources); + }); + }); +}); diff --git a/src/main/webapp/app/admin/configuration/configuration.service.ts b/src/main/webapp/app/admin/configuration/configuration.service.ts new file mode 100644 index 000000000..f03a0cce0 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { Bean, Beans, ConfigProps, Env, PropertySource } from './configuration.model'; + +@Injectable({ providedIn: 'root' }) +export class ConfigurationService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + getBeans(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/configprops')).pipe( + map(configProps => + Object.values( + Object.values(configProps.contexts) + .map(context => context.beans) + .reduce((allBeans: Beans, contextBeans: Beans) => ({ ...allBeans, ...contextBeans })), + ), + ), + ); + } + + getPropertySources(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/env')).pipe(map(env => env.propertySources)); + } +} diff --git a/src/main/webapp/app/admin/docs/docs.component.html b/src/main/webapp/app/admin/docs/docs.component.html new file mode 100644 index 000000000..24025522f --- /dev/null +++ b/src/main/webapp/app/admin/docs/docs.component.html @@ -0,0 +1,10 @@ + diff --git a/src/main/webapp/app/admin/docs/docs.component.scss b/src/main/webapp/app/admin/docs/docs.component.scss new file mode 100644 index 000000000..bb9a6cc81 --- /dev/null +++ b/src/main/webapp/app/admin/docs/docs.component.scss @@ -0,0 +1,6 @@ +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; + +iframe { + background: white; +} diff --git a/src/main/webapp/app/admin/docs/docs.component.ts b/src/main/webapp/app/admin/docs/docs.component.ts new file mode 100644 index 000000000..ea4188354 --- /dev/null +++ b/src/main/webapp/app/admin/docs/docs.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + standalone: true, + selector: 'jhi-docs', + templateUrl: './docs.component.html', + styleUrls: ['./docs.component.scss'], +}) +export default class DocsComponent {} diff --git a/src/main/webapp/app/admin/health/health.component.html b/src/main/webapp/app/admin/health/health.component.html new file mode 100644 index 000000000..08e62ac31 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.component.html @@ -0,0 +1,48 @@ +
+

+ Health Checks + + +

+ +
+ + + + + + + + + + + + + + + +
Service nameStatusDetails
+ {{ 'health.indicator.' + componentHealth.key | translate }} + + + {{ + { UNKNOWN: 'UNKNOWN', UP: 'UP', OUT_OF_SERVICE: 'OUT_OF_SERVICE', DOWN: 'DOWN' }[componentHealth.value!.status || 'UNKNOWN'] + }} + + + + + +
+
+
diff --git a/src/main/webapp/app/admin/health/health.component.spec.ts b/src/main/webapp/app/admin/health/health.component.spec.ts new file mode 100644 index 000000000..97ce8aba1 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.component.spec.ts @@ -0,0 +1,65 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of, throwError } from 'rxjs'; + +import HealthComponent from './health.component'; +import { HealthService } from './health.service'; +import { Health } from './health.model'; + +describe('HealthComponent', () => { + let comp: HealthComponent; + let fixture: ComponentFixture; + let service: HealthService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, HealthComponent], + }) + .overrideTemplate(HealthComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthComponent); + comp = fixture.componentInstance; + service = TestBed.inject(HealthService); + }); + + describe('getBadgeClass', () => { + it('should get badge class', () => { + const upBadgeClass = comp.getBadgeClass('UP'); + const downBadgeClass = comp.getBadgeClass('DOWN'); + expect(upBadgeClass).toEqual('bg-success'); + expect(downBadgeClass).toEqual('bg-danger'); + }); + }); + + describe('refresh', () => { + it('should call refresh on init', () => { + // GIVEN + const health: Health = { status: 'UP', components: { mail: { status: 'UP', details: { mailDetail: 'mail' } } } }; + jest.spyOn(service, 'checkHealth').mockReturnValue(of(health)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.checkHealth).toHaveBeenCalled(); + expect(comp.health).toEqual(health); + }); + + it('should handle a 503 on refreshing health data', () => { + // GIVEN + const health: Health = { status: 'DOWN', components: { mail: { status: 'DOWN' } } }; + jest.spyOn(service, 'checkHealth').mockReturnValue(throwError(new HttpErrorResponse({ status: 503, error: health }))); + + // WHEN + comp.refresh(); + + // THEN + expect(service.checkHealth).toHaveBeenCalled(); + expect(comp.health).toEqual(health); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/health.component.ts b/src/main/webapp/app/admin/health/health.component.ts new file mode 100644 index 000000000..4e33b9c06 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { HealthService } from './health.service'; +import { Health, HealthDetails, HealthStatus } from './health.model'; +import HealthModalComponent from './modal/health-modal.component'; + +@Component({ + standalone: true, + selector: 'jhi-health', + templateUrl: './health.component.html', + imports: [SharedModule, HealthModalComponent], +}) +export default class HealthComponent implements OnInit { + health?: Health; + + constructor( + private modalService: NgbModal, + private healthService: HealthService, + ) {} + + ngOnInit(): void { + this.refresh(); + } + + getBadgeClass(statusState: HealthStatus): string { + if (statusState === 'UP') { + return 'bg-success'; + } + return 'bg-danger'; + } + + refresh(): void { + this.healthService.checkHealth().subscribe({ + next: health => (this.health = health), + error: (error: HttpErrorResponse) => { + if (error.status === 503) { + this.health = error.error; + } + }, + }); + } + + showHealth(health: { key: string; value: HealthDetails }): void { + const modalRef = this.modalService.open(HealthModalComponent); + modalRef.componentInstance.health = health; + } +} diff --git a/src/main/webapp/app/admin/health/health.model.ts b/src/main/webapp/app/admin/health/health.model.ts new file mode 100644 index 000000000..08112898e --- /dev/null +++ b/src/main/webapp/app/admin/health/health.model.ts @@ -0,0 +1,15 @@ +export type HealthStatus = 'UP' | 'DOWN' | 'UNKNOWN' | 'OUT_OF_SERVICE'; + +export type HealthKey = 'diskSpace' | 'mail' | 'ping' | 'livenessState' | 'readinessState' | 'db'; + +export interface Health { + status: HealthStatus; + components: { + [key in HealthKey]?: HealthDetails; + }; +} + +export interface HealthDetails { + status: HealthStatus; + details?: { [key: string]: unknown }; +} diff --git a/src/main/webapp/app/admin/health/health.service.spec.ts b/src/main/webapp/app/admin/health/health.service.spec.ts new file mode 100644 index 000000000..1e1ff19ca --- /dev/null +++ b/src/main/webapp/app/admin/health/health.service.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { HealthService } from './health.service'; + +describe('HealthService Service', () => { + let service: HealthService; + let httpMock: HttpTestingController; + let applicationConfigService: ApplicationConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(HealthService); + applicationConfigService = TestBed.inject(ApplicationConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should call management/health endpoint with correct values', () => { + // GIVEN + let expectedResult; + const checkHealth = { + components: [], + }; + + // WHEN + service.checkHealth().subscribe(received => { + expectedResult = received; + }); + const testRequest = httpMock.expectOne({ + method: 'GET', + url: applicationConfigService.getEndpointFor('management/health'), + }); + testRequest.flush(checkHealth); + + // THEN + expect(expectedResult).toEqual(checkHealth); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/health.service.ts b/src/main/webapp/app/admin/health/health.service.ts new file mode 100644 index 000000000..7a2d420d2 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { Health } from './health.model'; + +@Injectable({ providedIn: 'root' }) +export class HealthService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + checkHealth(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/health')); + } +} diff --git a/src/main/webapp/app/admin/health/modal/health-modal.component.html b/src/main/webapp/app/admin/health/modal/health-modal.component.html new file mode 100644 index 000000000..f55b8765f --- /dev/null +++ b/src/main/webapp/app/admin/health/modal/health-modal.component.html @@ -0,0 +1,36 @@ + + + + + diff --git a/src/main/webapp/app/admin/health/modal/health-modal.component.spec.ts b/src/main/webapp/app/admin/health/modal/health-modal.component.spec.ts new file mode 100644 index 000000000..b9aa047b9 --- /dev/null +++ b/src/main/webapp/app/admin/health/modal/health-modal.component.spec.ts @@ -0,0 +1,111 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import HealthModalComponent from './health-modal.component'; + +describe('HealthModalComponent', () => { + let comp: HealthModalComponent; + let fixture: ComponentFixture; + let mockActiveModal: NgbActiveModal; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, HealthModalComponent], + providers: [NgbActiveModal], + }) + .overrideTemplate(HealthModalComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthModalComponent); + comp = fixture.componentInstance; + mockActiveModal = TestBed.inject(NgbActiveModal); + }); + + describe('readableValue', () => { + it('should return stringify value', () => { + // GIVEN + comp.health = undefined; + + // WHEN + const result = comp.readableValue({ name: 'jhipster' }); + + // THEN + expect(result).toEqual('{"name":"jhipster"}'); + }); + + it('should return string value', () => { + // GIVEN + comp.health = undefined; + + // WHEN + const result = comp.readableValue('jhipster'); + + // THEN + expect(result).toEqual('jhipster'); + }); + + it('should return storage space in an human readable unit (GB)', () => { + // GIVEN + comp.health = { + key: 'diskSpace', + value: { + status: 'UP', + }, + }; + + // WHEN + const result = comp.readableValue(1073741825); + + // THEN + expect(result).toEqual('1.00 GB'); + }); + + it('should return storage space in an human readable unit (MB)', () => { + // GIVEN + comp.health = { + key: 'diskSpace', + value: { + status: 'UP', + }, + }; + + // WHEN + const result = comp.readableValue(1073741824); + + // THEN + expect(result).toEqual('1024.00 MB'); + }); + + it('should return string value', () => { + // GIVEN + comp.health = { + key: 'mail', + value: { + status: 'UP', + }, + }; + + // WHEN + const result = comp.readableValue(1234); + + // THEN + expect(result).toEqual('1234'); + }); + }); + + describe('dismiss', () => { + it('should call dismiss when dismiss modal is called', () => { + // GIVEN + const spy = jest.spyOn(mockActiveModal, 'dismiss'); + + // WHEN + comp.dismiss(); + + // THEN + expect(spy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/modal/health-modal.component.ts b/src/main/webapp/app/admin/health/modal/health-modal.component.ts new file mode 100644 index 000000000..9eefa8bb2 --- /dev/null +++ b/src/main/webapp/app/admin/health/modal/health-modal.component.ts @@ -0,0 +1,37 @@ +import { Component } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { HealthKey, HealthDetails } from '../health.model'; + +@Component({ + standalone: true, + selector: 'jhi-health-modal', + templateUrl: './health-modal.component.html', + imports: [SharedModule], +}) +export default class HealthModalComponent { + health?: { key: HealthKey; value: HealthDetails }; + + constructor(private activeModal: NgbActiveModal) {} + + readableValue(value: any): string { + if (this.health?.key === 'diskSpace') { + // Should display storage space in an human readable unit + const val = value / 1073741824; + if (val > 1) { + return `${val.toFixed(2)} GB`; + } + return `${(value / 1048576).toFixed(2)} MB`; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + } + + dismiss(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/admin/logs/log.model.ts b/src/main/webapp/app/admin/logs/log.model.ts new file mode 100644 index 000000000..2606a8850 --- /dev/null +++ b/src/main/webapp/app/admin/logs/log.model.ts @@ -0,0 +1,18 @@ +export type Level = 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'OFF'; + +export interface Logger { + configuredLevel: Level | null; + effectiveLevel: Level; +} + +export interface LoggersResponse { + levels: Level[]; + loggers: { [key: string]: Logger }; +} + +export class Log { + constructor( + public name: string, + public level: Level, + ) {} +} diff --git a/src/main/webapp/app/admin/logs/logs.component.html b/src/main/webapp/app/admin/logs/logs.component.html new file mode 100644 index 000000000..256d2162b --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.component.html @@ -0,0 +1,78 @@ +
+

Logs

+ +
+
+
+ +

There are {{ loggers.length }} loggers.

+ + Filter + + + + + + + + + + + + + + + + +
Name Level
+ {{ logger.name | slice: 0 : 140 }} + + + + + + + + + + + + +
+
diff --git a/src/main/webapp/app/admin/logs/logs.component.spec.ts b/src/main/webapp/app/admin/logs/logs.component.spec.ts new file mode 100644 index 000000000..611b47d4b --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + +import LogsComponent from './logs.component'; +import { LogsService } from './logs.service'; +import { Log, LoggersResponse } from './log.model'; + +describe('LogsComponent', () => { + let comp: LogsComponent; + let fixture: ComponentFixture; + let service: LogsService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, LogsComponent], + providers: [LogsService], + }) + .overrideTemplate(LogsComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LogsComponent); + comp = fixture.componentInstance; + service = TestBed.inject(LogsService); + }); + + describe('OnInit', () => { + it('should set all default values correctly', () => { + expect(comp.filter).toBe(''); + expect(comp.orderProp).toBe('name'); + expect(comp.ascending).toBe(true); + }); + + it('Should call load all on init', () => { + // GIVEN + const log = new Log('main', 'WARN'); + jest.spyOn(service, 'findAll').mockReturnValue( + of({ + loggers: { + main: { + effectiveLevel: 'WARN', + }, + }, + } as unknown as LoggersResponse), + ); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.findAll).toHaveBeenCalled(); + expect(comp.loggers?.[0]).toEqual(expect.objectContaining(log)); + }); + }); + + describe('change log level', () => { + it('should change log level correctly', () => { + // GIVEN + const log = new Log('main', 'ERROR'); + jest.spyOn(service, 'changeLevel').mockReturnValue(of({})); + jest.spyOn(service, 'findAll').mockReturnValue( + of({ + loggers: { + main: { + effectiveLevel: 'ERROR', + }, + }, + } as unknown as LoggersResponse), + ); + + // WHEN + comp.changeLevel('main', 'ERROR'); + + // THEN + expect(service.changeLevel).toHaveBeenCalled(); + expect(service.findAll).toHaveBeenCalled(); + expect(comp.loggers?.[0]).toEqual(expect.objectContaining(log)); + }); + }); +}); diff --git a/src/main/webapp/app/admin/logs/logs.component.ts b/src/main/webapp/app/admin/logs/logs.component.ts new file mode 100644 index 000000000..e899e3688 --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.component.ts @@ -0,0 +1,65 @@ +import { Component, OnInit } from '@angular/core'; +import { finalize } from 'rxjs/operators'; + +import SharedModule from 'app/shared/shared.module'; +import { FormsModule } from '@angular/forms'; +import { SortDirective, SortByDirective } from 'app/shared/sort'; +import { Log, LoggersResponse, Level } from './log.model'; +import { LogsService } from './logs.service'; + +@Component({ + standalone: true, + selector: 'jhi-logs', + templateUrl: './logs.component.html', + imports: [SharedModule, FormsModule, SortDirective, SortByDirective], +}) +export default class LogsComponent implements OnInit { + loggers?: Log[]; + filteredAndOrderedLoggers?: Log[]; + isLoading = false; + filter = ''; + orderProp: keyof Log = 'name'; + ascending = true; + + constructor(private logsService: LogsService) {} + + ngOnInit(): void { + this.findAndExtractLoggers(); + } + + changeLevel(name: string, level: Level): void { + this.logsService.changeLevel(name, level).subscribe(() => this.findAndExtractLoggers()); + } + + filterAndSort(): void { + this.filteredAndOrderedLoggers = this.loggers!.filter( + logger => !this.filter || logger.name.toLowerCase().includes(this.filter.toLowerCase()), + ).sort((a, b) => { + if (a[this.orderProp] < b[this.orderProp]) { + return this.ascending ? -1 : 1; + } else if (a[this.orderProp] > b[this.orderProp]) { + return this.ascending ? 1 : -1; + } else if (this.orderProp === 'level') { + return a.name < b.name ? -1 : 1; + } + return 0; + }); + } + + private findAndExtractLoggers(): void { + this.isLoading = true; + this.logsService + .findAll() + .pipe( + finalize(() => { + this.filterAndSort(); + this.isLoading = false; + }), + ) + .subscribe({ + next: (response: LoggersResponse) => + (this.loggers = Object.entries(response.loggers).map(([key, logger]) => new Log(key, logger.effectiveLevel))), + error: () => (this.loggers = []), + }); + } +} diff --git a/src/main/webapp/app/admin/logs/logs.service.spec.ts b/src/main/webapp/app/admin/logs/logs.service.spec.ts new file mode 100644 index 000000000..cebee2ccd --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.service.spec.ts @@ -0,0 +1,31 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { LogsService } from './logs.service'; + +describe('Logs Service', () => { + let service: LogsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(LogsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should change log level', () => { + service.changeLevel('main', 'ERROR').subscribe(); + + const req = httpMock.expectOne({ method: 'POST' }); + expect(req.request.body).toEqual({ configuredLevel: 'ERROR' }); + }); + }); +}); diff --git a/src/main/webapp/app/admin/logs/logs.service.ts b/src/main/webapp/app/admin/logs/logs.service.ts new file mode 100644 index 000000000..61f64f230 --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { LoggersResponse, Level } from './log.model'; + +@Injectable({ providedIn: 'root' }) +export class LogsService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + changeLevel(name: string, configuredLevel: Level): Observable<{}> { + return this.http.post(this.applicationConfigService.getEndpointFor(`management/loggers/${name}`), { configuredLevel }); + } + + findAll(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/loggers')); + } +} diff --git a/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.html b/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.html new file mode 100644 index 000000000..8e802cce2 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.html @@ -0,0 +1,28 @@ +

Memory

+ +
+
+ + {{ entry.key }} + ({{ entry.value.used / 1048576 | number: '1.0-0' }}M / {{ entry.value.max / 1048576 | number: '1.0-0' }}M) + + +
Committed : {{ entry.value.committed / 1048576 | number: '1.0-0' }}M
+ + {{ entry.key }} {{ entry.value.used / 1048576 | number: '1.0-0' }}M + + + {{ (entry.value.used * 100) / entry.value.max | number: '1.0-0' }}% + +
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.ts b/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.ts new file mode 100644 index 000000000..8654a8b98 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; +import { JvmMetrics } from 'app/admin/metrics/metrics.model'; + +@Component({ + standalone: true, + selector: 'jhi-jvm-memory', + templateUrl: './jvm-memory.component.html', + imports: [SharedModule], +}) +export class JvmMemoryComponent { + /** + * object containing all jvm memory metrics + */ + @Input() jvmMemoryMetrics?: { [key: string]: JvmMetrics }; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; +} diff --git a/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.html b/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.html new file mode 100644 index 000000000..13bbe610d --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.html @@ -0,0 +1,55 @@ +

Threads

+ +Runnable {{ threadStats.threadDumpRunnable }} + + + {{ (threadStats.threadDumpRunnable * 100) / threadStats.threadDumpAll | number: '1.0-0' }}% + + +Timed waiting ({{ threadStats.threadDumpTimedWaiting }}) + + + {{ (threadStats.threadDumpTimedWaiting * 100) / threadStats.threadDumpAll | number: '1.0-0' }}% + + +Waiting ({{ threadStats.threadDumpWaiting }}) + + + {{ (threadStats.threadDumpWaiting * 100) / threadStats.threadDumpAll | number: '1.0-0' }}% + + +Blocked ({{ threadStats.threadDumpBlocked }}) + + + {{ (threadStats.threadDumpBlocked * 100) / threadStats.threadDumpAll | number: '1.0-0' }}% + + +
Total: {{ threadStats.threadDumpAll }}
+ + diff --git a/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.ts b/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.ts new file mode 100644 index 000000000..dc726935b --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.ts @@ -0,0 +1,58 @@ +import { Component, Input } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { Thread, ThreadState } from 'app/admin/metrics/metrics.model'; +import { MetricsModalThreadsComponent } from '../metrics-modal-threads/metrics-modal-threads.component'; + +@Component({ + standalone: true, + selector: 'jhi-jvm-threads', + templateUrl: './jvm-threads.component.html', + imports: [SharedModule], +}) +export class JvmThreadsComponent { + threadStats = { + threadDumpAll: 0, + threadDumpRunnable: 0, + threadDumpTimedWaiting: 0, + threadDumpWaiting: 0, + threadDumpBlocked: 0, + }; + + @Input() + set threads(threads: Thread[] | undefined) { + this._threads = threads; + + threads?.forEach(thread => { + if (thread.threadState === ThreadState.Runnable) { + this.threadStats.threadDumpRunnable += 1; + } else if (thread.threadState === ThreadState.Waiting) { + this.threadStats.threadDumpWaiting += 1; + } else if (thread.threadState === ThreadState.TimedWaiting) { + this.threadStats.threadDumpTimedWaiting += 1; + } else if (thread.threadState === ThreadState.Blocked) { + this.threadStats.threadDumpBlocked += 1; + } + }); + + this.threadStats.threadDumpAll = + this.threadStats.threadDumpRunnable + + this.threadStats.threadDumpWaiting + + this.threadStats.threadDumpTimedWaiting + + this.threadStats.threadDumpBlocked; + } + + get threads(): Thread[] | undefined { + return this._threads; + } + + private _threads: Thread[] | undefined; + + constructor(private modalService: NgbModal) {} + + open(): void { + const modalRef = this.modalService.open(MetricsModalThreadsComponent); + modalRef.componentInstance.threads = this.threads; + } +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.html new file mode 100644 index 000000000..18f1bcbd8 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.html @@ -0,0 +1,42 @@ +

Cache statistics

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Cache nameCache HitsCache MissesCache GetsCache PutsCache RemovalsCache EvictionsCache Hit %Cache Miss %
{{ entry.key }}{{ entry.value['cache.gets.hit'] }}{{ entry.value['cache.gets.miss'] }}{{ entry.value['cache.gets.hit'] + entry.value['cache.gets.miss'] }}{{ entry.value['cache.puts'] }}{{ entry.value['cache.removals'] }}{{ entry.value['cache.evictions'] }} + {{ + filterNaN((100 * entry.value['cache.gets.hit']) / (entry.value['cache.gets.hit'] + entry.value['cache.gets.miss'])) + | number: '1.0-4' + }} + + {{ + filterNaN((100 * entry.value['cache.gets.miss']) / (entry.value['cache.gets.hit'] + entry.value['cache.gets.miss'])) + | number: '1.0-4' + }} +
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.ts new file mode 100644 index 000000000..5a174f7c5 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; +import { CacheMetrics } from 'app/admin/metrics/metrics.model'; +import { filterNaN } from 'app/core/util/operators'; + +@Component({ + standalone: true, + selector: 'jhi-metrics-cache', + templateUrl: './metrics-cache.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SharedModule], +}) +export class MetricsCacheComponent { + /** + * object containing all cache related metrics + */ + @Input() cacheMetrics?: { [key: string]: CacheMetrics }; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; + + filterNaN = (input: number): number => filterNaN(input); +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.html new file mode 100644 index 000000000..ab524b860 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.html @@ -0,0 +1,57 @@ +

DataSource statistics (time in millisecond)

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Connection Pool Usage (active: {{ datasourceMetrics.active.value }}, min: + {{ datasourceMetrics.min.value }}, max: {{ datasourceMetrics.max.value }}, idle: {{ datasourceMetrics.idle.value }}) + CountMeanMinp50p75p95p99Max
Acquire{{ datasourceMetrics.acquire.count }}{{ filterNaN(datasourceMetrics.acquire.mean) | number: '1.0-2' }}{{ datasourceMetrics.acquire['0.0'] | number: '1.0-3' }}{{ datasourceMetrics.acquire['0.5'] | number: '1.0-3' }}{{ datasourceMetrics.acquire['0.75'] | number: '1.0-3' }}{{ datasourceMetrics.acquire['0.95'] | number: '1.0-3' }}{{ datasourceMetrics.acquire['0.99'] | number: '1.0-3' }}{{ filterNaN(datasourceMetrics.acquire.max) | number: '1.0-2' }}
Creation{{ datasourceMetrics.creation.count }}{{ filterNaN(datasourceMetrics.creation.mean) | number: '1.0-2' }}{{ datasourceMetrics.creation['0.0'] | number: '1.0-3' }}{{ datasourceMetrics.creation['0.5'] | number: '1.0-3' }}{{ datasourceMetrics.creation['0.75'] | number: '1.0-3' }}{{ datasourceMetrics.creation['0.95'] | number: '1.0-3' }}{{ datasourceMetrics.creation['0.99'] | number: '1.0-3' }}{{ filterNaN(datasourceMetrics.creation.max) | number: '1.0-2' }}
Usage{{ datasourceMetrics.usage.count }}{{ filterNaN(datasourceMetrics.usage.mean) | number: '1.0-2' }}{{ datasourceMetrics.usage['0.0'] | number: '1.0-3' }}{{ datasourceMetrics.usage['0.5'] | number: '1.0-3' }}{{ datasourceMetrics.usage['0.75'] | number: '1.0-3' }}{{ datasourceMetrics.usage['0.95'] | number: '1.0-3' }}{{ datasourceMetrics.usage['0.99'] | number: '1.0-3' }}{{ filterNaN(datasourceMetrics.usage.max) | number: '1.0-2' }}
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.ts new file mode 100644 index 000000000..b666e1a34 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; +import { Databases } from 'app/admin/metrics/metrics.model'; +import { filterNaN } from 'app/core/util/operators'; + +@Component({ + standalone: true, + selector: 'jhi-metrics-datasource', + templateUrl: './metrics-datasource.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SharedModule], +}) +export class MetricsDatasourceComponent { + /** + * object containing all datasource related metrics + */ + @Input() datasourceMetrics?: Databases; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; + + filterNaN = (input: number): number => filterNaN(input); +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.html new file mode 100644 index 000000000..ee47ec7ea --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.html @@ -0,0 +1,24 @@ +

Endpoints requests (time in millisecond)

+ +
+ + + + + + + + + + + + + + + + + + + +
MethodEndpoint urlCountMean
{{ method.key }}{{ entry.key }}{{ method.value!.count }}{{ method.value!.mean | number: '1.0-3' }}
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.ts new file mode 100644 index 000000000..f96e80ad4 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; +import { Services } from 'app/admin/metrics/metrics.model'; + +@Component({ + standalone: true, + selector: 'jhi-metrics-endpoints-requests', + templateUrl: './metrics-endpoints-requests.component.html', + imports: [SharedModule], +}) +export class MetricsEndpointsRequestsComponent { + /** + * object containing service related metrics + */ + @Input() endpointsRequestsMetrics?: Services; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.html new file mode 100644 index 000000000..85b86190d --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.html @@ -0,0 +1,92 @@ +

Garbage collections

+ +
+
+
+ + GC Live Data Size/GC Max Data Size ({{ garbageCollectorMetrics['jvm.gc.live.data.size'] / 1048576 | number: '1.0-0' }}M / + {{ garbageCollectorMetrics['jvm.gc.max.data.size'] / 1048576 | number: '1.0-0' }}M) + + + + + {{ + (100 * garbageCollectorMetrics['jvm.gc.live.data.size']) / garbageCollectorMetrics['jvm.gc.max.data.size'] | number: '1.0-2' + }}% + + +
+
+ +
+
+ + GC Memory Promoted/GC Memory Allocated ({{ garbageCollectorMetrics['jvm.gc.memory.promoted'] / 1048576 | number: '1.0-0' }}M / + {{ garbageCollectorMetrics['jvm.gc.memory.allocated'] / 1048576 | number: '1.0-0' }}M) + + + + + {{ + (100 * garbageCollectorMetrics['jvm.gc.memory.promoted']) / garbageCollectorMetrics['jvm.gc.memory.allocated'] + | number: '1.0-2' + }}% + + +
+
+ +
+
+
Classes loaded
+
{{ garbageCollectorMetrics.classesLoaded }}
+
+
+
Classes unloaded
+
{{ garbageCollectorMetrics.classesUnloaded }}
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
CountMeanMinp50p75p95p99Max
jvm.gc.pause{{ garbageCollectorMetrics['jvm.gc.pause'].count }}{{ garbageCollectorMetrics['jvm.gc.pause'].mean | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.0'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.5'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.75'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.95'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.99'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause'].max | number: '1.0-3' }}
+
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.ts new file mode 100644 index 000000000..49e397d6b --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; +import { GarbageCollector } from 'app/admin/metrics/metrics.model'; + +@Component({ + standalone: true, + selector: 'jhi-metrics-garbagecollector', + templateUrl: './metrics-garbagecollector.component.html', + imports: [SharedModule], +}) +export class MetricsGarbageCollectorComponent { + /** + * object containing garbage collector related metrics + */ + @Input() garbageCollectorMetrics?: GarbageCollector; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.html new file mode 100644 index 000000000..b53179bec --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.html @@ -0,0 +1,90 @@ + + + + diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.ts new file mode 100644 index 000000000..72c05cc77 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.ts @@ -0,0 +1,62 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { Thread, ThreadState } from 'app/admin/metrics/metrics.model'; + +@Component({ + standalone: true, + selector: 'jhi-thread-modal', + templateUrl: './metrics-modal-threads.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SharedModule], +}) +export class MetricsModalThreadsComponent implements OnInit { + ThreadState = ThreadState; + threadStateFilter?: ThreadState; + threads?: Thread[]; + threadDumpAll = 0; + threadDumpBlocked = 0; + threadDumpRunnable = 0; + threadDumpTimedWaiting = 0; + threadDumpWaiting = 0; + + constructor(private activeModal: NgbActiveModal) {} + + ngOnInit(): void { + this.threads?.forEach(thread => { + if (thread.threadState === ThreadState.Runnable) { + this.threadDumpRunnable += 1; + } else if (thread.threadState === ThreadState.Waiting) { + this.threadDumpWaiting += 1; + } else if (thread.threadState === ThreadState.TimedWaiting) { + this.threadDumpTimedWaiting += 1; + } else if (thread.threadState === ThreadState.Blocked) { + this.threadDumpBlocked += 1; + } + }); + + this.threadDumpAll = this.threadDumpRunnable + this.threadDumpWaiting + this.threadDumpTimedWaiting + this.threadDumpBlocked; + } + + getBadgeClass(threadState: ThreadState): string { + if (threadState === ThreadState.Runnable) { + return 'bg-success'; + } else if (threadState === ThreadState.Waiting) { + return 'bg-info'; + } else if (threadState === ThreadState.TimedWaiting) { + return 'bg-warning'; + } else if (threadState === ThreadState.Blocked) { + return 'bg-danger'; + } + return ''; + } + + getThreads(): Thread[] { + return this.threads?.filter(thread => !this.threadStateFilter || thread.threadState === this.threadStateFilter) ?? []; + } + + dismiss(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.html new file mode 100644 index 000000000..275acdf95 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.html @@ -0,0 +1,26 @@ +

HTTP requests (time in millisecond)

+ + + + + + + + + + + + + + + + + + +
CodeCountMeanMax
{{ entry.key }} + + {{ entry.value.count }} + + + {{ filterNaN(entry.value.mean) | number: '1.0-2' }} + {{ entry.value.max | number: '1.0-2' }}
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.ts new file mode 100644 index 000000000..d19bb0591 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; +import { HttpServerRequests } from 'app/admin/metrics/metrics.model'; +import { filterNaN } from 'app/core/util/operators'; + +@Component({ + standalone: true, + selector: 'jhi-metrics-request', + templateUrl: './metrics-request.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SharedModule], +}) +export class MetricsRequestComponent { + /** + * object containing http request related metrics + */ + @Input() requestMetrics?: HttpServerRequests; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; + + filterNaN = (input: number): number => filterNaN(input); +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.html new file mode 100644 index 000000000..d35be0cf0 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.html @@ -0,0 +1,51 @@ +

System

+ + +
+
Uptime
+
{{ convertMillisecondsToDuration(systemMetrics['process.uptime']) }}
+
+ +
+
Start time
+
{{ systemMetrics['process.start.time'] | date: 'full' }}
+
+ +
+
Process CPU usage
+
{{ 100 * systemMetrics['process.cpu.usage'] | number: '1.0-2' }} %
+
+ + + {{ 100 * systemMetrics['process.cpu.usage'] | number: '1.0-2' }} % + + +
+
System CPU usage
+
{{ 100 * systemMetrics['system.cpu.usage'] | number: '1.0-2' }} %
+
+ + + {{ 100 * systemMetrics['system.cpu.usage'] | number: '1.0-2' }} % + + +
+
System CPU count
+
{{ systemMetrics['system.cpu.count'] }}
+
+ +
+
System 1m Load average
+
{{ systemMetrics['system.load.average.1m'] | number: '1.0-2' }}
+
+ +
+
Process files max
+
{{ systemMetrics['process.files.max'] | number: '1.0-0' }}
+
+ +
+
Process files open
+
{{ systemMetrics['process.files.open'] | number: '1.0-0' }}
+
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.ts new file mode 100644 index 000000000..143298975 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import SharedModule from 'app/shared/shared.module'; +import { ProcessMetrics } from 'app/admin/metrics/metrics.model'; + +@Component({ + standalone: true, + selector: 'jhi-metrics-system', + templateUrl: './metrics-system.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SharedModule], +}) +export class MetricsSystemComponent { + /** + * object containing thread related metrics + */ + @Input() systemMetrics?: ProcessMetrics; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; + + convertMillisecondsToDuration(ms: number): string { + const times = { + year: 31557600000, + month: 2629746000, + day: 86400000, + hour: 3600000, + minute: 60000, + second: 1000, + }; + let timeString = ''; + for (const [key, value] of Object.entries(times)) { + if (Math.floor(ms / value) > 0) { + let plural = ''; + if (Math.floor(ms / value) > 1) { + plural = 's'; + } + timeString += `${Math.floor(ms / value).toString()} ${key.toString()}${plural} `; + ms = ms - value * Math.floor(ms / value); + } + } + return timeString; + } +} diff --git a/src/main/webapp/app/admin/metrics/metrics.component.html b/src/main/webapp/app/admin/metrics/metrics.component.html new file mode 100644 index 000000000..c59876ce8 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.component.html @@ -0,0 +1,51 @@ +
+

+ Application Metrics + + +

+ +

JVM Metrics

+ +
+ + + + + +
+ + + +
Updating...
+ + + + + + + + +
diff --git a/src/main/webapp/app/admin/metrics/metrics.component.spec.ts b/src/main/webapp/app/admin/metrics/metrics.component.spec.ts new file mode 100644 index 000000000..4d01399e7 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.component.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + +import MetricsComponent from './metrics.component'; +import { MetricsService } from './metrics.service'; +import { Metrics } from './metrics.model'; + +describe('MetricsComponent', () => { + let comp: MetricsComponent; + let fixture: ComponentFixture; + let service: MetricsService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, MetricsComponent], + }) + .overrideTemplate(MetricsComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MetricsComponent); + comp = fixture.componentInstance; + service = TestBed.inject(MetricsService); + }); + + describe('refresh', () => { + it('should call refresh on init', () => { + // GIVEN + jest.spyOn(service, 'getMetrics').mockReturnValue(of({} as Metrics)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.getMetrics).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/admin/metrics/metrics.component.ts b/src/main/webapp/app/admin/metrics/metrics.component.ts new file mode 100644 index 000000000..93f7a3fe8 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.component.ts @@ -0,0 +1,66 @@ +import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { combineLatest } from 'rxjs'; + +import SharedModule from 'app/shared/shared.module'; +import { MetricsService } from './metrics.service'; +import { Metrics, Thread } from './metrics.model'; +import { JvmMemoryComponent } from './blocks/jvm-memory/jvm-memory.component'; +import { JvmThreadsComponent } from './blocks/jvm-threads/jvm-threads.component'; +import { MetricsCacheComponent } from './blocks/metrics-cache/metrics-cache.component'; +import { MetricsDatasourceComponent } from './blocks/metrics-datasource/metrics-datasource.component'; +import { MetricsEndpointsRequestsComponent } from './blocks/metrics-endpoints-requests/metrics-endpoints-requests.component'; +import { MetricsGarbageCollectorComponent } from './blocks/metrics-garbagecollector/metrics-garbagecollector.component'; +import { MetricsModalThreadsComponent } from './blocks/metrics-modal-threads/metrics-modal-threads.component'; +import { MetricsRequestComponent } from './blocks/metrics-request/metrics-request.component'; +import { MetricsSystemComponent } from './blocks/metrics-system/metrics-system.component'; + +@Component({ + standalone: true, + selector: 'jhi-metrics', + templateUrl: './metrics.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + SharedModule, + JvmMemoryComponent, + JvmThreadsComponent, + MetricsCacheComponent, + MetricsDatasourceComponent, + MetricsEndpointsRequestsComponent, + MetricsGarbageCollectorComponent, + MetricsModalThreadsComponent, + MetricsRequestComponent, + MetricsSystemComponent, + ], +}) +export default class MetricsComponent implements OnInit { + metrics?: Metrics; + threads?: Thread[]; + updatingMetrics = true; + + constructor( + private metricsService: MetricsService, + private changeDetector: ChangeDetectorRef, + ) {} + + ngOnInit(): void { + this.refresh(); + } + + refresh(): void { + this.updatingMetrics = true; + combineLatest([this.metricsService.getMetrics(), this.metricsService.threadDump()]).subscribe(([metrics, threadDump]) => { + this.metrics = metrics; + this.threads = threadDump.threads; + this.updatingMetrics = false; + this.changeDetector.markForCheck(); + }); + } + + metricsKeyExists(key: keyof Metrics): boolean { + return Boolean(this.metrics?.[key]); + } + + metricsKeyExistsAndObjectNotEmpty(key: keyof Metrics): boolean { + return Boolean(this.metrics?.[key] && JSON.stringify(this.metrics[key]) !== '{}'); + } +} diff --git a/src/main/webapp/app/admin/metrics/metrics.model.ts b/src/main/webapp/app/admin/metrics/metrics.model.ts new file mode 100644 index 000000000..d9576a903 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.model.ts @@ -0,0 +1,159 @@ +export interface Metrics { + jvm: { [key: string]: JvmMetrics }; + databases: Databases; + 'http.server.requests': HttpServerRequests; + cache: { [key: string]: CacheMetrics }; + garbageCollector: GarbageCollector; + services: Services; + processMetrics: ProcessMetrics; +} + +export interface JvmMetrics { + committed: number; + max: number; + used: number; +} + +export interface Databases { + min: Value; + idle: Value; + max: Value; + usage: MetricsWithPercentile; + pending: Value; + active: Value; + acquire: MetricsWithPercentile; + creation: MetricsWithPercentile; + connections: Value; +} + +export interface Value { + value: number; +} + +export interface MetricsWithPercentile { + '0.0': number; + '1.0': number; + max: number; + totalTime: number; + mean: number; + '0.5': number; + count: number; + '0.99': number; + '0.75': number; + '0.95': number; +} + +export interface HttpServerRequests { + all: { + count: number; + }; + percode: { [key: string]: MaxMeanCount }; +} + +export interface MaxMeanCount { + max: number; + mean: number; + count: number; +} + +export interface CacheMetrics { + 'cache.gets.miss': number; + 'cache.puts': number; + 'cache.gets.hit': number; + 'cache.removals': number; + 'cache.evictions': number; +} + +export interface GarbageCollector { + 'jvm.gc.max.data.size': number; + 'jvm.gc.pause': MetricsWithPercentile; + 'jvm.gc.memory.promoted': number; + 'jvm.gc.memory.allocated': number; + classesLoaded: number; + 'jvm.gc.live.data.size': number; + classesUnloaded: number; +} + +export interface Services { + [key: string]: { + [key in HttpMethod]?: MaxMeanCount; + }; +} + +export enum HttpMethod { + Post = 'POST', + Get = 'GET', + Put = 'PUT', + Patch = 'PATCH', + Delete = 'DELETE', +} + +export interface ProcessMetrics { + 'system.cpu.usage': number; + 'system.cpu.count': number; + 'system.load.average.1m'?: number; + 'process.cpu.usage': number; + 'process.files.max'?: number; + 'process.files.open'?: number; + 'process.start.time': number; + 'process.uptime': number; +} + +export interface ThreadDump { + threads: Thread[]; +} + +export interface Thread { + threadName: string; + threadId: number; + blockedTime: number; + blockedCount: number; + waitedTime: number; + waitedCount: number; + lockName: string | null; + lockOwnerId: number; + lockOwnerName: string | null; + daemon: boolean; + inNative: boolean; + suspended: boolean; + threadState: ThreadState; + priority: number; + stackTrace: StackTrace[]; + lockedMonitors: LockedMonitor[]; + lockedSynchronizers: string[]; + lockInfo: LockInfo | null; + // custom field for showing-hiding thread dump + showThreadDump?: boolean; +} + +export interface LockInfo { + className: string; + identityHashCode: number; +} + +export interface LockedMonitor { + className: string; + identityHashCode: number; + lockedStackDepth: number; + lockedStackFrame: StackTrace; +} + +export interface StackTrace { + classLoaderName: string | null; + moduleName: string | null; + moduleVersion: string | null; + methodName: string; + fileName: string; + lineNumber: number; + className: string; + nativeMethod: boolean; +} + +export enum ThreadState { + Runnable = 'RUNNABLE', + TimedWaiting = 'TIMED_WAITING', + Waiting = 'WAITING', + Blocked = 'BLOCKED', + New = 'NEW', + Terminated = 'TERMINATED', +} diff --git a/src/main/webapp/app/admin/metrics/metrics.service.spec.ts b/src/main/webapp/app/admin/metrics/metrics.service.spec.ts new file mode 100644 index 000000000..468ebd52d --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.service.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { MetricsService } from './metrics.service'; +import { ThreadDump, ThreadState } from './metrics.model'; + +describe('Logs Service', () => { + let service: MetricsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + service = TestBed.inject(MetricsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should return Metrics', () => { + let expectedResult; + const metrics = { + jvm: {}, + 'http.server.requests': {}, + cache: {}, + services: {}, + databases: {}, + garbageCollector: {}, + processMetrics: {}, + }; + + service.getMetrics().subscribe(received => { + expectedResult = received; + }); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(metrics); + expect(expectedResult).toEqual(metrics); + }); + + it('should return Thread Dump', () => { + let expectedResult: ThreadDump | null = null; + const dump: ThreadDump = { + threads: [ + { + threadName: 'Reference Handler', + threadId: 2, + blockedTime: -1, + blockedCount: 7, + waitedTime: -1, + waitedCount: 0, + lockName: null, + lockOwnerId: -1, + lockOwnerName: null, + daemon: true, + inNative: false, + suspended: false, + threadState: ThreadState.Runnable, + priority: 10, + stackTrace: [], + lockedMonitors: [], + lockedSynchronizers: [], + lockInfo: null, + }, + ], + }; + + service.threadDump().subscribe(received => { + expectedResult = received; + }); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(dump); + expect(expectedResult).toEqual(dump); + }); + }); +}); diff --git a/src/main/webapp/app/admin/metrics/metrics.service.ts b/src/main/webapp/app/admin/metrics/metrics.service.ts new file mode 100644 index 000000000..5adb05c0c --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { Metrics, ThreadDump } from './metrics.model'; + +@Injectable({ providedIn: 'root' }) +export class MetricsService { + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + getMetrics(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/jhimetrics')); + } + + threadDump(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/threaddump')); + } +} diff --git a/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.html b/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.html new file mode 100644 index 000000000..0ae0c95bf --- /dev/null +++ b/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.html @@ -0,0 +1,25 @@ +
+ + + + + +
diff --git a/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.spec.ts b/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.spec.ts new file mode 100644 index 000000000..d4fc0a4c8 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.spec.ts @@ -0,0 +1,51 @@ +jest.mock('@ng-bootstrap/ng-bootstrap'); + +import { ComponentFixture, TestBed, waitForAsync, inject, fakeAsync, tick } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; + +import { UserManagementService } from '../service/user-management.service'; + +import UserManagementDeleteDialogComponent from './user-management-delete-dialog.component'; + +describe('User Management Delete Component', () => { + let comp: UserManagementDeleteDialogComponent; + let fixture: ComponentFixture; + let service: UserManagementService; + let mockActiveModal: NgbActiveModal; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, UserManagementDeleteDialogComponent], + providers: [NgbActiveModal], + }) + .overrideTemplate(UserManagementDeleteDialogComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserManagementDeleteDialogComponent); + comp = fixture.componentInstance; + service = TestBed.inject(UserManagementService); + mockActiveModal = TestBed.inject(NgbActiveModal); + }); + + describe('confirmDelete', () => { + it('Should call delete service on confirmDelete', inject( + [], + fakeAsync(() => { + // GIVEN + jest.spyOn(service, 'delete').mockReturnValue(of({})); + + // WHEN + comp.confirmDelete('user'); + tick(); + + // THEN + expect(service.delete).toHaveBeenCalledWith('user'); + expect(mockActiveModal.close).toHaveBeenCalledWith('deleted'); + }), + )); + }); +}); diff --git a/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.ts b/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.ts new file mode 100644 index 000000000..382b6409d --- /dev/null +++ b/src/main/webapp/app/admin/user-management/delete/user-management-delete-dialog.component.ts @@ -0,0 +1,32 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { User } from '../user-management.model'; +import { UserManagementService } from '../service/user-management.service'; + +@Component({ + standalone: true, + selector: 'jhi-user-mgmt-delete-dialog', + templateUrl: './user-management-delete-dialog.component.html', + imports: [SharedModule, FormsModule], +}) +export default class UserManagementDeleteDialogComponent { + user?: User; + + constructor( + private userService: UserManagementService, + private activeModal: NgbActiveModal, + ) {} + + cancel(): void { + this.activeModal.dismiss(); + } + + confirmDelete(login: string): void { + this.userService.delete(login).subscribe(() => { + this.activeModal.close('deleted'); + }); + } +} diff --git a/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.html b/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.html new file mode 100644 index 000000000..10515ec25 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.html @@ -0,0 +1,56 @@ +
+
+
+

+ User [{{ user.login }}] +

+ +
+
Login
+
+ {{ user.login }} + Activated + Deactivated +
+ +
First name
+
{{ user.firstName }}
+ +
Last name
+
{{ user.lastName }}
+ +
Email
+
{{ user.email }}
+ +
Language
+
{{ user.langKey }}
+ +
Created by
+
{{ user.createdBy }}
+ +
Created date
+
{{ user.createdDate | date: 'dd/MM/yy HH:mm' }}
+ +
Modified by
+
{{ user.lastModifiedBy }}
+ +
Modified date
+
{{ user.lastModifiedDate | date: 'dd/MM/yy HH:mm' }}
+ +
Profiles
+
+
    +
  • + {{ authority }} +
  • +
+
+
+ + +
+
+
diff --git a/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.spec.ts b/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.spec.ts new file mode 100644 index 000000000..21bc61218 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; + +import { Authority } from 'app/config/authority.constants'; +import { User } from '../user-management.model'; + +import UserManagementDetailComponent from './user-management-detail.component'; + +describe('User Management Detail Component', () => { + let comp: UserManagementDetailComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [UserManagementDetailComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + data: of({ user: new User(123, 'user', 'first', 'last', 'first@last.com', true, 'en', [Authority.USER], 'admin') }), + }, + }, + ], + }) + .overrideTemplate(UserManagementDetailComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserManagementDetailComponent); + comp = fixture.componentInstance; + }); + + describe('OnInit', () => { + it('Should call load all on init', () => { + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.user).toEqual( + expect.objectContaining({ + id: 123, + login: 'user', + firstName: 'first', + lastName: 'last', + email: 'first@last.com', + activated: true, + langKey: 'en', + authorities: [Authority.USER], + createdBy: 'admin', + }), + ); + }); + }); +}); diff --git a/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.ts b/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.ts new file mode 100644 index 000000000..b8a7c8fed --- /dev/null +++ b/src/main/webapp/app/admin/user-management/detail/user-management-detail.component.ts @@ -0,0 +1,23 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import SharedModule from 'app/shared/shared.module'; + +import { User } from '../user-management.model'; + +@Component({ + standalone: true, + selector: 'jhi-user-mgmt-detail', + templateUrl: './user-management-detail.component.html', + imports: [SharedModule], +}) +export default class UserManagementDetailComponent implements OnInit { + user: User | null = null; + + constructor(private route: ActivatedRoute) {} + + ngOnInit(): void { + this.route.data.subscribe(({ user }) => { + this.user = user; + }); + } +} diff --git a/src/main/webapp/app/admin/user-management/list/user-management.component.html b/src/main/webapp/app/admin/user-management/list/user-management.component.html new file mode 100644 index 000000000..041d7d6b9 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/list/user-management.component.html @@ -0,0 +1,124 @@ +
+

+ Users + +
+ + +
+

+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID Login Email + Language + Profiles + Created date + + Modified by + + Modified date +
+ {{ user.id }} + {{ user.login }}{{ user.email }} + + + {{ user.langKey }} +
+ {{ authority }} +
+
{{ user.createdDate | date: 'dd/MM/yy HH:mm' }}{{ user.lastModifiedBy }}{{ user.lastModifiedDate | date: 'dd/MM/yy HH:mm' }} +
+ + + + + +
+
+
+ +
+
+ +
+ +
+ +
+
+
diff --git a/src/main/webapp/app/admin/user-management/list/user-management.component.spec.ts b/src/main/webapp/app/admin/user-management/list/user-management.component.spec.ts new file mode 100644 index 000000000..ab9cbde68 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/list/user-management.component.spec.ts @@ -0,0 +1,103 @@ +jest.mock('app/core/auth/account.service'); + +import { ComponentFixture, TestBed, waitForAsync, inject, fakeAsync, tick } from '@angular/core/testing'; +import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { AccountService } from 'app/core/auth/account.service'; +import { UserManagementService } from '../service/user-management.service'; +import { User } from '../user-management.model'; + +import UserManagementComponent from './user-management.component'; + +describe('User Management Component', () => { + let comp: UserManagementComponent; + let fixture: ComponentFixture; + let service: UserManagementService; + let mockAccountService: AccountService; + const data = of({ + defaultSort: 'id,asc', + }); + const queryParamMap = of( + jest.requireActual('@angular/router').convertToParamMap({ + page: '1', + size: '1', + sort: 'id,desc', + }), + ); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([]), UserManagementComponent], + providers: [{ provide: ActivatedRoute, useValue: { data, queryParamMap } }, AccountService], + }) + .overrideTemplate(UserManagementComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserManagementComponent); + comp = fixture.componentInstance; + service = TestBed.inject(UserManagementService); + mockAccountService = TestBed.inject(AccountService); + mockAccountService.identity = jest.fn(() => of(null)); + }); + + describe('OnInit', () => { + it('Should call load all on init', inject( + [], + fakeAsync(() => { + // GIVEN + const headers = new HttpHeaders().append('link', 'link;link'); + jest.spyOn(service, 'query').mockReturnValue( + of( + new HttpResponse({ + body: [new User(123)], + headers, + }), + ), + ); + + // WHEN + comp.ngOnInit(); + tick(); // simulate async + + // THEN + expect(service.query).toHaveBeenCalled(); + expect(comp.users?.[0]).toEqual(expect.objectContaining({ id: 123 })); + }), + )); + }); + + describe('setActive', () => { + it('Should update user and call load all', inject( + [], + fakeAsync(() => { + // GIVEN + const headers = new HttpHeaders().append('link', 'link;link'); + const user = new User(123); + jest.spyOn(service, 'query').mockReturnValue( + of( + new HttpResponse({ + body: [user], + headers, + }), + ), + ); + jest.spyOn(service, 'update').mockReturnValue(of(user)); + + // WHEN + comp.setActive(user, true); + tick(); // simulate async + + // THEN + expect(service.update).toHaveBeenCalledWith({ ...user, activated: true }); + expect(service.query).toHaveBeenCalled(); + expect(comp.users?.[0]).toEqual(expect.objectContaining({ id: 123 })); + }), + )); + }); +}); diff --git a/src/main/webapp/app/admin/user-management/list/user-management.component.ts b/src/main/webapp/app/admin/user-management/list/user-management.component.ts new file mode 100644 index 000000000..96b3e9d6c --- /dev/null +++ b/src/main/webapp/app/admin/user-management/list/user-management.component.ts @@ -0,0 +1,116 @@ +import { Component, OnInit } from '@angular/core'; +import { RouterModule, ActivatedRoute, Router } from '@angular/router'; +import { HttpResponse, HttpHeaders } from '@angular/common/http'; +import { combineLatest } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { SortDirective, SortByDirective } from 'app/shared/sort'; +import { ITEMS_PER_PAGE } from 'app/config/pagination.constants'; +import { ASC, DESC, SORT } from 'app/config/navigation.constants'; +import { ItemCountComponent } from 'app/shared/pagination'; +import { AccountService } from 'app/core/auth/account.service'; +import { Account } from 'app/core/auth/account.model'; +import { UserManagementService } from '../service/user-management.service'; +import { User } from '../user-management.model'; +import UserManagementDeleteDialogComponent from '../delete/user-management-delete-dialog.component'; + +@Component({ + standalone: true, + selector: 'jhi-user-mgmt', + templateUrl: './user-management.component.html', + imports: [RouterModule, SharedModule, SortDirective, SortByDirective, UserManagementDeleteDialogComponent, ItemCountComponent], +}) +export default class UserManagementComponent implements OnInit { + currentAccount: Account | null = null; + users: User[] | null = null; + isLoading = false; + totalItems = 0; + itemsPerPage = ITEMS_PER_PAGE; + page!: number; + predicate!: string; + ascending!: boolean; + + constructor( + private userService: UserManagementService, + private accountService: AccountService, + private activatedRoute: ActivatedRoute, + private router: Router, + private modalService: NgbModal, + ) {} + + ngOnInit(): void { + this.accountService.identity().subscribe(account => (this.currentAccount = account)); + this.handleNavigation(); + } + + setActive(user: User, isActivated: boolean): void { + this.userService.update({ ...user, activated: isActivated }).subscribe(() => this.loadAll()); + } + + trackIdentity(_index: number, item: User): number { + return item.id!; + } + + deleteUser(user: User): void { + const modalRef = this.modalService.open(UserManagementDeleteDialogComponent, { size: 'lg', backdrop: 'static' }); + modalRef.componentInstance.user = user; + // unsubscribe not needed because closed completes on modal close + modalRef.closed.subscribe(reason => { + if (reason === 'deleted') { + this.loadAll(); + } + }); + } + + loadAll(): void { + this.isLoading = true; + this.userService + .query({ + page: this.page - 1, + size: this.itemsPerPage, + sort: this.sort(), + }) + .subscribe({ + next: (res: HttpResponse) => { + this.isLoading = false; + this.onSuccess(res.body, res.headers); + }, + error: () => (this.isLoading = false), + }); + } + + transition(): void { + this.router.navigate(['./'], { + relativeTo: this.activatedRoute.parent, + queryParams: { + page: this.page, + sort: `${this.predicate},${this.ascending ? ASC : DESC}`, + }, + }); + } + + private handleNavigation(): void { + combineLatest([this.activatedRoute.data, this.activatedRoute.queryParamMap]).subscribe(([data, params]) => { + const page = params.get('page'); + this.page = +(page ?? 1); + const sort = (params.get(SORT) ?? data['defaultSort']).split(','); + this.predicate = sort[0]; + this.ascending = sort[1] === ASC; + this.loadAll(); + }); + } + + private sort(): string[] { + const result = [`${this.predicate},${this.ascending ? ASC : DESC}`]; + if (this.predicate !== 'id') { + result.push('id'); + } + return result; + } + + private onSuccess(users: User[] | null, headers: HttpHeaders): void { + this.totalItems = Number(headers.get('X-Total-Count')); + this.users = users; + } +} diff --git a/src/main/webapp/app/admin/user-management/service/user-management.service.spec.ts b/src/main/webapp/app/admin/user-management/service/user-management.service.spec.ts new file mode 100644 index 000000000..41a6ec89d --- /dev/null +++ b/src/main/webapp/app/admin/user-management/service/user-management.service.spec.ts @@ -0,0 +1,67 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { Authority } from 'app/config/authority.constants'; +import { User } from '../user-management.model'; + +import { UserManagementService } from './user-management.service'; + +describe('User Service', () => { + let service: UserManagementService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(UserManagementService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should return User', () => { + let expectedResult: string | undefined; + + service.find('user').subscribe(received => { + expectedResult = received.login; + }); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(new User(123, 'user')); + expect(expectedResult).toEqual('user'); + }); + + it('should return Authorities', () => { + let expectedResult: string[] = []; + + service.authorities().subscribe(authorities => { + expectedResult = authorities; + }); + const req = httpMock.expectOne({ method: 'GET' }); + + req.flush([Authority.USER, Authority.ADMIN]); + expect(expectedResult).toEqual([Authority.USER, Authority.ADMIN]); + }); + + it('should propagate not found response', () => { + let expectedResult = 0; + + service.find('user').subscribe({ + error: (error: HttpErrorResponse) => (expectedResult = error.status), + }); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush('Invalid request parameters', { + status: 404, + statusText: 'Bad Request', + }); + expect(expectedResult).toEqual(404); + }); + }); +}); diff --git a/src/main/webapp/app/admin/user-management/service/user-management.service.ts b/src/main/webapp/app/admin/user-management/service/user-management.service.ts new file mode 100644 index 000000000..fda617aae --- /dev/null +++ b/src/main/webapp/app/admin/user-management/service/user-management.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { createRequestOption } from 'app/core/request/request-util'; +import { Pagination } from 'app/core/request/request.model'; +import { IUser } from '../user-management.model'; + +@Injectable({ providedIn: 'root' }) +export class UserManagementService { + private resourceUrl = this.applicationConfigService.getEndpointFor('api/admin/users'); + + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + create(user: IUser): Observable { + return this.http.post(this.resourceUrl, user); + } + + update(user: IUser): Observable { + return this.http.put(this.resourceUrl, user); + } + + find(login: string): Observable { + return this.http.get(`${this.resourceUrl}/${login}`); + } + + query(req?: Pagination): Observable> { + const options = createRequestOption(req); + return this.http.get(this.resourceUrl, { params: options, observe: 'response' }); + } + + delete(login: string): Observable<{}> { + return this.http.delete(`${this.resourceUrl}/${login}`); + } + + authorities(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('api/authorities')); + } +} diff --git a/src/main/webapp/app/admin/user-management/update/user-management-update.component.html b/src/main/webapp/app/admin/user-management/update/user-management-update.component.html new file mode 100644 index 000000000..147040f18 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/update/user-management-update.component.html @@ -0,0 +1,142 @@ +
+
+
+

Create or edit a user

+ + + +
+ + +
+ +
+ + + +
+ + This field is required. + + + + This field cannot be longer than 50 characters. + + + + This field can only contain letters, digits and e-mail addresses. + +
+
+ +
+ + + +
+ + This field cannot be longer than 50 characters. + +
+
+ +
+ + + +
+ + This field cannot be longer than 50 characters. + +
+
+ +
+ + + +
+ + This field is required. + + + + This field cannot be longer than 100 characters. + + + + This field is required to be at least 5 characters. + + + + Your email is invalid. + +
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ + + +
+
+
diff --git a/src/main/webapp/app/admin/user-management/update/user-management-update.component.spec.ts b/src/main/webapp/app/admin/user-management/update/user-management-update.component.spec.ts new file mode 100644 index 000000000..199eb4930 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/update/user-management-update.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed, waitForAsync, inject, fakeAsync, tick } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; + +import { Authority } from 'app/config/authority.constants'; +import { UserManagementService } from '../service/user-management.service'; +import { User } from '../user-management.model'; + +import UserManagementUpdateComponent from './user-management-update.component'; + +describe('User Management Update Component', () => { + let comp: UserManagementUpdateComponent; + let fixture: ComponentFixture; + let service: UserManagementService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, UserManagementUpdateComponent], + providers: [ + FormBuilder, + { + provide: ActivatedRoute, + useValue: { + data: of({ user: new User(123, 'user', 'first', 'last', 'first@last.com', true, 'en', [Authority.USER], 'admin') }), + }, + }, + ], + }) + .overrideTemplate(UserManagementUpdateComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserManagementUpdateComponent); + comp = fixture.componentInstance; + service = TestBed.inject(UserManagementService); + }); + + describe('OnInit', () => { + it('Should load authorities and language on init', inject( + [], + fakeAsync(() => { + // GIVEN + jest.spyOn(service, 'authorities').mockReturnValue(of(['USER'])); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.authorities).toHaveBeenCalled(); + expect(comp.authorities).toEqual(['USER']); + }), + )); + }); + + describe('save', () => { + it('Should call update service on save for existing user', inject( + [], + fakeAsync(() => { + // GIVEN + const entity = { id: 123 }; + jest.spyOn(service, 'update').mockReturnValue(of(entity)); + comp.editForm.patchValue(entity); + // WHEN + comp.save(); + tick(); // simulate async + + // THEN + expect(service.update).toHaveBeenCalledWith(expect.objectContaining(entity)); + expect(comp.isSaving).toEqual(false); + }), + )); + + it('Should call create service on save for new user', inject( + [], + fakeAsync(() => { + // GIVEN + const entity = { login: 'foo' } as User; + jest.spyOn(service, 'create').mockReturnValue(of(entity)); + comp.editForm.patchValue(entity); + // WHEN + comp.save(); + tick(); // simulate async + + // THEN + expect(comp.editForm.getRawValue().id).toBeNull(); + expect(service.create).toHaveBeenCalledWith(expect.objectContaining(entity)); + expect(comp.isSaving).toEqual(false); + }), + )); + }); +}); diff --git a/src/main/webapp/app/admin/user-management/update/user-management-update.component.ts b/src/main/webapp/app/admin/user-management/update/user-management-update.component.ts new file mode 100644 index 000000000..660629b75 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/update/user-management-update.component.ts @@ -0,0 +1,94 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import SharedModule from 'app/shared/shared.module'; +import { LANGUAGES } from 'app/config/language.constants'; +import { IUser } from '../user-management.model'; +import { UserManagementService } from '../service/user-management.service'; + +const userTemplate = {} as IUser; + +const newUser: IUser = { + langKey: 'en', + activated: true, +} as IUser; + +@Component({ + standalone: true, + selector: 'jhi-user-mgmt-update', + templateUrl: './user-management-update.component.html', + imports: [SharedModule, FormsModule, ReactiveFormsModule], +}) +export default class UserManagementUpdateComponent implements OnInit { + languages = LANGUAGES; + authorities: string[] = []; + isSaving = false; + + editForm = new FormGroup({ + id: new FormControl(userTemplate.id), + login: new FormControl(userTemplate.login, { + nonNullable: true, + validators: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(50), + Validators.pattern('^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$'), + ], + }), + firstName: new FormControl(userTemplate.firstName, { validators: [Validators.maxLength(50)] }), + lastName: new FormControl(userTemplate.lastName, { validators: [Validators.maxLength(50)] }), + email: new FormControl(userTemplate.email, { + nonNullable: true, + validators: [Validators.minLength(5), Validators.maxLength(254), Validators.email], + }), + activated: new FormControl(userTemplate.activated, { nonNullable: true }), + langKey: new FormControl(userTemplate.langKey, { nonNullable: true }), + authorities: new FormControl(userTemplate.authorities, { nonNullable: true }), + }); + + constructor( + private userService: UserManagementService, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + this.route.data.subscribe(({ user }) => { + if (user) { + this.editForm.reset(user); + } else { + this.editForm.reset(newUser); + } + }); + this.userService.authorities().subscribe(authorities => (this.authorities = authorities)); + } + + previousState(): void { + window.history.back(); + } + + save(): void { + this.isSaving = true; + const user = this.editForm.getRawValue(); + if (user.id !== null) { + this.userService.update(user).subscribe({ + next: () => this.onSaveSuccess(), + error: () => this.onSaveError(), + }); + } else { + this.userService.create(user).subscribe({ + next: () => this.onSaveSuccess(), + error: () => this.onSaveError(), + }); + } + } + + private onSaveSuccess(): void { + this.isSaving = false; + this.previousState(); + } + + private onSaveError(): void { + this.isSaving = false; + } +} diff --git a/src/main/webapp/app/admin/user-management/user-management.model.ts b/src/main/webapp/app/admin/user-management/user-management.model.ts new file mode 100644 index 000000000..bdb2844c2 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management.model.ts @@ -0,0 +1,31 @@ +export interface IUser { + id: number | null; + login?: string; + firstName?: string | null; + lastName?: string | null; + email?: string; + activated?: boolean; + langKey?: string; + authorities?: string[]; + createdBy?: string; + createdDate?: Date; + lastModifiedBy?: string; + lastModifiedDate?: Date; +} + +export class User implements IUser { + constructor( + public id: number | null, + public login?: string, + public firstName?: string | null, + public lastName?: string | null, + public email?: string, + public activated?: boolean, + public langKey?: string, + public authorities?: string[], + public createdBy?: string, + public createdDate?: Date, + public lastModifiedBy?: string, + public lastModifiedDate?: Date, + ) {} +} diff --git a/src/main/webapp/app/admin/user-management/user-management.route.ts b/src/main/webapp/app/admin/user-management/user-management.route.ts new file mode 100644 index 000000000..1a8f76446 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management.route.ts @@ -0,0 +1,50 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, Routes, ResolveFn } from '@angular/router'; +import { of } from 'rxjs'; + +import { IUser } from './user-management.model'; +import { UserManagementService } from './service/user-management.service'; +import UserManagementComponent from './list/user-management.component'; +import UserManagementDetailComponent from './detail/user-management-detail.component'; +import UserManagementUpdateComponent from './update/user-management-update.component'; + +export const UserManagementResolve: ResolveFn = (route: ActivatedRouteSnapshot) => { + const login = route.paramMap.get('login'); + if (login) { + return inject(UserManagementService).find(login); + } + return of(null); +}; + +const userManagementRoute: Routes = [ + { + path: '', + component: UserManagementComponent, + data: { + defaultSort: 'id,asc', + }, + }, + { + path: ':login/view', + component: UserManagementDetailComponent, + resolve: { + user: UserManagementResolve, + }, + }, + { + path: 'new', + component: UserManagementUpdateComponent, + resolve: { + user: UserManagementResolve, + }, + }, + { + path: ':login/edit', + component: UserManagementUpdateComponent, + resolve: { + user: UserManagementResolve, + }, + }, +]; + +export default userManagementRoute; diff --git a/src/main/webapp/app/app-page-title-strategy.ts b/src/main/webapp/app/app-page-title-strategy.ts new file mode 100644 index 000000000..afe66a4c8 --- /dev/null +++ b/src/main/webapp/app/app-page-title-strategy.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { RouterStateSnapshot, TitleStrategy } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable() +export class AppPageTitleStrategy extends TitleStrategy { + constructor(private translateService: TranslateService) { + super(); + } + + override updateTitle(routerState: RouterStateSnapshot): void { + let pageTitle = this.buildTitle(routerState); + if (!pageTitle) { + pageTitle = 'global.title'; + } + this.translateService.get(pageTitle).subscribe(title => { + document.title = title; + }); + } +} diff --git a/src/main/webapp/app/app-routing.module.ts b/src/main/webapp/app/app-routing.module.ts new file mode 100644 index 000000000..a076458e5 --- /dev/null +++ b/src/main/webapp/app/app-routing.module.ts @@ -0,0 +1,55 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { DEBUG_INFO_ENABLED } from 'app/app.constants'; +import { Authority } from 'app/config/authority.constants'; +import { UserRouteAccessService } from 'app/core/auth/user-route-access.service'; +import { errorRoute } from './layouts/error/error.route'; + +import HomeComponent from './home/home.component'; +import NavbarComponent from './layouts/navbar/navbar.component'; +import LoginComponent from './login/login.component'; + +@NgModule({ + imports: [ + RouterModule.forRoot( + [ + { + path: '', + component: HomeComponent, + title: 'home.title', + }, + { + path: '', + component: NavbarComponent, + outlet: 'navbar', + }, + { + path: 'admin', + data: { + authorities: [Authority.ADMIN], + }, + canActivate: [UserRouteAccessService], + loadChildren: () => import('./admin/admin-routing.module'), + }, + { + path: 'account', + loadChildren: () => import('./account/account.route'), + }, + { + path: 'login', + component: LoginComponent, + title: 'login.title', + }, + { + path: '', + loadChildren: () => import(`./entities/entity-routing.module`).then(({ EntityRoutingModule }) => EntityRoutingModule), + }, + ...errorRoute, + ], + { enableTracing: DEBUG_INFO_ENABLED, bindToComponentInputs: true }, + ), + ], + exports: [RouterModule], +}) +export class AppRoutingModule {} diff --git a/src/main/webapp/app/app.constants.ts b/src/main/webapp/app/app.constants.ts new file mode 100644 index 000000000..695015c41 --- /dev/null +++ b/src/main/webapp/app/app.constants.ts @@ -0,0 +1,9 @@ +// These constants are injected via webpack DefinePlugin variables. +// You can add more variables in webpack.common.js or in profile specific webpack..js files. +// If you change the values in the webpack config files, you need to re run webpack to update the application + +declare const __DEBUG_INFO_ENABLED__: boolean; +declare const __VERSION__: string; + +export const VERSION = __VERSION__; +export const DEBUG_INFO_ENABLED = __DEBUG_INFO_ENABLED__; diff --git a/src/main/webapp/app/app.module.ts b/src/main/webapp/app/app.module.ts new file mode 100644 index 000000000..6823ff032 --- /dev/null +++ b/src/main/webapp/app/app.module.ts @@ -0,0 +1,51 @@ +import { NgModule, LOCALE_ID } from '@angular/core'; +import { registerLocaleData } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; +import locale from '@angular/common/locales/en'; +import { BrowserModule, Title } from '@angular/platform-browser'; +import { TitleStrategy } from '@angular/router'; +import { ServiceWorkerModule } from '@angular/service-worker'; +import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; +import dayjs from 'dayjs/esm'; +import { NgbDateAdapter, NgbDatepickerConfig } from '@ng-bootstrap/ng-bootstrap'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import './config/dayjs'; +import { TranslationModule } from 'app/shared/language/translation.module'; +import { httpInterceptorProviders } from 'app/core/interceptor/index'; +import { AppRoutingModule } from './app-routing.module'; +// jhipster-needle-angular-add-module-import JHipster will add new module here +import { NgbDateDayjsAdapter } from './config/datepicker-adapter'; +import { fontAwesomeIcons } from './config/font-awesome-icons'; +import MainComponent from './layouts/main/main.component'; +import MainModule from './layouts/main/main.module'; +import { AppPageTitleStrategy } from './app-page-title-strategy'; + +@NgModule({ + imports: [ + BrowserModule, + // jhipster-needle-angular-add-module JHipster will add new module here + AppRoutingModule, + // Set this to true to enable service worker (PWA) + ServiceWorkerModule.register('ngsw-worker.js', { enabled: false }), + HttpClientModule, + MainModule, + TranslationModule, + ], + providers: [ + Title, + { provide: LOCALE_ID, useValue: 'en' }, + { provide: NgbDateAdapter, useClass: NgbDateDayjsAdapter }, + httpInterceptorProviders, + { provide: TitleStrategy, useClass: AppPageTitleStrategy }, + ], + bootstrap: [MainComponent], +}) +export class AppModule { + constructor(applicationConfigService: ApplicationConfigService, iconLibrary: FaIconLibrary, dpConfig: NgbDatepickerConfig) { + applicationConfigService.setEndpointPrefix(SERVER_API_URL); + registerLocaleData(locale); + iconLibrary.addIcons(...fontAwesomeIcons); + dpConfig.minDate = { year: dayjs().subtract(100, 'year').year(), month: 1, day: 1 }; + } +} diff --git a/src/main/webapp/app/config/authority.constants.ts b/src/main/webapp/app/config/authority.constants.ts new file mode 100644 index 000000000..1501bcf4e --- /dev/null +++ b/src/main/webapp/app/config/authority.constants.ts @@ -0,0 +1,4 @@ +export enum Authority { + ADMIN = 'ROLE_ADMIN', + USER = 'ROLE_USER', +} diff --git a/src/main/webapp/app/config/datepicker-adapter.ts b/src/main/webapp/app/config/datepicker-adapter.ts new file mode 100644 index 000000000..3f8b16c61 --- /dev/null +++ b/src/main/webapp/app/config/datepicker-adapter.ts @@ -0,0 +1,20 @@ +/** + * Angular bootstrap Date adapter + */ +import { Injectable } from '@angular/core'; +import { NgbDateAdapter, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; +import dayjs from 'dayjs/esm'; + +@Injectable() +export class NgbDateDayjsAdapter extends NgbDateAdapter { + fromModel(date: dayjs.Dayjs | null): NgbDateStruct | null { + if (date && dayjs.isDayjs(date) && date.isValid()) { + return { year: date.year(), month: date.month() + 1, day: date.date() }; + } + return null; + } + + toModel(date: NgbDateStruct | null): dayjs.Dayjs | null { + return date ? dayjs(`${date.year}-${date.month}-${date.day}`) : null; + } +} diff --git a/src/main/webapp/app/config/dayjs.ts b/src/main/webapp/app/config/dayjs.ts new file mode 100644 index 000000000..b0375e234 --- /dev/null +++ b/src/main/webapp/app/config/dayjs.ts @@ -0,0 +1,12 @@ +import dayjs from 'dayjs/esm'; +import customParseFormat from 'dayjs/esm/plugin/customParseFormat'; +import duration from 'dayjs/esm/plugin/duration'; +import relativeTime from 'dayjs/esm/plugin/relativeTime'; + +// jhipster-needle-i18n-language-dayjs-imports - JHipster will import languages from dayjs here +import 'dayjs/esm/locale/en'; + +// DAYJS CONFIGURATION +dayjs.extend(customParseFormat); +dayjs.extend(duration); +dayjs.extend(relativeTime); diff --git a/src/main/webapp/app/config/error.constants.ts b/src/main/webapp/app/config/error.constants.ts new file mode 100644 index 000000000..eff19a30d --- /dev/null +++ b/src/main/webapp/app/config/error.constants.ts @@ -0,0 +1,3 @@ +export const PROBLEM_BASE_URL = 'https://www.jhipster.tech/problem'; +export const EMAIL_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/email-already-used`; +export const LOGIN_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/login-already-used`; diff --git a/src/main/webapp/app/config/font-awesome-icons.ts b/src/main/webapp/app/config/font-awesome-icons.ts new file mode 100644 index 000000000..7fcf1696c --- /dev/null +++ b/src/main/webapp/app/config/font-awesome-icons.ts @@ -0,0 +1,83 @@ +import { + faArrowLeft, + faAsterisk, + faBan, + faBars, + faBell, + faBook, + faCalendarAlt, + faCheck, + faCloud, + faCogs, + faDatabase, + faEye, + faFlag, + faHeart, + faHome, + faList, + faLock, + faPencilAlt, + faPlus, + faRoad, + faSave, + faSearch, + faSignOutAlt, + faSignInAlt, + faSort, + faSortDown, + faSortUp, + faSync, + faTachometerAlt, + faTasks, + faThList, + faTimes, + faTrashAlt, + faUser, + faUserPlus, + faUsers, + faUsersCog, + faWrench, + // jhipster-needle-add-icon-import +} from '@fortawesome/free-solid-svg-icons'; + +export const fontAwesomeIcons = [ + faArrowLeft, + faAsterisk, + faBan, + faBars, + faBell, + faBook, + faCalendarAlt, + faCheck, + faCloud, + faCogs, + faDatabase, + faEye, + faFlag, + faHeart, + faHome, + faList, + faLock, + faPencilAlt, + faPlus, + faRoad, + faSave, + faSearch, + faSignOutAlt, + faSignInAlt, + faSort, + faSortDown, + faSortUp, + faSync, + faTachometerAlt, + faTasks, + faThList, + faTimes, + faTrashAlt, + faUser, + faUserPlus, + faUsers, + faUsersCog, + faWrench, + // jhipster-needle-add-icon-import +]; diff --git a/src/main/webapp/app/config/input.constants.ts b/src/main/webapp/app/config/input.constants.ts new file mode 100644 index 000000000..1e3978a9b --- /dev/null +++ b/src/main/webapp/app/config/input.constants.ts @@ -0,0 +1,2 @@ +export const DATE_FORMAT = 'YYYY-MM-DD'; +export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm'; diff --git a/src/main/webapp/app/config/language.constants.ts b/src/main/webapp/app/config/language.constants.ts new file mode 100644 index 000000000..aa0720850 --- /dev/null +++ b/src/main/webapp/app/config/language.constants.ts @@ -0,0 +1,8 @@ +/* + Languages codes are ISO_639-1 codes, see http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + They are written in English to avoid character encoding issues (not a perfect solution) +*/ +export const LANGUAGES: string[] = [ + 'en', + // jhipster-needle-i18n-language-constant - JHipster will add/remove languages in this array +]; diff --git a/src/main/webapp/app/config/navigation.constants.ts b/src/main/webapp/app/config/navigation.constants.ts new file mode 100644 index 000000000..609160d1b --- /dev/null +++ b/src/main/webapp/app/config/navigation.constants.ts @@ -0,0 +1,5 @@ +export const ASC = 'asc'; +export const DESC = 'desc'; +export const SORT = 'sort'; +export const ITEM_DELETED_EVENT = 'deleted'; +export const DEFAULT_SORT_DATA = 'defaultSort'; diff --git a/src/main/webapp/app/config/pagination.constants.ts b/src/main/webapp/app/config/pagination.constants.ts new file mode 100644 index 000000000..6bee3ff5a --- /dev/null +++ b/src/main/webapp/app/config/pagination.constants.ts @@ -0,0 +1,3 @@ +export const TOTAL_COUNT_RESPONSE_HEADER = 'X-Total-Count'; +export const PAGE_HEADER = 'page'; +export const ITEMS_PER_PAGE = 20; diff --git a/src/main/webapp/app/config/translation.config.ts b/src/main/webapp/app/config/translation.config.ts new file mode 100644 index 000000000..b5ac12974 --- /dev/null +++ b/src/main/webapp/app/config/translation.config.ts @@ -0,0 +1,20 @@ +import { HttpClient } from '@angular/common/http'; +import { MissingTranslationHandler, MissingTranslationHandlerParams, TranslateLoader } from '@ngx-translate/core'; +import { TranslateHttpLoader } from '@ngx-translate/http-loader'; + +export const translationNotFoundMessage = 'translation-not-found'; + +export class MissingTranslationHandlerImpl implements MissingTranslationHandler { + handle(params: MissingTranslationHandlerParams): string { + const key = params.key; + return `${translationNotFoundMessage}[${key}]`; + } +} + +export function translatePartialLoader(http: HttpClient): TranslateLoader { + return new TranslateHttpLoader(http, 'i18n/', `.json?_=${I18N_HASH}`); +} + +export function missingTranslationHandler(): MissingTranslationHandler { + return new MissingTranslationHandlerImpl(); +} diff --git a/src/main/webapp/app/config/uib-pagination.config.ts b/src/main/webapp/app/config/uib-pagination.config.ts new file mode 100644 index 000000000..ecabe1655 --- /dev/null +++ b/src/main/webapp/app/config/uib-pagination.config.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { NgbPaginationConfig } from '@ng-bootstrap/ng-bootstrap'; + +import { ITEMS_PER_PAGE } from 'app/config/pagination.constants'; + +@Injectable({ providedIn: 'root' }) +export class PaginationConfig { + constructor(config: NgbPaginationConfig) { + config.boundaryLinks = true; + config.maxSize = 5; + config.pageSize = ITEMS_PER_PAGE; + config.size = 'sm'; + } +} diff --git a/src/main/webapp/app/core/auth/account.model.ts b/src/main/webapp/app/core/auth/account.model.ts new file mode 100644 index 000000000..76e67637e --- /dev/null +++ b/src/main/webapp/app/core/auth/account.model.ts @@ -0,0 +1,12 @@ +export class Account { + constructor( + public activated: boolean, + public authorities: string[], + public email: string, + public firstName: string | null, + public langKey: string, + public lastName: string | null, + public login: string, + public imageUrl: string | null, + ) {} +} diff --git a/src/main/webapp/app/core/auth/account.service.spec.ts b/src/main/webapp/app/core/auth/account.service.spec.ts new file mode 100644 index 000000000..7ccd0aa36 --- /dev/null +++ b/src/main/webapp/app/core/auth/account.service.spec.ts @@ -0,0 +1,248 @@ +jest.mock('app/core/auth/state-storage.service'); + +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { Account } from 'app/core/auth/account.model'; +import { Authority } from 'app/config/authority.constants'; +import { StateStorageService } from 'app/core/auth/state-storage.service'; +import { ApplicationConfigService } from 'app/core/config/application-config.service'; + +import { AccountService } from './account.service'; + +function accountWithAuthorities(authorities: string[]): Account { + return { + activated: true, + authorities, + email: '', + firstName: '', + langKey: '', + lastName: '', + login: '', + imageUrl: '', + }; +} + +describe('Account Service', () => { + let service: AccountService; + let applicationConfigService: ApplicationConfigService; + let httpMock: HttpTestingController; + let mockStorageService: StateStorageService; + let mockRouter: Router; + let mockTranslateService: TranslateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot()], + providers: [StateStorageService], + }); + + service = TestBed.inject(AccountService); + applicationConfigService = TestBed.inject(ApplicationConfigService); + httpMock = TestBed.inject(HttpTestingController); + mockStorageService = TestBed.inject(StateStorageService); + mockRouter = TestBed.inject(Router); + jest.spyOn(mockRouter, 'navigateByUrl').mockImplementation(() => Promise.resolve(true)); + + mockTranslateService = TestBed.inject(TranslateService); + jest.spyOn(mockTranslateService, 'use').mockImplementation(() => of('')); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('save', () => { + it('should call account saving endpoint with correct values', () => { + // GIVEN + const account = accountWithAuthorities([]); + + // WHEN + service.save(account).subscribe(); + const testRequest = httpMock.expectOne({ method: 'POST', url: applicationConfigService.getEndpointFor('api/account') }); + testRequest.flush({}); + + // THEN + expect(testRequest.request.body).toEqual(account); + }); + }); + + describe('authenticate', () => { + it('authenticationState should emit null if input is null', () => { + // GIVEN + let userIdentity: Account | null = accountWithAuthorities([]); + service.getAuthenticationState().subscribe(account => (userIdentity = account)); + + // WHEN + service.authenticate(null); + + // THEN + expect(userIdentity).toBeNull(); + expect(service.isAuthenticated()).toBe(false); + }); + + it('authenticationState should emit the same account as was in input parameter', () => { + // GIVEN + const expectedResult = accountWithAuthorities([]); + let userIdentity: Account | null = null; + service.getAuthenticationState().subscribe(account => (userIdentity = account)); + + // WHEN + service.authenticate(expectedResult); + + // THEN + expect(userIdentity).toEqual(expectedResult); + expect(service.isAuthenticated()).toBe(true); + }); + }); + + describe('identity', () => { + it('should call /account only once if last call have not returned', () => { + // When I call + service.identity().subscribe(); + // Once more + service.identity().subscribe(); + // Then there is only request + httpMock.expectOne({ method: 'GET' }); + }); + + it('should call /account only once if not logged out after first authentication and should call /account again if user has logged out', () => { + // Given the user is authenticated + service.identity().subscribe(); + httpMock.expectOne({ method: 'GET' }).flush({}); + + // When I call + service.identity().subscribe(); + + // Then there is no second request + httpMock.expectNone({ method: 'GET' }); + + // When I log out + service.authenticate(null); + // and then call + service.identity().subscribe(); + + // Then there is a new request + httpMock.expectOne({ method: 'GET' }); + }); + + describe('should change the language on authentication if necessary', () => { + it('should change language if user has not changed language manually', () => { + // GIVEN + mockStorageService.getLocale = jest.fn(() => null); + + // WHEN + service.identity().subscribe(); + httpMock.expectOne({ method: 'GET' }).flush({ ...accountWithAuthorities([]), langKey: 'accountLang' }); + + // THEN + expect(mockTranslateService.use).toHaveBeenCalledWith('accountLang'); + }); + + it('should not change language if user has changed language manually', () => { + // GIVEN + mockStorageService.getLocale = jest.fn(() => 'sessionLang'); + + // WHEN + service.identity().subscribe(); + httpMock.expectOne({ method: 'GET' }).flush({ ...accountWithAuthorities([]), langKey: 'accountLang' }); + + // THEN + expect(mockTranslateService.use).not.toHaveBeenCalled(); + }); + }); + + describe('navigateToStoredUrl', () => { + it('should navigate to the previous stored url post successful authentication', () => { + // GIVEN + mockStorageService.getUrl = jest.fn(() => 'admin/users?page=0'); + + // WHEN + service.identity().subscribe(); + httpMock.expectOne({ method: 'GET' }).flush({}); + + // THEN + expect(mockStorageService.getUrl).toHaveBeenCalledTimes(1); + expect(mockStorageService.clearUrl).toHaveBeenCalledTimes(1); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('admin/users?page=0'); + }); + + it('should not navigate to the previous stored url when authentication fails', () => { + // WHEN + service.identity().subscribe(); + httpMock.expectOne({ method: 'GET' }).error(new ErrorEvent('')); + + // THEN + expect(mockStorageService.getUrl).not.toHaveBeenCalled(); + expect(mockStorageService.clearUrl).not.toHaveBeenCalled(); + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('should not navigate to the previous stored url when no such url exists post successful authentication', () => { + // GIVEN + mockStorageService.getUrl = jest.fn(() => null); + + // WHEN + service.identity().subscribe(); + httpMock.expectOne({ method: 'GET' }).flush({}); + + // THEN + expect(mockStorageService.getUrl).toHaveBeenCalledTimes(1); + expect(mockStorageService.clearUrl).not.toHaveBeenCalled(); + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + }); + + describe('hasAnyAuthority', () => { + describe('hasAnyAuthority string parameter', () => { + it('should return false if user is not logged', () => { + const hasAuthority = service.hasAnyAuthority(Authority.USER); + expect(hasAuthority).toBe(false); + }); + + it('should return false if user is logged and has not authority', () => { + service.authenticate(accountWithAuthorities([Authority.USER])); + + const hasAuthority = service.hasAnyAuthority(Authority.ADMIN); + + expect(hasAuthority).toBe(false); + }); + + it('should return true if user is logged and has authority', () => { + service.authenticate(accountWithAuthorities([Authority.USER])); + + const hasAuthority = service.hasAnyAuthority(Authority.USER); + + expect(hasAuthority).toBe(true); + }); + }); + + describe('hasAnyAuthority array parameter', () => { + it('should return false if user is not logged', () => { + const hasAuthority = service.hasAnyAuthority([Authority.USER]); + expect(hasAuthority).toBeFalsy(); + }); + + it('should return false if user is logged and has not authority', () => { + service.authenticate(accountWithAuthorities([Authority.USER])); + + const hasAuthority = service.hasAnyAuthority([Authority.ADMIN]); + + expect(hasAuthority).toBe(false); + }); + + it('should return true if user is logged and has authority', () => { + service.authenticate(accountWithAuthorities([Authority.USER])); + + const hasAuthority = service.hasAnyAuthority([Authority.USER, Authority.ADMIN]); + + expect(hasAuthority).toBe(true); + }); + }); + }); +}); diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts new file mode 100644 index 000000000..b58ad268b --- /dev/null +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable, ReplaySubject, of } from 'rxjs'; +import { shareReplay, tap, catchError } from 'rxjs/operators'; + +import { StateStorageService } from 'app/core/auth/state-storage.service'; +import { Account } from 'app/core/auth/account.model'; +import { ApplicationConfigService } from '../config/application-config.service'; + +@Injectable({ providedIn: 'root' }) +export class AccountService { + private userIdentity: Account | null = null; + private authenticationState = new ReplaySubject(1); + private accountCache$?: Observable | null; + + constructor( + private translateService: TranslateService, + private http: HttpClient, + private stateStorageService: StateStorageService, + private router: Router, + private applicationConfigService: ApplicationConfigService, + ) {} + + save(account: Account): Observable<{}> { + return this.http.post(this.applicationConfigService.getEndpointFor('api/account'), account); + } + + authenticate(identity: Account | null): void { + this.userIdentity = identity; + this.authenticationState.next(this.userIdentity); + if (!identity) { + this.accountCache$ = null; + } + } + + hasAnyAuthority(authorities: string[] | string): boolean { + if (!this.userIdentity) { + return false; + } + if (!Array.isArray(authorities)) { + authorities = [authorities]; + } + return this.userIdentity.authorities.some((authority: string) => authorities.includes(authority)); + } + + identity(force?: boolean): Observable { + if (!this.accountCache$ || force) { + this.accountCache$ = this.fetch().pipe( + tap((account: Account) => { + this.authenticate(account); + + // After retrieve the account info, the language will be changed to + // the user's preferred language configured in the account setting + // unless user have choosed other language in the current session + if (!this.stateStorageService.getLocale()) { + this.translateService.use(account.langKey); + } + + this.navigateToStoredUrl(); + }), + shareReplay(), + ); + } + return this.accountCache$.pipe(catchError(() => of(null))); + } + + isAuthenticated(): boolean { + return this.userIdentity !== null; + } + + getAuthenticationState(): Observable { + return this.authenticationState.asObservable(); + } + + private fetch(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('api/account')); + } + + private navigateToStoredUrl(): void { + // previousState can be set in the authExpiredInterceptor and in the userRouteAccessService + // if login is successful, go to stored previousState and clear previousState + const previousUrl = this.stateStorageService.getUrl(); + if (previousUrl) { + this.stateStorageService.clearUrl(); + this.router.navigateByUrl(previousUrl); + } + } +} diff --git a/src/main/webapp/app/core/auth/auth-jwt.service.spec.ts b/src/main/webapp/app/core/auth/auth-jwt.service.spec.ts new file mode 100644 index 000000000..5b0e83c66 --- /dev/null +++ b/src/main/webapp/app/core/auth/auth-jwt.service.spec.ts @@ -0,0 +1,80 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { AuthServerProvider } from 'app/core/auth/auth-jwt.service'; +import { StateStorageService } from './state-storage.service'; + +describe('Auth JWT', () => { + let service: AuthServerProvider; + let httpMock: HttpTestingController; + let mockStorageService: StateStorageService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + mockStorageService = TestBed.inject(StateStorageService); + httpMock = TestBed.inject(HttpTestingController); + service = TestBed.inject(AuthServerProvider); + }); + + describe('Get Token', () => { + it('should return empty token if not found in local storage nor session storage', () => { + const result = service.getToken(); + expect(result).toEqual(''); + }); + + it('should return token from session storage if local storage is empty', () => { + sessionStorage.setItem('jhi-authenticationToken', JSON.stringify('sessionStorageToken')); + const result = service.getToken(); + expect(result).toEqual('sessionStorageToken'); + }); + + it('should return token from localstorage storage', () => { + localStorage.setItem('jhi-authenticationToken', JSON.stringify('localStorageToken')); + const result = service.getToken(); + expect(result).toEqual('localStorageToken'); + }); + }); + + describe('Login', () => { + it('should clear session storage and save in local storage when rememberMe is true', () => { + // GIVEN + mockStorageService.storeAuthenticationToken = jest.fn(); + + // WHEN + service.login({ username: 'John', password: '123', rememberMe: true }).subscribe(); + httpMock.expectOne('api/authenticate').flush({ id_token: '1' }); + + // THEN + httpMock.verify(); + expect(mockStorageService.storeAuthenticationToken).toHaveBeenCalledWith('1', true); + }); + + it('should clear local storage and save in session storage when rememberMe is false', () => { + // GIVEN + mockStorageService.storeAuthenticationToken = jest.fn(); + + // WHEN + service.login({ username: 'John', password: '123', rememberMe: false }).subscribe(); + httpMock.expectOne('api/authenticate').flush({ id_token: '1' }); + + // THEN + httpMock.verify(); + expect(mockStorageService.storeAuthenticationToken).toHaveBeenCalledWith('1', false); + }); + }); + + describe('Logout', () => { + it('should clear storage', () => { + // GIVEN + mockStorageService.clearAuthenticationToken = jest.fn(); + + // WHEN + service.logout().subscribe(); + + // THEN + expect(mockStorageService.clearAuthenticationToken).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/core/auth/auth-jwt.service.ts b/src/main/webapp/app/core/auth/auth-jwt.service.ts new file mode 100644 index 000000000..1b6dc1f3a --- /dev/null +++ b/src/main/webapp/app/core/auth/auth-jwt.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { Login } from 'app/login/login.model'; +import { ApplicationConfigService } from '../config/application-config.service'; +import { StateStorageService } from './state-storage.service'; + +type JwtToken = { + id_token: string; +}; + +@Injectable({ providedIn: 'root' }) +export class AuthServerProvider { + constructor( + private http: HttpClient, + private stateStorageService: StateStorageService, + private applicationConfigService: ApplicationConfigService, + ) {} + + getToken(): string { + return this.stateStorageService.getAuthenticationToken() ?? ''; + } + + login(credentials: Login): Observable { + return this.http + .post(this.applicationConfigService.getEndpointFor('api/authenticate'), credentials) + .pipe(map(response => this.authenticateSuccess(response, credentials.rememberMe))); + } + + logout(): Observable { + return new Observable(observer => { + this.stateStorageService.clearAuthenticationToken(); + observer.complete(); + }); + } + + private authenticateSuccess(response: JwtToken, rememberMe: boolean): void { + this.stateStorageService.storeAuthenticationToken(response.id_token, rememberMe); + } +} diff --git a/src/main/webapp/app/core/auth/state-storage.service.ts b/src/main/webapp/app/core/auth/state-storage.service.ts new file mode 100644 index 000000000..14eec1bf4 --- /dev/null +++ b/src/main/webapp/app/core/auth/state-storage.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class StateStorageService { + private previousUrlKey = 'previousUrl'; + private authenticationKey = 'jhi-authenticationToken'; + private localeKey = 'locale'; + + storeUrl(url: string): void { + sessionStorage.setItem(this.previousUrlKey, JSON.stringify(url)); + } + + getUrl(): string | null { + const previousUrl = sessionStorage.getItem(this.previousUrlKey); + return previousUrl ? (JSON.parse(previousUrl) as string | null) : previousUrl; + } + + clearUrl(): void { + sessionStorage.removeItem(this.previousUrlKey); + } + + storeAuthenticationToken(authenticationToken: string, rememberMe: boolean): void { + authenticationToken = JSON.stringify(authenticationToken); + this.clearAuthenticationToken(); + if (rememberMe) { + localStorage.setItem(this.authenticationKey, authenticationToken); + } else { + sessionStorage.setItem(this.authenticationKey, authenticationToken); + } + } + + getAuthenticationToken(): string | null { + const authenticationToken = localStorage.getItem(this.authenticationKey) ?? sessionStorage.getItem(this.authenticationKey); + return authenticationToken ? (JSON.parse(authenticationToken) as string | null) : authenticationToken; + } + + clearAuthenticationToken(): void { + sessionStorage.removeItem(this.authenticationKey); + localStorage.removeItem(this.authenticationKey); + } + + storeLocale(locale: string): void { + sessionStorage.setItem(this.localeKey, locale); + } + + getLocale(): string | null { + return sessionStorage.getItem(this.localeKey); + } + + clearLocale(): void { + sessionStorage.removeItem(this.localeKey); + } +} diff --git a/src/main/webapp/app/core/auth/user-route-access.service.ts b/src/main/webapp/app/core/auth/user-route-access.service.ts new file mode 100644 index 000000000..6bdf53bd4 --- /dev/null +++ b/src/main/webapp/app/core/auth/user-route-access.service.ts @@ -0,0 +1,33 @@ +import { inject, isDevMode } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router'; +import { map } from 'rxjs/operators'; + +import { AccountService } from 'app/core/auth/account.service'; +import { StateStorageService } from './state-storage.service'; + +export const UserRouteAccessService: CanActivateFn = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const accountService = inject(AccountService); + const router = inject(Router); + const stateStorageService = inject(StateStorageService); + return accountService.identity().pipe( + map(account => { + if (account) { + const authorities = next.data['authorities']; + + if (!authorities || authorities.length === 0 || accountService.hasAnyAuthority(authorities)) { + return true; + } + + if (isDevMode()) { + console.error('User has not any of required authorities: ', authorities); + } + router.navigate(['accessdenied']); + return false; + } + + stateStorageService.storeUrl(state.url); + router.navigate(['/login']); + return false; + }), + ); +}; diff --git a/src/main/webapp/app/core/config/application-config.service.spec.ts b/src/main/webapp/app/core/config/application-config.service.spec.ts new file mode 100644 index 000000000..4451c9bb8 --- /dev/null +++ b/src/main/webapp/app/core/config/application-config.service.spec.ts @@ -0,0 +1,40 @@ +import { TestBed } from '@angular/core/testing'; + +import { ApplicationConfigService } from './application-config.service'; + +describe('ApplicationConfigService', () => { + let service: ApplicationConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ApplicationConfigService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('without prefix', () => { + it('should return correctly', () => { + expect(service.getEndpointFor('api')).toEqual('api'); + }); + + it('should return correctly when passing microservice', () => { + expect(service.getEndpointFor('api', 'microservice')).toEqual('services/microservice/api'); + }); + }); + + describe('with prefix', () => { + beforeEach(() => { + service.setEndpointPrefix('prefix/'); + }); + + it('should return correctly', () => { + expect(service.getEndpointFor('api')).toEqual('prefix/api'); + }); + + it('should return correctly when passing microservice', () => { + expect(service.getEndpointFor('api', 'microservice')).toEqual('prefix/services/microservice/api'); + }); + }); +}); diff --git a/src/main/webapp/app/core/config/application-config.service.ts b/src/main/webapp/app/core/config/application-config.service.ts new file mode 100644 index 000000000..0102e5f03 --- /dev/null +++ b/src/main/webapp/app/core/config/application-config.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class ApplicationConfigService { + private endpointPrefix = ''; + private microfrontend = false; + + setEndpointPrefix(endpointPrefix: string): void { + this.endpointPrefix = endpointPrefix; + } + + setMicrofrontend(microfrontend = true): void { + this.microfrontend = microfrontend; + } + + isMicrofrontend(): boolean { + return this.microfrontend; + } + + getEndpointFor(api: string, microservice?: string): string { + if (microservice) { + return `${this.endpointPrefix}services/${microservice}/${api}`; + } + return `${this.endpointPrefix}${api}`; + } +} diff --git a/src/main/webapp/app/core/interceptor/auth-expired.interceptor.ts b/src/main/webapp/app/core/interceptor/auth-expired.interceptor.ts new file mode 100644 index 000000000..fdca3377a --- /dev/null +++ b/src/main/webapp/app/core/interceptor/auth-expired.interceptor.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Router } from '@angular/router'; + +import { LoginService } from 'app/login/login.service'; +import { StateStorageService } from 'app/core/auth/state-storage.service'; +import { AccountService } from 'app/core/auth/account.service'; + +@Injectable() +export class AuthExpiredInterceptor implements HttpInterceptor { + constructor( + private loginService: LoginService, + private stateStorageService: StateStorageService, + private router: Router, + private accountService: AccountService, + ) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + return next.handle(request).pipe( + tap({ + error: (err: HttpErrorResponse) => { + if (err.status === 401 && err.url && !err.url.includes('api/account') && this.accountService.isAuthenticated()) { + this.stateStorageService.storeUrl(this.router.routerState.snapshot.url); + this.loginService.logout(); + this.router.navigate(['/login']); + } + }, + }), + ); + } +} diff --git a/src/main/webapp/app/core/interceptor/auth.interceptor.ts b/src/main/webapp/app/core/interceptor/auth.interceptor.ts new file mode 100644 index 000000000..4a7005347 --- /dev/null +++ b/src/main/webapp/app/core/interceptor/auth.interceptor.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { StateStorageService } from 'app/core/auth/state-storage.service'; +import { ApplicationConfigService } from '../config/application-config.service'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + constructor( + private stateStorageService: StateStorageService, + private applicationConfigService: ApplicationConfigService, + ) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + const serverApiUrl = this.applicationConfigService.getEndpointFor(''); + if (!request.url || (request.url.startsWith('http') && !(serverApiUrl && request.url.startsWith(serverApiUrl)))) { + return next.handle(request); + } + + const token: string | null = this.stateStorageService.getAuthenticationToken(); + if (token) { + request = request.clone({ + setHeaders: { + Authorization: `Bearer ${token}`, + }, + }); + } + return next.handle(request); + } +} diff --git a/src/main/webapp/app/core/interceptor/error-handler.interceptor.ts b/src/main/webapp/app/core/interceptor/error-handler.interceptor.ts new file mode 100644 index 000000000..a0234907e --- /dev/null +++ b/src/main/webapp/app/core/interceptor/error-handler.interceptor.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; +import { HttpInterceptor, HttpRequest, HttpErrorResponse, HttpHandler, HttpEvent } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { EventManager, EventWithContent } from 'app/core/util/event-manager.service'; + +@Injectable() +export class ErrorHandlerInterceptor implements HttpInterceptor { + constructor(private eventManager: EventManager) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + return next.handle(request).pipe( + tap({ + error: (err: HttpErrorResponse) => { + if (!(err.status === 401 && (err.message === '' || err.url?.includes('api/account')))) { + this.eventManager.broadcast(new EventWithContent('jhipsterSampleApplicationApp.httpError', err)); + } + }, + }), + ); + } +} diff --git a/src/main/webapp/app/core/interceptor/index.ts b/src/main/webapp/app/core/interceptor/index.ts new file mode 100644 index 000000000..f7e72e3a9 --- /dev/null +++ b/src/main/webapp/app/core/interceptor/index.ts @@ -0,0 +1,29 @@ +import { HTTP_INTERCEPTORS } from '@angular/common/http'; + +import { AuthInterceptor } from 'app/core/interceptor/auth.interceptor'; +import { AuthExpiredInterceptor } from 'app/core/interceptor/auth-expired.interceptor'; +import { ErrorHandlerInterceptor } from 'app/core/interceptor/error-handler.interceptor'; +import { NotificationInterceptor } from 'app/core/interceptor/notification.interceptor'; + +export const httpInterceptorProviders = [ + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: AuthExpiredInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: ErrorHandlerInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: NotificationInterceptor, + multi: true, + }, +]; diff --git a/src/main/webapp/app/core/interceptor/notification.interceptor.ts b/src/main/webapp/app/core/interceptor/notification.interceptor.ts new file mode 100644 index 000000000..6812e244c --- /dev/null +++ b/src/main/webapp/app/core/interceptor/notification.interceptor.ts @@ -0,0 +1,38 @@ +import { HttpInterceptor, HttpRequest, HttpResponse, HttpHandler, HttpEvent } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { AlertService } from 'app/core/util/alert.service'; + +@Injectable() +export class NotificationInterceptor implements HttpInterceptor { + constructor(private alertService: AlertService) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + return next.handle(request).pipe( + tap((event: HttpEvent) => { + if (event instanceof HttpResponse) { + let alert: string | null = null; + let alertParams: string | null = null; + + for (const headerKey of event.headers.keys()) { + if (headerKey.toLowerCase().endsWith('app-alert')) { + alert = event.headers.get(headerKey); + } else if (headerKey.toLowerCase().endsWith('app-params')) { + alertParams = decodeURIComponent(event.headers.get(headerKey)!.replace(/\+/g, ' ')); + } + } + + if (alert) { + this.alertService.addAlert({ + type: 'success', + translationKey: alert, + translationParams: { param: alertParams }, + }); + } + } + }), + ); + } +} diff --git a/src/main/webapp/app/core/request/request-util.ts b/src/main/webapp/app/core/request/request-util.ts new file mode 100644 index 000000000..694a238a9 --- /dev/null +++ b/src/main/webapp/app/core/request/request-util.ts @@ -0,0 +1,23 @@ +import { HttpParams } from '@angular/common/http'; + +export const createRequestOption = (req?: any): HttpParams => { + let options: HttpParams = new HttpParams(); + + if (req) { + Object.keys(req).forEach(key => { + if (key !== 'sort' && req[key] !== undefined) { + for (const value of [].concat(req[key]).filter(v => v !== '')) { + options = options.append(key, value); + } + } + }); + + if (req.sort) { + req.sort.forEach((val: string) => { + options = options.append('sort', val); + }); + } + } + + return options; +}; diff --git a/src/main/webapp/app/core/request/request.model.ts b/src/main/webapp/app/core/request/request.model.ts new file mode 100644 index 000000000..5de2b69a8 --- /dev/null +++ b/src/main/webapp/app/core/request/request.model.ts @@ -0,0 +1,11 @@ +export interface Pagination { + page: number; + size: number; + sort: string[]; +} + +export interface Search { + query: string; +} + +export interface SearchWithPagination extends Search, Pagination {} diff --git a/src/main/webapp/app/core/util/alert.service.spec.ts b/src/main/webapp/app/core/util/alert.service.spec.ts new file mode 100644 index 000000000..724412013 --- /dev/null +++ b/src/main/webapp/app/core/util/alert.service.spec.ts @@ -0,0 +1,285 @@ +import { inject, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService, MissingTranslationHandler } from '@ngx-translate/core'; +import { missingTranslationHandler } from '../../config/translation.config'; + +import { Alert, AlertService } from './alert.service'; + +describe('Alert service test', () => { + describe('Alert Service Test', () => { + let extAlerts: Alert[]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + missingTranslationHandler: { + provide: MissingTranslationHandler, + useFactory: missingTranslationHandler, + }, + }), + ], + }); + const translateService = TestBed.inject(TranslateService); + translateService.setDefaultLang('en'); + jest.useFakeTimers(); + extAlerts = []; + }); + + it('should produce a proper alert object and fetch it', inject([AlertService], (service: AlertService) => { + expect( + service.addAlert({ + type: 'success', + message: 'Hello Jhipster', + timeout: 3000, + toast: true, + position: 'top left', + }), + ).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster', + id: 0, + timeout: 3000, + toast: true, + position: 'top left', + } as Alert), + ); + + expect(service.get().length).toBe(1); + expect(service.get()[0]).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster', + id: 0, + timeout: 3000, + toast: true, + position: 'top left', + } as Alert), + ); + })); + + it('should produce a proper alert object and add it to external alert objects array', inject( + [AlertService], + (service: AlertService) => { + expect( + service.addAlert( + { + type: 'success', + message: 'Hello Jhipster', + timeout: 3000, + toast: true, + position: 'top left', + }, + extAlerts, + ), + ).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster', + id: 0, + timeout: 3000, + toast: true, + position: 'top left', + } as Alert), + ); + + expect(extAlerts.length).toBe(1); + expect(extAlerts[0]).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster', + id: 0, + timeout: 3000, + toast: true, + position: 'top left', + } as Alert), + ); + }, + )); + + it('should produce an alert object with correct id', inject([AlertService], (service: AlertService) => { + service.addAlert({ type: 'info', message: 'Hello Jhipster info' }); + expect(service.addAlert({ type: 'success', message: 'Hello Jhipster success' })).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster success', + id: 1, + } as Alert), + ); + + expect(service.get().length).toBe(2); + expect(service.get()[1]).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster success', + id: 1, + } as Alert), + ); + })); + + it('should close an alert correctly', inject([AlertService], (service: AlertService) => { + const alert0 = service.addAlert({ type: 'info', message: 'Hello Jhipster info' }); + const alert1 = service.addAlert({ type: 'info', message: 'Hello Jhipster info 2' }); + const alert2 = service.addAlert({ type: 'success', message: 'Hello Jhipster success' }); + expect(alert2).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster success', + id: 2, + } as Alert), + ); + + expect(service.get().length).toBe(3); + alert1.close?.(service.get()); + expect(service.get().length).toBe(2); + expect(service.get()[1]).not.toEqual( + expect.objectContaining({ + type: 'info', + message: 'Hello Jhipster info 2', + id: 1, + } as Alert), + ); + alert2.close?.(service.get()); + expect(service.get().length).toBe(1); + expect(service.get()[0]).not.toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster success', + id: 2, + } as Alert), + ); + alert0.close?.(service.get()); + expect(service.get().length).toBe(0); + })); + + it('should close an alert on timeout correctly', inject([AlertService], (service: AlertService) => { + service.addAlert({ type: 'info', message: 'Hello Jhipster info' }); + + expect(service.get().length).toBe(1); + + jest.advanceTimersByTime(6000); + + expect(service.get().length).toBe(0); + })); + + it('should clear alerts', inject([AlertService], (service: AlertService) => { + service.addAlert({ type: 'info', message: 'Hello Jhipster info' }); + service.addAlert({ type: 'danger', message: 'Hello Jhipster info' }); + service.addAlert({ type: 'success', message: 'Hello Jhipster info' }); + expect(service.get().length).toBe(3); + service.clear(); + expect(service.get().length).toBe(0); + })); + + it('should produce a scoped alert', inject([AlertService], (service: AlertService) => { + expect( + service.addAlert( + { + type: 'success', + message: 'Hello Jhipster', + timeout: 3000, + toast: true, + position: 'top left', + }, + [], + ), + ).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster', + id: 0, + timeout: 3000, + toast: true, + position: 'top left', + } as Alert), + ); + + expect(service.get().length).toBe(0); + })); + + it('should produce a success message', inject([AlertService], (service: AlertService) => { + expect(service.addAlert({ type: 'success', message: 'Hello Jhipster' })).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster', + } as Alert), + ); + })); + + it('should produce a success message with custom position', inject([AlertService], (service: AlertService) => { + expect(service.addAlert({ type: 'success', message: 'Hello Jhipster', position: 'bottom left' })).toEqual( + expect.objectContaining({ + type: 'success', + message: 'Hello Jhipster', + position: 'bottom left', + } as Alert), + ); + })); + + it('should produce a error message', inject([AlertService], (service: AlertService) => { + expect(service.addAlert({ type: 'danger', message: 'Hello Jhipster' })).toEqual( + expect.objectContaining({ + type: 'danger', + message: 'Hello Jhipster', + } as Alert), + ); + })); + + it('should produce a warning message', inject([AlertService], (service: AlertService) => { + expect(service.addAlert({ type: 'warning', message: 'Hello Jhipster' })).toEqual( + expect.objectContaining({ + type: 'warning', + message: 'Hello Jhipster', + } as Alert), + ); + })); + + it('should produce a info message', inject([AlertService], (service: AlertService) => { + expect(service.addAlert({ type: 'info', message: 'Hello Jhipster' })).toEqual( + expect.objectContaining({ + type: 'info', + message: 'Hello Jhipster', + } as Alert), + ); + })); + + it('should produce a info message with translated message if key exists', inject( + [AlertService, TranslateService], + (service: AlertService, translateService: TranslateService) => { + translateService.setTranslation('en', { + 'hello.jhipster': 'Translated message', + }); + expect(service.addAlert({ type: 'info', message: 'Hello Jhipster', translationKey: 'hello.jhipster' })).toEqual( + expect.objectContaining({ + type: 'info', + message: 'Translated message', + } as Alert), + ); + }, + )); + + it('should produce a info message with provided message if key does not exists', inject( + [AlertService, TranslateService], + (service: AlertService) => { + expect(service.addAlert({ type: 'info', message: 'Hello Jhipster', translationKey: 'hello.jhipster' })).toEqual( + expect.objectContaining({ + type: 'info', + message: 'Hello Jhipster', + } as Alert), + ); + }, + )); + + it('should produce a info message with provided key if transltion key does not exist in translations and message is not provided', inject( + [AlertService, TranslateService], + (service: AlertService) => { + expect(service.addAlert({ type: 'info', translationKey: 'hello.jhipster' })).toEqual( + expect.objectContaining({ + type: 'info', + message: 'hello.jhipster', + } as Alert), + ); + }, + )); + }); +}); diff --git a/src/main/webapp/app/core/util/alert.service.ts b/src/main/webapp/app/core/util/alert.service.ts new file mode 100644 index 000000000..202eb36ab --- /dev/null +++ b/src/main/webapp/app/core/util/alert.service.ts @@ -0,0 +1,93 @@ +import { Injectable, SecurityContext, NgZone } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; + +import { translationNotFoundMessage } from 'app/config/translation.config'; + +export type AlertType = 'success' | 'danger' | 'warning' | 'info'; + +export interface Alert { + id?: number; + type: AlertType; + message?: string; + translationKey?: string; + translationParams?: { [key: string]: unknown }; + timeout?: number; + toast?: boolean; + position?: string; + close?: (alerts: Alert[]) => void; +} + +@Injectable({ + providedIn: 'root', +}) +export class AlertService { + timeout = 5000; + toast = false; + position = 'top right'; + + // unique id for each alert. Starts from 0. + private alertId = 0; + private alerts: Alert[] = []; + + constructor( + private sanitizer: DomSanitizer, + private ngZone: NgZone, + private translateService: TranslateService, + ) {} + + clear(): void { + this.alerts = []; + } + + get(): Alert[] { + return this.alerts; + } + + /** + * Adds alert to alerts array and returns added alert. + * @param alert Alert to add. If `timeout`, `toast` or `position` is missing then applying default value. + * If `translateKey` is available then it's translation else `message` is used for showing. + * @param extAlerts If missing then adding `alert` to `AlertService` internal array and alerts can be retrieved by `get()`. + * Else adding `alert` to `extAlerts`. + * @returns Added alert + */ + addAlert(alert: Alert, extAlerts?: Alert[]): Alert { + alert.id = this.alertId++; + + if (alert.translationKey) { + const translatedMessage = this.translateService.instant(alert.translationKey, alert.translationParams); + // if translation key exists + if (translatedMessage !== `${translationNotFoundMessage}[${alert.translationKey}]`) { + alert.message = translatedMessage; + } else if (!alert.message) { + alert.message = alert.translationKey; + } + } + + alert.message = this.sanitizer.sanitize(SecurityContext.HTML, alert.message ?? '') ?? ''; + alert.timeout = alert.timeout ?? this.timeout; + alert.toast = alert.toast ?? this.toast; + alert.position = alert.position ?? this.position; + alert.close = (alertsArray: Alert[]) => this.closeAlert(alert.id!, alertsArray); + + (extAlerts ?? this.alerts).push(alert); + + if (alert.timeout > 0) { + setTimeout(() => { + this.closeAlert(alert.id!, extAlerts ?? this.alerts); + }, alert.timeout); + } + + return alert; + } + + private closeAlert(alertId: number, extAlerts?: Alert[]): void { + const alerts = extAlerts ?? this.alerts; + const alertIndex = alerts.map(alert => alert.id).indexOf(alertId); + // if found alert then remove + if (alertIndex >= 0) { + alerts.splice(alertIndex, 1); + } + } +} diff --git a/src/main/webapp/app/core/util/data-util.service.spec.ts b/src/main/webapp/app/core/util/data-util.service.spec.ts new file mode 100644 index 000000000..fccbcc646 --- /dev/null +++ b/src/main/webapp/app/core/util/data-util.service.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; + +import { DataUtils } from './data-util.service'; + +describe('Data Utils Service Test', () => { + let service: DataUtils; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DataUtils], + }); + service = TestBed.inject(DataUtils); + }); + + describe('byteSize', () => { + it('should return the bytesize of the text', () => { + expect(service.byteSize('Hello JHipster')).toBe(`10.5 bytes`); + }); + }); + + describe('openFile', () => { + it('should open the file in the new window', () => { + const newWindow = { ...window }; + newWindow.document.write = jest.fn(); + window.open = jest.fn(() => newWindow); + window.URL.createObjectURL = jest.fn(); + // 'JHipster' in base64 is 'SkhpcHN0ZXI=' + const data = 'SkhpcHN0ZXI='; + const contentType = 'text/plain'; + service.openFile(data, contentType); + expect(window.open).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/main/webapp/app/core/util/data-util.service.ts b/src/main/webapp/app/core/util/data-util.service.ts new file mode 100644 index 000000000..8ac137e08 --- /dev/null +++ b/src/main/webapp/app/core/util/data-util.service.ts @@ -0,0 +1,130 @@ +import { Buffer } from 'buffer'; +import { Injectable } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { Observable, Observer } from 'rxjs'; + +export type FileLoadErrorType = 'not.image' | 'could.not.extract'; + +export interface FileLoadError { + message: string; + key: FileLoadErrorType; + params?: any; +} + +/** + * An utility service for data. + */ +@Injectable({ + providedIn: 'root', +}) +export class DataUtils { + /** + * Method to find the byte size of the string provides + */ + byteSize(base64String: string): string { + return this.formatAsBytes(this.size(base64String)); + } + + /** + * Method to open file + */ + openFile(data: string, contentType: string | null | undefined): void { + contentType = contentType ?? ''; + + const byteCharacters = Buffer.from(data, 'base64').toString('binary'); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { + type: contentType, + }); + const fileURL = window.URL.createObjectURL(blob); + const win = window.open(fileURL); + win!.onload = function () { + URL.revokeObjectURL(fileURL); + }; + } + + /** + * Sets the base 64 data & file type of the 1st file on the event (event.target.files[0]) in the passed entity object + * and returns an observable. + * + * @param event the object containing the file (at event.target.files[0]) + * @param editForm the form group where the input field is located + * @param field the field name to set the file's 'base 64 data' on + * @param isImage boolean representing if the file represented by the event is an image + * @returns an observable that loads file to form field and completes if sussessful + * or returns error as FileLoadError on failure + */ + loadFileToForm(event: Event, editForm: FormGroup, field: string, isImage: boolean): Observable { + return new Observable((observer: Observer) => { + const eventTarget: HTMLInputElement | null = event.target as HTMLInputElement | null; + if (eventTarget?.files?.[0]) { + const file: File = eventTarget.files[0]; + if (isImage && !file.type.startsWith('image/')) { + const error: FileLoadError = { + message: `File was expected to be an image but was found to be '${file.type}'`, + key: 'not.image', + params: { fileType: file.type }, + }; + observer.error(error); + } else { + const fieldContentType: string = field + 'ContentType'; + this.toBase64(file, (base64Data: string) => { + editForm.patchValue({ + [field]: base64Data, + [fieldContentType]: file.type, + }); + observer.next(); + observer.complete(); + }); + } + } else { + const error: FileLoadError = { + message: 'Could not extract file', + key: 'could.not.extract', + params: { event }, + }; + observer.error(error); + } + }); + } + + /** + * Method to convert the file to base64 + */ + private toBase64(file: File, callback: (base64Data: string) => void): void { + const fileReader: FileReader = new FileReader(); + fileReader.onload = (e: ProgressEvent) => { + if (typeof e.target?.result === 'string') { + const base64Data: string = e.target.result.substring(e.target.result.indexOf('base64,') + 'base64,'.length); + callback(base64Data); + } + }; + fileReader.readAsDataURL(file); + } + + private endsWith(suffix: string, str: string): boolean { + return str.includes(suffix, str.length - suffix.length); + } + + private paddingSize(value: string): number { + if (this.endsWith('==', value)) { + return 2; + } + if (this.endsWith('=', value)) { + return 1; + } + return 0; + } + + private size(value: string): number { + return (value.length / 4) * 3 - this.paddingSize(value); + } + + private formatAsBytes(size: number): string { + return size.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + ' bytes'; // NOSONAR + } +} diff --git a/src/main/webapp/app/core/util/event-manager.service.spec.ts b/src/main/webapp/app/core/util/event-manager.service.spec.ts new file mode 100644 index 000000000..36c40feaa --- /dev/null +++ b/src/main/webapp/app/core/util/event-manager.service.spec.ts @@ -0,0 +1,84 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { EventManager, EventWithContent } from './event-manager.service'; + +describe('Event Manager tests', () => { + describe('EventWithContent', () => { + it('should create correctly EventWithContent', () => { + // WHEN + const eventWithContent = new EventWithContent('name', 'content'); + + // THEN + expect(eventWithContent).toEqual({ name: 'name', content: 'content' }); + }); + }); + + describe('EventManager', () => { + let recievedEvent: EventWithContent | string | null; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [EventManager], + }); + recievedEvent = null; + }); + + it('should not fail when nosubscriber and broadcasting', inject([EventManager], (eventManager: EventManager) => { + expect(eventManager.observer).toBeUndefined(); + eventManager.broadcast({ name: 'modifier', content: 'modified something' }); + })); + + it('should create an observable and callback when broadcasted EventWithContent', inject( + [EventManager], + (eventManager: EventManager) => { + // GIVEN + eventManager.subscribe('modifier', (event: EventWithContent | string) => (recievedEvent = event)); + + // WHEN + eventManager.broadcast({ name: 'unrelatedModifier', content: 'unreleated modification' }); + // THEN + expect(recievedEvent).toBeNull(); + + // WHEN + eventManager.broadcast({ name: 'modifier', content: 'modified something' }); + // THEN + expect(recievedEvent).toEqual({ name: 'modifier', content: 'modified something' }); + }, + )); + + it('should create an observable and callback when broadcasted string', inject([EventManager], (eventManager: EventManager) => { + // GIVEN + eventManager.subscribe('modifier', (event: EventWithContent | string) => (recievedEvent = event)); + + // WHEN + eventManager.broadcast('unrelatedModifier'); + // THEN + expect(recievedEvent).toBeNull(); + + // WHEN + eventManager.broadcast('modifier'); + // THEN + expect(recievedEvent).toEqual('modifier'); + })); + + it('should subscribe to multiple events', inject([EventManager], (eventManager: EventManager) => { + // GIVEN + eventManager.subscribe(['modifier', 'modifier2'], (event: EventWithContent | string) => (recievedEvent = event)); + + // WHEN + eventManager.broadcast('unrelatedModifier'); + // THEN + expect(recievedEvent).toBeNull(); + + // WHEN + eventManager.broadcast({ name: 'modifier', content: 'modified something' }); + // THEN + expect(recievedEvent).toEqual({ name: 'modifier', content: 'modified something' }); + + // WHEN + eventManager.broadcast('modifier2'); + // THEN + expect(recievedEvent).toEqual('modifier2'); + })); + }); +}); diff --git a/src/main/webapp/app/core/util/event-manager.service.ts b/src/main/webapp/app/core/util/event-manager.service.ts new file mode 100644 index 000000000..a73d21294 --- /dev/null +++ b/src/main/webapp/app/core/util/event-manager.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { Observable, Observer, Subscription } from 'rxjs'; +import { filter, share } from 'rxjs/operators'; + +export class EventWithContent { + constructor( + public name: string, + public content: T, + ) {} +} + +/** + * An utility class to manage RX events + */ +@Injectable({ + providedIn: 'root', +}) +export class EventManager { + observable: Observable | string>; + observer?: Observer | string>; + + constructor() { + this.observable = new Observable((observer: Observer | string>) => { + this.observer = observer; + }).pipe(share()); + } + + /** + * Method to broadcast the event to observer + */ + broadcast(event: EventWithContent | string): void { + if (this.observer) { + this.observer.next(event); + } + } + + /** + * Method to subscribe to an event with callback + * @param eventNames Single event name or array of event names to what subscribe + * @param callback Callback to run when the event occurs + */ + subscribe(eventNames: string | string[], callback: (event: EventWithContent | string) => void): Subscription { + if (typeof eventNames === 'string') { + eventNames = [eventNames]; + } + return this.observable + .pipe( + filter((event: EventWithContent | string) => { + for (const eventName of eventNames) { + if ((typeof event === 'string' && event === eventName) || (typeof event !== 'string' && event.name === eventName)) { + return true; + } + } + return false; + }), + ) + .subscribe(callback); + } + + /** + * Method to unsubscribe the subscription + */ + destroy(subscriber: Subscription): void { + subscriber.unsubscribe(); + } +} diff --git a/src/main/webapp/app/core/util/operators.spec.ts b/src/main/webapp/app/core/util/operators.spec.ts new file mode 100644 index 000000000..429647c47 --- /dev/null +++ b/src/main/webapp/app/core/util/operators.spec.ts @@ -0,0 +1,18 @@ +import { filterNaN, isPresent } from './operators'; + +describe('Operators Test', () => { + describe('isPresent', () => { + it('should remove null and undefined values', () => { + expect([1, null, undefined].filter(isPresent)).toEqual([1]); + }); + }); + + describe('filterNaN', () => { + it('should return 0 for NaN', () => { + expect(filterNaN(NaN)).toBe(0); + }); + it('should return number for a number', () => { + expect(filterNaN(12345)).toBe(12345); + }); + }); +}); diff --git a/src/main/webapp/app/core/util/operators.ts b/src/main/webapp/app/core/util/operators.ts new file mode 100644 index 000000000..c22459296 --- /dev/null +++ b/src/main/webapp/app/core/util/operators.ts @@ -0,0 +1,9 @@ +/* + * Function used to workaround https://github.com/microsoft/TypeScript/issues/16069 + * es2019 alternative `const filteredArr = myArr.flatMap((x) => x ? x : []);` + */ +export function isPresent(t: T | undefined | null | void): t is T { + return t !== undefined && t !== null; +} + +export const filterNaN = (input: number): number => (isNaN(input) ? 0 : input); diff --git a/src/main/webapp/app/core/util/parse-links.service.spec.ts b/src/main/webapp/app/core/util/parse-links.service.spec.ts new file mode 100644 index 000000000..40b6c75cf --- /dev/null +++ b/src/main/webapp/app/core/util/parse-links.service.spec.ts @@ -0,0 +1,36 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { ParseLinks } from './parse-links.service'; + +describe('Parse links service test', () => { + describe('Parse Links Service Test', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ParseLinks], + }); + }); + + it('should throw an error when passed an empty string', inject([ParseLinks], (service: ParseLinks) => { + expect(function () { + service.parse(''); + }).toThrow(new Error('input must not be of zero length')); + })); + + it('should throw an error when passed without comma', inject([ParseLinks], (service: ParseLinks) => { + expect(function () { + service.parse('test'); + }).toThrow(new Error('section could not be split on ";"')); + })); + + it('should throw an error when passed without semicolon', inject([ParseLinks], (service: ParseLinks) => { + expect(function () { + service.parse('test,test2'); + }).toThrow(new Error('section could not be split on ";"')); + })); + + it('should return links when headers are passed', inject([ParseLinks], (service: ParseLinks) => { + const links = { last: 0, first: 0 }; + expect(service.parse(' ; rel="last",; rel="first"')).toEqual(links); + })); + }); +}); diff --git a/src/main/webapp/app/core/util/parse-links.service.ts b/src/main/webapp/app/core/util/parse-links.service.ts new file mode 100644 index 000000000..dc1eb0e91 --- /dev/null +++ b/src/main/webapp/app/core/util/parse-links.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; + +/** + * An utility service for link parsing. + */ +@Injectable({ + providedIn: 'root', +}) +export class ParseLinks { + /** + * Method to parse the links + */ + parse(header: string): { [key: string]: number } { + if (header.length === 0) { + throw new Error('input must not be of zero length'); + } + + // Split parts by comma + const parts: string[] = header.split(','); + const links: { [key: string]: number } = {}; + + // Parse each part into a named link + parts.forEach(p => { + const section: string[] = p.split(';'); + + if (section.length !== 2) { + throw new Error('section could not be split on ";"'); + } + + const url: string = section[0].replace(/<(.*)>/, '$1').trim(); // NOSONAR + const queryString: { [key: string]: string | undefined } = {}; + + url.replace(/([^?=&]+)(=([^&]*))?/g, (_$0: string, $1: string | undefined, _$2: string | undefined, $3: string | undefined) => { + if ($1 !== undefined) { + queryString[$1] = $3; + } + return $3 ?? ''; + }); + + if (queryString.page !== undefined) { + const name: string = section[1].replace(/rel="(.*)"/, '$1').trim(); + links[name] = parseInt(queryString.page, 10); + } + }); + return links; + } +} diff --git a/src/main/webapp/app/entities/bank-account/bank-account.model.ts b/src/main/webapp/app/entities/bank-account/bank-account.model.ts new file mode 100644 index 000000000..e71ae8450 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/bank-account.model.ts @@ -0,0 +1,10 @@ +import { IUser } from 'app/entities/user/user.model'; + +export interface IBankAccount { + id: number; + name?: string | null; + balance?: number | null; + user?: Pick | null; +} + +export type NewBankAccount = Omit & { id: null }; diff --git a/src/main/webapp/app/entities/bank-account/bank-account.routes.ts b/src/main/webapp/app/entities/bank-account/bank-account.routes.ts new file mode 100644 index 000000000..cc4c2ad7f --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/bank-account.routes.ts @@ -0,0 +1,45 @@ +import { Routes } from '@angular/router'; + +import { UserRouteAccessService } from 'app/core/auth/user-route-access.service'; +import { ASC } from 'app/config/navigation.constants'; +import { BankAccountComponent } from './list/bank-account.component'; +import { BankAccountDetailComponent } from './detail/bank-account-detail.component'; +import { BankAccountUpdateComponent } from './update/bank-account-update.component'; +import BankAccountResolve from './route/bank-account-routing-resolve.service'; + +const bankAccountRoute: Routes = [ + { + path: '', + component: BankAccountComponent, + data: { + defaultSort: 'id,' + ASC, + }, + canActivate: [UserRouteAccessService], + }, + { + path: ':id/view', + component: BankAccountDetailComponent, + resolve: { + bankAccount: BankAccountResolve, + }, + canActivate: [UserRouteAccessService], + }, + { + path: 'new', + component: BankAccountUpdateComponent, + resolve: { + bankAccount: BankAccountResolve, + }, + canActivate: [UserRouteAccessService], + }, + { + path: ':id/edit', + component: BankAccountUpdateComponent, + resolve: { + bankAccount: BankAccountResolve, + }, + canActivate: [UserRouteAccessService], + }, +]; + +export default bankAccountRoute; diff --git a/src/main/webapp/app/entities/bank-account/bank-account.test-samples.ts b/src/main/webapp/app/entities/bank-account/bank-account.test-samples.ts new file mode 100644 index 000000000..896717b20 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/bank-account.test-samples.ts @@ -0,0 +1,30 @@ +import { IBankAccount, NewBankAccount } from './bank-account.model'; + +export const sampleWithRequiredData: IBankAccount = { + id: 7094, + name: 'solemnly', + balance: 11058.15, +}; + +export const sampleWithPartialData: IBankAccount = { + id: 9797, + name: 'woot er', + balance: 6523.78, +}; + +export const sampleWithFullData: IBankAccount = { + id: 17714, + name: 'phew', + balance: 21828.46, +}; + +export const sampleWithNewData: NewBankAccount = { + name: 'duh ice', + balance: 9789.22, + id: null, +}; + +Object.freeze(sampleWithNewData); +Object.freeze(sampleWithRequiredData); +Object.freeze(sampleWithPartialData); +Object.freeze(sampleWithFullData); diff --git a/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.html b/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.html new file mode 100644 index 000000000..a588f7ff2 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.html @@ -0,0 +1,28 @@ +
+ + + + + +
diff --git a/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.spec.ts b/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.spec.ts new file mode 100644 index 000000000..92cb5bdfd --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.spec.ts @@ -0,0 +1,62 @@ +jest.mock('@ng-bootstrap/ng-bootstrap'); + +import { ComponentFixture, TestBed, inject, fakeAsync, tick } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { BankAccountService } from '../service/bank-account.service'; + +import { BankAccountDeleteDialogComponent } from './bank-account-delete-dialog.component'; + +describe('BankAccount Management Delete Component', () => { + let comp: BankAccountDeleteDialogComponent; + let fixture: ComponentFixture; + let service: BankAccountService; + let mockActiveModal: NgbActiveModal; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, BankAccountDeleteDialogComponent], + providers: [NgbActiveModal], + }) + .overrideTemplate(BankAccountDeleteDialogComponent, '') + .compileComponents(); + fixture = TestBed.createComponent(BankAccountDeleteDialogComponent); + comp = fixture.componentInstance; + service = TestBed.inject(BankAccountService); + mockActiveModal = TestBed.inject(NgbActiveModal); + }); + + describe('confirmDelete', () => { + it('Should call delete service on confirmDelete', inject( + [], + fakeAsync(() => { + // GIVEN + jest.spyOn(service, 'delete').mockReturnValue(of(new HttpResponse({ body: {} }))); + + // WHEN + comp.confirmDelete(123); + tick(); + + // THEN + expect(service.delete).toHaveBeenCalledWith(123); + expect(mockActiveModal.close).toHaveBeenCalledWith('deleted'); + }), + )); + + it('Should not call delete service on clear', () => { + // GIVEN + jest.spyOn(service, 'delete'); + + // WHEN + comp.cancel(); + + // THEN + expect(service.delete).not.toHaveBeenCalled(); + expect(mockActiveModal.close).not.toHaveBeenCalled(); + expect(mockActiveModal.dismiss).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.ts b/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.ts new file mode 100644 index 000000000..13993a12d --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/delete/bank-account-delete-dialog.component.ts @@ -0,0 +1,32 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { ITEM_DELETED_EVENT } from 'app/config/navigation.constants'; +import { IBankAccount } from '../bank-account.model'; +import { BankAccountService } from '../service/bank-account.service'; + +@Component({ + standalone: true, + templateUrl: './bank-account-delete-dialog.component.html', + imports: [SharedModule, FormsModule], +}) +export class BankAccountDeleteDialogComponent { + bankAccount?: IBankAccount; + + constructor( + protected bankAccountService: BankAccountService, + protected activeModal: NgbActiveModal, + ) {} + + cancel(): void { + this.activeModal.dismiss(); + } + + confirmDelete(id: number): void { + this.bankAccountService.delete(id).subscribe(() => { + this.activeModal.close(ITEM_DELETED_EVENT); + }); + } +} diff --git a/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.html b/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.html new file mode 100644 index 000000000..cb01a02e8 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.html @@ -0,0 +1,42 @@ +
+
+
+

+ Bank Account +

+ +
+ + + + + +
+
ID
+
+ {{ bankAccount.id }} +
+
Name
+
+ {{ bankAccount.name }} +
+
Balance
+
+ {{ bankAccount.balance }} +
+
User
+
+ {{ bankAccount.user?.login }} +
+
+ + + + +
+
+
diff --git a/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.spec.ts b/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.spec.ts new file mode 100644 index 000000000..0c4e5d860 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { RouterTestingHarness, RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { BankAccountDetailComponent } from './bank-account-detail.component'; + +describe('BankAccount Management Detail Component', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BankAccountDetailComponent, RouterTestingModule.withRoutes([], { bindToComponentInputs: true })], + providers: [ + provideRouter( + [ + { + path: '**', + component: BankAccountDetailComponent, + resolve: { bankAccount: () => of({ id: 123 }) }, + }, + ], + withComponentInputBinding(), + ), + ], + }) + .overrideTemplate(BankAccountDetailComponent, '') + .compileComponents(); + }); + + describe('OnInit', () => { + it('Should load bankAccount on init', async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/', BankAccountDetailComponent); + + // THEN + expect(instance.bankAccount).toEqual(expect.objectContaining({ id: 123 })); + }); + }); +}); diff --git a/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.ts b/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.ts new file mode 100644 index 000000000..b12f270bf --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/detail/bank-account-detail.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; + +import SharedModule from 'app/shared/shared.module'; +import { DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe } from 'app/shared/date'; +import { IBankAccount } from '../bank-account.model'; + +@Component({ + standalone: true, + selector: 'jhi-bank-account-detail', + templateUrl: './bank-account-detail.component.html', + imports: [SharedModule, RouterModule, DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe], +}) +export class BankAccountDetailComponent { + @Input() bankAccount: IBankAccount | null = null; + + constructor(protected activatedRoute: ActivatedRoute) {} + + previousState(): void { + window.history.back(); + } +} diff --git a/src/main/webapp/app/entities/bank-account/list/bank-account.component.html b/src/main/webapp/app/entities/bank-account/list/bank-account.component.html new file mode 100644 index 000000000..a3ca5b9fe --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/list/bank-account.component.html @@ -0,0 +1,104 @@ +
+

+ Bank Accounts + +
+ + + +
+

+ + + + + +
+ No Bank Accounts found +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+ ID + +
+
+
+ Name + +
+
+
+ Balance + +
+
+
+ User + +
+
+ {{ bankAccount.id }} + {{ bankAccount.name }}{{ bankAccount.balance }} + {{ bankAccount.user?.login }} + +
+ + + + + +
+
+
+
diff --git a/src/main/webapp/app/entities/bank-account/list/bank-account.component.spec.ts b/src/main/webapp/app/entities/bank-account/list/bank-account.component.spec.ts new file mode 100644 index 000000000..593631807 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/list/bank-account.component.spec.ts @@ -0,0 +1,79 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { BankAccountService } from '../service/bank-account.service'; + +import { BankAccountComponent } from './bank-account.component'; + +describe('BankAccount Management Component', () => { + let comp: BankAccountComponent; + let fixture: ComponentFixture; + let service: BankAccountService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([{ path: 'bank-account', component: BankAccountComponent }]), + HttpClientTestingModule, + BankAccountComponent, + ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + data: of({ + defaultSort: 'id,asc', + }), + queryParamMap: of( + jest.requireActual('@angular/router').convertToParamMap({ + page: '1', + size: '1', + sort: 'id,desc', + }), + ), + snapshot: { queryParams: {} }, + }, + }, + ], + }) + .overrideTemplate(BankAccountComponent, '') + .compileComponents(); + + fixture = TestBed.createComponent(BankAccountComponent); + comp = fixture.componentInstance; + service = TestBed.inject(BankAccountService); + + const headers = new HttpHeaders(); + jest.spyOn(service, 'query').mockReturnValue( + of( + new HttpResponse({ + body: [{ id: 123 }], + headers, + }), + ), + ); + }); + + it('Should call load all on init', () => { + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.query).toHaveBeenCalled(); + expect(comp.bankAccounts?.[0]).toEqual(expect.objectContaining({ id: 123 })); + }); + + describe('trackId', () => { + it('Should forward to bankAccountService', () => { + const entity = { id: 123 }; + jest.spyOn(service, 'getBankAccountIdentifier'); + const id = comp.trackId(0, entity); + expect(service.getBankAccountIdentifier).toHaveBeenCalledWith(entity); + expect(id).toBe(entity.id); + }); + }); +}); diff --git a/src/main/webapp/app/entities/bank-account/list/bank-account.component.ts b/src/main/webapp/app/entities/bank-account/list/bank-account.component.ts new file mode 100644 index 000000000..4b3364a05 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/list/bank-account.component.ts @@ -0,0 +1,134 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Data, ParamMap, Router, RouterModule } from '@angular/router'; +import { combineLatest, filter, Observable, switchMap, tap } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { SortDirective, SortByDirective } from 'app/shared/sort'; +import { DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe } from 'app/shared/date'; +import { FormsModule } from '@angular/forms'; +import { ASC, DESC, SORT, ITEM_DELETED_EVENT, DEFAULT_SORT_DATA } from 'app/config/navigation.constants'; +import { SortService } from 'app/shared/sort/sort.service'; +import { IBankAccount } from '../bank-account.model'; +import { EntityArrayResponseType, BankAccountService } from '../service/bank-account.service'; +import { BankAccountDeleteDialogComponent } from '../delete/bank-account-delete-dialog.component'; + +@Component({ + standalone: true, + selector: 'jhi-bank-account', + templateUrl: './bank-account.component.html', + imports: [ + RouterModule, + FormsModule, + SharedModule, + SortDirective, + SortByDirective, + DurationPipe, + FormatMediumDatetimePipe, + FormatMediumDatePipe, + ], +}) +export class BankAccountComponent implements OnInit { + bankAccounts?: IBankAccount[]; + isLoading = false; + + predicate = 'id'; + ascending = true; + + constructor( + protected bankAccountService: BankAccountService, + protected activatedRoute: ActivatedRoute, + public router: Router, + protected sortService: SortService, + protected modalService: NgbModal, + ) {} + + trackId = (_index: number, item: IBankAccount): number => this.bankAccountService.getBankAccountIdentifier(item); + + ngOnInit(): void { + this.load(); + } + + delete(bankAccount: IBankAccount): void { + const modalRef = this.modalService.open(BankAccountDeleteDialogComponent, { size: 'lg', backdrop: 'static' }); + modalRef.componentInstance.bankAccount = bankAccount; + // unsubscribe not needed because closed completes on modal close + modalRef.closed + .pipe( + filter(reason => reason === ITEM_DELETED_EVENT), + switchMap(() => this.loadFromBackendWithRouteInformations()), + ) + .subscribe({ + next: (res: EntityArrayResponseType) => { + this.onResponseSuccess(res); + }, + }); + } + + load(): void { + this.loadFromBackendWithRouteInformations().subscribe({ + next: (res: EntityArrayResponseType) => { + this.onResponseSuccess(res); + }, + }); + } + + navigateToWithComponentValues(): void { + this.handleNavigation(this.predicate, this.ascending); + } + + protected loadFromBackendWithRouteInformations(): Observable { + return combineLatest([this.activatedRoute.queryParamMap, this.activatedRoute.data]).pipe( + tap(([params, data]) => this.fillComponentAttributeFromRoute(params, data)), + switchMap(() => this.queryBackend(this.predicate, this.ascending)), + ); + } + + protected fillComponentAttributeFromRoute(params: ParamMap, data: Data): void { + const sort = (params.get(SORT) ?? data[DEFAULT_SORT_DATA]).split(','); + this.predicate = sort[0]; + this.ascending = sort[1] === ASC; + } + + protected onResponseSuccess(response: EntityArrayResponseType): void { + const dataFromBody = this.fillComponentAttributesFromResponseBody(response.body); + this.bankAccounts = this.refineData(dataFromBody); + } + + protected refineData(data: IBankAccount[]): IBankAccount[] { + return data.sort(this.sortService.startSort(this.predicate, this.ascending ? 1 : -1)); + } + + protected fillComponentAttributesFromResponseBody(data: IBankAccount[] | null): IBankAccount[] { + return data ?? []; + } + + protected queryBackend(predicate?: string, ascending?: boolean): Observable { + this.isLoading = true; + const queryObject: any = { + eagerload: true, + sort: this.getSortQueryParam(predicate, ascending), + }; + return this.bankAccountService.query(queryObject).pipe(tap(() => (this.isLoading = false))); + } + + protected handleNavigation(predicate?: string, ascending?: boolean): void { + const queryParamsObj = { + sort: this.getSortQueryParam(predicate, ascending), + }; + + this.router.navigate(['./'], { + relativeTo: this.activatedRoute, + queryParams: queryParamsObj, + }); + } + + protected getSortQueryParam(predicate = this.predicate, ascending = this.ascending): string[] { + const ascendingQueryParam = ascending ? ASC : DESC; + if (predicate === '') { + return []; + } else { + return [predicate + ',' + ascendingQueryParam]; + } + } +} diff --git a/src/main/webapp/app/entities/bank-account/route/bank-account-routing-resolve.service.spec.ts b/src/main/webapp/app/entities/bank-account/route/bank-account-routing-resolve.service.spec.ts new file mode 100644 index 000000000..1b94a6990 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/route/bank-account-routing-resolve.service.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRouteSnapshot, ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { IBankAccount } from '../bank-account.model'; +import { BankAccountService } from '../service/bank-account.service'; + +import bankAccountResolve from './bank-account-routing-resolve.service'; + +describe('BankAccount routing resolve service', () => { + let mockRouter: Router; + let mockActivatedRouteSnapshot: ActivatedRouteSnapshot; + let service: BankAccountService; + let resultBankAccount: IBankAccount | null | undefined; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([])], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({}), + }, + }, + }, + ], + }); + mockRouter = TestBed.inject(Router); + jest.spyOn(mockRouter, 'navigate').mockImplementation(() => Promise.resolve(true)); + mockActivatedRouteSnapshot = TestBed.inject(ActivatedRoute).snapshot; + service = TestBed.inject(BankAccountService); + resultBankAccount = undefined; + }); + + describe('resolve', () => { + it('should return IBankAccount returned by find', () => { + // GIVEN + service.find = jest.fn(id => of(new HttpResponse({ body: { id } }))); + mockActivatedRouteSnapshot.params = { id: 123 }; + + // WHEN + TestBed.runInInjectionContext(() => { + bankAccountResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultBankAccount = result; + }, + }); + }); + + // THEN + expect(service.find).toBeCalledWith(123); + expect(resultBankAccount).toEqual({ id: 123 }); + }); + + it('should return null if id is not provided', () => { + // GIVEN + service.find = jest.fn(); + mockActivatedRouteSnapshot.params = {}; + + // WHEN + TestBed.runInInjectionContext(() => { + bankAccountResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultBankAccount = result; + }, + }); + }); + + // THEN + expect(service.find).not.toBeCalled(); + expect(resultBankAccount).toEqual(null); + }); + + it('should route to 404 page if data not found in server', () => { + // GIVEN + jest.spyOn(service, 'find').mockReturnValue(of(new HttpResponse({ body: null }))); + mockActivatedRouteSnapshot.params = { id: 123 }; + + // WHEN + TestBed.runInInjectionContext(() => { + bankAccountResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultBankAccount = result; + }, + }); + }); + + // THEN + expect(service.find).toBeCalledWith(123); + expect(resultBankAccount).toEqual(undefined); + expect(mockRouter.navigate).toHaveBeenCalledWith(['404']); + }); + }); +}); diff --git a/src/main/webapp/app/entities/bank-account/route/bank-account-routing-resolve.service.ts b/src/main/webapp/app/entities/bank-account/route/bank-account-routing-resolve.service.ts new file mode 100644 index 000000000..e547c7396 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/route/bank-account-routing-resolve.service.ts @@ -0,0 +1,29 @@ +import { inject } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { of, EMPTY, Observable } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +import { IBankAccount } from '../bank-account.model'; +import { BankAccountService } from '../service/bank-account.service'; + +export const bankAccountResolve = (route: ActivatedRouteSnapshot): Observable => { + const id = route.params['id']; + if (id) { + return inject(BankAccountService) + .find(id) + .pipe( + mergeMap((bankAccount: HttpResponse) => { + if (bankAccount.body) { + return of(bankAccount.body); + } else { + inject(Router).navigate(['404']); + return EMPTY; + } + }), + ); + } + return of(null); +}; + +export default bankAccountResolve; diff --git a/src/main/webapp/app/entities/bank-account/service/bank-account.service.spec.ts b/src/main/webapp/app/entities/bank-account/service/bank-account.service.spec.ts new file mode 100644 index 000000000..2a11ec811 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/service/bank-account.service.spec.ts @@ -0,0 +1,204 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { IBankAccount } from '../bank-account.model'; +import { sampleWithRequiredData, sampleWithNewData, sampleWithPartialData, sampleWithFullData } from '../bank-account.test-samples'; + +import { BankAccountService } from './bank-account.service'; + +const requireRestSample: IBankAccount = { + ...sampleWithRequiredData, +}; + +describe('BankAccount Service', () => { + let service: BankAccountService; + let httpMock: HttpTestingController; + let expectedResult: IBankAccount | IBankAccount[] | boolean | null; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + expectedResult = null; + service = TestBed.inject(BankAccountService); + httpMock = TestBed.inject(HttpTestingController); + }); + + describe('Service methods', () => { + it('should find an element', () => { + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.find(123).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should create a BankAccount', () => { + const bankAccount = { ...sampleWithNewData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.create(bankAccount).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'POST' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should update a BankAccount', () => { + const bankAccount = { ...sampleWithRequiredData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.update(bankAccount).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'PUT' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should partial update a BankAccount', () => { + const patchObject = { ...sampleWithPartialData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.partialUpdate(patchObject).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'PATCH' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should return a list of BankAccount', () => { + const returnedFromService = { ...requireRestSample }; + + const expected = { ...sampleWithRequiredData }; + + service.query().subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush([returnedFromService]); + httpMock.verify(); + expect(expectedResult).toMatchObject([expected]); + }); + + it('should delete a BankAccount', () => { + const expected = true; + + service.delete(123).subscribe(resp => (expectedResult = resp.ok)); + + const req = httpMock.expectOne({ method: 'DELETE' }); + req.flush({ status: 200 }); + expect(expectedResult).toBe(expected); + }); + + describe('addBankAccountToCollectionIfMissing', () => { + it('should add a BankAccount to an empty array', () => { + const bankAccount: IBankAccount = sampleWithRequiredData; + expectedResult = service.addBankAccountToCollectionIfMissing([], bankAccount); + expect(expectedResult).toHaveLength(1); + expect(expectedResult).toContain(bankAccount); + }); + + it('should not add a BankAccount to an array that contains it', () => { + const bankAccount: IBankAccount = sampleWithRequiredData; + const bankAccountCollection: IBankAccount[] = [ + { + ...bankAccount, + }, + sampleWithPartialData, + ]; + expectedResult = service.addBankAccountToCollectionIfMissing(bankAccountCollection, bankAccount); + expect(expectedResult).toHaveLength(2); + }); + + it("should add a BankAccount to an array that doesn't contain it", () => { + const bankAccount: IBankAccount = sampleWithRequiredData; + const bankAccountCollection: IBankAccount[] = [sampleWithPartialData]; + expectedResult = service.addBankAccountToCollectionIfMissing(bankAccountCollection, bankAccount); + expect(expectedResult).toHaveLength(2); + expect(expectedResult).toContain(bankAccount); + }); + + it('should add only unique BankAccount to an array', () => { + const bankAccountArray: IBankAccount[] = [sampleWithRequiredData, sampleWithPartialData, sampleWithFullData]; + const bankAccountCollection: IBankAccount[] = [sampleWithRequiredData]; + expectedResult = service.addBankAccountToCollectionIfMissing(bankAccountCollection, ...bankAccountArray); + expect(expectedResult).toHaveLength(3); + }); + + it('should accept varargs', () => { + const bankAccount: IBankAccount = sampleWithRequiredData; + const bankAccount2: IBankAccount = sampleWithPartialData; + expectedResult = service.addBankAccountToCollectionIfMissing([], bankAccount, bankAccount2); + expect(expectedResult).toHaveLength(2); + expect(expectedResult).toContain(bankAccount); + expect(expectedResult).toContain(bankAccount2); + }); + + it('should accept null and undefined values', () => { + const bankAccount: IBankAccount = sampleWithRequiredData; + expectedResult = service.addBankAccountToCollectionIfMissing([], null, bankAccount, undefined); + expect(expectedResult).toHaveLength(1); + expect(expectedResult).toContain(bankAccount); + }); + + it('should return initial array if no BankAccount is added', () => { + const bankAccountCollection: IBankAccount[] = [sampleWithRequiredData]; + expectedResult = service.addBankAccountToCollectionIfMissing(bankAccountCollection, undefined, null); + expect(expectedResult).toEqual(bankAccountCollection); + }); + }); + + describe('compareBankAccount', () => { + it('Should return true if both entities are null', () => { + const entity1 = null; + const entity2 = null; + + const compareResult = service.compareBankAccount(entity1, entity2); + + expect(compareResult).toEqual(true); + }); + + it('Should return false if one entity is null', () => { + const entity1 = { id: 123 }; + const entity2 = null; + + const compareResult1 = service.compareBankAccount(entity1, entity2); + const compareResult2 = service.compareBankAccount(entity2, entity1); + + expect(compareResult1).toEqual(false); + expect(compareResult2).toEqual(false); + }); + + it('Should return false if primaryKey differs', () => { + const entity1 = { id: 123 }; + const entity2 = { id: 456 }; + + const compareResult1 = service.compareBankAccount(entity1, entity2); + const compareResult2 = service.compareBankAccount(entity2, entity1); + + expect(compareResult1).toEqual(false); + expect(compareResult2).toEqual(false); + }); + + it('Should return false if primaryKey matches', () => { + const entity1 = { id: 123 }; + const entity2 = { id: 123 }; + + const compareResult1 = service.compareBankAccount(entity1, entity2); + const compareResult2 = service.compareBankAccount(entity2, entity1); + + expect(compareResult1).toEqual(true); + expect(compareResult2).toEqual(true); + }); + }); + }); + + afterEach(() => { + httpMock.verify(); + }); +}); diff --git a/src/main/webapp/app/entities/bank-account/service/bank-account.service.ts b/src/main/webapp/app/entities/bank-account/service/bank-account.service.ts new file mode 100644 index 000000000..bd1721412 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/service/bank-account.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { isPresent } from 'app/core/util/operators'; +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { createRequestOption } from 'app/core/request/request-util'; +import { IBankAccount, NewBankAccount } from '../bank-account.model'; + +export type PartialUpdateBankAccount = Partial & Pick; + +export type EntityResponseType = HttpResponse; +export type EntityArrayResponseType = HttpResponse; + +@Injectable({ providedIn: 'root' }) +export class BankAccountService { + protected resourceUrl = this.applicationConfigService.getEndpointFor('api/bank-accounts'); + + constructor( + protected http: HttpClient, + protected applicationConfigService: ApplicationConfigService, + ) {} + + create(bankAccount: NewBankAccount): Observable { + return this.http.post(this.resourceUrl, bankAccount, { observe: 'response' }); + } + + update(bankAccount: IBankAccount): Observable { + return this.http.put(`${this.resourceUrl}/${this.getBankAccountIdentifier(bankAccount)}`, bankAccount, { + observe: 'response', + }); + } + + partialUpdate(bankAccount: PartialUpdateBankAccount): Observable { + return this.http.patch(`${this.resourceUrl}/${this.getBankAccountIdentifier(bankAccount)}`, bankAccount, { + observe: 'response', + }); + } + + find(id: number): Observable { + return this.http.get(`${this.resourceUrl}/${id}`, { observe: 'response' }); + } + + query(req?: any): Observable { + const options = createRequestOption(req); + return this.http.get(this.resourceUrl, { params: options, observe: 'response' }); + } + + delete(id: number): Observable> { + return this.http.delete(`${this.resourceUrl}/${id}`, { observe: 'response' }); + } + + getBankAccountIdentifier(bankAccount: Pick): number { + return bankAccount.id; + } + + compareBankAccount(o1: Pick | null, o2: Pick | null): boolean { + return o1 && o2 ? this.getBankAccountIdentifier(o1) === this.getBankAccountIdentifier(o2) : o1 === o2; + } + + addBankAccountToCollectionIfMissing>( + bankAccountCollection: Type[], + ...bankAccountsToCheck: (Type | null | undefined)[] + ): Type[] { + const bankAccounts: Type[] = bankAccountsToCheck.filter(isPresent); + if (bankAccounts.length > 0) { + const bankAccountCollectionIdentifiers = bankAccountCollection.map( + bankAccountItem => this.getBankAccountIdentifier(bankAccountItem)!, + ); + const bankAccountsToAdd = bankAccounts.filter(bankAccountItem => { + const bankAccountIdentifier = this.getBankAccountIdentifier(bankAccountItem); + if (bankAccountCollectionIdentifiers.includes(bankAccountIdentifier)) { + return false; + } + bankAccountCollectionIdentifiers.push(bankAccountIdentifier); + return true; + }); + return [...bankAccountsToAdd, ...bankAccountCollection]; + } + return bankAccountCollection; + } +} diff --git a/src/main/webapp/app/entities/bank-account/update/bank-account-form.service.spec.ts b/src/main/webapp/app/entities/bank-account/update/bank-account-form.service.spec.ts new file mode 100644 index 000000000..dce065613 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/update/bank-account-form.service.spec.ts @@ -0,0 +1,90 @@ +import { TestBed } from '@angular/core/testing'; + +import { sampleWithRequiredData, sampleWithNewData } from '../bank-account.test-samples'; + +import { BankAccountFormService } from './bank-account-form.service'; + +describe('BankAccount Form Service', () => { + let service: BankAccountFormService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(BankAccountFormService); + }); + + describe('Service methods', () => { + describe('createBankAccountFormGroup', () => { + it('should create a new form with FormControl', () => { + const formGroup = service.createBankAccountFormGroup(); + + expect(formGroup.controls).toEqual( + expect.objectContaining({ + id: expect.any(Object), + name: expect.any(Object), + balance: expect.any(Object), + user: expect.any(Object), + }), + ); + }); + + it('passing IBankAccount should create a new form with FormGroup', () => { + const formGroup = service.createBankAccountFormGroup(sampleWithRequiredData); + + expect(formGroup.controls).toEqual( + expect.objectContaining({ + id: expect.any(Object), + name: expect.any(Object), + balance: expect.any(Object), + user: expect.any(Object), + }), + ); + }); + }); + + describe('getBankAccount', () => { + it('should return NewBankAccount for default BankAccount initial value', () => { + const formGroup = service.createBankAccountFormGroup(sampleWithNewData); + + const bankAccount = service.getBankAccount(formGroup) as any; + + expect(bankAccount).toMatchObject(sampleWithNewData); + }); + + it('should return NewBankAccount for empty BankAccount initial value', () => { + const formGroup = service.createBankAccountFormGroup(); + + const bankAccount = service.getBankAccount(formGroup) as any; + + expect(bankAccount).toMatchObject({}); + }); + + it('should return IBankAccount', () => { + const formGroup = service.createBankAccountFormGroup(sampleWithRequiredData); + + const bankAccount = service.getBankAccount(formGroup) as any; + + expect(bankAccount).toMatchObject(sampleWithRequiredData); + }); + }); + + describe('resetForm', () => { + it('passing IBankAccount should not enable id FormControl', () => { + const formGroup = service.createBankAccountFormGroup(); + expect(formGroup.controls.id.disabled).toBe(true); + + service.resetForm(formGroup, sampleWithRequiredData); + + expect(formGroup.controls.id.disabled).toBe(true); + }); + + it('passing NewBankAccount should disable id FormControl', () => { + const formGroup = service.createBankAccountFormGroup(sampleWithRequiredData); + expect(formGroup.controls.id.disabled).toBe(true); + + service.resetForm(formGroup, { id: null }); + + expect(formGroup.controls.id.disabled).toBe(true); + }); + }); + }); +}); diff --git a/src/main/webapp/app/entities/bank-account/update/bank-account-form.service.ts b/src/main/webapp/app/entities/bank-account/update/bank-account-form.service.ts new file mode 100644 index 000000000..a5d766486 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/update/bank-account-form.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; + +import { IBankAccount, NewBankAccount } from '../bank-account.model'; + +/** + * A partial Type with required key is used as form input. + */ +type PartialWithRequiredKeyOf = Partial> & { id: T['id'] }; + +/** + * Type for createFormGroup and resetForm argument. + * It accepts IBankAccount for edit and NewBankAccountFormGroupInput for create. + */ +type BankAccountFormGroupInput = IBankAccount | PartialWithRequiredKeyOf; + +type BankAccountFormDefaults = Pick; + +type BankAccountFormGroupContent = { + id: FormControl; + name: FormControl; + balance: FormControl; + user: FormControl; +}; + +export type BankAccountFormGroup = FormGroup; + +@Injectable({ providedIn: 'root' }) +export class BankAccountFormService { + createBankAccountFormGroup(bankAccount: BankAccountFormGroupInput = { id: null }): BankAccountFormGroup { + const bankAccountRawValue = { + ...this.getFormDefaults(), + ...bankAccount, + }; + return new FormGroup({ + id: new FormControl( + { value: bankAccountRawValue.id, disabled: true }, + { + nonNullable: true, + validators: [Validators.required], + }, + ), + name: new FormControl(bankAccountRawValue.name, { + validators: [Validators.required], + }), + balance: new FormControl(bankAccountRawValue.balance, { + validators: [Validators.required], + }), + user: new FormControl(bankAccountRawValue.user), + }); + } + + getBankAccount(form: BankAccountFormGroup): IBankAccount | NewBankAccount { + return form.getRawValue() as IBankAccount | NewBankAccount; + } + + resetForm(form: BankAccountFormGroup, bankAccount: BankAccountFormGroupInput): void { + const bankAccountRawValue = { ...this.getFormDefaults(), ...bankAccount }; + form.reset( + { + ...bankAccountRawValue, + id: { value: bankAccountRawValue.id, disabled: true }, + } as any /* cast to workaround https://github.com/angular/angular/issues/46458 */, + ); + } + + private getFormDefaults(): BankAccountFormDefaults { + return { + id: null, + }; + } +} diff --git a/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.html b/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.html new file mode 100644 index 000000000..b4493fe80 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.html @@ -0,0 +1,77 @@ +
+
+
+

+ Create or edit a Bank Account +

+ +
+ + +
+ + +
+ +
+ + +
+ + This field is required. + +
+
+ +
+ + +
+ + This field is required. + + + This field should be a number. + +
+
+ +
+ + +
+
+ +
+ + + +
+
+
+
diff --git a/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.spec.ts b/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.spec.ts new file mode 100644 index 000000000..5fa642ac3 --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.spec.ts @@ -0,0 +1,166 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, Subject, from } from 'rxjs'; + +import { IUser } from 'app/entities/user/user.model'; +import { UserService } from 'app/entities/user/user.service'; +import { BankAccountService } from '../service/bank-account.service'; +import { IBankAccount } from '../bank-account.model'; + +import { BankAccountFormService } from './bank-account-form.service'; + +import { BankAccountUpdateComponent } from './bank-account-update.component'; + +describe('BankAccount Management Update Component', () => { + let comp: BankAccountUpdateComponent; + let fixture: ComponentFixture; + let activatedRoute: ActivatedRoute; + let bankAccountFormService: BankAccountFormService; + let bankAccountService: BankAccountService; + let userService: UserService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([]), BankAccountUpdateComponent], + providers: [ + FormBuilder, + { + provide: ActivatedRoute, + useValue: { + params: from([{}]), + }, + }, + ], + }) + .overrideTemplate(BankAccountUpdateComponent, '') + .compileComponents(); + + fixture = TestBed.createComponent(BankAccountUpdateComponent); + activatedRoute = TestBed.inject(ActivatedRoute); + bankAccountFormService = TestBed.inject(BankAccountFormService); + bankAccountService = TestBed.inject(BankAccountService); + userService = TestBed.inject(UserService); + + comp = fixture.componentInstance; + }); + + describe('ngOnInit', () => { + it('Should call User query and add missing value', () => { + const bankAccount: IBankAccount = { id: 456 }; + const user: IUser = { id: 3995 }; + bankAccount.user = user; + + const userCollection: IUser[] = [{ id: 28177 }]; + jest.spyOn(userService, 'query').mockReturnValue(of(new HttpResponse({ body: userCollection }))); + const additionalUsers = [user]; + const expectedCollection: IUser[] = [...additionalUsers, ...userCollection]; + jest.spyOn(userService, 'addUserToCollectionIfMissing').mockReturnValue(expectedCollection); + + activatedRoute.data = of({ bankAccount }); + comp.ngOnInit(); + + expect(userService.query).toHaveBeenCalled(); + expect(userService.addUserToCollectionIfMissing).toHaveBeenCalledWith( + userCollection, + ...additionalUsers.map(expect.objectContaining), + ); + expect(comp.usersSharedCollection).toEqual(expectedCollection); + }); + + it('Should update editForm', () => { + const bankAccount: IBankAccount = { id: 456 }; + const user: IUser = { id: 7253 }; + bankAccount.user = user; + + activatedRoute.data = of({ bankAccount }); + comp.ngOnInit(); + + expect(comp.usersSharedCollection).toContain(user); + expect(comp.bankAccount).toEqual(bankAccount); + }); + }); + + describe('save', () => { + it('Should call update service on save for existing entity', () => { + // GIVEN + const saveSubject = new Subject>(); + const bankAccount = { id: 123 }; + jest.spyOn(bankAccountFormService, 'getBankAccount').mockReturnValue(bankAccount); + jest.spyOn(bankAccountService, 'update').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ bankAccount }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.next(new HttpResponse({ body: bankAccount })); + saveSubject.complete(); + + // THEN + expect(bankAccountFormService.getBankAccount).toHaveBeenCalled(); + expect(comp.previousState).toHaveBeenCalled(); + expect(bankAccountService.update).toHaveBeenCalledWith(expect.objectContaining(bankAccount)); + expect(comp.isSaving).toEqual(false); + }); + + it('Should call create service on save for new entity', () => { + // GIVEN + const saveSubject = new Subject>(); + const bankAccount = { id: 123 }; + jest.spyOn(bankAccountFormService, 'getBankAccount').mockReturnValue({ id: null }); + jest.spyOn(bankAccountService, 'create').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ bankAccount: null }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.next(new HttpResponse({ body: bankAccount })); + saveSubject.complete(); + + // THEN + expect(bankAccountFormService.getBankAccount).toHaveBeenCalled(); + expect(bankAccountService.create).toHaveBeenCalled(); + expect(comp.isSaving).toEqual(false); + expect(comp.previousState).toHaveBeenCalled(); + }); + + it('Should set isSaving to false on error', () => { + // GIVEN + const saveSubject = new Subject>(); + const bankAccount = { id: 123 }; + jest.spyOn(bankAccountService, 'update').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ bankAccount }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.error('This is an error!'); + + // THEN + expect(bankAccountService.update).toHaveBeenCalled(); + expect(comp.isSaving).toEqual(false); + expect(comp.previousState).not.toHaveBeenCalled(); + }); + }); + + describe('Compare relationships', () => { + describe('compareUser', () => { + it('Should forward to userService', () => { + const entity = { id: 123 }; + const entity2 = { id: 456 }; + jest.spyOn(userService, 'compareUser'); + comp.compareUser(entity, entity2); + expect(userService.compareUser).toHaveBeenCalledWith(entity, entity2); + }); + }); + }); +}); diff --git a/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.ts b/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.ts new file mode 100644 index 000000000..7af20632c --- /dev/null +++ b/src/main/webapp/app/entities/bank-account/update/bank-account-update.component.ts @@ -0,0 +1,97 @@ +import { Component, OnInit } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { finalize, map } from 'rxjs/operators'; + +import SharedModule from 'app/shared/shared.module'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { IUser } from 'app/entities/user/user.model'; +import { UserService } from 'app/entities/user/user.service'; +import { IBankAccount } from '../bank-account.model'; +import { BankAccountService } from '../service/bank-account.service'; +import { BankAccountFormService, BankAccountFormGroup } from './bank-account-form.service'; + +@Component({ + standalone: true, + selector: 'jhi-bank-account-update', + templateUrl: './bank-account-update.component.html', + imports: [SharedModule, FormsModule, ReactiveFormsModule], +}) +export class BankAccountUpdateComponent implements OnInit { + isSaving = false; + bankAccount: IBankAccount | null = null; + + usersSharedCollection: IUser[] = []; + + editForm: BankAccountFormGroup = this.bankAccountFormService.createBankAccountFormGroup(); + + constructor( + protected bankAccountService: BankAccountService, + protected bankAccountFormService: BankAccountFormService, + protected userService: UserService, + protected activatedRoute: ActivatedRoute, + ) {} + + compareUser = (o1: IUser | null, o2: IUser | null): boolean => this.userService.compareUser(o1, o2); + + ngOnInit(): void { + this.activatedRoute.data.subscribe(({ bankAccount }) => { + this.bankAccount = bankAccount; + if (bankAccount) { + this.updateForm(bankAccount); + } + + this.loadRelationshipsOptions(); + }); + } + + previousState(): void { + window.history.back(); + } + + save(): void { + this.isSaving = true; + const bankAccount = this.bankAccountFormService.getBankAccount(this.editForm); + if (bankAccount.id !== null) { + this.subscribeToSaveResponse(this.bankAccountService.update(bankAccount)); + } else { + this.subscribeToSaveResponse(this.bankAccountService.create(bankAccount)); + } + } + + protected subscribeToSaveResponse(result: Observable>): void { + result.pipe(finalize(() => this.onSaveFinalize())).subscribe({ + next: () => this.onSaveSuccess(), + error: () => this.onSaveError(), + }); + } + + protected onSaveSuccess(): void { + this.previousState(); + } + + protected onSaveError(): void { + // Api for inheritance. + } + + protected onSaveFinalize(): void { + this.isSaving = false; + } + + protected updateForm(bankAccount: IBankAccount): void { + this.bankAccount = bankAccount; + this.bankAccountFormService.resetForm(this.editForm, bankAccount); + + this.usersSharedCollection = this.userService.addUserToCollectionIfMissing(this.usersSharedCollection, bankAccount.user); + } + + protected loadRelationshipsOptions(): void { + this.userService + .query() + .pipe(map((res: HttpResponse) => res.body ?? [])) + .pipe(map((users: IUser[]) => this.userService.addUserToCollectionIfMissing(users, this.bankAccount?.user))) + .subscribe((users: IUser[]) => (this.usersSharedCollection = users)); + } +} diff --git a/src/main/webapp/app/entities/entity-navbar-items.ts b/src/main/webapp/app/entities/entity-navbar-items.ts new file mode 100644 index 000000000..7308848db --- /dev/null +++ b/src/main/webapp/app/entities/entity-navbar-items.ts @@ -0,0 +1,19 @@ +import NavbarItem from 'app/layouts/navbar/navbar-item.model'; + +export const EntityNavbarItems: NavbarItem[] = [ + { + name: 'BankAccount', + route: '/bank-account', + translationKey: 'global.menu.entities.bankAccount', + }, + { + name: 'Label', + route: '/label', + translationKey: 'global.menu.entities.label', + }, + { + name: 'Operation', + route: '/operation', + translationKey: 'global.menu.entities.operation', + }, +]; diff --git a/src/main/webapp/app/entities/entity-routing.module.ts b/src/main/webapp/app/entities/entity-routing.module.ts new file mode 100644 index 000000000..386cea95f --- /dev/null +++ b/src/main/webapp/app/entities/entity-routing.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: 'bank-account', + data: { pageTitle: 'jhipsterSampleApplicationApp.bankAccount.home.title' }, + loadChildren: () => import('./bank-account/bank-account.routes'), + }, + { + path: 'label', + data: { pageTitle: 'jhipsterSampleApplicationApp.label.home.title' }, + loadChildren: () => import('./label/label.routes'), + }, + { + path: 'operation', + data: { pageTitle: 'jhipsterSampleApplicationApp.operation.home.title' }, + loadChildren: () => import('./operation/operation.routes'), + }, + /* jhipster-needle-add-entity-route - JHipster will add entity modules routes here */ + ]), + ], +}) +export class EntityRoutingModule {} diff --git a/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.html b/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.html new file mode 100644 index 000000000..5cebedaf1 --- /dev/null +++ b/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.html @@ -0,0 +1,24 @@ +
+ + + + + +
diff --git a/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.spec.ts b/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.spec.ts new file mode 100644 index 000000000..774a1637b --- /dev/null +++ b/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.spec.ts @@ -0,0 +1,62 @@ +jest.mock('@ng-bootstrap/ng-bootstrap'); + +import { ComponentFixture, TestBed, inject, fakeAsync, tick } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { LabelService } from '../service/label.service'; + +import { LabelDeleteDialogComponent } from './label-delete-dialog.component'; + +describe('Label Management Delete Component', () => { + let comp: LabelDeleteDialogComponent; + let fixture: ComponentFixture; + let service: LabelService; + let mockActiveModal: NgbActiveModal; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, LabelDeleteDialogComponent], + providers: [NgbActiveModal], + }) + .overrideTemplate(LabelDeleteDialogComponent, '') + .compileComponents(); + fixture = TestBed.createComponent(LabelDeleteDialogComponent); + comp = fixture.componentInstance; + service = TestBed.inject(LabelService); + mockActiveModal = TestBed.inject(NgbActiveModal); + }); + + describe('confirmDelete', () => { + it('Should call delete service on confirmDelete', inject( + [], + fakeAsync(() => { + // GIVEN + jest.spyOn(service, 'delete').mockReturnValue(of(new HttpResponse({ body: {} }))); + + // WHEN + comp.confirmDelete(123); + tick(); + + // THEN + expect(service.delete).toHaveBeenCalledWith(123); + expect(mockActiveModal.close).toHaveBeenCalledWith('deleted'); + }), + )); + + it('Should not call delete service on clear', () => { + // GIVEN + jest.spyOn(service, 'delete'); + + // WHEN + comp.cancel(); + + // THEN + expect(service.delete).not.toHaveBeenCalled(); + expect(mockActiveModal.close).not.toHaveBeenCalled(); + expect(mockActiveModal.dismiss).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.ts b/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.ts new file mode 100644 index 000000000..6b5dc1306 --- /dev/null +++ b/src/main/webapp/app/entities/label/delete/label-delete-dialog.component.ts @@ -0,0 +1,32 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { ITEM_DELETED_EVENT } from 'app/config/navigation.constants'; +import { ILabel } from '../label.model'; +import { LabelService } from '../service/label.service'; + +@Component({ + standalone: true, + templateUrl: './label-delete-dialog.component.html', + imports: [SharedModule, FormsModule], +}) +export class LabelDeleteDialogComponent { + label?: ILabel; + + constructor( + protected labelService: LabelService, + protected activeModal: NgbActiveModal, + ) {} + + cancel(): void { + this.activeModal.dismiss(); + } + + confirmDelete(id: number): void { + this.labelService.delete(id).subscribe(() => { + this.activeModal.close(ITEM_DELETED_EVENT); + }); + } +} diff --git a/src/main/webapp/app/entities/label/detail/label-detail.component.html b/src/main/webapp/app/entities/label/detail/label-detail.component.html new file mode 100644 index 000000000..09d132da2 --- /dev/null +++ b/src/main/webapp/app/entities/label/detail/label-detail.component.html @@ -0,0 +1,32 @@ +
+
+
+

Label

+ +
+ + + + + +
+
ID
+
+ {{ label.id }} +
+
Label
+
+ {{ label.label }} +
+
+ + + + +
+
+
diff --git a/src/main/webapp/app/entities/label/detail/label-detail.component.spec.ts b/src/main/webapp/app/entities/label/detail/label-detail.component.spec.ts new file mode 100644 index 000000000..60c25167d --- /dev/null +++ b/src/main/webapp/app/entities/label/detail/label-detail.component.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { RouterTestingHarness, RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { LabelDetailComponent } from './label-detail.component'; + +describe('Label Management Detail Component', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LabelDetailComponent, RouterTestingModule.withRoutes([], { bindToComponentInputs: true })], + providers: [ + provideRouter( + [ + { + path: '**', + component: LabelDetailComponent, + resolve: { label: () => of({ id: 123 }) }, + }, + ], + withComponentInputBinding(), + ), + ], + }) + .overrideTemplate(LabelDetailComponent, '') + .compileComponents(); + }); + + describe('OnInit', () => { + it('Should load label on init', async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/', LabelDetailComponent); + + // THEN + expect(instance.label).toEqual(expect.objectContaining({ id: 123 })); + }); + }); +}); diff --git a/src/main/webapp/app/entities/label/detail/label-detail.component.ts b/src/main/webapp/app/entities/label/detail/label-detail.component.ts new file mode 100644 index 000000000..26c7a6301 --- /dev/null +++ b/src/main/webapp/app/entities/label/detail/label-detail.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; + +import SharedModule from 'app/shared/shared.module'; +import { DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe } from 'app/shared/date'; +import { ILabel } from '../label.model'; + +@Component({ + standalone: true, + selector: 'jhi-label-detail', + templateUrl: './label-detail.component.html', + imports: [SharedModule, RouterModule, DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe], +}) +export class LabelDetailComponent { + @Input() label: ILabel | null = null; + + constructor(protected activatedRoute: ActivatedRoute) {} + + previousState(): void { + window.history.back(); + } +} diff --git a/src/main/webapp/app/entities/label/label.model.ts b/src/main/webapp/app/entities/label/label.model.ts new file mode 100644 index 000000000..54468d7de --- /dev/null +++ b/src/main/webapp/app/entities/label/label.model.ts @@ -0,0 +1,9 @@ +import { IOperation } from 'app/entities/operation/operation.model'; + +export interface ILabel { + id: number; + label?: string | null; + operations?: Pick[] | null; +} + +export type NewLabel = Omit & { id: null }; diff --git a/src/main/webapp/app/entities/label/label.routes.ts b/src/main/webapp/app/entities/label/label.routes.ts new file mode 100644 index 000000000..d11317483 --- /dev/null +++ b/src/main/webapp/app/entities/label/label.routes.ts @@ -0,0 +1,45 @@ +import { Routes } from '@angular/router'; + +import { UserRouteAccessService } from 'app/core/auth/user-route-access.service'; +import { ASC } from 'app/config/navigation.constants'; +import { LabelComponent } from './list/label.component'; +import { LabelDetailComponent } from './detail/label-detail.component'; +import { LabelUpdateComponent } from './update/label-update.component'; +import LabelResolve from './route/label-routing-resolve.service'; + +const labelRoute: Routes = [ + { + path: '', + component: LabelComponent, + data: { + defaultSort: 'id,' + ASC, + }, + canActivate: [UserRouteAccessService], + }, + { + path: ':id/view', + component: LabelDetailComponent, + resolve: { + label: LabelResolve, + }, + canActivate: [UserRouteAccessService], + }, + { + path: 'new', + component: LabelUpdateComponent, + resolve: { + label: LabelResolve, + }, + canActivate: [UserRouteAccessService], + }, + { + path: ':id/edit', + component: LabelUpdateComponent, + resolve: { + label: LabelResolve, + }, + canActivate: [UserRouteAccessService], + }, +]; + +export default labelRoute; diff --git a/src/main/webapp/app/entities/label/label.test-samples.ts b/src/main/webapp/app/entities/label/label.test-samples.ts new file mode 100644 index 000000000..8ca420de1 --- /dev/null +++ b/src/main/webapp/app/entities/label/label.test-samples.ts @@ -0,0 +1,26 @@ +import { ILabel, NewLabel } from './label.model'; + +export const sampleWithRequiredData: ILabel = { + id: 3315, + label: 'deceivingly', +}; + +export const sampleWithPartialData: ILabel = { + id: 5191, + label: 'greens lever', +}; + +export const sampleWithFullData: ILabel = { + id: 15375, + label: 'ha imitation', +}; + +export const sampleWithNewData: NewLabel = { + label: 'because unto', + id: null, +}; + +Object.freeze(sampleWithNewData); +Object.freeze(sampleWithRequiredData); +Object.freeze(sampleWithPartialData); +Object.freeze(sampleWithFullData); diff --git a/src/main/webapp/app/entities/label/list/label.component.html b/src/main/webapp/app/entities/label/list/label.component.html new file mode 100644 index 000000000..d0b98967b --- /dev/null +++ b/src/main/webapp/app/entities/label/list/label.component.html @@ -0,0 +1,78 @@ +
+

+ Labels + +
+ + + +
+

+ + + + + +
+ No Labels found +
+ +
+ + + + + + + + + + + + + + + +
+
+ ID + +
+
+
+ Label + +
+
+ {{ label.id }} + {{ label.label }} +
+ + + + + +
+
+
+
diff --git a/src/main/webapp/app/entities/label/list/label.component.spec.ts b/src/main/webapp/app/entities/label/list/label.component.spec.ts new file mode 100644 index 000000000..150aebee5 --- /dev/null +++ b/src/main/webapp/app/entities/label/list/label.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { LabelService } from '../service/label.service'; + +import { LabelComponent } from './label.component'; + +describe('Label Management Component', () => { + let comp: LabelComponent; + let fixture: ComponentFixture; + let service: LabelService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([{ path: 'label', component: LabelComponent }]), HttpClientTestingModule, LabelComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + data: of({ + defaultSort: 'id,asc', + }), + queryParamMap: of( + jest.requireActual('@angular/router').convertToParamMap({ + page: '1', + size: '1', + sort: 'id,desc', + }), + ), + snapshot: { queryParams: {} }, + }, + }, + ], + }) + .overrideTemplate(LabelComponent, '') + .compileComponents(); + + fixture = TestBed.createComponent(LabelComponent); + comp = fixture.componentInstance; + service = TestBed.inject(LabelService); + + const headers = new HttpHeaders(); + jest.spyOn(service, 'query').mockReturnValue( + of( + new HttpResponse({ + body: [{ id: 123 }], + headers, + }), + ), + ); + }); + + it('Should call load all on init', () => { + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.query).toHaveBeenCalled(); + expect(comp.labels?.[0]).toEqual(expect.objectContaining({ id: 123 })); + }); + + describe('trackId', () => { + it('Should forward to labelService', () => { + const entity = { id: 123 }; + jest.spyOn(service, 'getLabelIdentifier'); + const id = comp.trackId(0, entity); + expect(service.getLabelIdentifier).toHaveBeenCalledWith(entity); + expect(id).toBe(entity.id); + }); + }); +}); diff --git a/src/main/webapp/app/entities/label/list/label.component.ts b/src/main/webapp/app/entities/label/list/label.component.ts new file mode 100644 index 000000000..aafcae265 --- /dev/null +++ b/src/main/webapp/app/entities/label/list/label.component.ts @@ -0,0 +1,133 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Data, ParamMap, Router, RouterModule } from '@angular/router'; +import { combineLatest, filter, Observable, switchMap, tap } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { SortDirective, SortByDirective } from 'app/shared/sort'; +import { DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe } from 'app/shared/date'; +import { FormsModule } from '@angular/forms'; +import { ASC, DESC, SORT, ITEM_DELETED_EVENT, DEFAULT_SORT_DATA } from 'app/config/navigation.constants'; +import { SortService } from 'app/shared/sort/sort.service'; +import { ILabel } from '../label.model'; +import { EntityArrayResponseType, LabelService } from '../service/label.service'; +import { LabelDeleteDialogComponent } from '../delete/label-delete-dialog.component'; + +@Component({ + standalone: true, + selector: 'jhi-label', + templateUrl: './label.component.html', + imports: [ + RouterModule, + FormsModule, + SharedModule, + SortDirective, + SortByDirective, + DurationPipe, + FormatMediumDatetimePipe, + FormatMediumDatePipe, + ], +}) +export class LabelComponent implements OnInit { + labels?: ILabel[]; + isLoading = false; + + predicate = 'id'; + ascending = true; + + constructor( + protected labelService: LabelService, + protected activatedRoute: ActivatedRoute, + public router: Router, + protected sortService: SortService, + protected modalService: NgbModal, + ) {} + + trackId = (_index: number, item: ILabel): number => this.labelService.getLabelIdentifier(item); + + ngOnInit(): void { + this.load(); + } + + delete(label: ILabel): void { + const modalRef = this.modalService.open(LabelDeleteDialogComponent, { size: 'lg', backdrop: 'static' }); + modalRef.componentInstance.label = label; + // unsubscribe not needed because closed completes on modal close + modalRef.closed + .pipe( + filter(reason => reason === ITEM_DELETED_EVENT), + switchMap(() => this.loadFromBackendWithRouteInformations()), + ) + .subscribe({ + next: (res: EntityArrayResponseType) => { + this.onResponseSuccess(res); + }, + }); + } + + load(): void { + this.loadFromBackendWithRouteInformations().subscribe({ + next: (res: EntityArrayResponseType) => { + this.onResponseSuccess(res); + }, + }); + } + + navigateToWithComponentValues(): void { + this.handleNavigation(this.predicate, this.ascending); + } + + protected loadFromBackendWithRouteInformations(): Observable { + return combineLatest([this.activatedRoute.queryParamMap, this.activatedRoute.data]).pipe( + tap(([params, data]) => this.fillComponentAttributeFromRoute(params, data)), + switchMap(() => this.queryBackend(this.predicate, this.ascending)), + ); + } + + protected fillComponentAttributeFromRoute(params: ParamMap, data: Data): void { + const sort = (params.get(SORT) ?? data[DEFAULT_SORT_DATA]).split(','); + this.predicate = sort[0]; + this.ascending = sort[1] === ASC; + } + + protected onResponseSuccess(response: EntityArrayResponseType): void { + const dataFromBody = this.fillComponentAttributesFromResponseBody(response.body); + this.labels = this.refineData(dataFromBody); + } + + protected refineData(data: ILabel[]): ILabel[] { + return data.sort(this.sortService.startSort(this.predicate, this.ascending ? 1 : -1)); + } + + protected fillComponentAttributesFromResponseBody(data: ILabel[] | null): ILabel[] { + return data ?? []; + } + + protected queryBackend(predicate?: string, ascending?: boolean): Observable { + this.isLoading = true; + const queryObject: any = { + sort: this.getSortQueryParam(predicate, ascending), + }; + return this.labelService.query(queryObject).pipe(tap(() => (this.isLoading = false))); + } + + protected handleNavigation(predicate?: string, ascending?: boolean): void { + const queryParamsObj = { + sort: this.getSortQueryParam(predicate, ascending), + }; + + this.router.navigate(['./'], { + relativeTo: this.activatedRoute, + queryParams: queryParamsObj, + }); + } + + protected getSortQueryParam(predicate = this.predicate, ascending = this.ascending): string[] { + const ascendingQueryParam = ascending ? ASC : DESC; + if (predicate === '') { + return []; + } else { + return [predicate + ',' + ascendingQueryParam]; + } + } +} diff --git a/src/main/webapp/app/entities/label/route/label-routing-resolve.service.spec.ts b/src/main/webapp/app/entities/label/route/label-routing-resolve.service.spec.ts new file mode 100644 index 000000000..e339e7da1 --- /dev/null +++ b/src/main/webapp/app/entities/label/route/label-routing-resolve.service.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRouteSnapshot, ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { ILabel } from '../label.model'; +import { LabelService } from '../service/label.service'; + +import labelResolve from './label-routing-resolve.service'; + +describe('Label routing resolve service', () => { + let mockRouter: Router; + let mockActivatedRouteSnapshot: ActivatedRouteSnapshot; + let service: LabelService; + let resultLabel: ILabel | null | undefined; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([])], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({}), + }, + }, + }, + ], + }); + mockRouter = TestBed.inject(Router); + jest.spyOn(mockRouter, 'navigate').mockImplementation(() => Promise.resolve(true)); + mockActivatedRouteSnapshot = TestBed.inject(ActivatedRoute).snapshot; + service = TestBed.inject(LabelService); + resultLabel = undefined; + }); + + describe('resolve', () => { + it('should return ILabel returned by find', () => { + // GIVEN + service.find = jest.fn(id => of(new HttpResponse({ body: { id } }))); + mockActivatedRouteSnapshot.params = { id: 123 }; + + // WHEN + TestBed.runInInjectionContext(() => { + labelResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultLabel = result; + }, + }); + }); + + // THEN + expect(service.find).toBeCalledWith(123); + expect(resultLabel).toEqual({ id: 123 }); + }); + + it('should return null if id is not provided', () => { + // GIVEN + service.find = jest.fn(); + mockActivatedRouteSnapshot.params = {}; + + // WHEN + TestBed.runInInjectionContext(() => { + labelResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultLabel = result; + }, + }); + }); + + // THEN + expect(service.find).not.toBeCalled(); + expect(resultLabel).toEqual(null); + }); + + it('should route to 404 page if data not found in server', () => { + // GIVEN + jest.spyOn(service, 'find').mockReturnValue(of(new HttpResponse({ body: null }))); + mockActivatedRouteSnapshot.params = { id: 123 }; + + // WHEN + TestBed.runInInjectionContext(() => { + labelResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultLabel = result; + }, + }); + }); + + // THEN + expect(service.find).toBeCalledWith(123); + expect(resultLabel).toEqual(undefined); + expect(mockRouter.navigate).toHaveBeenCalledWith(['404']); + }); + }); +}); diff --git a/src/main/webapp/app/entities/label/route/label-routing-resolve.service.ts b/src/main/webapp/app/entities/label/route/label-routing-resolve.service.ts new file mode 100644 index 000000000..32528969f --- /dev/null +++ b/src/main/webapp/app/entities/label/route/label-routing-resolve.service.ts @@ -0,0 +1,29 @@ +import { inject } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { of, EMPTY, Observable } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +import { ILabel } from '../label.model'; +import { LabelService } from '../service/label.service'; + +export const labelResolve = (route: ActivatedRouteSnapshot): Observable => { + const id = route.params['id']; + if (id) { + return inject(LabelService) + .find(id) + .pipe( + mergeMap((label: HttpResponse) => { + if (label.body) { + return of(label.body); + } else { + inject(Router).navigate(['404']); + return EMPTY; + } + }), + ); + } + return of(null); +}; + +export default labelResolve; diff --git a/src/main/webapp/app/entities/label/service/label.service.spec.ts b/src/main/webapp/app/entities/label/service/label.service.spec.ts new file mode 100644 index 000000000..bcf4175bc --- /dev/null +++ b/src/main/webapp/app/entities/label/service/label.service.spec.ts @@ -0,0 +1,204 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ILabel } from '../label.model'; +import { sampleWithRequiredData, sampleWithNewData, sampleWithPartialData, sampleWithFullData } from '../label.test-samples'; + +import { LabelService } from './label.service'; + +const requireRestSample: ILabel = { + ...sampleWithRequiredData, +}; + +describe('Label Service', () => { + let service: LabelService; + let httpMock: HttpTestingController; + let expectedResult: ILabel | ILabel[] | boolean | null; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + expectedResult = null; + service = TestBed.inject(LabelService); + httpMock = TestBed.inject(HttpTestingController); + }); + + describe('Service methods', () => { + it('should find an element', () => { + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.find(123).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should create a Label', () => { + const label = { ...sampleWithNewData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.create(label).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'POST' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should update a Label', () => { + const label = { ...sampleWithRequiredData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.update(label).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'PUT' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should partial update a Label', () => { + const patchObject = { ...sampleWithPartialData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.partialUpdate(patchObject).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'PATCH' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should return a list of Label', () => { + const returnedFromService = { ...requireRestSample }; + + const expected = { ...sampleWithRequiredData }; + + service.query().subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush([returnedFromService]); + httpMock.verify(); + expect(expectedResult).toMatchObject([expected]); + }); + + it('should delete a Label', () => { + const expected = true; + + service.delete(123).subscribe(resp => (expectedResult = resp.ok)); + + const req = httpMock.expectOne({ method: 'DELETE' }); + req.flush({ status: 200 }); + expect(expectedResult).toBe(expected); + }); + + describe('addLabelToCollectionIfMissing', () => { + it('should add a Label to an empty array', () => { + const label: ILabel = sampleWithRequiredData; + expectedResult = service.addLabelToCollectionIfMissing([], label); + expect(expectedResult).toHaveLength(1); + expect(expectedResult).toContain(label); + }); + + it('should not add a Label to an array that contains it', () => { + const label: ILabel = sampleWithRequiredData; + const labelCollection: ILabel[] = [ + { + ...label, + }, + sampleWithPartialData, + ]; + expectedResult = service.addLabelToCollectionIfMissing(labelCollection, label); + expect(expectedResult).toHaveLength(2); + }); + + it("should add a Label to an array that doesn't contain it", () => { + const label: ILabel = sampleWithRequiredData; + const labelCollection: ILabel[] = [sampleWithPartialData]; + expectedResult = service.addLabelToCollectionIfMissing(labelCollection, label); + expect(expectedResult).toHaveLength(2); + expect(expectedResult).toContain(label); + }); + + it('should add only unique Label to an array', () => { + const labelArray: ILabel[] = [sampleWithRequiredData, sampleWithPartialData, sampleWithFullData]; + const labelCollection: ILabel[] = [sampleWithRequiredData]; + expectedResult = service.addLabelToCollectionIfMissing(labelCollection, ...labelArray); + expect(expectedResult).toHaveLength(3); + }); + + it('should accept varargs', () => { + const label: ILabel = sampleWithRequiredData; + const label2: ILabel = sampleWithPartialData; + expectedResult = service.addLabelToCollectionIfMissing([], label, label2); + expect(expectedResult).toHaveLength(2); + expect(expectedResult).toContain(label); + expect(expectedResult).toContain(label2); + }); + + it('should accept null and undefined values', () => { + const label: ILabel = sampleWithRequiredData; + expectedResult = service.addLabelToCollectionIfMissing([], null, label, undefined); + expect(expectedResult).toHaveLength(1); + expect(expectedResult).toContain(label); + }); + + it('should return initial array if no Label is added', () => { + const labelCollection: ILabel[] = [sampleWithRequiredData]; + expectedResult = service.addLabelToCollectionIfMissing(labelCollection, undefined, null); + expect(expectedResult).toEqual(labelCollection); + }); + }); + + describe('compareLabel', () => { + it('Should return true if both entities are null', () => { + const entity1 = null; + const entity2 = null; + + const compareResult = service.compareLabel(entity1, entity2); + + expect(compareResult).toEqual(true); + }); + + it('Should return false if one entity is null', () => { + const entity1 = { id: 123 }; + const entity2 = null; + + const compareResult1 = service.compareLabel(entity1, entity2); + const compareResult2 = service.compareLabel(entity2, entity1); + + expect(compareResult1).toEqual(false); + expect(compareResult2).toEqual(false); + }); + + it('Should return false if primaryKey differs', () => { + const entity1 = { id: 123 }; + const entity2 = { id: 456 }; + + const compareResult1 = service.compareLabel(entity1, entity2); + const compareResult2 = service.compareLabel(entity2, entity1); + + expect(compareResult1).toEqual(false); + expect(compareResult2).toEqual(false); + }); + + it('Should return false if primaryKey matches', () => { + const entity1 = { id: 123 }; + const entity2 = { id: 123 }; + + const compareResult1 = service.compareLabel(entity1, entity2); + const compareResult2 = service.compareLabel(entity2, entity1); + + expect(compareResult1).toEqual(true); + expect(compareResult2).toEqual(true); + }); + }); + }); + + afterEach(() => { + httpMock.verify(); + }); +}); diff --git a/src/main/webapp/app/entities/label/service/label.service.ts b/src/main/webapp/app/entities/label/service/label.service.ts new file mode 100644 index 000000000..e72eeff52 --- /dev/null +++ b/src/main/webapp/app/entities/label/service/label.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { isPresent } from 'app/core/util/operators'; +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { createRequestOption } from 'app/core/request/request-util'; +import { ILabel, NewLabel } from '../label.model'; + +export type PartialUpdateLabel = Partial & Pick; + +export type EntityResponseType = HttpResponse; +export type EntityArrayResponseType = HttpResponse; + +@Injectable({ providedIn: 'root' }) +export class LabelService { + protected resourceUrl = this.applicationConfigService.getEndpointFor('api/labels'); + + constructor( + protected http: HttpClient, + protected applicationConfigService: ApplicationConfigService, + ) {} + + create(label: NewLabel): Observable { + return this.http.post(this.resourceUrl, label, { observe: 'response' }); + } + + update(label: ILabel): Observable { + return this.http.put(`${this.resourceUrl}/${this.getLabelIdentifier(label)}`, label, { observe: 'response' }); + } + + partialUpdate(label: PartialUpdateLabel): Observable { + return this.http.patch(`${this.resourceUrl}/${this.getLabelIdentifier(label)}`, label, { observe: 'response' }); + } + + find(id: number): Observable { + return this.http.get(`${this.resourceUrl}/${id}`, { observe: 'response' }); + } + + query(req?: any): Observable { + const options = createRequestOption(req); + return this.http.get(this.resourceUrl, { params: options, observe: 'response' }); + } + + delete(id: number): Observable> { + return this.http.delete(`${this.resourceUrl}/${id}`, { observe: 'response' }); + } + + getLabelIdentifier(label: Pick): number { + return label.id; + } + + compareLabel(o1: Pick | null, o2: Pick | null): boolean { + return o1 && o2 ? this.getLabelIdentifier(o1) === this.getLabelIdentifier(o2) : o1 === o2; + } + + addLabelToCollectionIfMissing>( + labelCollection: Type[], + ...labelsToCheck: (Type | null | undefined)[] + ): Type[] { + const labels: Type[] = labelsToCheck.filter(isPresent); + if (labels.length > 0) { + const labelCollectionIdentifiers = labelCollection.map(labelItem => this.getLabelIdentifier(labelItem)!); + const labelsToAdd = labels.filter(labelItem => { + const labelIdentifier = this.getLabelIdentifier(labelItem); + if (labelCollectionIdentifiers.includes(labelIdentifier)) { + return false; + } + labelCollectionIdentifiers.push(labelIdentifier); + return true; + }); + return [...labelsToAdd, ...labelCollection]; + } + return labelCollection; + } +} diff --git a/src/main/webapp/app/entities/label/update/label-form.service.spec.ts b/src/main/webapp/app/entities/label/update/label-form.service.spec.ts new file mode 100644 index 000000000..abbdf6a95 --- /dev/null +++ b/src/main/webapp/app/entities/label/update/label-form.service.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; + +import { sampleWithRequiredData, sampleWithNewData } from '../label.test-samples'; + +import { LabelFormService } from './label-form.service'; + +describe('Label Form Service', () => { + let service: LabelFormService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(LabelFormService); + }); + + describe('Service methods', () => { + describe('createLabelFormGroup', () => { + it('should create a new form with FormControl', () => { + const formGroup = service.createLabelFormGroup(); + + expect(formGroup.controls).toEqual( + expect.objectContaining({ + id: expect.any(Object), + label: expect.any(Object), + operations: expect.any(Object), + }), + ); + }); + + it('passing ILabel should create a new form with FormGroup', () => { + const formGroup = service.createLabelFormGroup(sampleWithRequiredData); + + expect(formGroup.controls).toEqual( + expect.objectContaining({ + id: expect.any(Object), + label: expect.any(Object), + operations: expect.any(Object), + }), + ); + }); + }); + + describe('getLabel', () => { + it('should return NewLabel for default Label initial value', () => { + const formGroup = service.createLabelFormGroup(sampleWithNewData); + + const label = service.getLabel(formGroup) as any; + + expect(label).toMatchObject(sampleWithNewData); + }); + + it('should return NewLabel for empty Label initial value', () => { + const formGroup = service.createLabelFormGroup(); + + const label = service.getLabel(formGroup) as any; + + expect(label).toMatchObject({}); + }); + + it('should return ILabel', () => { + const formGroup = service.createLabelFormGroup(sampleWithRequiredData); + + const label = service.getLabel(formGroup) as any; + + expect(label).toMatchObject(sampleWithRequiredData); + }); + }); + + describe('resetForm', () => { + it('passing ILabel should not enable id FormControl', () => { + const formGroup = service.createLabelFormGroup(); + expect(formGroup.controls.id.disabled).toBe(true); + + service.resetForm(formGroup, sampleWithRequiredData); + + expect(formGroup.controls.id.disabled).toBe(true); + }); + + it('passing NewLabel should disable id FormControl', () => { + const formGroup = service.createLabelFormGroup(sampleWithRequiredData); + expect(formGroup.controls.id.disabled).toBe(true); + + service.resetForm(formGroup, { id: null }); + + expect(formGroup.controls.id.disabled).toBe(true); + }); + }); + }); +}); diff --git a/src/main/webapp/app/entities/label/update/label-form.service.ts b/src/main/webapp/app/entities/label/update/label-form.service.ts new file mode 100644 index 000000000..c0c1d9796 --- /dev/null +++ b/src/main/webapp/app/entities/label/update/label-form.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; + +import { ILabel, NewLabel } from '../label.model'; + +/** + * A partial Type with required key is used as form input. + */ +type PartialWithRequiredKeyOf = Partial> & { id: T['id'] }; + +/** + * Type for createFormGroup and resetForm argument. + * It accepts ILabel for edit and NewLabelFormGroupInput for create. + */ +type LabelFormGroupInput = ILabel | PartialWithRequiredKeyOf; + +type LabelFormDefaults = Pick; + +type LabelFormGroupContent = { + id: FormControl; + label: FormControl; + operations: FormControl; +}; + +export type LabelFormGroup = FormGroup; + +@Injectable({ providedIn: 'root' }) +export class LabelFormService { + createLabelFormGroup(label: LabelFormGroupInput = { id: null }): LabelFormGroup { + const labelRawValue = { + ...this.getFormDefaults(), + ...label, + }; + return new FormGroup({ + id: new FormControl( + { value: labelRawValue.id, disabled: true }, + { + nonNullable: true, + validators: [Validators.required], + }, + ), + label: new FormControl(labelRawValue.label, { + validators: [Validators.required, Validators.minLength(3)], + }), + operations: new FormControl(labelRawValue.operations ?? []), + }); + } + + getLabel(form: LabelFormGroup): ILabel | NewLabel { + return form.getRawValue() as ILabel | NewLabel; + } + + resetForm(form: LabelFormGroup, label: LabelFormGroupInput): void { + const labelRawValue = { ...this.getFormDefaults(), ...label }; + form.reset( + { + ...labelRawValue, + id: { value: labelRawValue.id, disabled: true }, + } as any /* cast to workaround https://github.com/angular/angular/issues/46458 */, + ); + } + + private getFormDefaults(): LabelFormDefaults { + return { + id: null, + operations: [], + }; + } +} diff --git a/src/main/webapp/app/entities/label/update/label-update.component.html b/src/main/webapp/app/entities/label/update/label-update.component.html new file mode 100644 index 000000000..7d5b08f51 --- /dev/null +++ b/src/main/webapp/app/entities/label/update/label-update.component.html @@ -0,0 +1,56 @@ +
+
+
+

+ Create or edit a Label +

+ +
+ + +
+ + +
+ +
+ + +
+ + This field is required. + + + This field is required to be at least 3 characters. + +
+
+
+ +
+ + + +
+
+
+
diff --git a/src/main/webapp/app/entities/label/update/label-update.component.spec.ts b/src/main/webapp/app/entities/label/update/label-update.component.spec.ts new file mode 100644 index 000000000..13343c188 --- /dev/null +++ b/src/main/webapp/app/entities/label/update/label-update.component.spec.ts @@ -0,0 +1,124 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, Subject, from } from 'rxjs'; + +import { LabelService } from '../service/label.service'; +import { ILabel } from '../label.model'; +import { LabelFormService } from './label-form.service'; + +import { LabelUpdateComponent } from './label-update.component'; + +describe('Label Management Update Component', () => { + let comp: LabelUpdateComponent; + let fixture: ComponentFixture; + let activatedRoute: ActivatedRoute; + let labelFormService: LabelFormService; + let labelService: LabelService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([]), LabelUpdateComponent], + providers: [ + FormBuilder, + { + provide: ActivatedRoute, + useValue: { + params: from([{}]), + }, + }, + ], + }) + .overrideTemplate(LabelUpdateComponent, '') + .compileComponents(); + + fixture = TestBed.createComponent(LabelUpdateComponent); + activatedRoute = TestBed.inject(ActivatedRoute); + labelFormService = TestBed.inject(LabelFormService); + labelService = TestBed.inject(LabelService); + + comp = fixture.componentInstance; + }); + + describe('ngOnInit', () => { + it('Should update editForm', () => { + const label: ILabel = { id: 456 }; + + activatedRoute.data = of({ label }); + comp.ngOnInit(); + + expect(comp.label).toEqual(label); + }); + }); + + describe('save', () => { + it('Should call update service on save for existing entity', () => { + // GIVEN + const saveSubject = new Subject>(); + const label = { id: 123 }; + jest.spyOn(labelFormService, 'getLabel').mockReturnValue(label); + jest.spyOn(labelService, 'update').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ label }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.next(new HttpResponse({ body: label })); + saveSubject.complete(); + + // THEN + expect(labelFormService.getLabel).toHaveBeenCalled(); + expect(comp.previousState).toHaveBeenCalled(); + expect(labelService.update).toHaveBeenCalledWith(expect.objectContaining(label)); + expect(comp.isSaving).toEqual(false); + }); + + it('Should call create service on save for new entity', () => { + // GIVEN + const saveSubject = new Subject>(); + const label = { id: 123 }; + jest.spyOn(labelFormService, 'getLabel').mockReturnValue({ id: null }); + jest.spyOn(labelService, 'create').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ label: null }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.next(new HttpResponse({ body: label })); + saveSubject.complete(); + + // THEN + expect(labelFormService.getLabel).toHaveBeenCalled(); + expect(labelService.create).toHaveBeenCalled(); + expect(comp.isSaving).toEqual(false); + expect(comp.previousState).toHaveBeenCalled(); + }); + + it('Should set isSaving to false on error', () => { + // GIVEN + const saveSubject = new Subject>(); + const label = { id: 123 }; + jest.spyOn(labelService, 'update').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ label }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.error('This is an error!'); + + // THEN + expect(labelService.update).toHaveBeenCalled(); + expect(comp.isSaving).toEqual(false); + expect(comp.previousState).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/entities/label/update/label-update.component.ts b/src/main/webapp/app/entities/label/update/label-update.component.ts new file mode 100644 index 000000000..5a04154ce --- /dev/null +++ b/src/main/webapp/app/entities/label/update/label-update.component.ts @@ -0,0 +1,78 @@ +import { Component, OnInit } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; + +import SharedModule from 'app/shared/shared.module'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { ILabel } from '../label.model'; +import { LabelService } from '../service/label.service'; +import { LabelFormService, LabelFormGroup } from './label-form.service'; + +@Component({ + standalone: true, + selector: 'jhi-label-update', + templateUrl: './label-update.component.html', + imports: [SharedModule, FormsModule, ReactiveFormsModule], +}) +export class LabelUpdateComponent implements OnInit { + isSaving = false; + label: ILabel | null = null; + + editForm: LabelFormGroup = this.labelFormService.createLabelFormGroup(); + + constructor( + protected labelService: LabelService, + protected labelFormService: LabelFormService, + protected activatedRoute: ActivatedRoute, + ) {} + + ngOnInit(): void { + this.activatedRoute.data.subscribe(({ label }) => { + this.label = label; + if (label) { + this.updateForm(label); + } + }); + } + + previousState(): void { + window.history.back(); + } + + save(): void { + this.isSaving = true; + const label = this.labelFormService.getLabel(this.editForm); + if (label.id !== null) { + this.subscribeToSaveResponse(this.labelService.update(label)); + } else { + this.subscribeToSaveResponse(this.labelService.create(label)); + } + } + + protected subscribeToSaveResponse(result: Observable>): void { + result.pipe(finalize(() => this.onSaveFinalize())).subscribe({ + next: () => this.onSaveSuccess(), + error: () => this.onSaveError(), + }); + } + + protected onSaveSuccess(): void { + this.previousState(); + } + + protected onSaveError(): void { + // Api for inheritance. + } + + protected onSaveFinalize(): void { + this.isSaving = false; + } + + protected updateForm(label: ILabel): void { + this.label = label; + this.labelFormService.resetForm(this.editForm, label); + } +} diff --git a/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.html b/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.html new file mode 100644 index 000000000..b16678d3c --- /dev/null +++ b/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.html @@ -0,0 +1,28 @@ +
+ + + + + +
diff --git a/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.spec.ts b/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.spec.ts new file mode 100644 index 000000000..1a181daae --- /dev/null +++ b/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.spec.ts @@ -0,0 +1,62 @@ +jest.mock('@ng-bootstrap/ng-bootstrap'); + +import { ComponentFixture, TestBed, inject, fakeAsync, tick } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { OperationService } from '../service/operation.service'; + +import { OperationDeleteDialogComponent } from './operation-delete-dialog.component'; + +describe('Operation Management Delete Component', () => { + let comp: OperationDeleteDialogComponent; + let fixture: ComponentFixture; + let service: OperationService; + let mockActiveModal: NgbActiveModal; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OperationDeleteDialogComponent], + providers: [NgbActiveModal], + }) + .overrideTemplate(OperationDeleteDialogComponent, '') + .compileComponents(); + fixture = TestBed.createComponent(OperationDeleteDialogComponent); + comp = fixture.componentInstance; + service = TestBed.inject(OperationService); + mockActiveModal = TestBed.inject(NgbActiveModal); + }); + + describe('confirmDelete', () => { + it('Should call delete service on confirmDelete', inject( + [], + fakeAsync(() => { + // GIVEN + jest.spyOn(service, 'delete').mockReturnValue(of(new HttpResponse({ body: {} }))); + + // WHEN + comp.confirmDelete(123); + tick(); + + // THEN + expect(service.delete).toHaveBeenCalledWith(123); + expect(mockActiveModal.close).toHaveBeenCalledWith('deleted'); + }), + )); + + it('Should not call delete service on clear', () => { + // GIVEN + jest.spyOn(service, 'delete'); + + // WHEN + comp.cancel(); + + // THEN + expect(service.delete).not.toHaveBeenCalled(); + expect(mockActiveModal.close).not.toHaveBeenCalled(); + expect(mockActiveModal.dismiss).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.ts b/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.ts new file mode 100644 index 000000000..ffeac478c --- /dev/null +++ b/src/main/webapp/app/entities/operation/delete/operation-delete-dialog.component.ts @@ -0,0 +1,32 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { ITEM_DELETED_EVENT } from 'app/config/navigation.constants'; +import { IOperation } from '../operation.model'; +import { OperationService } from '../service/operation.service'; + +@Component({ + standalone: true, + templateUrl: './operation-delete-dialog.component.html', + imports: [SharedModule, FormsModule], +}) +export class OperationDeleteDialogComponent { + operation?: IOperation; + + constructor( + protected operationService: OperationService, + protected activeModal: NgbActiveModal, + ) {} + + cancel(): void { + this.activeModal.dismiss(); + } + + confirmDelete(id: number): void { + this.operationService.delete(id).subscribe(() => { + this.activeModal.close(ITEM_DELETED_EVENT); + }); + } +} diff --git a/src/main/webapp/app/entities/operation/detail/operation-detail.component.html b/src/main/webapp/app/entities/operation/detail/operation-detail.component.html new file mode 100644 index 000000000..ef0a36052 --- /dev/null +++ b/src/main/webapp/app/entities/operation/detail/operation-detail.component.html @@ -0,0 +1,53 @@ +
+
+
+

Operation

+ +
+ + + + + +
+
ID
+
+ {{ operation.id }} +
+
Date
+
+ {{ operation.date | formatMediumDatetime }} +
+
Description
+
+ {{ operation.description }} +
+
Amount
+
+ {{ operation.amount }} +
+
Bank Account
+
+ +
+
Label
+
+ + {{ label.label }}{{ last ? '' : ', ' }} + +
+
+ + + + +
+
+
diff --git a/src/main/webapp/app/entities/operation/detail/operation-detail.component.spec.ts b/src/main/webapp/app/entities/operation/detail/operation-detail.component.spec.ts new file mode 100644 index 000000000..8094298b9 --- /dev/null +++ b/src/main/webapp/app/entities/operation/detail/operation-detail.component.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { RouterTestingHarness, RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { OperationDetailComponent } from './operation-detail.component'; + +describe('Operation Management Detail Component', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OperationDetailComponent, RouterTestingModule.withRoutes([], { bindToComponentInputs: true })], + providers: [ + provideRouter( + [ + { + path: '**', + component: OperationDetailComponent, + resolve: { operation: () => of({ id: 123 }) }, + }, + ], + withComponentInputBinding(), + ), + ], + }) + .overrideTemplate(OperationDetailComponent, '') + .compileComponents(); + }); + + describe('OnInit', () => { + it('Should load operation on init', async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/', OperationDetailComponent); + + // THEN + expect(instance.operation).toEqual(expect.objectContaining({ id: 123 })); + }); + }); +}); diff --git a/src/main/webapp/app/entities/operation/detail/operation-detail.component.ts b/src/main/webapp/app/entities/operation/detail/operation-detail.component.ts new file mode 100644 index 000000000..707f07d9e --- /dev/null +++ b/src/main/webapp/app/entities/operation/detail/operation-detail.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; + +import SharedModule from 'app/shared/shared.module'; +import { DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe } from 'app/shared/date'; +import { IOperation } from '../operation.model'; + +@Component({ + standalone: true, + selector: 'jhi-operation-detail', + templateUrl: './operation-detail.component.html', + imports: [SharedModule, RouterModule, DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe], +}) +export class OperationDetailComponent { + @Input() operation: IOperation | null = null; + + constructor(protected activatedRoute: ActivatedRoute) {} + + previousState(): void { + window.history.back(); + } +} diff --git a/src/main/webapp/app/entities/operation/list/operation.component.html b/src/main/webapp/app/entities/operation/list/operation.component.html new file mode 100644 index 000000000..80f02f13c --- /dev/null +++ b/src/main/webapp/app/entities/operation/list/operation.component.html @@ -0,0 +1,118 @@ +
+

+ Operations + +
+ + + +
+

+ + + + + +
+ No Operations found +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ ID + +
+
+
+ Date + +
+
+
+ Description + +
+
+
+ Amount + +
+
+
+ Bank Account + +
+
+ {{ operation.id }} + {{ operation.date | formatMediumDatetime }}{{ operation.description }}{{ operation.amount }} + + +
+ + + + + +
+
+
+
diff --git a/src/main/webapp/app/entities/operation/list/operation.component.spec.ts b/src/main/webapp/app/entities/operation/list/operation.component.spec.ts new file mode 100644 index 000000000..d149d047d --- /dev/null +++ b/src/main/webapp/app/entities/operation/list/operation.component.spec.ts @@ -0,0 +1,127 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { OperationService } from '../service/operation.service'; + +import { OperationComponent } from './operation.component'; +import SpyInstance = jest.SpyInstance; + +describe('Operation Management Component', () => { + let comp: OperationComponent; + let fixture: ComponentFixture; + let service: OperationService; + let routerNavigateSpy: SpyInstance>; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([{ path: 'operation', component: OperationComponent }]), + HttpClientTestingModule, + OperationComponent, + ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + data: of({ + defaultSort: 'id,asc', + }), + queryParamMap: of( + jest.requireActual('@angular/router').convertToParamMap({ + page: '1', + size: '1', + sort: 'id,desc', + }), + ), + snapshot: { queryParams: {} }, + }, + }, + ], + }) + .overrideTemplate(OperationComponent, '') + .compileComponents(); + + fixture = TestBed.createComponent(OperationComponent); + comp = fixture.componentInstance; + service = TestBed.inject(OperationService); + routerNavigateSpy = jest.spyOn(comp.router, 'navigate'); + + const headers = new HttpHeaders(); + jest.spyOn(service, 'query').mockReturnValue( + of( + new HttpResponse({ + body: [{ id: 123 }], + headers, + }), + ), + ); + }); + + it('Should call load all on init', () => { + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.query).toHaveBeenCalled(); + expect(comp.operations?.[0]).toEqual(expect.objectContaining({ id: 123 })); + }); + + describe('trackId', () => { + it('Should forward to operationService', () => { + const entity = { id: 123 }; + jest.spyOn(service, 'getOperationIdentifier'); + const id = comp.trackId(0, entity); + expect(service.getOperationIdentifier).toHaveBeenCalledWith(entity); + expect(id).toBe(entity.id); + }); + }); + + it('should load a page', () => { + // WHEN + comp.navigateToPage(1); + + // THEN + expect(routerNavigateSpy).toHaveBeenCalled(); + }); + + it('should calculate the sort attribute for an id', () => { + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.query).toHaveBeenLastCalledWith(expect.objectContaining({ sort: ['id,desc'] })); + }); + + it('should calculate the sort attribute for a non-id attribute', () => { + // GIVEN + comp.predicate = 'name'; + + // WHEN + comp.navigateToWithComponentValues(); + + // THEN + expect(routerNavigateSpy).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + queryParams: expect.objectContaining({ + sort: ['name,asc'], + }), + }), + ); + }); + + it('should re-initialize the page', () => { + // WHEN + comp.loadPage(1); + comp.reset(); + + // THEN + expect(comp.page).toEqual(1); + expect(service.query).toHaveBeenCalledTimes(2); + expect(comp.operations?.[0]).toEqual(expect.objectContaining({ id: 123 })); + }); +}); diff --git a/src/main/webapp/app/entities/operation/list/operation.component.ts b/src/main/webapp/app/entities/operation/list/operation.component.ts new file mode 100644 index 000000000..40fb4ef51 --- /dev/null +++ b/src/main/webapp/app/entities/operation/list/operation.component.ts @@ -0,0 +1,185 @@ +import { Component, OnInit } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; +import { ActivatedRoute, Data, ParamMap, Router, RouterModule } from '@angular/router'; +import { combineLatest, filter, Observable, switchMap, tap } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import SharedModule from 'app/shared/shared.module'; +import { SortDirective, SortByDirective } from 'app/shared/sort'; +import { DurationPipe, FormatMediumDatetimePipe, FormatMediumDatePipe } from 'app/shared/date'; +import { FormsModule } from '@angular/forms'; + +import { ITEMS_PER_PAGE } from 'app/config/pagination.constants'; +import { ASC, DESC, SORT, ITEM_DELETED_EVENT, DEFAULT_SORT_DATA } from 'app/config/navigation.constants'; +import { ParseLinks } from 'app/core/util/parse-links.service'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { EntityArrayResponseType, OperationService } from '../service/operation.service'; +import { OperationDeleteDialogComponent } from '../delete/operation-delete-dialog.component'; +import { IOperation } from '../operation.model'; + +@Component({ + standalone: true, + selector: 'jhi-operation', + templateUrl: './operation.component.html', + imports: [ + RouterModule, + FormsModule, + SharedModule, + SortDirective, + SortByDirective, + DurationPipe, + FormatMediumDatetimePipe, + FormatMediumDatePipe, + InfiniteScrollModule, + ], +}) +export class OperationComponent implements OnInit { + operations?: IOperation[]; + isLoading = false; + + predicate = 'id'; + ascending = true; + + itemsPerPage = ITEMS_PER_PAGE; + links: { [key: string]: number } = { + last: 0, + }; + page = 1; + + constructor( + protected operationService: OperationService, + protected activatedRoute: ActivatedRoute, + public router: Router, + protected parseLinks: ParseLinks, + protected modalService: NgbModal, + ) {} + + reset(): void { + this.page = 1; + this.operations = []; + this.load(); + } + + loadPage(page: number): void { + this.page = page; + this.load(); + } + + trackId = (_index: number, item: IOperation): number => this.operationService.getOperationIdentifier(item); + + ngOnInit(): void { + this.load(); + } + + delete(operation: IOperation): void { + const modalRef = this.modalService.open(OperationDeleteDialogComponent, { size: 'lg', backdrop: 'static' }); + modalRef.componentInstance.operation = operation; + // unsubscribe not needed because closed completes on modal close + modalRef.closed + .pipe( + filter(reason => reason === ITEM_DELETED_EVENT), + switchMap(() => this.loadFromBackendWithRouteInformations()), + ) + .subscribe({ + next: (res: EntityArrayResponseType) => { + this.onResponseSuccess(res); + }, + }); + } + + load(): void { + this.loadFromBackendWithRouteInformations().subscribe({ + next: (res: EntityArrayResponseType) => { + this.onResponseSuccess(res); + }, + }); + } + + navigateToWithComponentValues(): void { + this.handleNavigation(this.page, this.predicate, this.ascending); + } + + navigateToPage(page = this.page): void { + this.handleNavigation(page, this.predicate, this.ascending); + } + + protected loadFromBackendWithRouteInformations(): Observable { + return combineLatest([this.activatedRoute.queryParamMap, this.activatedRoute.data]).pipe( + tap(([params, data]) => this.fillComponentAttributeFromRoute(params, data)), + switchMap(() => this.queryBackend(this.page, this.predicate, this.ascending)), + ); + } + + protected fillComponentAttributeFromRoute(params: ParamMap, data: Data): void { + const sort = (params.get(SORT) ?? data[DEFAULT_SORT_DATA]).split(','); + this.predicate = sort[0]; + this.ascending = sort[1] === ASC; + } + + protected onResponseSuccess(response: EntityArrayResponseType): void { + this.fillComponentAttributesFromResponseHeader(response.headers); + const dataFromBody = this.fillComponentAttributesFromResponseBody(response.body); + this.operations = dataFromBody; + } + + protected fillComponentAttributesFromResponseBody(data: IOperation[] | null): IOperation[] { + // If there is previus link, data is a infinite scroll pagination content. + if ('prev' in this.links) { + const operationsNew = this.operations ?? []; + if (data) { + for (const d of data) { + if (operationsNew.map(op => op.id).indexOf(d.id) === -1) { + operationsNew.push(d); + } + } + } + return operationsNew; + } + return data ?? []; + } + + protected fillComponentAttributesFromResponseHeader(headers: HttpHeaders): void { + const linkHeader = headers.get('link'); + if (linkHeader) { + this.links = this.parseLinks.parse(linkHeader); + } else { + this.links = { + last: 0, + }; + } + } + + protected queryBackend(page?: number, predicate?: string, ascending?: boolean): Observable { + this.isLoading = true; + const pageToLoad: number = page ?? 1; + const queryObject: any = { + page: pageToLoad - 1, + size: this.itemsPerPage, + eagerload: true, + sort: this.getSortQueryParam(predicate, ascending), + }; + return this.operationService.query(queryObject).pipe(tap(() => (this.isLoading = false))); + } + + protected handleNavigation(page = this.page, predicate?: string, ascending?: boolean): void { + const queryParamsObj = { + page, + size: this.itemsPerPage, + sort: this.getSortQueryParam(predicate, ascending), + }; + + this.router.navigate(['./'], { + relativeTo: this.activatedRoute, + queryParams: queryParamsObj, + }); + } + + protected getSortQueryParam(predicate = this.predicate, ascending = this.ascending): string[] { + const ascendingQueryParam = ascending ? ASC : DESC; + if (predicate === '') { + return []; + } else { + return [predicate + ',' + ascendingQueryParam]; + } + } +} diff --git a/src/main/webapp/app/entities/operation/operation.model.ts b/src/main/webapp/app/entities/operation/operation.model.ts new file mode 100644 index 000000000..1e4fbf473 --- /dev/null +++ b/src/main/webapp/app/entities/operation/operation.model.ts @@ -0,0 +1,14 @@ +import dayjs from 'dayjs/esm'; +import { IBankAccount } from 'app/entities/bank-account/bank-account.model'; +import { ILabel } from 'app/entities/label/label.model'; + +export interface IOperation { + id: number; + date?: dayjs.Dayjs | null; + description?: string | null; + amount?: number | null; + bankAccount?: Pick | null; + labels?: Pick[] | null; +} + +export type NewOperation = Omit & { id: null }; diff --git a/src/main/webapp/app/entities/operation/operation.routes.ts b/src/main/webapp/app/entities/operation/operation.routes.ts new file mode 100644 index 000000000..ceb1e1905 --- /dev/null +++ b/src/main/webapp/app/entities/operation/operation.routes.ts @@ -0,0 +1,45 @@ +import { Routes } from '@angular/router'; + +import { UserRouteAccessService } from 'app/core/auth/user-route-access.service'; +import { ASC } from 'app/config/navigation.constants'; +import { OperationComponent } from './list/operation.component'; +import { OperationDetailComponent } from './detail/operation-detail.component'; +import { OperationUpdateComponent } from './update/operation-update.component'; +import OperationResolve from './route/operation-routing-resolve.service'; + +const operationRoute: Routes = [ + { + path: '', + component: OperationComponent, + data: { + defaultSort: 'id,' + ASC, + }, + canActivate: [UserRouteAccessService], + }, + { + path: ':id/view', + component: OperationDetailComponent, + resolve: { + operation: OperationResolve, + }, + canActivate: [UserRouteAccessService], + }, + { + path: 'new', + component: OperationUpdateComponent, + resolve: { + operation: OperationResolve, + }, + canActivate: [UserRouteAccessService], + }, + { + path: ':id/edit', + component: OperationUpdateComponent, + resolve: { + operation: OperationResolve, + }, + canActivate: [UserRouteAccessService], + }, +]; + +export default operationRoute; diff --git a/src/main/webapp/app/entities/operation/operation.test-samples.ts b/src/main/webapp/app/entities/operation/operation.test-samples.ts new file mode 100644 index 000000000..e3f4bf074 --- /dev/null +++ b/src/main/webapp/app/entities/operation/operation.test-samples.ts @@ -0,0 +1,34 @@ +import dayjs from 'dayjs/esm'; + +import { IOperation, NewOperation } from './operation.model'; + +export const sampleWithRequiredData: IOperation = { + id: 17097, + date: dayjs('2015-08-05T05:46'), + amount: 507.48, +}; + +export const sampleWithPartialData: IOperation = { + id: 27922, + date: dayjs('2015-08-05T10:26'), + description: 'huzzah', + amount: 30952.43, +}; + +export const sampleWithFullData: IOperation = { + id: 5049, + date: dayjs('2015-08-04T15:22'), + description: 'than arithmetic ah', + amount: 7644.88, +}; + +export const sampleWithNewData: NewOperation = { + date: dayjs('2015-08-04T17:07'), + amount: 18820.14, + id: null, +}; + +Object.freeze(sampleWithNewData); +Object.freeze(sampleWithRequiredData); +Object.freeze(sampleWithPartialData); +Object.freeze(sampleWithFullData); diff --git a/src/main/webapp/app/entities/operation/route/operation-routing-resolve.service.spec.ts b/src/main/webapp/app/entities/operation/route/operation-routing-resolve.service.spec.ts new file mode 100644 index 000000000..014f9cca7 --- /dev/null +++ b/src/main/webapp/app/entities/operation/route/operation-routing-resolve.service.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRouteSnapshot, ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { IOperation } from '../operation.model'; +import { OperationService } from '../service/operation.service'; + +import operationResolve from './operation-routing-resolve.service'; + +describe('Operation routing resolve service', () => { + let mockRouter: Router; + let mockActivatedRouteSnapshot: ActivatedRouteSnapshot; + let service: OperationService; + let resultOperation: IOperation | null | undefined; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([])], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({}), + }, + }, + }, + ], + }); + mockRouter = TestBed.inject(Router); + jest.spyOn(mockRouter, 'navigate').mockImplementation(() => Promise.resolve(true)); + mockActivatedRouteSnapshot = TestBed.inject(ActivatedRoute).snapshot; + service = TestBed.inject(OperationService); + resultOperation = undefined; + }); + + describe('resolve', () => { + it('should return IOperation returned by find', () => { + // GIVEN + service.find = jest.fn(id => of(new HttpResponse({ body: { id } }))); + mockActivatedRouteSnapshot.params = { id: 123 }; + + // WHEN + TestBed.runInInjectionContext(() => { + operationResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultOperation = result; + }, + }); + }); + + // THEN + expect(service.find).toBeCalledWith(123); + expect(resultOperation).toEqual({ id: 123 }); + }); + + it('should return null if id is not provided', () => { + // GIVEN + service.find = jest.fn(); + mockActivatedRouteSnapshot.params = {}; + + // WHEN + TestBed.runInInjectionContext(() => { + operationResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultOperation = result; + }, + }); + }); + + // THEN + expect(service.find).not.toBeCalled(); + expect(resultOperation).toEqual(null); + }); + + it('should route to 404 page if data not found in server', () => { + // GIVEN + jest.spyOn(service, 'find').mockReturnValue(of(new HttpResponse({ body: null }))); + mockActivatedRouteSnapshot.params = { id: 123 }; + + // WHEN + TestBed.runInInjectionContext(() => { + operationResolve(mockActivatedRouteSnapshot).subscribe({ + next(result) { + resultOperation = result; + }, + }); + }); + + // THEN + expect(service.find).toBeCalledWith(123); + expect(resultOperation).toEqual(undefined); + expect(mockRouter.navigate).toHaveBeenCalledWith(['404']); + }); + }); +}); diff --git a/src/main/webapp/app/entities/operation/route/operation-routing-resolve.service.ts b/src/main/webapp/app/entities/operation/route/operation-routing-resolve.service.ts new file mode 100644 index 000000000..6807205c5 --- /dev/null +++ b/src/main/webapp/app/entities/operation/route/operation-routing-resolve.service.ts @@ -0,0 +1,29 @@ +import { inject } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { of, EMPTY, Observable } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +import { IOperation } from '../operation.model'; +import { OperationService } from '../service/operation.service'; + +export const operationResolve = (route: ActivatedRouteSnapshot): Observable => { + const id = route.params['id']; + if (id) { + return inject(OperationService) + .find(id) + .pipe( + mergeMap((operation: HttpResponse) => { + if (operation.body) { + return of(operation.body); + } else { + inject(Router).navigate(['404']); + return EMPTY; + } + }), + ); + } + return of(null); +}; + +export default operationResolve; diff --git a/src/main/webapp/app/entities/operation/service/operation.service.spec.ts b/src/main/webapp/app/entities/operation/service/operation.service.spec.ts new file mode 100644 index 000000000..b4888e812 --- /dev/null +++ b/src/main/webapp/app/entities/operation/service/operation.service.spec.ts @@ -0,0 +1,205 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { IOperation } from '../operation.model'; +import { sampleWithRequiredData, sampleWithNewData, sampleWithPartialData, sampleWithFullData } from '../operation.test-samples'; + +import { OperationService, RestOperation } from './operation.service'; + +const requireRestSample: RestOperation = { + ...sampleWithRequiredData, + date: sampleWithRequiredData.date?.toJSON(), +}; + +describe('Operation Service', () => { + let service: OperationService; + let httpMock: HttpTestingController; + let expectedResult: IOperation | IOperation[] | boolean | null; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + expectedResult = null; + service = TestBed.inject(OperationService); + httpMock = TestBed.inject(HttpTestingController); + }); + + describe('Service methods', () => { + it('should find an element', () => { + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.find(123).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should create a Operation', () => { + const operation = { ...sampleWithNewData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.create(operation).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'POST' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should update a Operation', () => { + const operation = { ...sampleWithRequiredData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.update(operation).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'PUT' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should partial update a Operation', () => { + const patchObject = { ...sampleWithPartialData }; + const returnedFromService = { ...requireRestSample }; + const expected = { ...sampleWithRequiredData }; + + service.partialUpdate(patchObject).subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'PATCH' }); + req.flush(returnedFromService); + expect(expectedResult).toMatchObject(expected); + }); + + it('should return a list of Operation', () => { + const returnedFromService = { ...requireRestSample }; + + const expected = { ...sampleWithRequiredData }; + + service.query().subscribe(resp => (expectedResult = resp.body)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush([returnedFromService]); + httpMock.verify(); + expect(expectedResult).toMatchObject([expected]); + }); + + it('should delete a Operation', () => { + const expected = true; + + service.delete(123).subscribe(resp => (expectedResult = resp.ok)); + + const req = httpMock.expectOne({ method: 'DELETE' }); + req.flush({ status: 200 }); + expect(expectedResult).toBe(expected); + }); + + describe('addOperationToCollectionIfMissing', () => { + it('should add a Operation to an empty array', () => { + const operation: IOperation = sampleWithRequiredData; + expectedResult = service.addOperationToCollectionIfMissing([], operation); + expect(expectedResult).toHaveLength(1); + expect(expectedResult).toContain(operation); + }); + + it('should not add a Operation to an array that contains it', () => { + const operation: IOperation = sampleWithRequiredData; + const operationCollection: IOperation[] = [ + { + ...operation, + }, + sampleWithPartialData, + ]; + expectedResult = service.addOperationToCollectionIfMissing(operationCollection, operation); + expect(expectedResult).toHaveLength(2); + }); + + it("should add a Operation to an array that doesn't contain it", () => { + const operation: IOperation = sampleWithRequiredData; + const operationCollection: IOperation[] = [sampleWithPartialData]; + expectedResult = service.addOperationToCollectionIfMissing(operationCollection, operation); + expect(expectedResult).toHaveLength(2); + expect(expectedResult).toContain(operation); + }); + + it('should add only unique Operation to an array', () => { + const operationArray: IOperation[] = [sampleWithRequiredData, sampleWithPartialData, sampleWithFullData]; + const operationCollection: IOperation[] = [sampleWithRequiredData]; + expectedResult = service.addOperationToCollectionIfMissing(operationCollection, ...operationArray); + expect(expectedResult).toHaveLength(3); + }); + + it('should accept varargs', () => { + const operation: IOperation = sampleWithRequiredData; + const operation2: IOperation = sampleWithPartialData; + expectedResult = service.addOperationToCollectionIfMissing([], operation, operation2); + expect(expectedResult).toHaveLength(2); + expect(expectedResult).toContain(operation); + expect(expectedResult).toContain(operation2); + }); + + it('should accept null and undefined values', () => { + const operation: IOperation = sampleWithRequiredData; + expectedResult = service.addOperationToCollectionIfMissing([], null, operation, undefined); + expect(expectedResult).toHaveLength(1); + expect(expectedResult).toContain(operation); + }); + + it('should return initial array if no Operation is added', () => { + const operationCollection: IOperation[] = [sampleWithRequiredData]; + expectedResult = service.addOperationToCollectionIfMissing(operationCollection, undefined, null); + expect(expectedResult).toEqual(operationCollection); + }); + }); + + describe('compareOperation', () => { + it('Should return true if both entities are null', () => { + const entity1 = null; + const entity2 = null; + + const compareResult = service.compareOperation(entity1, entity2); + + expect(compareResult).toEqual(true); + }); + + it('Should return false if one entity is null', () => { + const entity1 = { id: 123 }; + const entity2 = null; + + const compareResult1 = service.compareOperation(entity1, entity2); + const compareResult2 = service.compareOperation(entity2, entity1); + + expect(compareResult1).toEqual(false); + expect(compareResult2).toEqual(false); + }); + + it('Should return false if primaryKey differs', () => { + const entity1 = { id: 123 }; + const entity2 = { id: 456 }; + + const compareResult1 = service.compareOperation(entity1, entity2); + const compareResult2 = service.compareOperation(entity2, entity1); + + expect(compareResult1).toEqual(false); + expect(compareResult2).toEqual(false); + }); + + it('Should return false if primaryKey matches', () => { + const entity1 = { id: 123 }; + const entity2 = { id: 123 }; + + const compareResult1 = service.compareOperation(entity1, entity2); + const compareResult2 = service.compareOperation(entity2, entity1); + + expect(compareResult1).toEqual(true); + expect(compareResult2).toEqual(true); + }); + }); + }); + + afterEach(() => { + httpMock.verify(); + }); +}); diff --git a/src/main/webapp/app/entities/operation/service/operation.service.ts b/src/main/webapp/app/entities/operation/service/operation.service.ts new file mode 100644 index 000000000..a4c1d2737 --- /dev/null +++ b/src/main/webapp/app/entities/operation/service/operation.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { map } from 'rxjs/operators'; + +import dayjs from 'dayjs/esm'; + +import { isPresent } from 'app/core/util/operators'; +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { createRequestOption } from 'app/core/request/request-util'; +import { IOperation, NewOperation } from '../operation.model'; + +export type PartialUpdateOperation = Partial & Pick; + +type RestOf = Omit & { + date?: string | null; +}; + +export type RestOperation = RestOf; + +export type NewRestOperation = RestOf; + +export type PartialUpdateRestOperation = RestOf; + +export type EntityResponseType = HttpResponse; +export type EntityArrayResponseType = HttpResponse; + +@Injectable({ providedIn: 'root' }) +export class OperationService { + protected resourceUrl = this.applicationConfigService.getEndpointFor('api/operations'); + + constructor( + protected http: HttpClient, + protected applicationConfigService: ApplicationConfigService, + ) {} + + create(operation: NewOperation): Observable { + const copy = this.convertDateFromClient(operation); + return this.http + .post(this.resourceUrl, copy, { observe: 'response' }) + .pipe(map(res => this.convertResponseFromServer(res))); + } + + update(operation: IOperation): Observable { + const copy = this.convertDateFromClient(operation); + return this.http + .put(`${this.resourceUrl}/${this.getOperationIdentifier(operation)}`, copy, { observe: 'response' }) + .pipe(map(res => this.convertResponseFromServer(res))); + } + + partialUpdate(operation: PartialUpdateOperation): Observable { + const copy = this.convertDateFromClient(operation); + return this.http + .patch(`${this.resourceUrl}/${this.getOperationIdentifier(operation)}`, copy, { observe: 'response' }) + .pipe(map(res => this.convertResponseFromServer(res))); + } + + find(id: number): Observable { + return this.http + .get(`${this.resourceUrl}/${id}`, { observe: 'response' }) + .pipe(map(res => this.convertResponseFromServer(res))); + } + + query(req?: any): Observable { + const options = createRequestOption(req); + return this.http + .get(this.resourceUrl, { params: options, observe: 'response' }) + .pipe(map(res => this.convertResponseArrayFromServer(res))); + } + + delete(id: number): Observable> { + return this.http.delete(`${this.resourceUrl}/${id}`, { observe: 'response' }); + } + + getOperationIdentifier(operation: Pick): number { + return operation.id; + } + + compareOperation(o1: Pick | null, o2: Pick | null): boolean { + return o1 && o2 ? this.getOperationIdentifier(o1) === this.getOperationIdentifier(o2) : o1 === o2; + } + + addOperationToCollectionIfMissing>( + operationCollection: Type[], + ...operationsToCheck: (Type | null | undefined)[] + ): Type[] { + const operations: Type[] = operationsToCheck.filter(isPresent); + if (operations.length > 0) { + const operationCollectionIdentifiers = operationCollection.map(operationItem => this.getOperationIdentifier(operationItem)!); + const operationsToAdd = operations.filter(operationItem => { + const operationIdentifier = this.getOperationIdentifier(operationItem); + if (operationCollectionIdentifiers.includes(operationIdentifier)) { + return false; + } + operationCollectionIdentifiers.push(operationIdentifier); + return true; + }); + return [...operationsToAdd, ...operationCollection]; + } + return operationCollection; + } + + protected convertDateFromClient(operation: T): RestOf { + return { + ...operation, + date: operation.date?.toJSON() ?? null, + }; + } + + protected convertDateFromServer(restOperation: RestOperation): IOperation { + return { + ...restOperation, + date: restOperation.date ? dayjs(restOperation.date) : undefined, + }; + } + + protected convertResponseFromServer(res: HttpResponse): HttpResponse { + return res.clone({ + body: res.body ? this.convertDateFromServer(res.body) : null, + }); + } + + protected convertResponseArrayFromServer(res: HttpResponse): HttpResponse { + return res.clone({ + body: res.body ? res.body.map(item => this.convertDateFromServer(item)) : null, + }); + } +} diff --git a/src/main/webapp/app/entities/operation/update/operation-form.service.spec.ts b/src/main/webapp/app/entities/operation/update/operation-form.service.spec.ts new file mode 100644 index 000000000..b1e1823f5 --- /dev/null +++ b/src/main/webapp/app/entities/operation/update/operation-form.service.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from '@angular/core/testing'; + +import { sampleWithRequiredData, sampleWithNewData } from '../operation.test-samples'; + +import { OperationFormService } from './operation-form.service'; + +describe('Operation Form Service', () => { + let service: OperationFormService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(OperationFormService); + }); + + describe('Service methods', () => { + describe('createOperationFormGroup', () => { + it('should create a new form with FormControl', () => { + const formGroup = service.createOperationFormGroup(); + + expect(formGroup.controls).toEqual( + expect.objectContaining({ + id: expect.any(Object), + date: expect.any(Object), + description: expect.any(Object), + amount: expect.any(Object), + bankAccount: expect.any(Object), + labels: expect.any(Object), + }), + ); + }); + + it('passing IOperation should create a new form with FormGroup', () => { + const formGroup = service.createOperationFormGroup(sampleWithRequiredData); + + expect(formGroup.controls).toEqual( + expect.objectContaining({ + id: expect.any(Object), + date: expect.any(Object), + description: expect.any(Object), + amount: expect.any(Object), + bankAccount: expect.any(Object), + labels: expect.any(Object), + }), + ); + }); + }); + + describe('getOperation', () => { + it('should return NewOperation for default Operation initial value', () => { + const formGroup = service.createOperationFormGroup(sampleWithNewData); + + const operation = service.getOperation(formGroup) as any; + + expect(operation).toMatchObject(sampleWithNewData); + }); + + it('should return NewOperation for empty Operation initial value', () => { + const formGroup = service.createOperationFormGroup(); + + const operation = service.getOperation(formGroup) as any; + + expect(operation).toMatchObject({}); + }); + + it('should return IOperation', () => { + const formGroup = service.createOperationFormGroup(sampleWithRequiredData); + + const operation = service.getOperation(formGroup) as any; + + expect(operation).toMatchObject(sampleWithRequiredData); + }); + }); + + describe('resetForm', () => { + it('passing IOperation should not enable id FormControl', () => { + const formGroup = service.createOperationFormGroup(); + expect(formGroup.controls.id.disabled).toBe(true); + + service.resetForm(formGroup, sampleWithRequiredData); + + expect(formGroup.controls.id.disabled).toBe(true); + }); + + it('passing NewOperation should disable id FormControl', () => { + const formGroup = service.createOperationFormGroup(sampleWithRequiredData); + expect(formGroup.controls.id.disabled).toBe(true); + + service.resetForm(formGroup, { id: null }); + + expect(formGroup.controls.id.disabled).toBe(true); + }); + }); + }); +}); diff --git a/src/main/webapp/app/entities/operation/update/operation-form.service.ts b/src/main/webapp/app/entities/operation/update/operation-form.service.ts new file mode 100644 index 000000000..95421346a --- /dev/null +++ b/src/main/webapp/app/entities/operation/update/operation-form.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; + +import dayjs from 'dayjs/esm'; +import { DATE_TIME_FORMAT } from 'app/config/input.constants'; +import { IOperation, NewOperation } from '../operation.model'; + +/** + * A partial Type with required key is used as form input. + */ +type PartialWithRequiredKeyOf = Partial> & { id: T['id'] }; + +/** + * Type for createFormGroup and resetForm argument. + * It accepts IOperation for edit and NewOperationFormGroupInput for create. + */ +type OperationFormGroupInput = IOperation | PartialWithRequiredKeyOf; + +/** + * Type that converts some properties for forms. + */ +type FormValueOf = Omit & { + date?: string | null; +}; + +type OperationFormRawValue = FormValueOf; + +type NewOperationFormRawValue = FormValueOf; + +type OperationFormDefaults = Pick; + +type OperationFormGroupContent = { + id: FormControl; + date: FormControl; + description: FormControl; + amount: FormControl; + bankAccount: FormControl; + labels: FormControl; +}; + +export type OperationFormGroup = FormGroup; + +@Injectable({ providedIn: 'root' }) +export class OperationFormService { + createOperationFormGroup(operation: OperationFormGroupInput = { id: null }): OperationFormGroup { + const operationRawValue = this.convertOperationToOperationRawValue({ + ...this.getFormDefaults(), + ...operation, + }); + return new FormGroup({ + id: new FormControl( + { value: operationRawValue.id, disabled: true }, + { + nonNullable: true, + validators: [Validators.required], + }, + ), + date: new FormControl(operationRawValue.date, { + validators: [Validators.required], + }), + description: new FormControl(operationRawValue.description), + amount: new FormControl(operationRawValue.amount, { + validators: [Validators.required], + }), + bankAccount: new FormControl(operationRawValue.bankAccount), + labels: new FormControl(operationRawValue.labels ?? []), + }); + } + + getOperation(form: OperationFormGroup): IOperation | NewOperation { + return this.convertOperationRawValueToOperation(form.getRawValue() as OperationFormRawValue | NewOperationFormRawValue); + } + + resetForm(form: OperationFormGroup, operation: OperationFormGroupInput): void { + const operationRawValue = this.convertOperationToOperationRawValue({ ...this.getFormDefaults(), ...operation }); + form.reset( + { + ...operationRawValue, + id: { value: operationRawValue.id, disabled: true }, + } as any /* cast to workaround https://github.com/angular/angular/issues/46458 */, + ); + } + + private getFormDefaults(): OperationFormDefaults { + const currentTime = dayjs(); + + return { + id: null, + date: currentTime, + labels: [], + }; + } + + private convertOperationRawValueToOperation(rawOperation: OperationFormRawValue | NewOperationFormRawValue): IOperation | NewOperation { + return { + ...rawOperation, + date: dayjs(rawOperation.date, DATE_TIME_FORMAT), + }; + } + + private convertOperationToOperationRawValue( + operation: IOperation | (Partial & OperationFormDefaults), + ): OperationFormRawValue | PartialWithRequiredKeyOf { + return { + ...operation, + date: operation.date ? operation.date.format(DATE_TIME_FORMAT) : undefined, + labels: operation.labels ?? [], + }; + } +} diff --git a/src/main/webapp/app/entities/operation/update/operation-update.component.html b/src/main/webapp/app/entities/operation/update/operation-update.component.html new file mode 100644 index 000000000..3d62e1bda --- /dev/null +++ b/src/main/webapp/app/entities/operation/update/operation-update.component.html @@ -0,0 +1,126 @@ +
+
+
+

+ Create or edit a Operation +

+ +
+ + +
+ + +
+ +
+ +
+ +
+
+ + This field is required. + + + This field should be a date and time. + +
+
+ +
+ + +
+ +
+ + +
+ + This field is required. + + + This field should be a number. + +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ + + +
+
+
+
diff --git a/src/main/webapp/app/entities/operation/update/operation-update.component.spec.ts b/src/main/webapp/app/entities/operation/update/operation-update.component.spec.ts new file mode 100644 index 000000000..b9494357a --- /dev/null +++ b/src/main/webapp/app/entities/operation/update/operation-update.component.spec.ts @@ -0,0 +1,204 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, Subject, from } from 'rxjs'; + +import { IBankAccount } from 'app/entities/bank-account/bank-account.model'; +import { BankAccountService } from 'app/entities/bank-account/service/bank-account.service'; +import { ILabel } from 'app/entities/label/label.model'; +import { LabelService } from 'app/entities/label/service/label.service'; +import { IOperation } from '../operation.model'; +import { OperationService } from '../service/operation.service'; +import { OperationFormService } from './operation-form.service'; + +import { OperationUpdateComponent } from './operation-update.component'; + +describe('Operation Management Update Component', () => { + let comp: OperationUpdateComponent; + let fixture: ComponentFixture; + let activatedRoute: ActivatedRoute; + let operationFormService: OperationFormService; + let operationService: OperationService; + let bankAccountService: BankAccountService; + let labelService: LabelService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes([]), OperationUpdateComponent], + providers: [ + FormBuilder, + { + provide: ActivatedRoute, + useValue: { + params: from([{}]), + }, + }, + ], + }) + .overrideTemplate(OperationUpdateComponent, '') + .compileComponents(); + + fixture = TestBed.createComponent(OperationUpdateComponent); + activatedRoute = TestBed.inject(ActivatedRoute); + operationFormService = TestBed.inject(OperationFormService); + operationService = TestBed.inject(OperationService); + bankAccountService = TestBed.inject(BankAccountService); + labelService = TestBed.inject(LabelService); + + comp = fixture.componentInstance; + }); + + describe('ngOnInit', () => { + it('Should call BankAccount query and add missing value', () => { + const operation: IOperation = { id: 456 }; + const bankAccount: IBankAccount = { id: 21320 }; + operation.bankAccount = bankAccount; + + const bankAccountCollection: IBankAccount[] = [{ id: 14791 }]; + jest.spyOn(bankAccountService, 'query').mockReturnValue(of(new HttpResponse({ body: bankAccountCollection }))); + const additionalBankAccounts = [bankAccount]; + const expectedCollection: IBankAccount[] = [...additionalBankAccounts, ...bankAccountCollection]; + jest.spyOn(bankAccountService, 'addBankAccountToCollectionIfMissing').mockReturnValue(expectedCollection); + + activatedRoute.data = of({ operation }); + comp.ngOnInit(); + + expect(bankAccountService.query).toHaveBeenCalled(); + expect(bankAccountService.addBankAccountToCollectionIfMissing).toHaveBeenCalledWith( + bankAccountCollection, + ...additionalBankAccounts.map(expect.objectContaining), + ); + expect(comp.bankAccountsSharedCollection).toEqual(expectedCollection); + }); + + it('Should call Label query and add missing value', () => { + const operation: IOperation = { id: 456 }; + const labels: ILabel[] = [{ id: 21293 }]; + operation.labels = labels; + + const labelCollection: ILabel[] = [{ id: 8783 }]; + jest.spyOn(labelService, 'query').mockReturnValue(of(new HttpResponse({ body: labelCollection }))); + const additionalLabels = [...labels]; + const expectedCollection: ILabel[] = [...additionalLabels, ...labelCollection]; + jest.spyOn(labelService, 'addLabelToCollectionIfMissing').mockReturnValue(expectedCollection); + + activatedRoute.data = of({ operation }); + comp.ngOnInit(); + + expect(labelService.query).toHaveBeenCalled(); + expect(labelService.addLabelToCollectionIfMissing).toHaveBeenCalledWith( + labelCollection, + ...additionalLabels.map(expect.objectContaining), + ); + expect(comp.labelsSharedCollection).toEqual(expectedCollection); + }); + + it('Should update editForm', () => { + const operation: IOperation = { id: 456 }; + const bankAccount: IBankAccount = { id: 6174 }; + operation.bankAccount = bankAccount; + const label: ILabel = { id: 25360 }; + operation.labels = [label]; + + activatedRoute.data = of({ operation }); + comp.ngOnInit(); + + expect(comp.bankAccountsSharedCollection).toContain(bankAccount); + expect(comp.labelsSharedCollection).toContain(label); + expect(comp.operation).toEqual(operation); + }); + }); + + describe('save', () => { + it('Should call update service on save for existing entity', () => { + // GIVEN + const saveSubject = new Subject>(); + const operation = { id: 123 }; + jest.spyOn(operationFormService, 'getOperation').mockReturnValue(operation); + jest.spyOn(operationService, 'update').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ operation }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.next(new HttpResponse({ body: operation })); + saveSubject.complete(); + + // THEN + expect(operationFormService.getOperation).toHaveBeenCalled(); + expect(comp.previousState).toHaveBeenCalled(); + expect(operationService.update).toHaveBeenCalledWith(expect.objectContaining(operation)); + expect(comp.isSaving).toEqual(false); + }); + + it('Should call create service on save for new entity', () => { + // GIVEN + const saveSubject = new Subject>(); + const operation = { id: 123 }; + jest.spyOn(operationFormService, 'getOperation').mockReturnValue({ id: null }); + jest.spyOn(operationService, 'create').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ operation: null }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.next(new HttpResponse({ body: operation })); + saveSubject.complete(); + + // THEN + expect(operationFormService.getOperation).toHaveBeenCalled(); + expect(operationService.create).toHaveBeenCalled(); + expect(comp.isSaving).toEqual(false); + expect(comp.previousState).toHaveBeenCalled(); + }); + + it('Should set isSaving to false on error', () => { + // GIVEN + const saveSubject = new Subject>(); + const operation = { id: 123 }; + jest.spyOn(operationService, 'update').mockReturnValue(saveSubject); + jest.spyOn(comp, 'previousState'); + activatedRoute.data = of({ operation }); + comp.ngOnInit(); + + // WHEN + comp.save(); + expect(comp.isSaving).toEqual(true); + saveSubject.error('This is an error!'); + + // THEN + expect(operationService.update).toHaveBeenCalled(); + expect(comp.isSaving).toEqual(false); + expect(comp.previousState).not.toHaveBeenCalled(); + }); + }); + + describe('Compare relationships', () => { + describe('compareBankAccount', () => { + it('Should forward to bankAccountService', () => { + const entity = { id: 123 }; + const entity2 = { id: 456 }; + jest.spyOn(bankAccountService, 'compareBankAccount'); + comp.compareBankAccount(entity, entity2); + expect(bankAccountService.compareBankAccount).toHaveBeenCalledWith(entity, entity2); + }); + }); + + describe('compareLabel', () => { + it('Should forward to labelService', () => { + const entity = { id: 123 }; + const entity2 = { id: 456 }; + jest.spyOn(labelService, 'compareLabel'); + comp.compareLabel(entity, entity2); + expect(labelService.compareLabel).toHaveBeenCalledWith(entity, entity2); + }); + }); + }); +}); diff --git a/src/main/webapp/app/entities/operation/update/operation-update.component.ts b/src/main/webapp/app/entities/operation/update/operation-update.component.ts new file mode 100644 index 000000000..fc25156e8 --- /dev/null +++ b/src/main/webapp/app/entities/operation/update/operation-update.component.ts @@ -0,0 +1,120 @@ +import { Component, OnInit } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { finalize, map } from 'rxjs/operators'; + +import SharedModule from 'app/shared/shared.module'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { IBankAccount } from 'app/entities/bank-account/bank-account.model'; +import { BankAccountService } from 'app/entities/bank-account/service/bank-account.service'; +import { ILabel } from 'app/entities/label/label.model'; +import { LabelService } from 'app/entities/label/service/label.service'; +import { OperationService } from '../service/operation.service'; +import { IOperation } from '../operation.model'; +import { OperationFormService, OperationFormGroup } from './operation-form.service'; + +@Component({ + standalone: true, + selector: 'jhi-operation-update', + templateUrl: './operation-update.component.html', + imports: [SharedModule, FormsModule, ReactiveFormsModule], +}) +export class OperationUpdateComponent implements OnInit { + isSaving = false; + operation: IOperation | null = null; + + bankAccountsSharedCollection: IBankAccount[] = []; + labelsSharedCollection: ILabel[] = []; + + editForm: OperationFormGroup = this.operationFormService.createOperationFormGroup(); + + constructor( + protected operationService: OperationService, + protected operationFormService: OperationFormService, + protected bankAccountService: BankAccountService, + protected labelService: LabelService, + protected activatedRoute: ActivatedRoute, + ) {} + + compareBankAccount = (o1: IBankAccount | null, o2: IBankAccount | null): boolean => this.bankAccountService.compareBankAccount(o1, o2); + + compareLabel = (o1: ILabel | null, o2: ILabel | null): boolean => this.labelService.compareLabel(o1, o2); + + ngOnInit(): void { + this.activatedRoute.data.subscribe(({ operation }) => { + this.operation = operation; + if (operation) { + this.updateForm(operation); + } + + this.loadRelationshipsOptions(); + }); + } + + previousState(): void { + window.history.back(); + } + + save(): void { + this.isSaving = true; + const operation = this.operationFormService.getOperation(this.editForm); + if (operation.id !== null) { + this.subscribeToSaveResponse(this.operationService.update(operation)); + } else { + this.subscribeToSaveResponse(this.operationService.create(operation)); + } + } + + protected subscribeToSaveResponse(result: Observable>): void { + result.pipe(finalize(() => this.onSaveFinalize())).subscribe({ + next: () => this.onSaveSuccess(), + error: () => this.onSaveError(), + }); + } + + protected onSaveSuccess(): void { + this.previousState(); + } + + protected onSaveError(): void { + // Api for inheritance. + } + + protected onSaveFinalize(): void { + this.isSaving = false; + } + + protected updateForm(operation: IOperation): void { + this.operation = operation; + this.operationFormService.resetForm(this.editForm, operation); + + this.bankAccountsSharedCollection = this.bankAccountService.addBankAccountToCollectionIfMissing( + this.bankAccountsSharedCollection, + operation.bankAccount, + ); + this.labelsSharedCollection = this.labelService.addLabelToCollectionIfMissing( + this.labelsSharedCollection, + ...(operation.labels ?? []), + ); + } + + protected loadRelationshipsOptions(): void { + this.bankAccountService + .query() + .pipe(map((res: HttpResponse) => res.body ?? [])) + .pipe( + map((bankAccounts: IBankAccount[]) => + this.bankAccountService.addBankAccountToCollectionIfMissing(bankAccounts, this.operation?.bankAccount), + ), + ) + .subscribe((bankAccounts: IBankAccount[]) => (this.bankAccountsSharedCollection = bankAccounts)); + + this.labelService + .query() + .pipe(map((res: HttpResponse) => res.body ?? [])) + .pipe(map((labels: ILabel[]) => this.labelService.addLabelToCollectionIfMissing(labels, ...(this.operation?.labels ?? [])))) + .subscribe((labels: ILabel[]) => (this.labelsSharedCollection = labels)); + } +} diff --git a/src/main/webapp/app/entities/user/user.model.ts b/src/main/webapp/app/entities/user/user.model.ts new file mode 100644 index 000000000..74137a9b1 --- /dev/null +++ b/src/main/webapp/app/entities/user/user.model.ts @@ -0,0 +1,15 @@ +export interface IUser { + id: number; + login?: string; +} + +export class User implements IUser { + constructor( + public id: number, + public login: string, + ) {} +} + +export function getUserIdentifier(user: IUser): number { + return user.id; +} diff --git a/src/main/webapp/app/entities/user/user.service.spec.ts b/src/main/webapp/app/entities/user/user.service.spec.ts new file mode 100644 index 000000000..27fd86056 --- /dev/null +++ b/src/main/webapp/app/entities/user/user.service.spec.ts @@ -0,0 +1,109 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { User, IUser } from './user.model'; + +import { UserService } from './user.service'; + +describe('User Service', () => { + let service: UserService; + let httpMock: HttpTestingController; + let expectedResult: IUser | IUser[] | boolean | number | null; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + expectedResult = null; + service = TestBed.inject(UserService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should return Users', () => { + service.query().subscribe(received => { + expectedResult = received.body; + }); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush([new User(123, 'user')]); + expect(expectedResult).toEqual([{ id: 123, login: 'user' }]); + }); + + it('should propagate not found response', () => { + service.query().subscribe({ + error: (error: HttpErrorResponse) => (expectedResult = error.status), + }); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush('Internal Server Error', { + status: 500, + statusText: 'Inernal Server Error', + }); + expect(expectedResult).toEqual(500); + }); + + describe('addUserToCollectionIfMissing', () => { + it('should add a User to an empty array', () => { + const user: IUser = { id: 123 }; + expectedResult = service.addUserToCollectionIfMissing([], user); + expect(expectedResult).toHaveLength(1); + expect(expectedResult).toContain(user); + }); + + it('should not add a User to an array that contains it', () => { + const user: IUser = { id: 123 }; + const userCollection: IUser[] = [ + { + ...user, + }, + { id: 456 }, + ]; + expectedResult = service.addUserToCollectionIfMissing(userCollection, user); + expect(expectedResult).toHaveLength(2); + }); + + it("should add a User to an array that doesn't contain it", () => { + const user: IUser = { id: 123 }; + const userCollection: IUser[] = [{ id: 456 }]; + expectedResult = service.addUserToCollectionIfMissing(userCollection, user); + expect(expectedResult).toHaveLength(2); + expect(expectedResult).toContain(user); + }); + + it('should add only unique User to an array', () => { + const userArray: IUser[] = [{ id: 123 }, { id: 456 }, { id: 9396 }]; + const userCollection: IUser[] = [{ id: 456 }]; + expectedResult = service.addUserToCollectionIfMissing(userCollection, ...userArray); + expect(expectedResult).toHaveLength(3); + }); + + it('should accept varargs', () => { + const user: IUser = { id: 123 }; + const user2: IUser = { id: 456 }; + expectedResult = service.addUserToCollectionIfMissing([], user, user2); + expect(expectedResult).toHaveLength(2); + expect(expectedResult).toContain(user); + expect(expectedResult).toContain(user2); + }); + + it('should accept null and undefined values', () => { + const user: IUser = { id: 123 }; + expectedResult = service.addUserToCollectionIfMissing([], null, user, undefined); + expect(expectedResult).toHaveLength(1); + expect(expectedResult).toContain(user); + }); + + it('should return initial array if no users is added', () => { + const userCollection: IUser[] = [{ id: 456 }]; + expectedResult = service.addUserToCollectionIfMissing(userCollection, null, undefined); + expect(expectedResult).toEqual(userCollection); + }); + }); + }); +}); diff --git a/src/main/webapp/app/entities/user/user.service.ts b/src/main/webapp/app/entities/user/user.service.ts new file mode 100644 index 000000000..02f2c6597 --- /dev/null +++ b/src/main/webapp/app/entities/user/user.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { createRequestOption } from 'app/core/request/request-util'; +import { isPresent } from 'app/core/util/operators'; +import { Pagination } from 'app/core/request/request.model'; +import { IUser, getUserIdentifier } from './user.model'; + +@Injectable({ providedIn: 'root' }) +export class UserService { + private resourceUrl = this.applicationConfigService.getEndpointFor('api/users'); + + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + query(req?: Pagination): Observable> { + const options = createRequestOption(req); + return this.http.get(this.resourceUrl, { params: options, observe: 'response' }); + } + + compareUser(o1: Pick | null, o2: Pick | null): boolean { + return o1 && o2 ? o1.id === o2.id : o1 === o2; + } + + addUserToCollectionIfMissing & Pick>( + userCollection: Type[], + ...usersToCheck: (Type | null | undefined)[] + ): IUser[] { + const users: Type[] = usersToCheck.filter(isPresent); + if (users.length > 0) { + const userCollectionIdentifiers = userCollection.map(userItem => getUserIdentifier(userItem)!); + const usersToAdd = users.filter(userItem => { + const userIdentifier = getUserIdentifier(userItem); + if (userCollectionIdentifiers.includes(userIdentifier)) { + return false; + } + userCollectionIdentifiers.push(userIdentifier); + return true; + }); + return [...usersToAdd, ...userCollection]; + } + return userCollection; + } +} diff --git a/src/main/webapp/app/home/home.component.html b/src/main/webapp/app/home/home.component.html new file mode 100644 index 000000000..9945eb119 --- /dev/null +++ b/src/main/webapp/app/home/home.component.html @@ -0,0 +1,78 @@ +
+
+ +
+ +
+

Welcome, Java Hipster! (Jhipster Sample Application)

+ +

This is your homepage

+ +
+
+ You are logged in as user "{{ account.login }}". +
+ +
+ If you want to + sign in, you can try the default accounts:
- Administrator (login="admin" and password="admin")
- User (login="user" and + password="user").
+
+ +
+ You don't have an account yet?  + Register a new account +
+
+ +

If you have any question on JHipster:

+ + + +

+ If you like JHipster, don't forget to give us a star on + GitHub! +

+
+
diff --git a/src/main/webapp/app/home/home.component.scss b/src/main/webapp/app/home/home.component.scss new file mode 100644 index 000000000..a22e3e880 --- /dev/null +++ b/src/main/webapp/app/home/home.component.scss @@ -0,0 +1,23 @@ +/* ========================================================================== +Main page styles +========================================================================== */ + +.hipster { + display: inline-block; + width: 347px; + height: 497px; + background: url('../../content/images/jhipster_family_member_1.svg') no-repeat center top; + background-size: contain; +} + +/* wait autoprefixer update to allow simple generation of high pixel density media query */ +@media only screen and (-webkit-min-device-pixel-ratio: 2), + only screen and (-moz-min-device-pixel-ratio: 2), + only screen and (-o-min-device-pixel-ratio: 2/1), + only screen and (min-resolution: 192dpi), + only screen and (min-resolution: 2dppx) { + .hipster { + background: url('../../content/images/jhipster_family_member_1.svg') no-repeat center top; + background-size: contain; + } +} diff --git a/src/main/webapp/app/home/home.component.spec.ts b/src/main/webapp/app/home/home.component.spec.ts new file mode 100644 index 000000000..c5122231f --- /dev/null +++ b/src/main/webapp/app/home/home.component.spec.ts @@ -0,0 +1,111 @@ +jest.mock('app/core/auth/account.service'); + +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, Subject } from 'rxjs'; + +import { AccountService } from 'app/core/auth/account.service'; +import { Account } from 'app/core/auth/account.model'; + +import HomeComponent from './home.component'; + +describe('Home Component', () => { + let comp: HomeComponent; + let fixture: ComponentFixture; + let mockAccountService: AccountService; + let mockRouter: Router; + const account: Account = { + activated: true, + authorities: [], + email: '', + firstName: null, + langKey: '', + lastName: null, + login: 'login', + imageUrl: null, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HomeComponent, RouterTestingModule.withRoutes([])], + providers: [AccountService], + }) + .overrideTemplate(HomeComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HomeComponent); + comp = fixture.componentInstance; + mockAccountService = TestBed.inject(AccountService); + mockAccountService.identity = jest.fn(() => of(null)); + mockAccountService.getAuthenticationState = jest.fn(() => of(null)); + + mockRouter = TestBed.inject(Router); + jest.spyOn(mockRouter, 'navigate').mockImplementation(() => Promise.resolve(true)); + }); + + describe('ngOnInit', () => { + it('Should synchronize account variable with current account', () => { + // GIVEN + const authenticationState = new Subject(); + mockAccountService.getAuthenticationState = jest.fn(() => authenticationState.asObservable()); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.account).toBeNull(); + + // WHEN + authenticationState.next(account); + + // THEN + expect(comp.account).toEqual(account); + + // WHEN + authenticationState.next(null); + + // THEN + expect(comp.account).toBeNull(); + }); + }); + + describe('login', () => { + it('Should navigate to /login on login', () => { + // WHEN + comp.login(); + + // THEN + expect(mockRouter.navigate).toHaveBeenCalledWith(['/login']); + }); + }); + + describe('ngOnDestroy', () => { + it('Should destroy authentication state subscription on component destroy', () => { + // GIVEN + const authenticationState = new Subject(); + mockAccountService.getAuthenticationState = jest.fn(() => authenticationState.asObservable()); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.account).toBeNull(); + + // WHEN + authenticationState.next(account); + + // THEN + expect(comp.account).toEqual(account); + + // WHEN + comp.ngOnDestroy(); + authenticationState.next(null); + + // THEN + expect(comp.account).toEqual(account); + }); + }); +}); diff --git a/src/main/webapp/app/home/home.component.ts b/src/main/webapp/app/home/home.component.ts new file mode 100644 index 000000000..99c64b225 --- /dev/null +++ b/src/main/webapp/app/home/home.component.ts @@ -0,0 +1,42 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import SharedModule from 'app/shared/shared.module'; +import { AccountService } from 'app/core/auth/account.service'; +import { Account } from 'app/core/auth/account.model'; + +@Component({ + standalone: true, + selector: 'jhi-home', + templateUrl: './home.component.html', + styleUrls: ['./home.component.scss'], + imports: [SharedModule, RouterModule], +}) +export default class HomeComponent implements OnInit, OnDestroy { + account: Account | null = null; + + private readonly destroy$ = new Subject(); + + constructor( + private accountService: AccountService, + private router: Router, + ) {} + + ngOnInit(): void { + this.accountService + .getAuthenticationState() + .pipe(takeUntil(this.destroy$)) + .subscribe(account => (this.account = account)); + } + + login(): void { + this.router.navigate(['/login']); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/main/webapp/app/layouts/error/error.component.html b/src/main/webapp/app/layouts/error/error.component.html new file mode 100644 index 000000000..c1b52a551 --- /dev/null +++ b/src/main/webapp/app/layouts/error/error.component.html @@ -0,0 +1,15 @@ +
+
+
+ +
+ +
+

Error page!

+ +
+
{{ errorMessage }}
+
+
+
+
diff --git a/src/main/webapp/app/layouts/error/error.component.ts b/src/main/webapp/app/layouts/error/error.component.ts new file mode 100644 index 000000000..9767ef62a --- /dev/null +++ b/src/main/webapp/app/layouts/error/error.component.ts @@ -0,0 +1,47 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import SharedModule from 'app/shared/shared.module'; + +@Component({ + standalone: true, + selector: 'jhi-error', + templateUrl: './error.component.html', + imports: [SharedModule], +}) +export default class ErrorComponent implements OnInit, OnDestroy { + errorMessage?: string; + errorKey?: string; + langChangeSubscription?: Subscription; + + constructor( + private translateService: TranslateService, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + this.route.data.subscribe(routeData => { + if (routeData.errorMessage) { + this.errorKey = routeData.errorMessage; + this.getErrorMessageTranslation(); + this.langChangeSubscription = this.translateService.onLangChange.subscribe(() => this.getErrorMessageTranslation()); + } + }); + } + + ngOnDestroy(): void { + if (this.langChangeSubscription) { + this.langChangeSubscription.unsubscribe(); + } + } + + private getErrorMessageTranslation(): void { + this.errorMessage = ''; + if (this.errorKey) { + this.translateService.get(this.errorKey).subscribe(translatedErrorMessage => { + this.errorMessage = translatedErrorMessage; + }); + } + } +} diff --git a/src/main/webapp/app/layouts/error/error.route.ts b/src/main/webapp/app/layouts/error/error.route.ts new file mode 100644 index 000000000..85f911b5f --- /dev/null +++ b/src/main/webapp/app/layouts/error/error.route.ts @@ -0,0 +1,31 @@ +import { Routes } from '@angular/router'; + +import ErrorComponent from './error.component'; + +export const errorRoute: Routes = [ + { + path: 'error', + component: ErrorComponent, + title: 'error.title', + }, + { + path: 'accessdenied', + component: ErrorComponent, + data: { + errorMessage: 'error.http.403', + }, + title: 'error.title', + }, + { + path: '404', + component: ErrorComponent, + data: { + errorMessage: 'error.http.404', + }, + title: 'error.title', + }, + { + path: '**', + redirectTo: '/404', + }, +]; diff --git a/src/main/webapp/app/layouts/footer/footer.component.html b/src/main/webapp/app/layouts/footer/footer.component.html new file mode 100644 index 000000000..30aca31d3 --- /dev/null +++ b/src/main/webapp/app/layouts/footer/footer.component.html @@ -0,0 +1,3 @@ + diff --git a/src/main/webapp/app/layouts/footer/footer.component.ts b/src/main/webapp/app/layouts/footer/footer.component.ts new file mode 100644 index 000000000..7ab093848 --- /dev/null +++ b/src/main/webapp/app/layouts/footer/footer.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + standalone: true, + selector: 'jhi-footer', + templateUrl: './footer.component.html', +}) +export default class FooterComponent {} diff --git a/src/main/webapp/app/layouts/main/main.component.html b/src/main/webapp/app/layouts/main/main.component.html new file mode 100644 index 000000000..3ac9be942 --- /dev/null +++ b/src/main/webapp/app/layouts/main/main.component.html @@ -0,0 +1,13 @@ + + +
+ +
+ +
+
+ +
+ + +
diff --git a/src/main/webapp/app/layouts/main/main.component.spec.ts b/src/main/webapp/app/layouts/main/main.component.spec.ts new file mode 100644 index 000000000..488b91c3a --- /dev/null +++ b/src/main/webapp/app/layouts/main/main.component.spec.ts @@ -0,0 +1,230 @@ +jest.mock('app/core/auth/account.service'); + +import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { Router, TitleStrategy } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { DOCUMENT } from '@angular/common'; +import { Component } from '@angular/core'; +import { of } from 'rxjs'; +import { TranslateModule, TranslateService, LangChangeEvent } from '@ngx-translate/core'; + +import { AccountService } from 'app/core/auth/account.service'; + +import { AppPageTitleStrategy } from 'app/app-page-title-strategy'; +import MainComponent from './main.component'; + +describe('MainComponent', () => { + let comp: MainComponent; + let fixture: ComponentFixture; + let titleService: Title; + let translateService: TranslateService; + let mockAccountService: AccountService; + const routerState: any = { snapshot: { root: { data: {} } } }; + let router: Router; + let document: Document; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule], + declarations: [MainComponent], + providers: [Title, AccountService, { provide: TitleStrategy, useClass: AppPageTitleStrategy }], + }) + .overrideTemplate(MainComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MainComponent); + comp = fixture.componentInstance; + titleService = TestBed.inject(Title); + translateService = TestBed.inject(TranslateService); + mockAccountService = TestBed.inject(AccountService); + mockAccountService.identity = jest.fn(() => of(null)); + mockAccountService.getAuthenticationState = jest.fn(() => of(null)); + router = TestBed.inject(Router); + document = TestBed.inject(DOCUMENT); + }); + + describe('page title', () => { + const defaultPageTitle = 'global.title'; + const parentRoutePageTitle = 'parentTitle'; + const childRoutePageTitle = 'childTitle'; + const langChangeEvent: LangChangeEvent = { lang: 'en', translations: null }; + + beforeEach(() => { + routerState.snapshot.root = { data: {} }; + jest.spyOn(translateService, 'get').mockImplementation((key: string | string[]) => of(`${key as string} translated`)); + translateService.currentLang = 'en'; + jest.spyOn(titleService, 'setTitle'); + comp.ngOnInit(); + }); + + describe('navigation end', () => { + it('should set page title to default title if pageTitle is missing on routes', fakeAsync(() => { + // WHEN + router.navigateByUrl(''); + tick(); + + // THEN + expect(document.title).toBe(defaultPageTitle + ' translated'); + })); + + it('should set page title to root route pageTitle if there is no child routes', fakeAsync(() => { + // GIVEN + router.resetConfig([{ path: '', title: parentRoutePageTitle, component: BlankComponent }]); + + // WHEN + router.navigateByUrl(''); + tick(); + + // THEN + expect(document.title).toBe(parentRoutePageTitle + ' translated'); + })); + + it('should set page title to child route pageTitle if child routes exist and pageTitle is set for child route', fakeAsync(() => { + // GIVEN + router.resetConfig([ + { + path: 'home', + title: parentRoutePageTitle, + children: [{ path: '', title: childRoutePageTitle, component: BlankComponent }], + }, + ]); + + // WHEN + router.navigateByUrl('home'); + tick(); + + // THEN + expect(document.title).toBe(childRoutePageTitle + ' translated'); + })); + + it('should set page title to parent route pageTitle if child routes exists but pageTitle is not set for child route data', fakeAsync(() => { + // GIVEN + router.resetConfig([ + { + path: 'home', + title: parentRoutePageTitle, + children: [{ path: '', component: BlankComponent }], + }, + ]); + + // WHEN + router.navigateByUrl('home'); + tick(); + + // THEN + expect(document.title).toBe(parentRoutePageTitle + ' translated'); + })); + }); + + describe('language change', () => { + it('should set page title to default title if pageTitle is missing on routes', () => { + // WHEN + translateService.onLangChange.emit(langChangeEvent); + + // THEN + expect(document.title).toBe(defaultPageTitle + ' translated'); + }); + + it('should set page title to root route pageTitle if there is no child routes', fakeAsync(() => { + // GIVEN + routerState.snapshot.root.data = { pageTitle: parentRoutePageTitle }; + router.resetConfig([{ path: '', title: parentRoutePageTitle, component: BlankComponent }]); + + // WHEN + router.navigateByUrl(''); + tick(); + + // THEN + expect(document.title).toBe(parentRoutePageTitle + ' translated'); + + // GIVEN + document.title = 'other title'; + + // WHEN + translateService.onLangChange.emit(langChangeEvent); + + // THEN + expect(document.title).toBe(parentRoutePageTitle + ' translated'); + })); + + it('should set page title to child route pageTitle if child routes exist and pageTitle is set for child route', fakeAsync(() => { + // GIVEN + router.resetConfig([ + { + path: 'home', + title: parentRoutePageTitle, + children: [{ path: '', title: childRoutePageTitle, component: BlankComponent }], + }, + ]); + + // WHEN + router.navigateByUrl('home'); + tick(); + + // THEN + expect(document.title).toBe(childRoutePageTitle + ' translated'); + + // GIVEN + document.title = 'other title'; + + // WHEN + translateService.onLangChange.emit(langChangeEvent); + + // THEN + expect(document.title).toBe(childRoutePageTitle + ' translated'); + })); + + it('should set page title to parent route pageTitle if child routes exists but pageTitle is not set for child route data', fakeAsync(() => { + // GIVEN + router.resetConfig([ + { + path: 'home', + title: parentRoutePageTitle, + children: [{ path: '', component: BlankComponent }], + }, + ]); + + // WHEN + router.navigateByUrl('home'); + tick(); + + // THEN + expect(document.title).toBe(parentRoutePageTitle + ' translated'); + + // GIVEN + document.title = 'other title'; + + // WHEN + translateService.onLangChange.emit(langChangeEvent); + + // THEN + expect(document.title).toBe(parentRoutePageTitle + ' translated'); + })); + }); + }); + + describe('page language attribute', () => { + it('should change page language attribute on language change', () => { + // GIVEN + comp.ngOnInit(); + + // WHEN + translateService.onLangChange.emit({ lang: 'lang1', translations: null }); + + // THEN + expect(document.querySelector('html')?.getAttribute('lang')).toEqual('lang1'); + + // WHEN + translateService.onLangChange.emit({ lang: 'lang2', translations: null }); + + // THEN + expect(document.querySelector('html')?.getAttribute('lang')).toEqual('lang2'); + }); + }); +}); + +@Component({ template: '' }) +export class BlankComponent {} diff --git a/src/main/webapp/app/layouts/main/main.component.ts b/src/main/webapp/app/layouts/main/main.component.ts new file mode 100644 index 000000000..e70bd5ac2 --- /dev/null +++ b/src/main/webapp/app/layouts/main/main.component.ts @@ -0,0 +1,37 @@ +import { Component, OnInit, RendererFactory2, Renderer2 } from '@angular/core'; +import { TranslateService, LangChangeEvent } from '@ngx-translate/core'; +import dayjs from 'dayjs/esm'; + +import { AccountService } from 'app/core/auth/account.service'; +import { AppPageTitleStrategy } from 'app/app-page-title-strategy'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'jhi-main', + templateUrl: './main.component.html', + providers: [AppPageTitleStrategy], +}) +export default class MainComponent implements OnInit { + private renderer: Renderer2; + + constructor( + private router: Router, + private appPageTitleStrategy: AppPageTitleStrategy, + private accountService: AccountService, + private translateService: TranslateService, + rootRenderer: RendererFactory2, + ) { + this.renderer = rootRenderer.createRenderer(document.querySelector('html'), null); + } + + ngOnInit(): void { + // try to log in automatically + this.accountService.identity().subscribe(); + + this.translateService.onLangChange.subscribe((langChangeEvent: LangChangeEvent) => { + this.appPageTitleStrategy.updateTitle(this.router.routerState.snapshot); + dayjs.locale(langChangeEvent.lang); + this.renderer.setAttribute(document.querySelector('html'), 'lang', langChangeEvent.lang); + }); + } +} diff --git a/src/main/webapp/app/layouts/main/main.module.ts b/src/main/webapp/app/layouts/main/main.module.ts new file mode 100644 index 000000000..f03354939 --- /dev/null +++ b/src/main/webapp/app/layouts/main/main.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import SharedModule from 'app/shared/shared.module'; +import FooterComponent from '../footer/footer.component'; +import PageRibbonComponent from '../profiles/page-ribbon.component'; +import MainComponent from './main.component'; + +@NgModule({ + imports: [SharedModule, RouterModule, FooterComponent, PageRibbonComponent], + declarations: [MainComponent], +}) +export default class MainModule {} diff --git a/src/main/webapp/app/layouts/navbar/active-menu.directive.ts b/src/main/webapp/app/layouts/navbar/active-menu.directive.ts new file mode 100644 index 000000000..3b0e48d1f --- /dev/null +++ b/src/main/webapp/app/layouts/navbar/active-menu.directive.ts @@ -0,0 +1,32 @@ +import { Directive, OnInit, ElementRef, Renderer2, Input } from '@angular/core'; +import { TranslateService, LangChangeEvent } from '@ngx-translate/core'; + +@Directive({ + standalone: true, + selector: '[jhiActiveMenu]', +}) +export default class ActiveMenuDirective implements OnInit { + @Input() jhiActiveMenu?: string; + + constructor( + private el: ElementRef, + private renderer: Renderer2, + private translateService: TranslateService, + ) {} + + ngOnInit(): void { + this.translateService.onLangChange.subscribe((event: LangChangeEvent) => { + this.updateActiveFlag(event.lang); + }); + + this.updateActiveFlag(this.translateService.currentLang); + } + + updateActiveFlag(selectedLanguage: string): void { + if (this.jhiActiveMenu === selectedLanguage) { + this.renderer.addClass(this.el.nativeElement, 'active'); + } else { + this.renderer.removeClass(this.el.nativeElement, 'active'); + } + } +} diff --git a/src/main/webapp/app/layouts/navbar/navbar-item.model.d.ts b/src/main/webapp/app/layouts/navbar/navbar-item.model.d.ts new file mode 100644 index 000000000..d6f1cca11 --- /dev/null +++ b/src/main/webapp/app/layouts/navbar/navbar-item.model.d.ts @@ -0,0 +1,7 @@ +type NavbarItem = { + name: string; + route: string; + translationKey: string; +}; + +export default NavbarItem; diff --git a/src/main/webapp/app/layouts/navbar/navbar.component.html b/src/main/webapp/app/layouts/navbar/navbar.component.html new file mode 100644 index 000000000..396564c0b --- /dev/null +++ b/src/main/webapp/app/layouts/navbar/navbar.component.html @@ -0,0 +1,234 @@ + diff --git a/src/main/webapp/app/layouts/navbar/navbar.component.scss b/src/main/webapp/app/layouts/navbar/navbar.component.scss new file mode 100644 index 000000000..90617d40b --- /dev/null +++ b/src/main/webapp/app/layouts/navbar/navbar.component.scss @@ -0,0 +1,36 @@ +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; + +/* ========================================================================== +Navbar +========================================================================== */ + +.navbar-version { + font-size: 0.65em; + color: $navbar-dark-color; +} + +.profile-image { + height: 1.75em; + width: 1.75em; +} + +.navbar { + padding: 0.2rem 1rem; + + a.nav-link { + font-weight: 400; + } +} + +/* ========================================================================== +Logo styles +========================================================================== */ +.logo-img { + height: 45px; + width: 45px; + display: inline-block; + vertical-align: middle; + background: url('../../../content/images/logo-jhipster.png') no-repeat center center; + background-size: contain; +} diff --git a/src/main/webapp/app/layouts/navbar/navbar.component.spec.ts b/src/main/webapp/app/layouts/navbar/navbar.component.spec.ts new file mode 100644 index 000000000..983b1c594 --- /dev/null +++ b/src/main/webapp/app/layouts/navbar/navbar.component.spec.ts @@ -0,0 +1,96 @@ +jest.mock('app/login/login.service'); + +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ProfileInfo } from 'app/layouts/profiles/profile-info.model'; +import { Account } from 'app/core/auth/account.model'; +import { AccountService } from 'app/core/auth/account.service'; +import { ProfileService } from 'app/layouts/profiles/profile.service'; +import { LoginService } from 'app/login/login.service'; + +import NavbarComponent from './navbar.component'; + +describe('Navbar Component', () => { + let comp: NavbarComponent; + let fixture: ComponentFixture; + let accountService: AccountService; + let profileService: ProfileService; + const account: Account = { + activated: true, + authorities: [], + email: '', + firstName: 'John', + langKey: '', + lastName: 'Doe', + login: 'john.doe', + imageUrl: '', + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NavbarComponent, HttpClientTestingModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot()], + providers: [LoginService], + }) + .overrideTemplate(NavbarComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NavbarComponent); + comp = fixture.componentInstance; + accountService = TestBed.inject(AccountService); + profileService = TestBed.inject(ProfileService); + }); + + it('Should call profileService.getProfileInfo on init', () => { + // GIVEN + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(new ProfileInfo())); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(profileService.getProfileInfo).toHaveBeenCalled(); + }); + + it('Should hold current authenticated user in variable account', () => { + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.account).toBeNull(); + + // WHEN + accountService.authenticate(account); + + // THEN + expect(comp.account).toEqual(account); + + // WHEN + accountService.authenticate(null); + + // THEN + expect(comp.account).toBeNull(); + }); + + it('Should hold current authenticated user in variable account if user is authenticated before page load', () => { + // GIVEN + accountService.authenticate(account); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.account).toEqual(account); + + // WHEN + accountService.authenticate(null); + + // THEN + expect(comp.account).toBeNull(); + }); +}); diff --git a/src/main/webapp/app/layouts/navbar/navbar.component.ts b/src/main/webapp/app/layouts/navbar/navbar.component.ts new file mode 100644 index 000000000..af49de722 --- /dev/null +++ b/src/main/webapp/app/layouts/navbar/navbar.component.ts @@ -0,0 +1,81 @@ +import { Component, OnInit } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; + +import { StateStorageService } from 'app/core/auth/state-storage.service'; +import SharedModule from 'app/shared/shared.module'; +import HasAnyAuthorityDirective from 'app/shared/auth/has-any-authority.directive'; +import { VERSION } from 'app/app.constants'; +import { LANGUAGES } from 'app/config/language.constants'; +import { Account } from 'app/core/auth/account.model'; +import { AccountService } from 'app/core/auth/account.service'; +import { LoginService } from 'app/login/login.service'; +import { ProfileService } from 'app/layouts/profiles/profile.service'; +import { EntityNavbarItems } from 'app/entities/entity-navbar-items'; +import ActiveMenuDirective from './active-menu.directive'; +import NavbarItem from './navbar-item.model'; + +@Component({ + standalone: true, + selector: 'jhi-navbar', + templateUrl: './navbar.component.html', + styleUrls: ['./navbar.component.scss'], + imports: [RouterModule, SharedModule, HasAnyAuthorityDirective, ActiveMenuDirective], +}) +export default class NavbarComponent implements OnInit { + inProduction?: boolean; + isNavbarCollapsed = true; + languages = LANGUAGES; + openAPIEnabled?: boolean; + version = ''; + account: Account | null = null; + entitiesNavbarItems: NavbarItem[] = []; + + constructor( + private loginService: LoginService, + private translateService: TranslateService, + private stateStorageService: StateStorageService, + private accountService: AccountService, + private profileService: ProfileService, + private router: Router, + ) { + if (VERSION) { + this.version = VERSION.toLowerCase().startsWith('v') ? VERSION : `v${VERSION}`; + } + } + + ngOnInit(): void { + this.entitiesNavbarItems = EntityNavbarItems; + this.profileService.getProfileInfo().subscribe(profileInfo => { + this.inProduction = profileInfo.inProduction; + this.openAPIEnabled = profileInfo.openAPIEnabled; + }); + + this.accountService.getAuthenticationState().subscribe(account => { + this.account = account; + }); + } + + changeLanguage(languageKey: string): void { + this.stateStorageService.storeLocale(languageKey); + this.translateService.use(languageKey); + } + + collapseNavbar(): void { + this.isNavbarCollapsed = true; + } + + login(): void { + this.router.navigate(['/login']); + } + + logout(): void { + this.collapseNavbar(); + this.loginService.logout(); + this.router.navigate(['']); + } + + toggleNavbar(): void { + this.isNavbarCollapsed = !this.isNavbarCollapsed; + } +} diff --git a/src/main/webapp/app/layouts/profiles/page-ribbon.component.scss b/src/main/webapp/app/layouts/profiles/page-ribbon.component.scss new file mode 100644 index 000000000..88b060226 --- /dev/null +++ b/src/main/webapp/app/layouts/profiles/page-ribbon.component.scss @@ -0,0 +1,25 @@ +/* ========================================================================== +Developement Ribbon +========================================================================== */ +.ribbon { + background-color: rgba(170, 0, 0, 0.5); + overflow: hidden; + position: absolute; + top: 40px; + white-space: nowrap; + width: 15em; + z-index: 9999; + pointer-events: none; + opacity: 0.75; + a { + color: #fff; + display: block; + font-weight: 400; + margin: 1px 0; + padding: 10px 50px; + text-align: center; + text-decoration: none; + text-shadow: 0 0 5px #444; + pointer-events: none; + } +} diff --git a/src/main/webapp/app/layouts/profiles/page-ribbon.component.spec.ts b/src/main/webapp/app/layouts/profiles/page-ribbon.component.spec.ts new file mode 100644 index 000000000..4d5176cda --- /dev/null +++ b/src/main/webapp/app/layouts/profiles/page-ribbon.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + +import { ProfileInfo } from 'app/layouts/profiles/profile-info.model'; +import { ProfileService } from 'app/layouts/profiles/profile.service'; + +import PageRibbonComponent from './page-ribbon.component'; + +describe('Page Ribbon Component', () => { + let comp: PageRibbonComponent; + let fixture: ComponentFixture; + let profileService: ProfileService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, PageRibbonComponent], + }) + .overrideTemplate(PageRibbonComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PageRibbonComponent); + comp = fixture.componentInstance; + profileService = TestBed.inject(ProfileService); + }); + + it('Should call profileService.getProfileInfo on init', () => { + // GIVEN + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(new ProfileInfo())); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(profileService.getProfileInfo).toHaveBeenCalled(); + }); +}); diff --git a/src/main/webapp/app/layouts/profiles/page-ribbon.component.ts b/src/main/webapp/app/layouts/profiles/page-ribbon.component.ts new file mode 100644 index 000000000..1cd00f64f --- /dev/null +++ b/src/main/webapp/app/layouts/profiles/page-ribbon.component.ts @@ -0,0 +1,27 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import SharedModule from 'app/shared/shared.module'; +import { ProfileService } from './profile.service'; + +@Component({ + standalone: true, + selector: 'jhi-page-ribbon', + template: ` + + `, + styleUrls: ['./page-ribbon.component.scss'], + imports: [SharedModule], +}) +export default class PageRibbonComponent implements OnInit { + ribbonEnv$?: Observable; + + constructor(private profileService: ProfileService) {} + + ngOnInit(): void { + this.ribbonEnv$ = this.profileService.getProfileInfo().pipe(map(profileInfo => profileInfo.ribbonEnv)); + } +} diff --git a/src/main/webapp/app/layouts/profiles/profile-info.model.ts b/src/main/webapp/app/layouts/profiles/profile-info.model.ts new file mode 100644 index 000000000..14e920f1a --- /dev/null +++ b/src/main/webapp/app/layouts/profiles/profile-info.model.ts @@ -0,0 +1,15 @@ +export interface InfoResponse { + 'display-ribbon-on-profiles'?: string; + git?: any; + build?: any; + activeProfiles?: string[]; +} + +export class ProfileInfo { + constructor( + public activeProfiles?: string[], + public ribbonEnv?: string, + public inProduction?: boolean, + public openAPIEnabled?: boolean, + ) {} +} diff --git a/src/main/webapp/app/layouts/profiles/profile.service.ts b/src/main/webapp/app/layouts/profiles/profile.service.ts new file mode 100644 index 000000000..ec11dd34b --- /dev/null +++ b/src/main/webapp/app/layouts/profiles/profile.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { map, shareReplay } from 'rxjs/operators'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { ProfileInfo, InfoResponse } from './profile-info.model'; + +@Injectable({ providedIn: 'root' }) +export class ProfileService { + private infoUrl = this.applicationConfigService.getEndpointFor('management/info'); + private profileInfo$?: Observable; + + constructor( + private http: HttpClient, + private applicationConfigService: ApplicationConfigService, + ) {} + + getProfileInfo(): Observable { + if (this.profileInfo$) { + return this.profileInfo$; + } + + this.profileInfo$ = this.http.get(this.infoUrl).pipe( + map((response: InfoResponse) => { + const profileInfo: ProfileInfo = { + activeProfiles: response.activeProfiles, + inProduction: response.activeProfiles?.includes('prod'), + openAPIEnabled: response.activeProfiles?.includes('api-docs'), + }; + if (response.activeProfiles && response['display-ribbon-on-profiles']) { + const displayRibbonOnProfiles = response['display-ribbon-on-profiles'].split(','); + const ribbonProfiles = displayRibbonOnProfiles.filter(profile => response.activeProfiles?.includes(profile)); + if (ribbonProfiles.length > 0) { + profileInfo.ribbonEnv = ribbonProfiles[0]; + } + } + return profileInfo; + }), + shareReplay(), + ); + return this.profileInfo$; + } +} diff --git a/src/main/webapp/app/login/login.component.html b/src/main/webapp/app/login/login.component.html new file mode 100644 index 000000000..f4e6b31bb --- /dev/null +++ b/src/main/webapp/app/login/login.component.html @@ -0,0 +1,57 @@ +
+
+
+

Sign in

+
+ Failed to sign in! Please check your credentials and try again. +
+
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + +
+ You don't have an account yet? + Register a new account +
+
+
+
diff --git a/src/main/webapp/app/login/login.component.spec.ts b/src/main/webapp/app/login/login.component.spec.ts new file mode 100644 index 000000000..b537b6020 --- /dev/null +++ b/src/main/webapp/app/login/login.component.spec.ts @@ -0,0 +1,152 @@ +jest.mock('app/core/auth/account.service'); +jest.mock('app/login/login.service'); + +import { ElementRef } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { Router, Navigation } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; + +import { AccountService } from 'app/core/auth/account.service'; + +import { LoginService } from './login.service'; +import LoginComponent from './login.component'; + +describe('LoginComponent', () => { + let comp: LoginComponent; + let fixture: ComponentFixture; + let mockRouter: Router; + let mockAccountService: AccountService; + let mockLoginService: LoginService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([]), LoginComponent], + providers: [ + FormBuilder, + AccountService, + { + provide: LoginService, + useValue: { + login: jest.fn(() => of({})), + }, + }, + ], + }) + .overrideTemplate(LoginComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + comp = fixture.componentInstance; + mockRouter = TestBed.inject(Router); + jest.spyOn(mockRouter, 'navigate').mockImplementation(() => Promise.resolve(true)); + mockLoginService = TestBed.inject(LoginService); + mockAccountService = TestBed.inject(AccountService); + }); + + describe('ngOnInit', () => { + it('Should call accountService.identity on Init', () => { + // GIVEN + mockAccountService.identity = jest.fn(() => of(null)); + mockAccountService.getAuthenticationState = jest.fn(() => of(null)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(mockAccountService.identity).toHaveBeenCalled(); + }); + + it('Should call accountService.isAuthenticated on Init', () => { + // GIVEN + mockAccountService.identity = jest.fn(() => of(null)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(mockAccountService.isAuthenticated).toHaveBeenCalled(); + }); + + it('should navigate to home page on Init if authenticated=true', () => { + // GIVEN + mockAccountService.identity = jest.fn(() => of(null)); + mockAccountService.getAuthenticationState = jest.fn(() => of(null)); + mockAccountService.isAuthenticated = () => true; + + // WHEN + comp.ngOnInit(); + + // THEN + expect(mockRouter.navigate).toHaveBeenCalledWith(['']); + }); + }); + + describe('ngAfterViewInit', () => { + it('shoult set focus to username input after the view has been initialized', () => { + // GIVEN + const node = { + focus: jest.fn(), + }; + comp.username = new ElementRef(node); + + // WHEN + comp.ngAfterViewInit(); + + // THEN + expect(node.focus).toHaveBeenCalled(); + }); + }); + + describe('login', () => { + it('should authenticate the user and navigate to home page', () => { + // GIVEN + const credentials = { + username: 'admin', + password: 'admin', + rememberMe: true, + }; + + comp.loginForm.patchValue({ + username: 'admin', + password: 'admin', + rememberMe: true, + }); + + // WHEN + comp.login(); + + // THEN + expect(comp.authenticationError).toEqual(false); + expect(mockLoginService.login).toHaveBeenCalledWith(credentials); + expect(mockRouter.navigate).toHaveBeenCalledWith(['']); + }); + + it('should authenticate the user but not navigate to home page if authentication process is already routing to cached url from localstorage', () => { + // GIVEN + jest.spyOn(mockRouter, 'getCurrentNavigation').mockReturnValue({} as Navigation); + + // WHEN + comp.login(); + + // THEN + expect(comp.authenticationError).toEqual(false); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should stay on login form and show error message on login error', () => { + // GIVEN + mockLoginService.login = jest.fn(() => throwError({})); + + // WHEN + comp.login(); + + // THEN + expect(comp.authenticationError).toEqual(true); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/login/login.component.ts b/src/main/webapp/app/login/login.component.ts new file mode 100644 index 000000000..73b19a961 --- /dev/null +++ b/src/main/webapp/app/login/login.component.ts @@ -0,0 +1,58 @@ +import { Component, ViewChild, OnInit, AfterViewInit, ElementRef } from '@angular/core'; +import { FormGroup, FormControl, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; + +import SharedModule from 'app/shared/shared.module'; +import { LoginService } from 'app/login/login.service'; +import { AccountService } from 'app/core/auth/account.service'; + +@Component({ + selector: 'jhi-login', + standalone: true, + imports: [SharedModule, FormsModule, ReactiveFormsModule, RouterModule], + templateUrl: './login.component.html', +}) +export default class LoginComponent implements OnInit, AfterViewInit { + @ViewChild('username', { static: false }) + username!: ElementRef; + + authenticationError = false; + + loginForm = new FormGroup({ + username: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + password: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + rememberMe: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), + }); + + constructor( + private accountService: AccountService, + private loginService: LoginService, + private router: Router, + ) {} + + ngOnInit(): void { + // if already authenticated then navigate to home page + this.accountService.identity().subscribe(() => { + if (this.accountService.isAuthenticated()) { + this.router.navigate(['']); + } + }); + } + + ngAfterViewInit(): void { + this.username.nativeElement.focus(); + } + + login(): void { + this.loginService.login(this.loginForm.getRawValue()).subscribe({ + next: () => { + this.authenticationError = false; + if (!this.router.getCurrentNavigation()) { + // There were no routing during login (eg from navigationToStoredUrl) + this.router.navigate(['']); + } + }, + error: () => (this.authenticationError = true), + }); + } +} diff --git a/src/main/webapp/app/login/login.model.ts b/src/main/webapp/app/login/login.model.ts new file mode 100644 index 000000000..10faab799 --- /dev/null +++ b/src/main/webapp/app/login/login.model.ts @@ -0,0 +1,7 @@ +export class Login { + constructor( + public username: string, + public password: string, + public rememberMe: boolean, + ) {} +} diff --git a/src/main/webapp/app/login/login.service.ts b/src/main/webapp/app/login/login.service.ts new file mode 100644 index 000000000..7b79a17bf --- /dev/null +++ b/src/main/webapp/app/login/login.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +import { Account } from 'app/core/auth/account.model'; +import { AccountService } from 'app/core/auth/account.service'; +import { AuthServerProvider } from 'app/core/auth/auth-jwt.service'; +import { Login } from './login.model'; + +@Injectable({ providedIn: 'root' }) +export class LoginService { + constructor( + private accountService: AccountService, + private authServerProvider: AuthServerProvider, + ) {} + + login(credentials: Login): Observable { + return this.authServerProvider.login(credentials).pipe(mergeMap(() => this.accountService.identity(true))); + } + + logout(): void { + this.authServerProvider.logout().subscribe({ complete: () => this.accountService.authenticate(null) }); + } +} diff --git a/src/main/webapp/app/shared/alert/alert-error.component.html b/src/main/webapp/app/shared/alert/alert-error.component.html new file mode 100644 index 000000000..76ff881dd --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert-error.component.html @@ -0,0 +1,7 @@ + diff --git a/src/main/webapp/app/shared/alert/alert-error.component.spec.ts b/src/main/webapp/app/shared/alert/alert-error.component.spec.ts new file mode 100644 index 000000000..ef51799b0 --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert-error.component.spec.ts @@ -0,0 +1,159 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EventManager } from 'app/core/util/event-manager.service'; +import { Alert, AlertService } from 'app/core/util/alert.service'; + +import { AlertErrorComponent } from './alert-error.component'; + +describe('Alert Error Component', () => { + let comp: AlertErrorComponent; + let fixture: ComponentFixture; + let eventManager: EventManager; + let alertService: AlertService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), AlertErrorComponent], + providers: [EventManager, AlertService], + }) + .overrideTemplate(AlertErrorComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AlertErrorComponent); + comp = fixture.componentInstance; + eventManager = TestBed.inject(EventManager); + alertService = TestBed.inject(AlertService); + alertService.addAlert = (alert: Alert, alerts?: Alert[]) => { + if (alerts) { + alerts.push(alert); + } + return alert; + }; + }); + + describe('Error Handling', () => { + it('Should display an alert on status 0', () => { + // GIVEN + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: { status: 0 } }); + // THEN + expect(comp.alerts.length).toBe(1); + expect(comp.alerts[0].translationKey).toBe('error.server.not.reachable'); + }); + + it('Should display an alert on status 404', () => { + // GIVEN + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: { status: 404 } }); + // THEN + expect(comp.alerts.length).toBe(1); + expect(comp.alerts[0].translationKey).toBe('error.url.not.found'); + }); + + it('Should display an alert on generic error', () => { + // GIVEN + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: { error: { message: 'Error Message' } } }); + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: { error: 'Second Error Message' } }); + // THEN + expect(comp.alerts.length).toBe(2); + expect(comp.alerts[0].translationKey).toBe('Error Message'); + expect(comp.alerts[1].translationKey).toBe('Second Error Message'); + }); + + it('Should display an alert on status 400 for generic error', () => { + // GIVEN + const response = new HttpErrorResponse({ + url: 'http://localhost:8080/api/foos', + headers: new HttpHeaders(), + status: 400, + statusText: 'Bad Request', + error: { + type: 'https://www.jhipster.tech/problem/constraint-violation', + title: 'Bad Request', + status: 400, + path: '/api/foos', + message: 'error.validation', + }, + }); + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: response }); + // THEN + expect(comp.alerts.length).toBe(1); + expect(comp.alerts[0].translationKey).toBe('error.validation'); + }); + + it('Should display an alert on status 400 for generic error without message', () => { + // GIVEN + const response = new HttpErrorResponse({ + url: 'http://localhost:8080/api/foos', + headers: new HttpHeaders(), + status: 400, + error: 'Bad Request', + }); + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: response }); + // THEN + expect(comp.alerts.length).toBe(1); + expect(comp.alerts[0].translationKey).toBe('Bad Request'); + }); + + it('Should display an alert on status 400 for invalid parameters', () => { + // GIVEN + const response = new HttpErrorResponse({ + url: 'http://localhost:8080/api/foos', + headers: new HttpHeaders(), + status: 400, + statusText: 'Bad Request', + error: { + type: 'https://www.jhipster.tech/problem/constraint-violation', + title: 'Method argument not valid', + status: 400, + path: '/api/foos', + message: 'error.validation', + fieldErrors: [{ objectName: 'foo', field: 'minField', message: 'Min' }], + }, + }); + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: response }); + // THEN + expect(comp.alerts.length).toBe(1); + expect(comp.alerts[0].translationKey).toBe('error.Size'); + }); + + it('Should display an alert on status 400 for error headers', () => { + // GIVEN + const response = new HttpErrorResponse({ + url: 'http://localhost:8080/api/foos', + headers: new HttpHeaders().append('app-error', 'Error Message').append('app-params', 'foo'), + status: 400, + statusText: 'Bad Request', + error: { + status: 400, + message: 'error.validation', + }, + }); + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: response }); + // THEN + expect(comp.alerts.length).toBe(1); + expect(comp.alerts[0].translationKey).toBe('Error Message'); + }); + + it('Should display an alert on status 500 with detail', () => { + // GIVEN + const response = new HttpErrorResponse({ + url: 'http://localhost:8080/api/foos', + headers: new HttpHeaders(), + status: 500, + statusText: 'Internal server error', + error: { + status: 500, + message: 'error.http.500', + detail: 'Detailed error message', + }, + }); + eventManager.broadcast({ name: 'jhipsterSampleApplicationApp.httpError', content: response }); + // THEN + expect(comp.alerts.length).toBe(1); + expect(comp.alerts[0].translationKey).toBe('error.http.500'); + }); + }); +}); diff --git a/src/main/webapp/app/shared/alert/alert-error.component.ts b/src/main/webapp/app/shared/alert/alert-error.component.ts new file mode 100644 index 000000000..4b62606c3 --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert-error.component.ts @@ -0,0 +1,121 @@ +import { Component, OnDestroy } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Subscription } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; + +import { Alert, AlertService } from 'app/core/util/alert.service'; +import { EventManager, EventWithContent } from 'app/core/util/event-manager.service'; +import { AlertError } from './alert-error.model'; + +@Component({ + standalone: true, + selector: 'jhi-alert-error', + templateUrl: './alert-error.component.html', + imports: [CommonModule, NgbModule], +}) +export class AlertErrorComponent implements OnDestroy { + alerts: Alert[] = []; + errorListener: Subscription; + httpErrorListener: Subscription; + + constructor( + private alertService: AlertService, + private eventManager: EventManager, + translateService: TranslateService, + ) { + this.errorListener = eventManager.subscribe('jhipsterSampleApplicationApp.error', (response: EventWithContent | string) => { + const errorResponse = (response as EventWithContent).content; + this.addErrorAlert(errorResponse.message, errorResponse.key, errorResponse.params); + }); + + this.httpErrorListener = eventManager.subscribe( + 'jhipsterSampleApplicationApp.httpError', + (response: EventWithContent | string) => { + const httpErrorResponse = (response as EventWithContent).content; + switch (httpErrorResponse.status) { + // connection refused, server not reachable + case 0: + this.addErrorAlert('Server not reachable', 'error.server.not.reachable'); + break; + + case 400: { + const arr = httpErrorResponse.headers.keys(); + let errorHeader: string | null = null; + let entityKey: string | null = null; + for (const entry of arr) { + if (entry.toLowerCase().endsWith('app-error')) { + errorHeader = httpErrorResponse.headers.get(entry); + } else if (entry.toLowerCase().endsWith('app-params')) { + entityKey = httpErrorResponse.headers.get(entry); + } + } + if (errorHeader) { + const alertData = entityKey ? { entityName: translateService.instant(`global.menu.entities.${entityKey}`) } : undefined; + this.addErrorAlert(errorHeader, errorHeader, alertData); + } else if (httpErrorResponse.error !== '' && httpErrorResponse.error.fieldErrors) { + const fieldErrors = httpErrorResponse.error.fieldErrors; + for (const fieldError of fieldErrors) { + if (['Min', 'Max', 'DecimalMin', 'DecimalMax'].includes(fieldError.message)) { + fieldError.message = 'Size'; + } + // convert 'something[14].other[4].id' to 'something[].other[].id' so translations can be written to it + const convertedField: string = fieldError.field.replace(/\[\d*\]/g, '[]'); + const fieldName: string = translateService.instant( + `jhipsterSampleApplicationApp.${fieldError.objectName as string}.${convertedField}`, + ); + this.addErrorAlert(`Error on field "${fieldName}"`, `error.${fieldError.message as string}`, { fieldName }); + } + } else if (httpErrorResponse.error !== '' && httpErrorResponse.error.message) { + this.addErrorAlert( + httpErrorResponse.error.detail ?? httpErrorResponse.error.message, + httpErrorResponse.error.message, + httpErrorResponse.error.params, + ); + } else { + this.addErrorAlert(httpErrorResponse.error, httpErrorResponse.error); + } + break; + } + + case 404: + this.addErrorAlert('Not found', 'error.url.not.found'); + break; + + default: + if (httpErrorResponse.error !== '' && httpErrorResponse.error.message) { + this.addErrorAlert( + httpErrorResponse.error.detail ?? httpErrorResponse.error.message, + httpErrorResponse.error.message, + httpErrorResponse.error.params, + ); + } else { + this.addErrorAlert(httpErrorResponse.error, httpErrorResponse.error); + } + } + }, + ); + } + + setClasses(alert: Alert): { [key: string]: boolean } { + const classes = { 'jhi-toast': Boolean(alert.toast) }; + if (alert.position) { + return { ...classes, [alert.position]: true }; + } + return classes; + } + + ngOnDestroy(): void { + this.eventManager.destroy(this.errorListener); + this.eventManager.destroy(this.httpErrorListener); + } + + close(alert: Alert): void { + alert.close?.(this.alerts); + } + + private addErrorAlert(message?: string, translationKey?: string, translationParams?: { [key: string]: unknown }): void { + this.alertService.addAlert({ type: 'danger', message, translationKey, translationParams }, this.alerts); + } +} diff --git a/src/main/webapp/app/shared/alert/alert-error.model.ts b/src/main/webapp/app/shared/alert/alert-error.model.ts new file mode 100644 index 000000000..cc8ca7d6c --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert-error.model.ts @@ -0,0 +1,7 @@ +export class AlertError { + constructor( + public message: string, + public key?: string, + public params?: { [key: string]: unknown }, + ) {} +} diff --git a/src/main/webapp/app/shared/alert/alert.component.html b/src/main/webapp/app/shared/alert/alert.component.html new file mode 100644 index 000000000..76ff881dd --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert.component.html @@ -0,0 +1,7 @@ + diff --git a/src/main/webapp/app/shared/alert/alert.component.spec.ts b/src/main/webapp/app/shared/alert/alert.component.spec.ts new file mode 100644 index 000000000..79fe41fa4 --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert.component.spec.ts @@ -0,0 +1,44 @@ +jest.mock('app/core/util/alert.service'); + +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { AlertService } from 'app/core/util/alert.service'; + +import { AlertComponent } from './alert.component'; + +describe('Alert Component', () => { + let comp: AlertComponent; + let fixture: ComponentFixture; + let mockAlertService: AlertService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [AlertComponent], + providers: [AlertService], + }) + .overrideTemplate(AlertComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AlertComponent); + comp = fixture.componentInstance; + mockAlertService = TestBed.inject(AlertService); + }); + + it('Should call alertService.get on init', () => { + // WHEN + comp.ngOnInit(); + + // THEN + expect(mockAlertService.get).toHaveBeenCalled(); + }); + + it('Should call alertService.clear on destroy', () => { + // WHEN + comp.ngOnDestroy(); + + // THEN + expect(mockAlertService.clear).toHaveBeenCalled(); + }); +}); diff --git a/src/main/webapp/app/shared/alert/alert.component.ts b/src/main/webapp/app/shared/alert/alert.component.ts new file mode 100644 index 000000000..098a90f54 --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert.component.ts @@ -0,0 +1,37 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; + +import { AlertService, Alert } from 'app/core/util/alert.service'; + +@Component({ + standalone: true, + selector: 'jhi-alert', + templateUrl: './alert.component.html', + imports: [CommonModule, NgbModule], +}) +export class AlertComponent implements OnInit, OnDestroy { + alerts: Alert[] = []; + + constructor(private alertService: AlertService) {} + + ngOnInit(): void { + this.alerts = this.alertService.get(); + } + + setClasses(alert: Alert): { [key: string]: boolean } { + const classes = { 'jhi-toast': Boolean(alert.toast) }; + if (alert.position) { + return { ...classes, [alert.position]: true }; + } + return classes; + } + + ngOnDestroy(): void { + this.alertService.clear(); + } + + close(alert: Alert): void { + alert.close?.(this.alerts); + } +} diff --git a/src/main/webapp/app/shared/auth/has-any-authority.directive.spec.ts b/src/main/webapp/app/shared/auth/has-any-authority.directive.spec.ts new file mode 100644 index 000000000..3b8c21a28 --- /dev/null +++ b/src/main/webapp/app/shared/auth/has-any-authority.directive.spec.ts @@ -0,0 +1,131 @@ +jest.mock('app/core/auth/account.service'); + +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Subject } from 'rxjs'; + +import { AccountService } from 'app/core/auth/account.service'; +import { Account } from 'app/core/auth/account.model'; + +import HasAnyAuthorityDirective from './has-any-authority.directive'; + +@Component({ + template: `
`, +}) +class TestHasAnyAuthorityDirectiveComponent { + @ViewChild('content', { static: false }) + content?: ElementRef; +} + +describe('HasAnyAuthorityDirective tests', () => { + let mockAccountService: AccountService; + const authenticationState = new Subject(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HasAnyAuthorityDirective], + declarations: [TestHasAnyAuthorityDirectiveComponent], + providers: [AccountService], + }); + })); + + beforeEach(() => { + mockAccountService = TestBed.inject(AccountService); + mockAccountService.getAuthenticationState = jest.fn(() => authenticationState.asObservable()); + }); + + describe('set jhiHasAnyAuthority', () => { + it('should show restricted content to user if user has required role', () => { + // GIVEN + mockAccountService.hasAnyAuthority = jest.fn(() => true); + const fixture = TestBed.createComponent(TestHasAnyAuthorityDirectiveComponent); + const comp = fixture.componentInstance; + + // WHEN + fixture.detectChanges(); + + // THEN + expect(comp.content).toBeDefined(); + }); + + it('should not show restricted content to user if user has not required role', () => { + // GIVEN + mockAccountService.hasAnyAuthority = jest.fn(() => false); + const fixture = TestBed.createComponent(TestHasAnyAuthorityDirectiveComponent); + const comp = fixture.componentInstance; + + // WHEN + fixture.detectChanges(); + + // THEN + expect(comp.content).toBeUndefined(); + }); + }); + + describe('change authorities', () => { + it('should show or not show restricted content correctly if user authorities are changing', () => { + // GIVEN + mockAccountService.hasAnyAuthority = jest.fn(() => true); + const fixture = TestBed.createComponent(TestHasAnyAuthorityDirectiveComponent); + const comp = fixture.componentInstance; + + // WHEN + fixture.detectChanges(); + + // THEN + expect(comp.content).toBeDefined(); + + // GIVEN + mockAccountService.hasAnyAuthority = jest.fn(() => false); + + // WHEN + authenticationState.next(null); + fixture.detectChanges(); + + // THEN + expect(comp.content).toBeUndefined(); + + // GIVEN + mockAccountService.hasAnyAuthority = jest.fn(() => true); + + // WHEN + authenticationState.next(null); + fixture.detectChanges(); + + // THEN + expect(comp.content).toBeDefined(); + }); + }); + + describe('ngOnDestroy', () => { + it('should destroy authentication state subscription on component destroy', () => { + // GIVEN + mockAccountService.hasAnyAuthority = jest.fn(() => true); + const fixture = TestBed.createComponent(TestHasAnyAuthorityDirectiveComponent); + const div = fixture.debugElement.queryAllNodes(By.directive(HasAnyAuthorityDirective))[0]; + const hasAnyAuthorityDirective = div.injector.get(HasAnyAuthorityDirective); + + // WHEN + fixture.detectChanges(); + + // THEN + expect(mockAccountService.hasAnyAuthority).toHaveBeenCalled(); + + // WHEN + jest.clearAllMocks(); + authenticationState.next(null); + + // THEN + expect(mockAccountService.hasAnyAuthority).toHaveBeenCalled(); + + // WHEN + jest.clearAllMocks(); + hasAnyAuthorityDirective.ngOnDestroy(); + authenticationState.next(null); + + // THEN + expect(mockAccountService.hasAnyAuthority).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/shared/auth/has-any-authority.directive.ts b/src/main/webapp/app/shared/auth/has-any-authority.directive.ts new file mode 100644 index 000000000..2cec009eb --- /dev/null +++ b/src/main/webapp/app/shared/auth/has-any-authority.directive.ts @@ -0,0 +1,58 @@ +import { Directive, Input, TemplateRef, ViewContainerRef, OnDestroy } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { AccountService } from 'app/core/auth/account.service'; + +/** + * @whatItDoes Conditionally includes an HTML element if current user has any + * of the authorities passed as the `expression`. + * + * @howToUse + * ``` + * ... + * + * ... + * ``` + */ +@Directive({ + standalone: true, + selector: '[jhiHasAnyAuthority]', +}) +export default class HasAnyAuthorityDirective implements OnDestroy { + private authorities!: string | string[]; + + private readonly destroy$ = new Subject(); + + constructor( + private accountService: AccountService, + private templateRef: TemplateRef, + private viewContainerRef: ViewContainerRef, + ) {} + + @Input() + set jhiHasAnyAuthority(value: string | string[]) { + this.authorities = value; + this.updateView(); + // Get notified each time authentication state changes. + this.accountService + .getAuthenticationState() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.updateView(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private updateView(): void { + const hasAnyAuthority = this.accountService.hasAnyAuthority(this.authorities); + this.viewContainerRef.clear(); + if (hasAnyAuthority) { + this.viewContainerRef.createEmbeddedView(this.templateRef); + } + } +} diff --git a/src/main/webapp/app/shared/date/duration.pipe.ts b/src/main/webapp/app/shared/date/duration.pipe.ts new file mode 100644 index 000000000..fda99e3a8 --- /dev/null +++ b/src/main/webapp/app/shared/date/duration.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import dayjs from 'dayjs/esm'; + +@Pipe({ + standalone: true, + name: 'duration', +}) +export default class DurationPipe implements PipeTransform { + transform(value: any): string { + if (value) { + return dayjs.duration(value).humanize(); + } + return ''; + } +} diff --git a/src/main/webapp/app/shared/date/format-medium-date.pipe.spec.ts b/src/main/webapp/app/shared/date/format-medium-date.pipe.spec.ts new file mode 100644 index 000000000..bdb618e41 --- /dev/null +++ b/src/main/webapp/app/shared/date/format-medium-date.pipe.spec.ts @@ -0,0 +1,19 @@ +import dayjs from 'dayjs/esm'; + +import FormatMediumDatePipe from './format-medium-date.pipe'; + +describe('FormatMediumDatePipe', () => { + const formatMediumDatePipe = new FormatMediumDatePipe(); + + it('should return an empty string when receive undefined', () => { + expect(formatMediumDatePipe.transform(undefined)).toBe(''); + }); + + it('should return an empty string when receive null', () => { + expect(formatMediumDatePipe.transform(null)).toBe(''); + }); + + it('should format date like this D MMM YYYY', () => { + expect(formatMediumDatePipe.transform(dayjs('2020-11-16').locale('fr'))).toBe('16 Nov 2020'); + }); +}); diff --git a/src/main/webapp/app/shared/date/format-medium-date.pipe.ts b/src/main/webapp/app/shared/date/format-medium-date.pipe.ts new file mode 100644 index 000000000..96b679b27 --- /dev/null +++ b/src/main/webapp/app/shared/date/format-medium-date.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import dayjs from 'dayjs/esm'; + +@Pipe({ + standalone: true, + name: 'formatMediumDate', +}) +export default class FormatMediumDatePipe implements PipeTransform { + transform(day: dayjs.Dayjs | null | undefined): string { + return day ? day.format('D MMM YYYY') : ''; + } +} diff --git a/src/main/webapp/app/shared/date/format-medium-datetime.pipe.spec.ts b/src/main/webapp/app/shared/date/format-medium-datetime.pipe.spec.ts new file mode 100644 index 000000000..c08aa47a6 --- /dev/null +++ b/src/main/webapp/app/shared/date/format-medium-datetime.pipe.spec.ts @@ -0,0 +1,19 @@ +import dayjs from 'dayjs/esm'; + +import FormatMediumDatetimePipe from './format-medium-datetime.pipe'; + +describe('FormatMediumDatePipe', () => { + const formatMediumDatetimePipe = new FormatMediumDatetimePipe(); + + it('should return an empty string when receive undefined', () => { + expect(formatMediumDatetimePipe.transform(undefined)).toBe(''); + }); + + it('should return an empty string when receive null', () => { + expect(formatMediumDatetimePipe.transform(null)).toBe(''); + }); + + it('should format date like this D MMM YYYY', () => { + expect(formatMediumDatetimePipe.transform(dayjs('2020-11-16').locale('fr'))).toBe('16 Nov 2020 00:00:00'); + }); +}); diff --git a/src/main/webapp/app/shared/date/format-medium-datetime.pipe.ts b/src/main/webapp/app/shared/date/format-medium-datetime.pipe.ts new file mode 100644 index 000000000..bd09cfbfa --- /dev/null +++ b/src/main/webapp/app/shared/date/format-medium-datetime.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import dayjs from 'dayjs/esm'; + +@Pipe({ + standalone: true, + name: 'formatMediumDatetime', +}) +export default class FormatMediumDatetimePipe implements PipeTransform { + transform(day: dayjs.Dayjs | null | undefined): string { + return day ? day.format('D MMM YYYY HH:mm:ss') : ''; + } +} diff --git a/src/main/webapp/app/shared/date/index.ts b/src/main/webapp/app/shared/date/index.ts new file mode 100644 index 000000000..5372ce8ab --- /dev/null +++ b/src/main/webapp/app/shared/date/index.ts @@ -0,0 +1,3 @@ +export { default as DurationPipe } from './duration.pipe'; +export { default as FormatMediumDatePipe } from './format-medium-date.pipe'; +export { default as FormatMediumDatetimePipe } from './format-medium-datetime.pipe'; diff --git a/src/main/webapp/app/shared/filter/filter.component.html b/src/main/webapp/app/shared/filter/filter.component.html new file mode 100644 index 000000000..092a93197 --- /dev/null +++ b/src/main/webapp/app/shared/filter/filter.component.html @@ -0,0 +1,12 @@ +
+ Following filters are set + +
    + +
  • + {{ filterOption.name }}: {{ value }} + +
  • +
    +
+
diff --git a/src/main/webapp/app/shared/filter/filter.component.ts b/src/main/webapp/app/shared/filter/filter.component.ts new file mode 100644 index 000000000..8117666df --- /dev/null +++ b/src/main/webapp/app/shared/filter/filter.component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core'; +import SharedModule from '../shared.module'; +import { IFilterOptions } from './filter.model'; + +@Component({ + selector: 'jhi-filter', + standalone: true, + imports: [SharedModule], + templateUrl: './filter.component.html', +}) +export default class FilterComponent { + @Input() filters!: IFilterOptions; + + clearAllFilters(): void { + this.filters.clear(); + } + + clearFilter(filterName: string, value: string): void { + this.filters.removeFilter(filterName, value); + } +} diff --git a/src/main/webapp/app/shared/filter/filter.model.spec.ts b/src/main/webapp/app/shared/filter/filter.model.spec.ts new file mode 100644 index 000000000..6c42c911d --- /dev/null +++ b/src/main/webapp/app/shared/filter/filter.model.spec.ts @@ -0,0 +1,242 @@ +import { convertToParamMap, ParamMap, Params } from '@angular/router'; +import { FilterOptions, FilterOption } from './filter.model'; + +describe('FilterModel Tests', () => { + describe('FilterOption', () => { + let filterOption: FilterOption; + + beforeEach(() => { + filterOption = new FilterOption('foo', ['bar', 'bar2']); + }); + + it('nameAsQueryParam returns query key', () => { + expect(filterOption.nameAsQueryParam()).toEqual('filter[foo]'); + }); + + describe('addValue', () => { + it('adds multiples unique values and returns true', () => { + const ret = filterOption.addValue('bar2', 'bar3', 'bar4'); + expect(filterOption.values).toMatchObject(['bar', 'bar2', 'bar3', 'bar4']); + expect(ret).toBe(true); + }); + it("doesn't adds duplicated values and return false", () => { + const ret = filterOption.addValue('bar', 'bar2'); + expect(filterOption.values).toMatchObject(['bar', 'bar2']); + expect(ret).toBe(false); + }); + }); + + describe('removeValue', () => { + it('removes the exiting value and return true', () => { + const ret = filterOption.removeValue('bar'); + expect(filterOption.values).toMatchObject(['bar2']); + expect(ret).toBe(true); + }); + it("doesn't removes the value and return false", () => { + const ret = filterOption.removeValue('foo'); + expect(filterOption.values).toMatchObject(['bar', 'bar2']); + expect(ret).toBe(false); + }); + }); + + describe('equals', () => { + it('returns true to matching options', () => { + const otherFilterOption = new FilterOption(filterOption.name, filterOption.values.concat()); + expect(filterOption.equals(otherFilterOption)).toBe(true); + expect(otherFilterOption.equals(filterOption)).toBe(true); + }); + it('returns false to different name', () => { + const otherFilterOption = new FilterOption('bar', filterOption.values.concat()); + expect(filterOption.equals(otherFilterOption)).toBe(false); + expect(otherFilterOption.equals(filterOption)).toBe(false); + }); + it('returns false to different values', () => { + const otherFilterOption = new FilterOption('bar', []); + expect(filterOption.equals(otherFilterOption)).toBe(false); + expect(otherFilterOption.equals(filterOption)).toBe(false); + }); + }); + }); + + describe('FilterOptions', () => { + describe('hasAnyFilterSet', () => { + it('with empty options returns false', () => { + const filters = new FilterOptions(); + expect(filters.hasAnyFilterSet()).toBe(false); + }); + it('with options and empty values returns false', () => { + const filters = new FilterOptions([new FilterOption('foo'), new FilterOption('bar')]); + expect(filters.hasAnyFilterSet()).toBe(false); + }); + it('with option and value returns true', () => { + const filters = new FilterOptions([new FilterOption('foo', ['bar'])]); + expect(filters.hasAnyFilterSet()).toBe(true); + }); + }); + + describe('clear', () => { + it("removes empty filters and dosn't emit next element", () => { + const filters = new FilterOptions([new FilterOption('foo'), new FilterOption('bar')]); + jest.spyOn(filters.filterChanges, 'next'); + + filters.clear(); + + expect(filters.filterChanges.next).not.toBeCalled(); + expect(filters.filterOptions).toMatchObject([]); + }); + it('removes empty filters and emits next element', () => { + const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1']), new FilterOption('bar')]); + jest.spyOn(filters.filterChanges, 'next'); + + filters.clear(); + + expect(filters.filterChanges.next).toHaveBeenCalledTimes(1); + expect(filters.filterOptions).toMatchObject([]); + }); + }); + + describe('addFilter', () => { + it('adds a non existing FilterOption, returns true and emit next element', () => { + const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]); + jest.spyOn(filters.filterChanges, 'next'); + + const result = filters.addFilter('addedFilter', 'addedValue'); + + expect(result).toBe(true); + expect(filters.filterChanges.next).toHaveBeenCalledTimes(1); + expect(filters.filterOptions).toMatchObject([ + { name: 'foo', values: ['existingFoo1', 'existingFoo2'] }, + { name: 'addedFilter', values: ['addedValue'] }, + ]); + }); + it('adds a non existing value to FilterOption, returns true and emit next element', () => { + const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]); + jest.spyOn(filters.filterChanges, 'next'); + + const result = filters.addFilter('foo', 'addedValue1', 'addedValue2'); + + expect(result).toBe(true); + expect(filters.filterChanges.next).toHaveBeenCalledTimes(1); + expect(filters.filterOptions).toMatchObject([ + { name: 'foo', values: ['existingFoo1', 'existingFoo2', 'addedValue1', 'addedValue2'] }, + ]); + }); + it("doesn't add FilterOption values already added, returns false and doesn't emit next element", () => { + const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]); + jest.spyOn(filters.filterChanges, 'next'); + + const result = filters.addFilter('foo', 'existingFoo1', 'existingFoo2'); + + expect(result).toBe(false); + expect(filters.filterChanges.next).not.toBeCalled(); + expect(filters.filterOptions).toMatchObject([{ name: 'foo', values: ['existingFoo1', 'existingFoo2'] }]); + }); + }); + + describe('removeFilter', () => { + it('removes an existing FilterOptions and returns true', () => { + const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]); + jest.spyOn(filters.filterChanges, 'next'); + + const result = filters.removeFilter('foo', 'existingFoo1'); + + expect(result).toBe(true); + expect(filters.filterChanges.next).toHaveBeenCalledTimes(1); + expect(filters.filterOptions).toMatchObject([{ name: 'foo', values: ['existingFoo2'] }]); + }); + it("doesn't remove a non existing FilterOptions values returns false", () => { + const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]); + jest.spyOn(filters.filterChanges, 'next'); + + const result = filters.removeFilter('foo', 'nonExisting1'); + + expect(result).toBe(false); + expect(filters.filterChanges.next).not.toBeCalled(); + expect(filters.filterOptions).toMatchObject([{ name: 'foo', values: ['existingFoo1', 'existingFoo2'] }]); + }); + it("doesn't remove a non existing FilterOptions returns false", () => { + const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]); + jest.spyOn(filters.filterChanges, 'next'); + + const result = filters.removeFilter('nonExisting', 'nonExisting1'); + + expect(result).toBe(false); + expect(filters.filterChanges.next).not.toBeCalled(); + expect(filters.filterOptions).toMatchObject([{ name: 'foo', values: ['existingFoo1', 'existingFoo2'] }]); + }); + }); + + describe('initializeFromParams', () => { + const oneValidParam: Params = { + test: 'blub', + 'filter[hello.in]': 'world', + 'filter[invalid': 'invalid', + filter_invalid2: 'invalid', + }; + + const noValidParam: Params = { + test: 'blub', + 'filter[invalid': 'invalid', + filter_invalid2: 'invalid', + }; + + const paramWithTwoValues: Params = { + 'filter[hello.in]': ['world', 'world2'], + }; + + const paramWithTwoKeys: Params = { + 'filter[hello.in]': ['world', 'world2'], + 'filter[hello.notIn]': ['world3', 'world4'], + }; + + it('should parse from Params if there are any and not emit next element', () => { + const filters: FilterOptions = new FilterOptions([new FilterOption('foo', ['bar'])]); + jest.spyOn(filters.filterChanges, 'next'); + const paramMap: ParamMap = convertToParamMap(oneValidParam); + + filters.initializeFromParams(paramMap); + + expect(filters.filterChanges.next).not.toHaveBeenCalled(); + expect(filters.filterOptions).toMatchObject([{ name: 'hello.in', values: ['world'] }]); + }); + + it('should parse from Params and have none if there are none', () => { + const filters: FilterOptions = new FilterOptions(); + const paramMap: ParamMap = convertToParamMap(noValidParam); + jest.spyOn(filters.filterChanges, 'next'); + + filters.initializeFromParams(paramMap); + + expect(filters.filterChanges.next).not.toHaveBeenCalled(); + expect(filters.filterOptions).toMatchObject([]); + }); + + it('should parse from Params and have a parameter with 2 values and one aditional value', () => { + const filters: FilterOptions = new FilterOptions([new FilterOption('hello.in', ['world'])]); + jest.spyOn(filters.filterChanges, 'next'); + + const paramMap: ParamMap = convertToParamMap(paramWithTwoValues); + + filters.initializeFromParams(paramMap); + + expect(filters.filterChanges.next).not.toHaveBeenCalled(); + expect(filters.filterOptions).toMatchObject([{ name: 'hello.in', values: ['world', 'world2'] }]); + }); + + it('should parse from Params and have a parameter with 2 keys', () => { + const filters: FilterOptions = new FilterOptions(); + jest.spyOn(filters.filterChanges, 'next'); + + const paramMap: ParamMap = convertToParamMap(paramWithTwoKeys); + + filters.initializeFromParams(paramMap); + + expect(filters.filterChanges.next).not.toHaveBeenCalled(); + expect(filters.filterOptions).toMatchObject([ + { name: 'hello.in', values: ['world', 'world2'] }, + { name: 'hello.notIn', values: ['world3', 'world4'] }, + ]); + }); + }); + }); +}); diff --git a/src/main/webapp/app/shared/filter/filter.model.ts b/src/main/webapp/app/shared/filter/filter.model.ts new file mode 100644 index 000000000..794a0c096 --- /dev/null +++ b/src/main/webapp/app/shared/filter/filter.model.ts @@ -0,0 +1,159 @@ +import { ParamMap } from '@angular/router'; +import { Subject } from 'rxjs'; + +export interface IFilterOptions { + readonly filterChanges: Subject; + get filterOptions(): IFilterOption[]; + hasAnyFilterSet(): boolean; + clear(): boolean; + initializeFromParams(params: ParamMap): boolean; + addFilter(name: string, ...values: string[]): boolean; + removeFilter(name: string, value: string): boolean; +} + +export interface IFilterOption { + name: string; + values: string[]; + nameAsQueryParam(): string; +} + +export class FilterOption implements IFilterOption { + constructor( + public name: string, + public values: string[] = [], + ) { + this.values = [...new Set(values)]; + } + + nameAsQueryParam(): string { + return 'filter[' + this.name + ']'; + } + + isSet(): boolean { + return this.values.length > 0; + } + + addValue(...values: string[]): boolean { + const missingValues = values.filter(value => value && !this.values.includes(value)); + if (missingValues.length > 0) { + this.values.push(...missingValues); + return true; + } + return false; + } + + removeValue(value: string): boolean { + const indexOf = this.values.indexOf(value); + if (indexOf === -1) { + return false; + } + + this.values.splice(indexOf, 1); + return true; + } + + clone(): FilterOption { + return new FilterOption(this.name, this.values.concat()); + } + + equals(other: IFilterOption): boolean { + return ( + this.name === other.name && + this.values.length === other.values.length && + this.values.every(thisValue => other.values.includes(thisValue)) && + other.values.every(otherValue => this.values.includes(otherValue)) + ); + } +} + +export class FilterOptions implements IFilterOptions { + readonly filterChanges: Subject = new Subject(); + private _filterOptions: FilterOption[]; + + constructor(filterOptions: FilterOption[] = []) { + this._filterOptions = filterOptions; + } + + get filterOptions(): FilterOption[] { + return this._filterOptions.filter(option => option.isSet()); + } + + hasAnyFilterSet(): boolean { + return this._filterOptions.some(e => e.isSet()); + } + + clear(): boolean { + const hasFields = this.hasAnyFilterSet(); + this._filterOptions = []; + if (hasFields) { + this.changed(); + } + return hasFields; + } + + initializeFromParams(params: ParamMap): boolean { + const oldFilters: FilterOptions = this.clone(); + + this._filterOptions = []; + + const filterRegex = /filter\[(.+)\]/; + params.keys + .filter(paramKey => filterRegex.test(paramKey)) + .forEach(matchingParam => { + const matches = filterRegex.exec(matchingParam); + if (matches && matches.length > 1) { + this.getFilterOptionByName(matches[1], true).addValue(...params.getAll(matchingParam)); + } + }); + + if (oldFilters.equals(this)) { + return false; + } + return true; + } + + addFilter(name: string, ...values: string[]): boolean { + if (this.getFilterOptionByName(name, true).addValue(...values)) { + this.changed(); + return true; + } + return false; + } + + removeFilter(name: string, value: string): boolean { + if (this.getFilterOptionByName(name)?.removeValue(value)) { + this.changed(); + return true; + } + return false; + } + + protected changed(): void { + this.filterChanges.next(this.filterOptions.map(option => option.clone())); + } + + protected equals(other: FilterOptions): boolean { + const thisFilters = this.filterOptions; + const otherFilters = other.filterOptions; + if (thisFilters.length !== otherFilters.length) { + return false; + } + return thisFilters.every(option => other.getFilterOptionByName(option.name)?.equals(option)); + } + + protected clone(): FilterOptions { + return new FilterOptions(this.filterOptions.map(option => new FilterOption(option.name, option.values.concat()))); + } + + protected getFilterOptionByName(name: string, add: true): FilterOption; + protected getFilterOptionByName(name: string, add: false): FilterOption | null; + protected getFilterOptionByName(name: string): FilterOption | null; + protected getFilterOptionByName(name: string, add = false): FilterOption | null { + const addOption = (option: FilterOption): FilterOption => { + this._filterOptions.push(option); + return option; + }; + + return this._filterOptions.find(thisOption => thisOption.name === name) ?? (add ? addOption(new FilterOption(name)) : null); + } +} diff --git a/src/main/webapp/app/shared/filter/index.ts b/src/main/webapp/app/shared/filter/index.ts new file mode 100644 index 000000000..ae0af5a7b --- /dev/null +++ b/src/main/webapp/app/shared/filter/index.ts @@ -0,0 +1,2 @@ +export { default as FilterComponent } from './filter.component'; +export * from './filter.model'; diff --git a/src/main/webapp/app/shared/language/find-language-from-key.pipe.ts b/src/main/webapp/app/shared/language/find-language-from-key.pipe.ts new file mode 100644 index 000000000..1c2e3e09b --- /dev/null +++ b/src/main/webapp/app/shared/language/find-language-from-key.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + standalone: true, + name: 'findLanguageFromKey', +}) +export default class FindLanguageFromKeyPipe implements PipeTransform { + private languages: { [key: string]: { name: string; rtl?: boolean } } = { + en: { name: 'English' }, + // jhipster-needle-i18n-language-key-pipe - JHipster will add/remove languages in this object + }; + + transform(lang: string): string { + return this.languages[lang].name; + } +} diff --git a/src/main/webapp/app/shared/language/index.ts b/src/main/webapp/app/shared/language/index.ts new file mode 100644 index 000000000..3446ac20c --- /dev/null +++ b/src/main/webapp/app/shared/language/index.ts @@ -0,0 +1,2 @@ +export { default as TranslateDirective } from './translate.directive'; +export { default as FindLanguageFromKeyPipe } from './find-language-from-key.pipe'; diff --git a/src/main/webapp/app/shared/language/translate.directive.spec.ts b/src/main/webapp/app/shared/language/translate.directive.spec.ts new file mode 100644 index 000000000..b7b5657d1 --- /dev/null +++ b/src/main/webapp/app/shared/language/translate.directive.spec.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import TranslateDirective from './translate.directive'; + +@Component({ + template: `
`, +}) +class TestTranslateDirectiveComponent {} + +describe('TranslateDirective Tests', () => { + let fixture: ComponentFixture; + let translateService: TranslateService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), TranslateDirective], + declarations: [TestTranslateDirectiveComponent], + }); + })); + + beforeEach(() => { + translateService = TestBed.inject(TranslateService); + fixture = TestBed.createComponent(TestTranslateDirectiveComponent); + }); + + it('should change HTML', () => { + const spy = jest.spyOn(translateService, 'get'); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/src/main/webapp/app/shared/language/translate.directive.ts b/src/main/webapp/app/shared/language/translate.directive.ts new file mode 100644 index 000000000..93f0f2ff7 --- /dev/null +++ b/src/main/webapp/app/shared/language/translate.directive.ts @@ -0,0 +1,55 @@ +import { Input, Directive, ElementRef, OnChanges, OnInit, OnDestroy } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { translationNotFoundMessage } from 'app/config/translation.config'; + +/** + * A wrapper directive on top of the translate pipe as the inbuilt translate directive from ngx-translate is too verbose and buggy + */ +@Directive({ + standalone: true, + selector: '[jhiTranslate]', +}) +export default class TranslateDirective implements OnChanges, OnInit, OnDestroy { + @Input() jhiTranslate!: string; + @Input() translateValues?: { [key: string]: unknown }; + + private readonly directiveDestroyed = new Subject(); + + constructor( + private el: ElementRef, + private translateService: TranslateService, + ) {} + + ngOnInit(): void { + this.translateService.onLangChange.pipe(takeUntil(this.directiveDestroyed)).subscribe(() => { + this.getTranslation(); + }); + this.translateService.onTranslationChange.pipe(takeUntil(this.directiveDestroyed)).subscribe(() => { + this.getTranslation(); + }); + } + + ngOnChanges(): void { + this.getTranslation(); + } + + ngOnDestroy(): void { + this.directiveDestroyed.next(null); + this.directiveDestroyed.complete(); + } + + private getTranslation(): void { + this.translateService + .get(this.jhiTranslate, this.translateValues) + .pipe(takeUntil(this.directiveDestroyed)) + .subscribe({ + next: value => { + this.el.nativeElement.innerHTML = value; + }, + error: () => `${translationNotFoundMessage}[${this.jhiTranslate}]`, + }); + } +} diff --git a/src/main/webapp/app/shared/language/translation.module.ts b/src/main/webapp/app/shared/language/translation.module.ts new file mode 100644 index 000000000..e6f1a6c79 --- /dev/null +++ b/src/main/webapp/app/shared/language/translation.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { TranslateModule, TranslateService, TranslateLoader, MissingTranslationHandler } from '@ngx-translate/core'; +import { translatePartialLoader, missingTranslationHandler } from 'app/config/translation.config'; +import { StateStorageService } from 'app/core/auth/state-storage.service'; + +@NgModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useFactory: translatePartialLoader, + deps: [HttpClient], + }, + missingTranslationHandler: { + provide: MissingTranslationHandler, + useFactory: missingTranslationHandler, + }, + }), + ], +}) +export class TranslationModule { + constructor( + private translateService: TranslateService, + private stateStorageService: StateStorageService, + ) { + this.translateService.setDefaultLang('en'); + // if user have changed language and navigates away from the application and back to the application then use previously choosed language + const langKey = this.stateStorageService.getLocale() ?? 'en'; + this.translateService.use(langKey); + } +} diff --git a/src/main/webapp/app/shared/pagination/index.ts b/src/main/webapp/app/shared/pagination/index.ts new file mode 100644 index 000000000..395ed882f --- /dev/null +++ b/src/main/webapp/app/shared/pagination/index.ts @@ -0,0 +1 @@ +export { default as ItemCountComponent } from './item-count.component'; diff --git a/src/main/webapp/app/shared/pagination/item-count.component.spec.ts b/src/main/webapp/app/shared/pagination/item-count.component.spec.ts new file mode 100644 index 000000000..9e91b1d45 --- /dev/null +++ b/src/main/webapp/app/shared/pagination/item-count.component.spec.ts @@ -0,0 +1,67 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import TranslateDirective from 'app/shared/language/translate.directive'; + +import ItemCountComponent from './item-count.component'; + +describe('ItemCountComponent test', () => { + let comp: ItemCountComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ItemCountComponent, TranslateModule.forRoot(), TranslateDirective], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemCountComponent); + comp = fixture.componentInstance; + }); + + describe('UI logic tests', () => { + it('should initialize with undefined', () => { + expect(comp.first).toBeUndefined(); + expect(comp.second).toBeUndefined(); + expect(comp.total).toBeUndefined(); + }); + + it('should set calculated numbers to undefined if the page value is not yet defined', () => { + // GIVEN + comp.params = { page: undefined, totalItems: 0, itemsPerPage: 10 }; + + // THEN + expect(comp.first).toBeUndefined(); + expect(comp.second).toBeUndefined(); + }); + + it('should change the content on page change', () => { + // GIVEN + comp.params = { page: 1, totalItems: 100, itemsPerPage: 10 }; + + // THEN + expect(comp.first).toBe(1); + expect(comp.second).toBe(10); + expect(comp.total).toBe(100); + + // GIVEN + comp.params = { page: 2, totalItems: 100, itemsPerPage: 10 }; + + // THEN + expect(comp.first).toBe(11); + expect(comp.second).toBe(20); + expect(comp.total).toBe(100); + }); + + it('should set the second number to totalItems if this is the last page which contains less than itemsPerPage items', () => { + // GIVEN + comp.params = { page: 2, totalItems: 16, itemsPerPage: 10 }; + + // THEN + expect(comp.first).toBe(11); + expect(comp.second).toBe(16); + expect(comp.total).toBe(16); + }); + }); +}); diff --git a/src/main/webapp/app/shared/pagination/item-count.component.ts b/src/main/webapp/app/shared/pagination/item-count.component.ts new file mode 100644 index 000000000..0ac352101 --- /dev/null +++ b/src/main/webapp/app/shared/pagination/item-count.component.ts @@ -0,0 +1,34 @@ +import { Component, Input } from '@angular/core'; +import TranslateDirective from '../language/translate.directive'; + +/** + * A component that will take care of item count statistics of a pagination. + */ +@Component({ + standalone: true, + selector: 'jhi-item-count', + template: `
`, + imports: [TranslateDirective], +}) +export default class ItemCountComponent { + /** + * @param params Contains parameters for component: + * page Current page number + * totalItems Total number of items + * itemsPerPage Number of items per page + */ + @Input() set params(params: { page?: number; totalItems?: number; itemsPerPage?: number }) { + if (params.page && params.totalItems !== undefined && params.itemsPerPage) { + this.first = (params.page - 1) * params.itemsPerPage + 1; + this.second = params.page * params.itemsPerPage < params.totalItems ? params.page * params.itemsPerPage : params.totalItems; + } else { + this.first = undefined; + this.second = undefined; + } + this.total = params.totalItems; + } + + first?: number; + second?: number; + total?: number; +} diff --git a/src/main/webapp/app/shared/shared.module.ts b/src/main/webapp/app/shared/shared.module.ts new file mode 100644 index 000000000..6511f7bc4 --- /dev/null +++ b/src/main/webapp/app/shared/shared.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; + +import FindLanguageFromKeyPipe from './language/find-language-from-key.pipe'; +import TranslateDirective from './language/translate.directive'; +import { AlertComponent } from './alert/alert.component'; +import { AlertErrorComponent } from './alert/alert-error.component'; + +/** + * Application wide Module + */ +@NgModule({ + imports: [AlertComponent, AlertErrorComponent, FindLanguageFromKeyPipe, TranslateDirective], + exports: [ + CommonModule, + NgbModule, + FontAwesomeModule, + AlertComponent, + AlertErrorComponent, + TranslateModule, + FindLanguageFromKeyPipe, + TranslateDirective, + ], +}) +export default class SharedModule {} diff --git a/src/main/webapp/app/shared/sort/index.ts b/src/main/webapp/app/shared/sort/index.ts new file mode 100644 index 000000000..1a04bec6f --- /dev/null +++ b/src/main/webapp/app/shared/sort/index.ts @@ -0,0 +1,2 @@ +export { default as SortDirective } from './sort.directive'; +export { default as SortByDirective } from './sort-by.directive'; diff --git a/src/main/webapp/app/shared/sort/sort-by.directive.spec.ts b/src/main/webapp/app/shared/sort/sort-by.directive.spec.ts new file mode 100644 index 000000000..51fef72d7 --- /dev/null +++ b/src/main/webapp/app/shared/sort/sort-by.directive.spec.ts @@ -0,0 +1,140 @@ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FaIconComponent, FaIconLibrary } from '@fortawesome/angular-fontawesome'; +import { fas, faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'; + +import SortByDirective from './sort-by.directive'; +import SortDirective from './sort.directive'; + +@Component({ + template: ` + + + + + + +
ID
+ `, +}) +class TestSortByDirectiveComponent { + predicate?: string; + ascending?: boolean; + sortAllowed = true; + transition = jest.fn(); + + constructor(library: FaIconLibrary) { + library.addIconPacks(fas); + library.addIcons(faSort, faSortDown, faSortUp); + } +} + +describe('Directive: SortByDirective', () => { + let component: TestSortByDirectiveComponent; + let fixture: ComponentFixture; + let tableHead: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SortDirective, SortByDirective], + declarations: [TestSortByDirectiveComponent, FaIconComponent], + }); + fixture = TestBed.createComponent(TestSortByDirectiveComponent); + component = fixture.componentInstance; + tableHead = fixture.debugElement.query(By.directive(SortByDirective)); + }); + + it('should initialize predicate, order, icon when initial component predicate differs from column predicate', () => { + // GIVEN + component.predicate = 'id'; + const sortByDirective = tableHead.injector.get(SortByDirective); + + // WHEN + fixture.detectChanges(); + + // THEN + expect(sortByDirective.jhiSortBy).toEqual('name'); + expect(component.predicate).toEqual('id'); + expect(sortByDirective.iconComponent?.icon).toEqual('sort'); + expect(component.transition).toHaveBeenCalledTimes(0); + }); + + it('should initialize predicate, order, icon when initial component predicate is same as column predicate', () => { + // GIVEN + component.predicate = 'name'; + component.ascending = true; + const sortByDirective = tableHead.injector.get(SortByDirective); + + // WHEN + fixture.detectChanges(); + + // THEN + expect(sortByDirective.jhiSortBy).toEqual('name'); + expect(component.predicate).toEqual('name'); + expect(component.ascending).toEqual(true); + expect(sortByDirective.iconComponent?.icon).toEqual(faSortUp.iconName); + expect(component.transition).toHaveBeenCalledTimes(0); + }); + + it('should update component predicate, order, icon when user clicks on column header', () => { + // GIVEN + component.predicate = 'name'; + component.ascending = true; + const sortByDirective = tableHead.injector.get(SortByDirective); + + // WHEN + fixture.detectChanges(); + tableHead.triggerEventHandler('click', null); + fixture.detectChanges(); + + // THEN + expect(component.predicate).toEqual('name'); + expect(component.ascending).toEqual(false); + expect(sortByDirective.iconComponent?.icon).toEqual(faSortDown.iconName); + expect(component.transition).toHaveBeenCalledTimes(1); + expect(component.transition).toHaveBeenCalledWith({ predicate: 'name', ascending: false }); + }); + + it('should update component predicate, order, icon when user double clicks on column header', () => { + // GIVEN + component.predicate = 'name'; + component.ascending = true; + const sortByDirective = tableHead.injector.get(SortByDirective); + + // WHEN + fixture.detectChanges(); + + tableHead.triggerEventHandler('click', null); + fixture.detectChanges(); + + tableHead.triggerEventHandler('click', null); + fixture.detectChanges(); + + // THEN + expect(component.predicate).toEqual('name'); + expect(component.ascending).toEqual(true); + expect(sortByDirective.iconComponent?.icon).toEqual(faSortUp.iconName); + expect(component.transition).toHaveBeenCalledTimes(2); + expect(component.transition).toHaveBeenNthCalledWith(1, { predicate: 'name', ascending: false }); + expect(component.transition).toHaveBeenNthCalledWith(2, { predicate: 'name', ascending: true }); + }); + + it('should not run sorting on click if sorting icon is hidden', () => { + // GIVEN + component.predicate = 'id'; + component.ascending = false; + component.sortAllowed = false; + + // WHEN + fixture.detectChanges(); + + tableHead.triggerEventHandler('click', null); + fixture.detectChanges(); + + // THEN + expect(component.predicate).toEqual('id'); + expect(component.ascending).toEqual(false); + expect(component.transition).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/webapp/app/shared/sort/sort-by.directive.ts b/src/main/webapp/app/shared/sort/sort-by.directive.ts new file mode 100644 index 000000000..1e8eda6ec --- /dev/null +++ b/src/main/webapp/app/shared/sort/sort-by.directive.ts @@ -0,0 +1,56 @@ +import { AfterContentInit, ContentChild, Directive, Host, HostListener, Input, OnDestroy } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faSort, faSortDown, faSortUp, IconDefinition } from '@fortawesome/free-solid-svg-icons'; + +import SortDirective from './sort.directive'; + +@Directive({ + standalone: true, + selector: '[jhiSortBy]', +}) +export default class SortByDirective implements AfterContentInit, OnDestroy { + @Input() jhiSortBy!: T; + + @ContentChild(FaIconComponent, { static: false }) + iconComponent?: FaIconComponent; + + sortIcon = faSort; + sortAscIcon = faSortUp; + sortDescIcon = faSortDown; + + private readonly destroy$ = new Subject(); + + constructor(@Host() private sort: SortDirective) { + sort.predicateChange.pipe(takeUntil(this.destroy$)).subscribe(() => this.updateIconDefinition()); + sort.ascendingChange.pipe(takeUntil(this.destroy$)).subscribe(() => this.updateIconDefinition()); + } + + @HostListener('click') + onClick(): void { + if (this.iconComponent) { + this.sort.sort(this.jhiSortBy); + } + } + + ngAfterContentInit(): void { + this.updateIconDefinition(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private updateIconDefinition(): void { + if (this.iconComponent) { + let icon: IconDefinition = this.sortIcon; + if (this.sort.predicate === this.jhiSortBy) { + icon = this.sort.ascending ? this.sortAscIcon : this.sortDescIcon; + } + this.iconComponent.icon = icon.iconName; + this.iconComponent.render(); + } + } +} diff --git a/src/main/webapp/app/shared/sort/sort.directive.spec.ts b/src/main/webapp/app/shared/sort/sort.directive.spec.ts new file mode 100644 index 000000000..5dc7b8751 --- /dev/null +++ b/src/main/webapp/app/shared/sort/sort.directive.spec.ts @@ -0,0 +1,87 @@ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import SortDirective from './sort.directive'; + +@Component({ + template: ` + + + + +
+ `, +}) +class TestSortDirectiveComponent { + predicate?: string; + ascending?: boolean; + transition = jest.fn(); +} + +describe('Directive: SortDirective', () => { + let component: TestSortDirectiveComponent; + let fixture: ComponentFixture; + let tableRow: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SortDirective], + declarations: [TestSortDirectiveComponent], + }); + fixture = TestBed.createComponent(TestSortDirectiveComponent); + component = fixture.componentInstance; + tableRow = fixture.debugElement.query(By.directive(SortDirective)); + }); + + it('should update predicate, order and invoke sortChange function', () => { + // GIVEN + const sortDirective = tableRow.injector.get(SortDirective); + + // WHEN + fixture.detectChanges(); + sortDirective.sort('ID'); + + // THEN + expect(component.predicate).toEqual('ID'); + expect(component.ascending).toEqual(true); + expect(component.transition).toHaveBeenCalledTimes(1); + expect(component.transition).toHaveBeenCalledWith({ predicate: 'ID', ascending: true }); + }); + + it('should change sort order to descending when same field is sorted again', () => { + // GIVEN + const sortDirective = tableRow.injector.get(SortDirective); + + // WHEN + fixture.detectChanges(); + sortDirective.sort('ID'); + // sort again + sortDirective.sort('ID'); + + // THEN + expect(component.predicate).toEqual('ID'); + expect(component.ascending).toEqual(false); + expect(component.transition).toHaveBeenCalledTimes(2); + expect(component.transition).toHaveBeenNthCalledWith(1, { predicate: 'ID', ascending: true }); + expect(component.transition).toHaveBeenNthCalledWith(2, { predicate: 'ID', ascending: false }); + }); + + it('should change sort order to ascending when different field is sorted', () => { + // GIVEN + const sortDirective = tableRow.injector.get(SortDirective); + + // WHEN + fixture.detectChanges(); + sortDirective.sort('ID'); + // sort again + sortDirective.sort('NAME'); + + // THEN + expect(component.predicate).toEqual('NAME'); + expect(component.ascending).toEqual(true); + expect(component.transition).toHaveBeenCalledTimes(2); + expect(component.transition).toHaveBeenNthCalledWith(1, { predicate: 'ID', ascending: true }); + expect(component.transition).toHaveBeenNthCalledWith(2, { predicate: 'NAME', ascending: true }); + }); +}); diff --git a/src/main/webapp/app/shared/sort/sort.directive.ts b/src/main/webapp/app/shared/sort/sort.directive.ts new file mode 100644 index 000000000..9bc4117e2 --- /dev/null +++ b/src/main/webapp/app/shared/sort/sort.directive.ts @@ -0,0 +1,40 @@ +import { Directive, EventEmitter, Input, Output } from '@angular/core'; + +@Directive({ + standalone: true, + selector: '[jhiSort]', +}) +export default class SortDirective { + @Input() + get predicate(): T | undefined { + return this._predicate; + } + set predicate(predicate: T | undefined) { + this._predicate = predicate; + this.predicateChange.emit(predicate); + } + + @Input() + get ascending(): boolean | undefined { + return this._ascending; + } + set ascending(ascending: boolean | undefined) { + this._ascending = ascending; + this.ascendingChange.emit(ascending); + } + + @Output() predicateChange = new EventEmitter(); + @Output() ascendingChange = new EventEmitter(); + @Output() sortChange = new EventEmitter<{ predicate: T; ascending: boolean }>(); + + private _predicate?: T; + private _ascending?: boolean; + + sort(field: T): void { + this.ascending = field !== this.predicate ? true : !this.ascending; + this.predicate = field; + this.predicateChange.emit(field); + this.ascendingChange.emit(this.ascending); + this.sortChange.emit({ predicate: this.predicate, ascending: this.ascending }); + } +} diff --git a/src/main/webapp/app/shared/sort/sort.service.ts b/src/main/webapp/app/shared/sort/sort.service.ts new file mode 100644 index 000000000..d27605992 --- /dev/null +++ b/src/main/webapp/app/shared/sort/sort.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class SortService { + private collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + }); + + public startSort(property: string, order: number): (a: any, b: any) => number { + return (a: any, b: any) => this.collator.compare(a[property], b[property]) * order; + } +} diff --git a/src/main/webapp/bootstrap.ts b/src/main/webapp/bootstrap.ts new file mode 100644 index 000000000..e5038d525 --- /dev/null +++ b/src/main/webapp/bootstrap.ts @@ -0,0 +1,16 @@ +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { DEBUG_INFO_ENABLED } from './app/app.constants'; +import { AppModule } from './app/app.module'; + +// disable debug data on prod profile to improve performance +if (!DEBUG_INFO_ENABLED) { + enableProdMode(); +} + +platformBrowserDynamic() + .bootstrapModule(AppModule, { preserveWhitespaces: true }) + // eslint-disable-next-line no-console + .then(() => console.log('Application started')) + .catch(err => console.error(err)); diff --git a/src/main/webapp/content/scss/_bootstrap-variables.scss b/src/main/webapp/content/scss/_bootstrap-variables.scss new file mode 100644 index 000000000..89712f709 --- /dev/null +++ b/src/main/webapp/content/scss/_bootstrap-variables.scss @@ -0,0 +1,45 @@ +/* + * Bootstrap overrides https://getbootstrap.com/docs/5.1/customize/sass/ + * All values defined in bootstrap source + * https://github.com/twbs/bootstrap/blob/v5.1.3/scss/_variables.scss can be overwritten here + * Make sure not to add !default to values here + */ + +// Colors: +// Grayscale and brand colors for use across Bootstrap. + +$primary: #3e8acc; +$success: #28a745; +$info: #17a2b8; +$warning: #ffc107; +$danger: #dc3545; + +// Options: +// Quickly modify global styling by enabling or disabling optional features. +$enable-rounded: true; +$enable-shadows: false; +$enable-gradients: false; +$enable-transitions: true; +$enable-hover-media-query: false; +$enable-grid-classes: true; +$enable-print-styles: true; + +// Components: +// Define common padding and border radius sizes and more. + +$border-radius: 0.15rem; +$border-radius-lg: 0.125rem; +$border-radius-sm: 0.1rem; + +// Body: +// Settings for the `` element. + +$body-bg: #ffffff; + +// Typography: +// Font, line-height, and color for body text, headings, and more. + +$font-size-base: 1rem; + +$dropdown-link-hover-color: white; +$dropdown-link-hover-bg: #343a40; diff --git a/src/main/webapp/content/scss/global.scss b/src/main/webapp/content/scss/global.scss new file mode 100644 index 000000000..f93917465 --- /dev/null +++ b/src/main/webapp/content/scss/global.scss @@ -0,0 +1,239 @@ +@import 'bootstrap-variables'; +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; + +/* ============================================================== +Bootstrap tweaks +===============================================================*/ + +body, +h1, +h2, +h3, +h4 { + font-weight: 300; +} + +/* Increase contrast of links to get 100% on Lighthouse Accessability Audit. Override this color if you want to change the link color, or use a Bootswatch theme */ +a { + color: #533f03; + font-weight: bold; +} + +a:hover { + color: #533f03; +} + +/* override hover color for dropdown-item forced by bootstrap to all a:not([href]):not([tabindex]) elements in _reboot.scss */ +a:not([href]):not([tabindex]):hover.dropdown-item { + color: $dropdown-link-hover-color; +} + +/* override .dropdown-item.active background-color on hover */ +.dropdown-item.active:hover { + background-color: mix($dropdown-link-hover-bg, $dropdown-link-active-bg, 50%); +} + +a:hover { + /* make sure browsers use the pointer cursor for anchors, even with no href */ + cursor: pointer; +} + +.dropdown-item:hover { + color: $dropdown-link-hover-color; +} + +/* ========================================================================== +Browser Upgrade Prompt +========================================================================== */ +.browserupgrade { + margin: 0.2em 0; + background: #ccc; + color: #000; + padding: 0.2em 0; +} + +/* ========================================================================== +Generic styles +========================================================================== */ + +/* Error highlight on input fields */ +.ng-valid[required], +.ng-valid.required { + border-left: 5px solid green; +} + +.ng-invalid:not(form) { + border-left: 5px solid red; +} + +/* other generic styles */ + +.jh-card { + padding: 1.5%; + margin-top: 20px; + border: none; +} + +.error { + color: white; + background-color: red; +} + +.pad { + padding: 10px; +} + +.w-40 { + width: 40% !important; +} + +.w-60 { + width: 60% !important; +} + +.break { + white-space: normal; + word-break: break-all; +} + +.form-control { + background-color: #fff; +} + +.readonly { + background-color: #eee; + opacity: 1; +} + +.footer { + border-top: 1px solid rgba(0, 0, 0, 0.125); +} + +.hand, +[jhisortby] { + cursor: pointer; +} + +/* ========================================================================== +Custom alerts for notification +========================================================================== */ +.alerts { + .alert { + text-overflow: ellipsis; + pre { + background: none; + border: none; + font: inherit; + color: inherit; + padding: 0; + margin: 0; + } + .popover pre { + font-size: 10px; + } + } + .jhi-toast { + position: fixed; + width: 100%; + &.left { + left: 5px; + } + &.right { + right: 5px; + } + &.top { + top: 55px; + } + &.bottom { + bottom: 55px; + } + } +} + +@media screen and (min-width: 480px) { + .alerts .jhi-toast { + width: 50%; + } +} + +/* ========================================================================== +entity list page css +========================================================================== */ + +.table-entities thead th .d-flex > * { + margin: auto 0; +} + +/* ========================================================================== +entity detail page css +========================================================================== */ +.row-md.jh-entity-details { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 10px; + line-height: 1.5; +} + +@media screen and (min-width: 768px) { + .row-md.jh-entity-details > { + dt { + float: left; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0.5em 0; + } + dd { + border-bottom: 1px solid #eee; + padding: 0.5em 0; + margin-left: 0; + } + } +} + +/* ========================================================================== +ui bootstrap tweaks +========================================================================== */ +.nav, +.pagination, +.carousel, +.panel-title a { + cursor: pointer; +} + +.thread-dump-modal-lock { + max-width: 450px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-menu { + padding-left: 0px; +} + +/* ========================================================================== +angular-cli removes postcss-rtl processed inline css, processed rules must be added here instead +========================================================================== */ +/* page-ribbon.component.scss */ +.ribbon { + left: -3.5em; + -moz-transform: rotate(-45deg); + -ms-transform: rotate(-45deg); + -o-transform: rotate(-45deg); + -webkit-transform: rotate(-45deg); + transform: rotate(-45deg); +} + +/* navbar.component.scss */ +.navbar { + ul.navbar-nav { + .nav-item { + margin-left: 0.5em; + } + } +} +/* jhipster-needle-scss-add-main JHipster will add new css style */ diff --git a/src/main/webapp/content/scss/vendor.scss b/src/main/webapp/content/scss/vendor.scss new file mode 100644 index 000000000..acf2df290 --- /dev/null +++ b/src/main/webapp/content/scss/vendor.scss @@ -0,0 +1,12 @@ +/* after changing this file run 'npm run webapp:build' */ + +/*************************** +put Sass variables here: +eg $input-color: red; +****************************/ +// Override Bootstrap variables +@import 'bootstrap-variables'; +// Import Bootstrap source files from node_modules +@import 'bootstrap/scss/bootstrap'; + +/* jhipster-needle-scss-add-vendor JHipster will add new css style */ diff --git a/src/main/webapp/declarations.d.ts b/src/main/webapp/declarations.d.ts new file mode 100644 index 000000000..38352e9b6 --- /dev/null +++ b/src/main/webapp/declarations.d.ts @@ -0,0 +1,2 @@ +declare const SERVER_API_URL: string; +declare const I18N_HASH: string; diff --git a/src/main/webapp/i18n/en/global.json b/src/main/webapp/i18n/en/global.json index 8c7ecfd5b..43453c9af 100644 --- a/src/main/webapp/i18n/en/global.json +++ b/src/main/webapp/i18n/en/global.json @@ -7,10 +7,10 @@ "jhipster-needle-menu-add-element": "JHipster will add additional menu entries here (do not translate!)", "entities": { "main": "Entities", + "jhipster-needle-menu-add-entry": "JHipster will add additional entities here (do not translate!)", "bankAccount": "Bank Account", "label": "Label", - "operation": "Operation", - "jhipster-needle-menu-add-entry": "JHipster will add additional entities here (do not translate!)" + "operation": "Operation" }, "account": { "main": "Account", diff --git a/src/main/webapp/i18n/en/health.json b/src/main/webapp/i18n/en/health.json index 10f40f00a..cbc45bace 100644 --- a/src/main/webapp/i18n/en/health.json +++ b/src/main/webapp/i18n/en/health.json @@ -25,6 +25,7 @@ "status": { "UNKNOWN": "UNKNOWN", "UP": "UP", + "OUT_OF_SERVICE": "OUT_OF_SERVICE", "DOWN": "DOWN" } } diff --git a/src/main/webapp/i18n/en/reset.json b/src/main/webapp/i18n/en/reset.json index 4c35e9a2c..062d3e768 100644 --- a/src/main/webapp/i18n/en/reset.json +++ b/src/main/webapp/i18n/en/reset.json @@ -7,7 +7,7 @@ }, "messages": { "info": "Enter the email address you used to register", - "success": "Check your emails for details on how to reset your password." + "success": "Check your email for details on how to reset your password." } }, "finish": { diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html index f3397c5cc..a462b9550 100644 --- a/src/main/webapp/index.html +++ b/src/main/webapp/index.html @@ -1,4 +1,4 @@ - + @@ -8,6 +8,7 @@ + @@ -20,7 +21,7 @@ your experience.

-
+
@@ -98,7 +99,7 @@

If you want to chat with contributors and other users

-
+
@@ -109,31 +110,18 @@

You must enable JavaScript to view this page.

}; function showError() { var errorElm = document.getElementById('jhipster-error'); - if (errorElm && errorElm.style) { + if (errorElm?.style) { errorElm.style.display = 'block'; } } - - diff --git a/src/main/webapp/main.ts b/src/main/webapp/main.ts new file mode 100644 index 000000000..73c3a0e83 --- /dev/null +++ b/src/main/webapp/main.ts @@ -0,0 +1 @@ +import('./bootstrap').catch(err => console.error(err)); diff --git a/src/main/webapp/swagger-ui/index.html b/src/main/webapp/swagger-ui/index.html index e2ae0b687..e8e1bc715 100644 --- a/src/main/webapp/swagger-ui/index.html +++ b/src/main/webapp/swagger-ui/index.html @@ -1,8 +1,9 @@ - + jhipsterSampleApplication - Swagger UI + @@ -25,27 +26,30 @@ }; window.onload = async function () { + const serverBaseUri = document.baseURI.replace('swagger-ui/', ''); const getBearerToken = () => { var authToken = localStorage.getItem('jhi-authenticationToken') || sessionStorage.getItem('jhi-authenticationToken'); if (authToken) { + authToken = JSON.parse(authToken); return `Bearer ${authToken}`; } return null; }; const axiosConfig = { timeout: 5000, + baseURL: serverBaseUri, headers: { Authorization: getBearerToken() }, }; - const baseUrl = '/v3/api-docs'; + const baseUrl = 'v3/api-docs'; let urls; if (!urls || urls.length === 0) { const response = await axios.get('/management/jhiopenapigroups', axiosConfig); if (Array.isArray(response.data)) { - urls = response.data.map(({ group, description }) => ({ name: description, url: `${baseUrl}/${group}` })); + urls = response.data.map(({ group, description }) => ({ name: description, url: `${serverBaseUri}${baseUrl}/${group}` })); } else { - urls = [{ name: 'default', url: baseUrl }]; + urls = [{ name: 'default', url: `${serverBaseUri}${baseUrl}` }]; } } console.log(`Swagger urls`, urls); @@ -58,7 +62,8 @@ if (y.includes('(default)')) return 1; if (x.includes('(management)')) return -1; if (y.includes('(management)')) return 1; - return x < y ? -1 : x > y ? 1 : 0; + if (x < y) return -1; + return x > y ? 1 : 0; }); } diff --git a/src/test/gatling/user-files/simulations/BankAccountGatlingTest.scala b/src/test/gatling/user-files/simulations/BankAccountGatlingTest.scala deleted file mode 100644 index 7130fab78..000000000 --- a/src/test/gatling/user-files/simulations/BankAccountGatlingTest.scala +++ /dev/null @@ -1,97 +0,0 @@ -import _root_.io.gatling.core.scenario.Simulation -import ch.qos.logback.classic.{Level, LoggerContext} -import io.gatling.core.Predef._ -import io.gatling.http.Predef._ -import org.slf4j.LoggerFactory - -import scala.concurrent.duration._ - -/** - * Performance test for the BankAccount entity. - */ -class BankAccountGatlingTest extends Simulation { - - val context: LoggerContext = LoggerFactory.getILoggerFactory.asInstanceOf[LoggerContext] - // Log all HTTP requests - //context.getLogger("io.gatling.http").setLevel(Level.valueOf("TRACE")) - // Log failed HTTP requests - //context.getLogger("io.gatling.http").setLevel(Level.valueOf("DEBUG")) - - val baseURL = Option(System.getProperty("baseURL")) getOrElse """http://localhost:8080""" - - val httpConf = http - .baseUrl(baseURL) - .inferHtmlResources() - .acceptHeader("*/*") - .acceptEncodingHeader("gzip, deflate") - .acceptLanguageHeader("fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3") - .connectionHeader("keep-alive") - .userAgentHeader("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:33.0) Gecko/20100101 Firefox/33.0") - .silentResources // Silence all resources like css or css so they don't clutter the results - - val headers_http = Map( - "Accept" -> """application/json""" - ) - - val headers_http_authentication = Map( - "Content-Type" -> """application/json""", - "Accept" -> """application/json""" - ) - - val headers_http_authenticated = Map( - "Accept" -> """application/json""", - "Authorization" -> "${access_token}" - ) - - val scn = scenario("Test the BankAccount entity") - .exec(http("First unauthenticated request") - .get("/api/account") - .headers(headers_http) - .check(status.is(401)) - ).exitHereIfFailed - .pause(10) - .exec(http("Authentication") - .post("/api/authenticate") - .headers(headers_http_authentication) - .body(StringBody("""{"username":"admin", "password":"admin"}""")).asJson - .check(header("Authorization").saveAs("access_token"))).exitHereIfFailed - .pause(2) - .exec(http("Authenticated request") - .get("/api/account") - .headers(headers_http_authenticated) - .check(status.is(200))) - .pause(10) - .repeat(2) { - exec(http("Get all bankAccounts") - .get("/api/bank-accounts") - .headers(headers_http_authenticated) - .check(status.is(200))) - .pause(10 seconds, 20 seconds) - .exec(http("Create new bankAccount") - .post("/api/bank-accounts") - .headers(headers_http_authenticated) - .body(StringBody("""{ - "name":"SAMPLE_TEXT" - , "balance":"0" - }""")).asJson - .check(status.is(201)) - .check(headerRegex("Location", "(.*)").saveAs("new_bankAccount_url"))).exitHereIfFailed - .pause(10) - .repeat(5) { - exec(http("Get created bankAccount") - .get("${new_bankAccount_url}") - .headers(headers_http_authenticated)) - .pause(10) - } - .exec(http("Delete created bankAccount") - .delete("${new_bankAccount_url}") - .headers(headers_http_authenticated)) - .pause(10) - } - - val users = scenario("Users").exec(scn) - - setUp( - users.inject(rampUsers(Integer.getInteger("users", 100)) during (Integer.getInteger("ramp", 1) minutes)) - ).protocols(httpConf) -} diff --git a/src/test/gatling/user-files/simulations/LabelGatlingTest.scala b/src/test/gatling/user-files/simulations/LabelGatlingTest.scala deleted file mode 100644 index c0308ce89..000000000 --- a/src/test/gatling/user-files/simulations/LabelGatlingTest.scala +++ /dev/null @@ -1,96 +0,0 @@ -import _root_.io.gatling.core.scenario.Simulation -import ch.qos.logback.classic.{Level, LoggerContext} -import io.gatling.core.Predef._ -import io.gatling.http.Predef._ -import org.slf4j.LoggerFactory - -import scala.concurrent.duration._ - -/** - * Performance test for the Label entity. - */ -class LabelGatlingTest extends Simulation { - - val context: LoggerContext = LoggerFactory.getILoggerFactory.asInstanceOf[LoggerContext] - // Log all HTTP requests - //context.getLogger("io.gatling.http").setLevel(Level.valueOf("TRACE")) - // Log failed HTTP requests - //context.getLogger("io.gatling.http").setLevel(Level.valueOf("DEBUG")) - - val baseURL = Option(System.getProperty("baseURL")) getOrElse """http://localhost:8080""" - - val httpConf = http - .baseUrl(baseURL) - .inferHtmlResources() - .acceptHeader("*/*") - .acceptEncodingHeader("gzip, deflate") - .acceptLanguageHeader("fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3") - .connectionHeader("keep-alive") - .userAgentHeader("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:33.0) Gecko/20100101 Firefox/33.0") - .silentResources // Silence all resources like css or css so they don't clutter the results - - val headers_http = Map( - "Accept" -> """application/json""" - ) - - val headers_http_authentication = Map( - "Content-Type" -> """application/json""", - "Accept" -> """application/json""" - ) - - val headers_http_authenticated = Map( - "Accept" -> """application/json""", - "Authorization" -> "${access_token}" - ) - - val scn = scenario("Test the Label entity") - .exec(http("First unauthenticated request") - .get("/api/account") - .headers(headers_http) - .check(status.is(401)) - ).exitHereIfFailed - .pause(10) - .exec(http("Authentication") - .post("/api/authenticate") - .headers(headers_http_authentication) - .body(StringBody("""{"username":"admin", "password":"admin"}""")).asJson - .check(header("Authorization").saveAs("access_token"))).exitHereIfFailed - .pause(2) - .exec(http("Authenticated request") - .get("/api/account") - .headers(headers_http_authenticated) - .check(status.is(200))) - .pause(10) - .repeat(2) { - exec(http("Get all labels") - .get("/api/labels") - .headers(headers_http_authenticated) - .check(status.is(200))) - .pause(10 seconds, 20 seconds) - .exec(http("Create new label") - .post("/api/labels") - .headers(headers_http_authenticated) - .body(StringBody("""{ - "label":"SAMPLE_TEXT" - }""")).asJson - .check(status.is(201)) - .check(headerRegex("Location", "(.*)").saveAs("new_label_url"))).exitHereIfFailed - .pause(10) - .repeat(5) { - exec(http("Get created label") - .get("${new_label_url}") - .headers(headers_http_authenticated)) - .pause(10) - } - .exec(http("Delete created label") - .delete("${new_label_url}") - .headers(headers_http_authenticated)) - .pause(10) - } - - val users = scenario("Users").exec(scn) - - setUp( - users.inject(rampUsers(Integer.getInteger("users", 100)) during (Integer.getInteger("ramp", 1) minutes)) - ).protocols(httpConf) -} diff --git a/src/test/gatling/user-files/simulations/OperationGatlingTest.scala b/src/test/gatling/user-files/simulations/OperationGatlingTest.scala deleted file mode 100644 index b77c0271b..000000000 --- a/src/test/gatling/user-files/simulations/OperationGatlingTest.scala +++ /dev/null @@ -1,98 +0,0 @@ -import _root_.io.gatling.core.scenario.Simulation -import ch.qos.logback.classic.{Level, LoggerContext} -import io.gatling.core.Predef._ -import io.gatling.http.Predef._ -import org.slf4j.LoggerFactory - -import scala.concurrent.duration._ - -/** - * Performance test for the Operation entity. - */ -class OperationGatlingTest extends Simulation { - - val context: LoggerContext = LoggerFactory.getILoggerFactory.asInstanceOf[LoggerContext] - // Log all HTTP requests - //context.getLogger("io.gatling.http").setLevel(Level.valueOf("TRACE")) - // Log failed HTTP requests - //context.getLogger("io.gatling.http").setLevel(Level.valueOf("DEBUG")) - - val baseURL = Option(System.getProperty("baseURL")) getOrElse """http://localhost:8080""" - - val httpConf = http - .baseUrl(baseURL) - .inferHtmlResources() - .acceptHeader("*/*") - .acceptEncodingHeader("gzip, deflate") - .acceptLanguageHeader("fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3") - .connectionHeader("keep-alive") - .userAgentHeader("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:33.0) Gecko/20100101 Firefox/33.0") - .silentResources // Silence all resources like css or css so they don't clutter the results - - val headers_http = Map( - "Accept" -> """application/json""" - ) - - val headers_http_authentication = Map( - "Content-Type" -> """application/json""", - "Accept" -> """application/json""" - ) - - val headers_http_authenticated = Map( - "Accept" -> """application/json""", - "Authorization" -> "${access_token}" - ) - - val scn = scenario("Test the Operation entity") - .exec(http("First unauthenticated request") - .get("/api/account") - .headers(headers_http) - .check(status.is(401)) - ).exitHereIfFailed - .pause(10) - .exec(http("Authentication") - .post("/api/authenticate") - .headers(headers_http_authentication) - .body(StringBody("""{"username":"admin", "password":"admin"}""")).asJson - .check(header("Authorization").saveAs("access_token"))).exitHereIfFailed - .pause(2) - .exec(http("Authenticated request") - .get("/api/account") - .headers(headers_http_authenticated) - .check(status.is(200))) - .pause(10) - .repeat(2) { - exec(http("Get all operations") - .get("/api/operations") - .headers(headers_http_authenticated) - .check(status.is(200))) - .pause(10 seconds, 20 seconds) - .exec(http("Create new operation") - .post("/api/operations") - .headers(headers_http_authenticated) - .body(StringBody("""{ - "date":"2020-01-01T00:00:00.000Z" - , "description":"SAMPLE_TEXT" - , "amount":"0" - }""")).asJson - .check(status.is(201)) - .check(headerRegex("Location", "(.*)").saveAs("new_operation_url"))).exitHereIfFailed - .pause(10) - .repeat(5) { - exec(http("Get created operation") - .get("${new_operation_url}") - .headers(headers_http_authenticated)) - .pause(10) - } - .exec(http("Delete created operation") - .delete("${new_operation_url}") - .headers(headers_http_authenticated)) - .pause(10) - } - - val users = scenario("Users").exec(scn) - - setUp( - users.inject(rampUsers(Integer.getInteger("users", 100)) during (Integer.getInteger("ramp", 1) minutes)) - ).protocols(httpConf) -} diff --git a/src/test/java/gatling/simulations/BankAccountGatlingTest.java b/src/test/java/gatling/simulations/BankAccountGatlingTest.java new file mode 100644 index 000000000..8906228fa --- /dev/null +++ b/src/test/java/gatling/simulations/BankAccountGatlingTest.java @@ -0,0 +1,96 @@ +package gatling.simulations; + +import static io.gatling.javaapi.core.CoreDsl.StringBody; +import static io.gatling.javaapi.core.CoreDsl.exec; +import static io.gatling.javaapi.core.CoreDsl.rampUsers; +import static io.gatling.javaapi.core.CoreDsl.scenario; +import static io.gatling.javaapi.http.HttpDsl.header; +import static io.gatling.javaapi.http.HttpDsl.headerRegex; +import static io.gatling.javaapi.http.HttpDsl.http; +import static io.gatling.javaapi.http.HttpDsl.status; + +import ch.qos.logback.classic.LoggerContext; +import io.gatling.javaapi.core.ChainBuilder; +import io.gatling.javaapi.core.ScenarioBuilder; +import io.gatling.javaapi.core.Simulation; +import io.gatling.javaapi.http.HttpProtocolBuilder; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import org.slf4j.LoggerFactory; + +/** + * Performance test for the BankAccount entity. + */ +public class BankAccountGatlingTest extends Simulation { + + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + + { + // Log all HTTP requests + //context.getLogger("io.gatling.http").setLevel(Level.valueOf("TRACE")); + // Log failed HTTP requests + //context.getLogger("io.gatling.http").setLevel(Level.valueOf("DEBUG")); + } + + String baseURL = Optional.ofNullable(System.getProperty("baseURL")).orElse("http://localhost:8080"); + + HttpProtocolBuilder httpConf = http + .baseUrl(baseURL) + .inferHtmlResources() + .acceptHeader("*/*") + .acceptEncodingHeader("gzip, deflate") + .acceptLanguageHeader("fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3") + .connectionHeader("keep-alive") + .userAgentHeader("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:33.0) Gecko/20100101 Firefox/33.0") + .silentResources(); // Silence all resources like css or css so they don't clutter the results + + Map headers_http = Map.of("Accept", "application/json"); + + Map headers_http_authentication = Map.of("Content-Type", "application/json", "Accept", "application/json"); + + Map headers_http_authenticated = Map.of("Accept", "application/json", "Authorization", "${access_token}"); + + ChainBuilder scn = exec(http("First unauthenticated request").get("/api/account").headers(headers_http).check(status().is(401))) + .exitHereIfFailed() + .pause(10) + .exec( + http("Authentication") + .post("/api/authenticate") + .headers(headers_http_authentication) + .body(StringBody("{\"username\":\"admin\", \"password\":\"admin\"}")) + .asJson() + .check(header("Authorization").saveAs("access_token")) + ) + .exitHereIfFailed() + .pause(2) + .exec(http("Authenticated request").get("/api/account").headers(headers_http_authenticated).check(status().is(200))) + .pause(10) + .repeat(2) + .on( + exec(http("Get all bankAccounts").get("/api/bank-accounts").headers(headers_http_authenticated).check(status().is(200))) + .pause(Duration.ofSeconds(10), Duration.ofSeconds(20)) + .exec( + http("Create new bankAccount") + .post("/api/bank-accounts") + .headers(headers_http_authenticated) + .body(StringBody("{" + "\"name\": \"SAMPLE_TEXT\"" + ", \"balance\": 0" + "}")) + .asJson() + .check(status().is(201)) + .check(headerRegex("Location", "(.*)").saveAs("new_bankAccount_url")) + ) + .exitHereIfFailed() + .pause(10) + .repeat(5) + .on(exec(http("Get created bankAccount").get("${new_bankAccount_url}").headers(headers_http_authenticated)).pause(10)) + .exec(http("Delete created bankAccount").delete("${new_bankAccount_url}").headers(headers_http_authenticated)) + .pause(10) + ); + + ScenarioBuilder users = scenario("Test the BankAccount entity").exec(scn); + + { + setUp(users.injectOpen(rampUsers(Integer.getInteger("users", 100)).during(Duration.ofMinutes(Integer.getInteger("ramp", 1))))) + .protocols(httpConf); + } +} diff --git a/src/test/java/gatling/simulations/LabelGatlingTest.java b/src/test/java/gatling/simulations/LabelGatlingTest.java new file mode 100644 index 000000000..420bd54a1 --- /dev/null +++ b/src/test/java/gatling/simulations/LabelGatlingTest.java @@ -0,0 +1,96 @@ +package gatling.simulations; + +import static io.gatling.javaapi.core.CoreDsl.StringBody; +import static io.gatling.javaapi.core.CoreDsl.exec; +import static io.gatling.javaapi.core.CoreDsl.rampUsers; +import static io.gatling.javaapi.core.CoreDsl.scenario; +import static io.gatling.javaapi.http.HttpDsl.header; +import static io.gatling.javaapi.http.HttpDsl.headerRegex; +import static io.gatling.javaapi.http.HttpDsl.http; +import static io.gatling.javaapi.http.HttpDsl.status; + +import ch.qos.logback.classic.LoggerContext; +import io.gatling.javaapi.core.ChainBuilder; +import io.gatling.javaapi.core.ScenarioBuilder; +import io.gatling.javaapi.core.Simulation; +import io.gatling.javaapi.http.HttpProtocolBuilder; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import org.slf4j.LoggerFactory; + +/** + * Performance test for the Label entity. + */ +public class LabelGatlingTest extends Simulation { + + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + + { + // Log all HTTP requests + //context.getLogger("io.gatling.http").setLevel(Level.valueOf("TRACE")); + // Log failed HTTP requests + //context.getLogger("io.gatling.http").setLevel(Level.valueOf("DEBUG")); + } + + String baseURL = Optional.ofNullable(System.getProperty("baseURL")).orElse("http://localhost:8080"); + + HttpProtocolBuilder httpConf = http + .baseUrl(baseURL) + .inferHtmlResources() + .acceptHeader("*/*") + .acceptEncodingHeader("gzip, deflate") + .acceptLanguageHeader("fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3") + .connectionHeader("keep-alive") + .userAgentHeader("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:33.0) Gecko/20100101 Firefox/33.0") + .silentResources(); // Silence all resources like css or css so they don't clutter the results + + Map headers_http = Map.of("Accept", "application/json"); + + Map headers_http_authentication = Map.of("Content-Type", "application/json", "Accept", "application/json"); + + Map headers_http_authenticated = Map.of("Accept", "application/json", "Authorization", "${access_token}"); + + ChainBuilder scn = exec(http("First unauthenticated request").get("/api/account").headers(headers_http).check(status().is(401))) + .exitHereIfFailed() + .pause(10) + .exec( + http("Authentication") + .post("/api/authenticate") + .headers(headers_http_authentication) + .body(StringBody("{\"username\":\"admin\", \"password\":\"admin\"}")) + .asJson() + .check(header("Authorization").saveAs("access_token")) + ) + .exitHereIfFailed() + .pause(2) + .exec(http("Authenticated request").get("/api/account").headers(headers_http_authenticated).check(status().is(200))) + .pause(10) + .repeat(2) + .on( + exec(http("Get all labels").get("/api/labels").headers(headers_http_authenticated).check(status().is(200))) + .pause(Duration.ofSeconds(10), Duration.ofSeconds(20)) + .exec( + http("Create new label") + .post("/api/labels") + .headers(headers_http_authenticated) + .body(StringBody("{" + "\"label\": \"SAMPLE_TEXT\"" + "}")) + .asJson() + .check(status().is(201)) + .check(headerRegex("Location", "(.*)").saveAs("new_label_url")) + ) + .exitHereIfFailed() + .pause(10) + .repeat(5) + .on(exec(http("Get created label").get("${new_label_url}").headers(headers_http_authenticated)).pause(10)) + .exec(http("Delete created label").delete("${new_label_url}").headers(headers_http_authenticated)) + .pause(10) + ); + + ScenarioBuilder users = scenario("Test the Label entity").exec(scn); + + { + setUp(users.injectOpen(rampUsers(Integer.getInteger("users", 100)).during(Duration.ofMinutes(Integer.getInteger("ramp", 1))))) + .protocols(httpConf); + } +} diff --git a/src/test/java/gatling/simulations/OperationGatlingTest.java b/src/test/java/gatling/simulations/OperationGatlingTest.java new file mode 100644 index 000000000..b4f7fc24c --- /dev/null +++ b/src/test/java/gatling/simulations/OperationGatlingTest.java @@ -0,0 +1,104 @@ +package gatling.simulations; + +import static io.gatling.javaapi.core.CoreDsl.StringBody; +import static io.gatling.javaapi.core.CoreDsl.exec; +import static io.gatling.javaapi.core.CoreDsl.rampUsers; +import static io.gatling.javaapi.core.CoreDsl.scenario; +import static io.gatling.javaapi.http.HttpDsl.header; +import static io.gatling.javaapi.http.HttpDsl.headerRegex; +import static io.gatling.javaapi.http.HttpDsl.http; +import static io.gatling.javaapi.http.HttpDsl.status; + +import ch.qos.logback.classic.LoggerContext; +import io.gatling.javaapi.core.ChainBuilder; +import io.gatling.javaapi.core.ScenarioBuilder; +import io.gatling.javaapi.core.Simulation; +import io.gatling.javaapi.http.HttpProtocolBuilder; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import org.slf4j.LoggerFactory; + +/** + * Performance test for the Operation entity. + */ +public class OperationGatlingTest extends Simulation { + + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + + { + // Log all HTTP requests + //context.getLogger("io.gatling.http").setLevel(Level.valueOf("TRACE")); + // Log failed HTTP requests + //context.getLogger("io.gatling.http").setLevel(Level.valueOf("DEBUG")); + } + + String baseURL = Optional.ofNullable(System.getProperty("baseURL")).orElse("http://localhost:8080"); + + HttpProtocolBuilder httpConf = http + .baseUrl(baseURL) + .inferHtmlResources() + .acceptHeader("*/*") + .acceptEncodingHeader("gzip, deflate") + .acceptLanguageHeader("fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3") + .connectionHeader("keep-alive") + .userAgentHeader("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:33.0) Gecko/20100101 Firefox/33.0") + .silentResources(); // Silence all resources like css or css so they don't clutter the results + + Map headers_http = Map.of("Accept", "application/json"); + + Map headers_http_authentication = Map.of("Content-Type", "application/json", "Accept", "application/json"); + + Map headers_http_authenticated = Map.of("Accept", "application/json", "Authorization", "${access_token}"); + + ChainBuilder scn = exec(http("First unauthenticated request").get("/api/account").headers(headers_http).check(status().is(401))) + .exitHereIfFailed() + .pause(10) + .exec( + http("Authentication") + .post("/api/authenticate") + .headers(headers_http_authentication) + .body(StringBody("{\"username\":\"admin\", \"password\":\"admin\"}")) + .asJson() + .check(header("Authorization").saveAs("access_token")) + ) + .exitHereIfFailed() + .pause(2) + .exec(http("Authenticated request").get("/api/account").headers(headers_http_authenticated).check(status().is(200))) + .pause(10) + .repeat(2) + .on( + exec(http("Get all operations").get("/api/operations").headers(headers_http_authenticated).check(status().is(200))) + .pause(Duration.ofSeconds(10), Duration.ofSeconds(20)) + .exec( + http("Create new operation") + .post("/api/operations") + .headers(headers_http_authenticated) + .body( + StringBody( + "{" + + "\"date\": \"2020-01-01T00:00:00.000Z\"" + + ", \"description\": \"SAMPLE_TEXT\"" + + ", \"amount\": 0" + + "}" + ) + ) + .asJson() + .check(status().is(201)) + .check(headerRegex("Location", "(.*)").saveAs("new_operation_url")) + ) + .exitHereIfFailed() + .pause(10) + .repeat(5) + .on(exec(http("Get created operation").get("${new_operation_url}").headers(headers_http_authenticated)).pause(10)) + .exec(http("Delete created operation").delete("${new_operation_url}").headers(headers_http_authenticated)) + .pause(10) + ); + + ScenarioBuilder users = scenario("Test the Operation entity").exec(scn); + + { + setUp(users.injectOpen(rampUsers(Integer.getInteger("users", 100)).during(Duration.ofMinutes(Integer.getInteger("ramp", 1))))) + .protocols(httpConf); + } +} diff --git a/src/test/java/io/github/jhipster/sample/IntegrationTest.java b/src/test/java/io/github/jhipster/sample/IntegrationTest.java index 33731eab0..6db442851 100644 --- a/src/test/java/io/github/jhipster/sample/IntegrationTest.java +++ b/src/test/java/io/github/jhipster/sample/IntegrationTest.java @@ -1,6 +1,5 @@ package io.github.jhipster.sample; -import io.github.jhipster.sample.JhipsterSampleApplicationApp; import io.github.jhipster.sample.config.AsyncSyncConfiguration; import io.github.jhipster.sample.config.EmbeddedSQL; import java.lang.annotation.ElementType; diff --git a/src/test/java/io/github/jhipster/sample/TechnicalStructureTest.java b/src/test/java/io/github/jhipster/sample/TechnicalStructureTest.java index 1480987d4..4b13083b7 100644 --- a/src/test/java/io/github/jhipster/sample/TechnicalStructureTest.java +++ b/src/test/java/io/github/jhipster/sample/TechnicalStructureTest.java @@ -15,11 +15,12 @@ class TechnicalStructureTest { // prettier-ignore @ArchTest static final ArchRule respectsTechnicalArchitectureLayers = layeredArchitecture() + .consideringAllDependencies() .layer("Config").definedBy("..config..") .layer("Web").definedBy("..web..") .optionalLayer("Service").definedBy("..service..") .layer("Security").definedBy("..security..") - .layer("Persistence").definedBy("..repository..") + .optionalLayer("Persistence").definedBy("..repository..") .layer("Domain").definedBy("..domain..") .whereLayer("Config").mayNotBeAccessedByAnyLayer() diff --git a/src/test/java/io/github/jhipster/sample/config/AsyncSyncConfiguration.java b/src/test/java/io/github/jhipster/sample/config/AsyncSyncConfiguration.java index 07ed6907f..3af86752c 100644 --- a/src/test/java/io/github/jhipster/sample/config/AsyncSyncConfiguration.java +++ b/src/test/java/io/github/jhipster/sample/config/AsyncSyncConfiguration.java @@ -3,7 +3,6 @@ import java.util.concurrent.Executor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; import org.springframework.core.task.SyncTaskExecutor; @Configuration diff --git a/src/test/java/io/github/jhipster/sample/config/PostgreSqlTestContainer.java b/src/test/java/io/github/jhipster/sample/config/PostgreSqlTestContainer.java index 532337455..8af33378f 100644 --- a/src/test/java/io/github/jhipster/sample/config/PostgreSqlTestContainer.java +++ b/src/test/java/io/github/jhipster/sample/config/PostgreSqlTestContainer.java @@ -24,7 +24,7 @@ public void destroy() { public void afterPropertiesSet() { if (null == postgreSQLContainer) { postgreSQLContainer = - new PostgreSQLContainer<>("postgres:14.5") + new PostgreSQLContainer<>("postgres:15.4") .withDatabaseName("jhipsterSampleApplication") .withTmpFs(Collections.singletonMap("/testtmpfs", "rw")) .withLogConsumer(new Slf4jLogConsumer(log)) diff --git a/src/test/java/io/github/jhipster/sample/config/SqlTestContainersSpringContextCustomizerFactory.java b/src/test/java/io/github/jhipster/sample/config/SqlTestContainersSpringContextCustomizerFactory.java new file mode 100644 index 000000000..57f210ccd --- /dev/null +++ b/src/test/java/io/github/jhipster/sample/config/SqlTestContainersSpringContextCustomizerFactory.java @@ -0,0 +1,52 @@ +package io.github.jhipster.sample.config; + +import java.util.Arrays; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; +import tech.jhipster.config.JHipsterConstants; + +public class SqlTestContainersSpringContextCustomizerFactory implements ContextCustomizerFactory { + + private Logger log = LoggerFactory.getLogger(SqlTestContainersSpringContextCustomizerFactory.class); + + private static SqlTestContainer prodTestContainer; + + @Override + public ContextCustomizer createContextCustomizer(Class testClass, List configAttributes) { + return (context, mergedConfig) -> { + ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); + TestPropertyValues testValues = TestPropertyValues.empty(); + EmbeddedSQL sqlAnnotation = AnnotatedElementUtils.findMergedAnnotation(testClass, EmbeddedSQL.class); + boolean usingTestProdProfile = Arrays + .asList(context.getEnvironment().getActiveProfiles()) + .contains("test" + JHipsterConstants.SPRING_PROFILE_PRODUCTION); + if (null != sqlAnnotation && usingTestProdProfile) { + log.debug("detected the EmbeddedSQL annotation on class {}", testClass.getName()); + log.info("Warming up the sql database"); + if (null == prodTestContainer) { + try { + Class containerClass = (Class) Class.forName( + this.getClass().getPackageName() + ".PostgreSqlTestContainer" + ); + prodTestContainer = beanFactory.createBean(containerClass); + beanFactory.registerSingleton(containerClass.getName(), prodTestContainer); + // ((DefaultListableBeanFactory)beanFactory).registerDisposableBean(containerClass.getName(), prodTestContainer); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + testValues = testValues.and("spring.datasource.url=" + prodTestContainer.getTestContainer().getJdbcUrl() + ""); + testValues = testValues.and("spring.datasource.username=" + prodTestContainer.getTestContainer().getUsername()); + testValues = testValues.and("spring.datasource.password=" + prodTestContainer.getTestContainer().getPassword()); + } + testValues.applyTo(context); + }; + } +} diff --git a/src/test/java/io/github/jhipster/sample/config/TestContainersSpringContextCustomizerFactory.java b/src/test/java/io/github/jhipster/sample/config/TestContainersSpringContextCustomizerFactory.java deleted file mode 100644 index b34736897..000000000 --- a/src/test/java/io/github/jhipster/sample/config/TestContainersSpringContextCustomizerFactory.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.github.jhipster.sample.config; - -import java.util.Arrays; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.beans.factory.support.DefaultListableBeanFactory; -import org.springframework.beans.factory.support.DefaultSingletonBeanRegistry; -import org.springframework.boot.test.util.TestPropertyValues; -import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.test.context.ContextConfigurationAttributes; -import org.springframework.test.context.ContextCustomizer; -import org.springframework.test.context.ContextCustomizerFactory; -import tech.jhipster.config.JHipsterConstants; - -public class TestContainersSpringContextCustomizerFactory implements ContextCustomizerFactory { - - private Logger log = LoggerFactory.getLogger(TestContainersSpringContextCustomizerFactory.class); - - private static SqlTestContainer prodTestContainer; - - @Override - public ContextCustomizer createContextCustomizer(Class testClass, List configAttributes) { - return (context, mergedConfig) -> { - ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); - TestPropertyValues testValues = TestPropertyValues.empty(); - EmbeddedSQL sqlAnnotation = AnnotatedElementUtils.findMergedAnnotation(testClass, EmbeddedSQL.class); - if (null != sqlAnnotation) { - log.debug("detected the EmbeddedSQL annotation on class {}", testClass.getName()); - log.info("Warming up the sql database"); - if ( - Arrays - .asList(context.getEnvironment().getActiveProfiles()) - .contains("test" + JHipsterConstants.SPRING_PROFILE_PRODUCTION) - ) { - if (null == prodTestContainer) { - try { - Class containerClass = (Class) Class.forName( - this.getClass().getPackageName() + ".PostgreSqlTestContainer" - ); - prodTestContainer = beanFactory.createBean(containerClass); - beanFactory.registerSingleton(containerClass.getName(), prodTestContainer); - // ((DefaultListableBeanFactory)beanFactory).registerDisposableBean(containerClass.getName(), prodTestContainer); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - } - testValues = testValues.and("spring.datasource.url=" + prodTestContainer.getTestContainer().getJdbcUrl() + ""); - testValues = testValues.and("spring.datasource.username=" + prodTestContainer.getTestContainer().getUsername()); - testValues = testValues.and("spring.datasource.password=" + prodTestContainer.getTestContainer().getPassword()); - } - } - testValues.applyTo(context); - }; - } -} diff --git a/src/test/java/io/github/jhipster/sample/config/WebConfigurerTest.java b/src/test/java/io/github/jhipster/sample/config/WebConfigurerTest.java index bf81aade2..be2a49ce6 100644 --- a/src/test/java/io/github/jhipster/sample/config/WebConfigurerTest.java +++ b/src/test/java/io/github/jhipster/sample/config/WebConfigurerTest.java @@ -1,7 +1,6 @@ package io.github.jhipster.sample.config; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @@ -10,9 +9,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import jakarta.servlet.*; import java.io.File; import java.util.*; -import javax.servlet.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory; diff --git a/src/test/java/io/github/jhipster/sample/config/timezone/HibernateTimeZoneIT.java b/src/test/java/io/github/jhipster/sample/config/timezone/HibernateTimeZoneIT.java index 51321e820..77531bec8 100644 --- a/src/test/java/io/github/jhipster/sample/config/timezone/HibernateTimeZoneIT.java +++ b/src/test/java/io/github/jhipster/sample/config/timezone/HibernateTimeZoneIT.java @@ -17,7 +17,13 @@ import org.springframework.transaction.annotation.Transactional; /** - * Integration tests for the ZoneId Hibernate configuration. + * Integration tests for verifying the behavior of Hibernate in the context of storing various date and time types across different databases. + * The tests focus on ensuring that the stored values are correctly transformed and stored according to the configured timezone. + * Timezone is environment specific, and can be adjusted according to your needs. + * + * For more context, refer to: + * - GitHub Issue: https://github.com/jhipster/generator-jhipster/issues/22579 + * - Pull Request: https://github.com/jhipster/generator-jhipster/pull/22946 */ @IntegrationTest class HibernateTimeZoneIT { @@ -34,77 +40,77 @@ class HibernateTimeZoneIT { private DateTimeWrapper dateTimeWrapper; private DateTimeFormatter dateTimeFormatter; private DateTimeFormatter timeFormatter; + private DateTimeFormatter offsetTimeFormatter; private DateTimeFormatter dateFormatter; @BeforeEach public void setup() { dateTimeWrapper = new DateTimeWrapper(); - dateTimeWrapper.setInstant(Instant.parse("2014-11-12T05:50:00.0Z")); - dateTimeWrapper.setLocalDateTime(LocalDateTime.parse("2014-11-12T07:50:00.0")); + dateTimeWrapper.setInstant(Instant.parse("2014-11-12T05:10:00.0Z")); + dateTimeWrapper.setLocalDateTime(LocalDateTime.parse("2014-11-12T07:20:00.0")); dateTimeWrapper.setOffsetDateTime(OffsetDateTime.parse("2011-12-14T08:30:00.0Z")); - dateTimeWrapper.setZonedDateTime(ZonedDateTime.parse("2011-12-14T08:30:00.0Z")); - dateTimeWrapper.setLocalTime(LocalTime.parse("14:30:00")); - dateTimeWrapper.setOffsetTime(OffsetTime.parse("14:30:00+02:00")); + dateTimeWrapper.setZonedDateTime(ZonedDateTime.parse("2011-12-14T08:40:00.0Z")); + dateTimeWrapper.setLocalTime(LocalTime.parse("14:50:00")); + dateTimeWrapper.setOffsetTime(OffsetTime.parse("14:00:00+02:00")); dateTimeWrapper.setLocalDate(LocalDate.parse("2016-09-10")); dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.S").withZone(ZoneId.of(zoneId)); - timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.of(zoneId)); - + offsetTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss"); dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); } @Test @Transactional - void storeInstantWithZoneIdConfigShouldBeStoredOnGMTTimeZone() { + void storeInstantWithZoneIdConfigShouldBeStoredOnConfiguredTimeZone() { dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); String request = generateSqlRequest("instant", dateTimeWrapper.getId()); SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); String expectedValue = dateTimeFormatter.format(dateTimeWrapper.getInstant()); - assertThatDateStoredValueIsEqualToInsertDateValueOnGMTTimeZone(resultSet, expectedValue); + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); } @Test @Transactional - void storeLocalDateTimeWithZoneIdConfigShouldBeStoredOnGMTTimeZone() { + void storeLocalDateTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZone() { dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); String request = generateSqlRequest("local_date_time", dateTimeWrapper.getId()); SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); String expectedValue = dateTimeWrapper.getLocalDateTime().atZone(ZoneId.systemDefault()).format(dateTimeFormatter); - assertThatDateStoredValueIsEqualToInsertDateValueOnGMTTimeZone(resultSet, expectedValue); + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); } @Test @Transactional - void storeOffsetDateTimeWithZoneIdConfigShouldBeStoredOnGMTTimeZone() { + void storeOffsetDateTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZone() { dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); String request = generateSqlRequest("offset_date_time", dateTimeWrapper.getId()); SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); String expectedValue = dateTimeWrapper.getOffsetDateTime().format(dateTimeFormatter); - assertThatDateStoredValueIsEqualToInsertDateValueOnGMTTimeZone(resultSet, expectedValue); + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); } @Test @Transactional - void storeZoneDateTimeWithZoneIdConfigShouldBeStoredOnGMTTimeZone() { + void storeZoneDateTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZone() { dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); String request = generateSqlRequest("zoned_date_time", dateTimeWrapper.getId()); SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); String expectedValue = dateTimeWrapper.getZonedDateTime().format(dateTimeFormatter); - assertThatDateStoredValueIsEqualToInsertDateValueOnGMTTimeZone(resultSet, expectedValue); + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); } @Test @Transactional - void storeLocalTimeWithZoneIdConfigShouldBeStoredOnGMTTimeZoneAccordingToHis1stJan1970Value() { + void storeLocalTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZoneAccordingToHis1stJan1970Value() { dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); String request = generateSqlRequest("local_time", dateTimeWrapper.getId()); @@ -115,24 +121,28 @@ void storeLocalTimeWithZoneIdConfigShouldBeStoredOnGMTTimeZoneAccordingToHis1stJ .atZone(ZoneId.systemDefault()) .format(timeFormatter); - assertThatDateStoredValueIsEqualToInsertDateValueOnGMTTimeZone(resultSet, expectedValue); + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); } @Test @Transactional - void storeOffsetTimeWithZoneIdConfigShouldBeStoredOnGMTTimeZoneAccordingToHis1stJan1970Value() { + void storeOffsetTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZoneAccordingToHis1stJan1970Value() { dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); String request = generateSqlRequest("offset_time", dateTimeWrapper.getId()); SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); String expectedValue = dateTimeWrapper .getOffsetTime() - .toLocalTime() - .atDate(LocalDate.of(1970, Month.JANUARY, 1)) - .atZone(ZoneId.systemDefault()) - .format(timeFormatter); - - assertThatDateStoredValueIsEqualToInsertDateValueOnGMTTimeZone(resultSet, expectedValue); + // Convert to configured timezone + .withOffsetSameInstant(ZoneId.of(zoneId).getRules().getOffset(Instant.now())) + // Normalize to System TimeZone. + // TODO this behavior looks a bug, refer to https://github.com/jhipster/generator-jhipster/issues/22579. + .withOffsetSameLocal(OffsetDateTime.ofInstant(Instant.EPOCH, ZoneId.systemDefault()).getOffset()) + // Convert the normalized value to configured timezone + .withOffsetSameInstant(ZoneId.of(zoneId).getRules().getOffset(Instant.EPOCH)) + .format(offsetTimeFormatter); + + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); } @Test @@ -144,14 +154,14 @@ void storeLocalDateWithZoneIdConfigShouldBeStoredWithoutTransformation() { SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); String expectedValue = dateTimeWrapper.getLocalDate().format(dateFormatter); - assertThatDateStoredValueIsEqualToInsertDateValueOnGMTTimeZone(resultSet, expectedValue); + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); } private String generateSqlRequest(String fieldName, long id) { return format("SELECT %s FROM jhi_date_time_wrapper where id=%d", fieldName, id); } - private void assertThatDateStoredValueIsEqualToInsertDateValueOnGMTTimeZone(SqlRowSet sqlRowSet, String expectedValue) { + private void assertThatValueFromSqlRowSetIsEqualToExpectedValue(SqlRowSet sqlRowSet, String expectedValue) { while (sqlRowSet.next()) { String dbValue = sqlRowSet.getString(1); diff --git a/src/test/java/io/github/jhipster/sample/repository/timezone/DateTimeWrapper.java b/src/test/java/io/github/jhipster/sample/repository/timezone/DateTimeWrapper.java index 2e278e38f..3ef84db22 100644 --- a/src/test/java/io/github/jhipster/sample/repository/timezone/DateTimeWrapper.java +++ b/src/test/java/io/github/jhipster/sample/repository/timezone/DateTimeWrapper.java @@ -1,9 +1,9 @@ package io.github.jhipster.sample.repository.timezone; +import jakarta.persistence.*; import java.io.Serializable; import java.time.*; import java.util.Objects; -import javax.persistence.*; @Entity @Table(name = "jhi_date_time_wrapper") diff --git a/src/test/java/io/github/jhipster/sample/security/jwt/AuthenticationIntegrationTest.java b/src/test/java/io/github/jhipster/sample/security/jwt/AuthenticationIntegrationTest.java new file mode 100644 index 000000000..e9448d3ed --- /dev/null +++ b/src/test/java/io/github/jhipster/sample/security/jwt/AuthenticationIntegrationTest.java @@ -0,0 +1,35 @@ +package io.github.jhipster.sample.security.jwt; + +import io.github.jhipster.sample.config.SecurityConfiguration; +import io.github.jhipster.sample.config.SecurityJwtConfiguration; +import io.github.jhipster.sample.config.WebConfigurer; +import io.github.jhipster.sample.management.SecurityMetersService; +import io.github.jhipster.sample.web.rest.AuthenticateController; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import tech.jhipster.config.JHipsterProperties; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest( + properties = { + "jhipster.security.authentication.jwt.base64-secret=fd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8", + "jhipster.security.authentication.jwt.token-validity-in-seconds=60000", + }, + classes = { + JHipsterProperties.class, + WebConfigurer.class, + SecurityConfiguration.class, + SecurityJwtConfiguration.class, + SecurityMetersService.class, + AuthenticateController.class, + JwtAuthenticationTestUtils.class, + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +public @interface AuthenticationIntegrationTest { +} diff --git a/src/test/java/io/github/jhipster/sample/security/jwt/JWTFilterTest.java b/src/test/java/io/github/jhipster/sample/security/jwt/JWTFilterTest.java deleted file mode 100644 index 4311b7630..000000000 --- a/src/test/java/io/github/jhipster/sample/security/jwt/JWTFilterTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package io.github.jhipster.sample.security.jwt; - -import static org.assertj.core.api.Assertions.assertThat; - -import io.github.jhipster.sample.management.SecurityMetersService; -import io.github.jhipster.sample.security.AuthoritiesConstants; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.util.Collections; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.mock.web.MockFilterChain; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.util.ReflectionTestUtils; -import tech.jhipster.config.JHipsterProperties; - -class JWTFilterTest { - - private TokenProvider tokenProvider; - - private JWTFilter jwtFilter; - - @BeforeEach - public void setup() { - JHipsterProperties jHipsterProperties = new JHipsterProperties(); - String base64Secret = "fd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8"; - jHipsterProperties.getSecurity().getAuthentication().getJwt().setBase64Secret(base64Secret); - - SecurityMetersService securityMetersService = new SecurityMetersService(new SimpleMeterRegistry()); - - tokenProvider = new TokenProvider(jHipsterProperties, securityMetersService); - ReflectionTestUtils.setField(tokenProvider, "key", Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))); - - ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", 60000); - jwtFilter = new JWTFilter(tokenProvider); - SecurityContextHolder.getContext().setAuthentication(null); - } - - @Test - void testJWTFilter() throws Exception { - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - "test-user", - "test-password", - Collections.singletonList(new SimpleGrantedAuthority(AuthoritiesConstants.USER)) - ); - String jwt = tokenProvider.createToken(authentication, false); - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(JWTFilter.AUTHORIZATION_HEADER, "Bearer " + jwt); - request.setRequestURI("/api/test"); - MockHttpServletResponse response = new MockHttpServletResponse(); - MockFilterChain filterChain = new MockFilterChain(); - jwtFilter.doFilter(request, response, filterChain); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("test-user"); - assertThat(SecurityContextHolder.getContext().getAuthentication().getCredentials()).hasToString(jwt); - } - - @Test - void testJWTFilterInvalidToken() throws Exception { - String jwt = "wrong_jwt"; - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(JWTFilter.AUTHORIZATION_HEADER, "Bearer " + jwt); - request.setRequestURI("/api/test"); - MockHttpServletResponse response = new MockHttpServletResponse(); - MockFilterChain filterChain = new MockFilterChain(); - jwtFilter.doFilter(request, response, filterChain); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); - } - - @Test - void testJWTFilterMissingAuthorization() throws Exception { - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRequestURI("/api/test"); - MockHttpServletResponse response = new MockHttpServletResponse(); - MockFilterChain filterChain = new MockFilterChain(); - jwtFilter.doFilter(request, response, filterChain); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); - } - - @Test - void testJWTFilterMissingToken() throws Exception { - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(JWTFilter.AUTHORIZATION_HEADER, "Bearer "); - request.setRequestURI("/api/test"); - MockHttpServletResponse response = new MockHttpServletResponse(); - MockFilterChain filterChain = new MockFilterChain(); - jwtFilter.doFilter(request, response, filterChain); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); - } - - @Test - void testJWTFilterWrongScheme() throws Exception { - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - "test-user", - "test-password", - Collections.singletonList(new SimpleGrantedAuthority(AuthoritiesConstants.USER)) - ); - String jwt = tokenProvider.createToken(authentication, false); - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(JWTFilter.AUTHORIZATION_HEADER, "Basic " + jwt); - request.setRequestURI("/api/test"); - MockHttpServletResponse response = new MockHttpServletResponse(); - MockFilterChain filterChain = new MockFilterChain(); - jwtFilter.doFilter(request, response, filterChain); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); - } -} diff --git a/src/test/java/io/github/jhipster/sample/security/jwt/JwtAuthenticationTestUtils.java b/src/test/java/io/github/jhipster/sample/security/jwt/JwtAuthenticationTestUtils.java new file mode 100644 index 000000000..6a3111de1 --- /dev/null +++ b/src/test/java/io/github/jhipster/sample/security/jwt/JwtAuthenticationTestUtils.java @@ -0,0 +1,106 @@ +package io.github.jhipster.sample.security.jwt; + +import static io.github.jhipster.sample.security.SecurityUtils.AUTHORITIES_KEY; +import static io.github.jhipster.sample.security.SecurityUtils.JWT_ALGORITHM; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.util.Base64; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Instant; +import java.util.Collections; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +public class JwtAuthenticationTestUtils { + + public static final String BEARER = "Bearer "; + + @Bean + private HandlerMappingIntrospector mvcHandlerMappingIntrospector() { + return new HandlerMappingIntrospector(); + } + + @Bean + private MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + + public static String createValidToken(String jwtKey) { + return createValidTokenForUser(jwtKey, "anonymous"); + } + + public static String createValidTokenForUser(String jwtKey, String user) { + JwtEncoder encoder = jwtEncoder(jwtKey); + + var now = Instant.now(); + + JwtClaimsSet claims = JwtClaimsSet + .builder() + .issuedAt(now) + .expiresAt(now.plusSeconds(60)) + .subject(user) + .claims(customClain -> customClain.put(AUTHORITIES_KEY, Collections.singletonList("ROLE_ADMIN"))) + .build(); + + JwsHeader jwsHeader = JwsHeader.with(JWT_ALGORITHM).build(); + return encoder.encode(JwtEncoderParameters.from(jwsHeader, claims)).getTokenValue(); + } + + public static String createTokenWithDifferentSignature() { + JwtEncoder encoder = jwtEncoder("Xfd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8"); + + var now = Instant.now(); + var past = now.plusSeconds(60); + + JwtClaimsSet claims = JwtClaimsSet.builder().issuedAt(now).expiresAt(past).subject("anonymous").build(); + + JwsHeader jwsHeader = JwsHeader.with(JWT_ALGORITHM).build(); + return encoder.encode(JwtEncoderParameters.from(jwsHeader, claims)).getTokenValue(); + } + + public static String createExpiredToken(String jwtKey) { + JwtEncoder encoder = jwtEncoder(jwtKey); + + var now = Instant.now(); + var past = now.minusSeconds(600); + + JwtClaimsSet claims = JwtClaimsSet.builder().issuedAt(past).expiresAt(past.plusSeconds(1)).subject("anonymous").build(); + + JwsHeader jwsHeader = JwsHeader.with(JWT_ALGORITHM).build(); + return encoder.encode(JwtEncoderParameters.from(jwsHeader, claims)).getTokenValue(); + } + + public static String createInvalidToken(String jwtKey) throws Exception { + return createValidToken(jwtKey).substring(1); + } + + public static String createSignedInvalidJwt(String jwtKey) throws Exception { + return calculateHMAC("foo", jwtKey); + } + + private static JwtEncoder jwtEncoder(String jwtKey) { + return new NimbusJwtEncoder(new ImmutableSecret<>(getSecretKey(jwtKey))); + } + + private static SecretKey getSecretKey(String jwtKey) { + byte[] keyBytes = Base64.from(jwtKey).decode(); + return new SecretKeySpec(keyBytes, 0, keyBytes.length, JWT_ALGORITHM.getName()); + } + + private static String calculateHMAC(String data, String key) throws Exception { + SecretKeySpec secretKeySpec = new SecretKeySpec(Base64.from(key).decode(), "HmacSHA512"); + Mac mac = Mac.getInstance("HmacSHA512"); + mac.init(secretKeySpec); + return String.copyValueOf(Hex.encode(mac.doFinal(data.getBytes()))); + } +} diff --git a/src/test/java/io/github/jhipster/sample/security/jwt/TokenAuthenticationIT.java b/src/test/java/io/github/jhipster/sample/security/jwt/TokenAuthenticationIT.java new file mode 100644 index 000000000..77b28096b --- /dev/null +++ b/src/test/java/io/github/jhipster/sample/security/jwt/TokenAuthenticationIT.java @@ -0,0 +1,53 @@ +package io.github.jhipster.sample.security.jwt; + +import static io.github.jhipster.sample.security.jwt.JwtAuthenticationTestUtils.*; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@AutoConfigureMockMvc +@AuthenticationIntegrationTest +class TokenAuthenticationIT { + + @Autowired + private MockMvc mvc; + + @Value("${jhipster.security.authentication.jwt.base64-secret}") + private String jwtKey; + + @Test + void testLoginWithValidToken() throws Exception { + expectOk(createValidToken(jwtKey)); + } + + @Test + void testReturnFalseWhenJWThasInvalidSignature() throws Exception { + expectUnauthorized(createTokenWithDifferentSignature()); + } + + @Test + void testReturnFalseWhenJWTisMalformed() throws Exception { + expectUnauthorized(createSignedInvalidJwt(jwtKey)); + } + + @Test + void testReturnFalseWhenJWTisExpired() throws Exception { + expectUnauthorized(createExpiredToken(jwtKey)); + } + + private void expectOk(String token) throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/authenticate").header(AUTHORIZATION, BEARER + token)).andExpect(status().isOk()); + } + + private void expectUnauthorized(String token) throws Exception { + mvc + .perform(MockMvcRequestBuilders.get("/api/authenticate").header(AUTHORIZATION, BEARER + token)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/io/github/jhipster/sample/security/jwt/TokenAuthenticationSecurityMetersIT.java b/src/test/java/io/github/jhipster/sample/security/jwt/TokenAuthenticationSecurityMetersIT.java new file mode 100644 index 000000000..4be3fefb2 --- /dev/null +++ b/src/test/java/io/github/jhipster/sample/security/jwt/TokenAuthenticationSecurityMetersIT.java @@ -0,0 +1,87 @@ +package io.github.jhipster.sample.security.jwt; + +import static io.github.jhipster.sample.security.jwt.JwtAuthenticationTestUtils.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Collection; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@AutoConfigureMockMvc +@AuthenticationIntegrationTest +class TokenAuthenticationSecurityMetersIT { + + private static final String INVALID_TOKENS_METER_EXPECTED_NAME = "security.authentication.invalid-tokens"; + + @Autowired + private MockMvc mvc; + + @Value("${jhipster.security.authentication.jwt.base64-secret}") + private String jwtKey; + + @Autowired + private MeterRegistry meterRegistry; + + @Test + void testValidTokenShouldNotCountAnything() throws Exception { + Collection counters = meterRegistry.find(INVALID_TOKENS_METER_EXPECTED_NAME).counters(); + + var count = aggregate(counters); + + tryToAuthenticate(createValidToken(jwtKey)); + + assertThat(aggregate(counters)).isEqualTo(count); + } + + @Test + void testTokenExpiredCount() throws Exception { + var count = meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter().count(); + + tryToAuthenticate(createExpiredToken(jwtKey)); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter().count()).isEqualTo(count + 1); + } + + @Test + void testTokenSignatureInvalidCount() throws Exception { + var count = meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter().count(); + + tryToAuthenticate(createTokenWithDifferentSignature()); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter().count()) + .isEqualTo(count + 1); + } + + @Test + void testTokenMalformedCount() throws Exception { + var count = meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count(); + + tryToAuthenticate(createSignedInvalidJwt(jwtKey)); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count()).isEqualTo(count + 1); + } + + @Test + void testTokenInvalidCount() throws Exception { + var count = meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count(); + + tryToAuthenticate(createInvalidToken(jwtKey)); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count()).isEqualTo(count + 1); + } + + private void tryToAuthenticate(String token) throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/authenticate").header(AUTHORIZATION, BEARER + token)); + } + + private double aggregate(Collection counters) { + return counters.stream().mapToDouble(Counter::count).sum(); + } +} diff --git a/src/test/java/io/github/jhipster/sample/security/jwt/TokenProviderSecurityMetersTests.java b/src/test/java/io/github/jhipster/sample/security/jwt/TokenProviderSecurityMetersTests.java deleted file mode 100644 index ca22383dd..000000000 --- a/src/test/java/io/github/jhipster/sample/security/jwt/TokenProviderSecurityMetersTests.java +++ /dev/null @@ -1,158 +0,0 @@ -package io.github.jhipster.sample.security.jwt; - -import static org.assertj.core.api.Assertions.assertThat; - -import io.github.jhipster.sample.management.SecurityMetersService; -import io.github.jhipster.sample.security.AuthoritiesConstants; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.security.Key; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Date; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.test.util.ReflectionTestUtils; -import tech.jhipster.config.JHipsterProperties; - -class TokenProviderSecurityMetersTests { - - private static final long ONE_MINUTE = 60000; - private static final String INVALID_TOKENS_METER_EXPECTED_NAME = "security.authentication.invalid-tokens"; - - private MeterRegistry meterRegistry; - - private TokenProvider tokenProvider; - - @BeforeEach - public void setup() { - JHipsterProperties jHipsterProperties = new JHipsterProperties(); - String base64Secret = "fd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8"; - jHipsterProperties.getSecurity().getAuthentication().getJwt().setBase64Secret(base64Secret); - - meterRegistry = new SimpleMeterRegistry(); - - SecurityMetersService securityMetersService = new SecurityMetersService(meterRegistry); - - tokenProvider = new TokenProvider(jHipsterProperties, securityMetersService); - Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)); - - ReflectionTestUtils.setField(tokenProvider, "key", key); - ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", ONE_MINUTE); - } - - @Test - void testValidTokenShouldNotCountAnything() { - Collection counters = meterRegistry.find(INVALID_TOKENS_METER_EXPECTED_NAME).counters(); - - assertThat(aggregate(counters)).isZero(); - - String validToken = createValidToken(); - - tokenProvider.validateToken(validToken); - - assertThat(aggregate(counters)).isZero(); - } - - @Test - void testTokenExpiredCount() { - assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter().count()).isZero(); - - String expiredToken = createExpiredToken(); - - tokenProvider.validateToken(expiredToken); - - assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter().count()).isEqualTo(1); - } - - @Test - void testTokenUnsupportedCount() { - assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "unsupported").counter().count()).isZero(); - - String unsupportedToken = createUnsupportedToken(); - - tokenProvider.validateToken(unsupportedToken); - - assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "unsupported").counter().count()).isEqualTo(1); - } - - @Test - void testTokenSignatureInvalidCount() { - assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter().count()).isZero(); - - String tokenWithDifferentSignature = createTokenWithDifferentSignature(); - - tokenProvider.validateToken(tokenWithDifferentSignature); - - assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter().count()).isEqualTo(1); - } - - @Test - void testTokenMalformedCount() { - assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count()).isZero(); - - String malformedToken = createMalformedToken(); - - tokenProvider.validateToken(malformedToken); - - assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count()).isEqualTo(1); - } - - private String createValidToken() { - Authentication authentication = createAuthentication(); - - return tokenProvider.createToken(authentication, false); - } - - private String createExpiredToken() { - ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", -ONE_MINUTE); - - Authentication authentication = createAuthentication(); - - return tokenProvider.createToken(authentication, false); - } - - private Authentication createAuthentication() { - Collection authorities = new ArrayList<>(); - authorities.add(new SimpleGrantedAuthority(AuthoritiesConstants.ANONYMOUS)); - return new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities); - } - - private String createUnsupportedToken() { - Key key = (Key) ReflectionTestUtils.getField(tokenProvider, "key"); - - return Jwts.builder().setPayload("payload").signWith(key, SignatureAlgorithm.HS256).compact(); - } - - private String createMalformedToken() { - String validToken = createValidToken(); - - return "X" + validToken; - } - - private String createTokenWithDifferentSignature() { - Key otherKey = Keys.hmacShaKeyFor( - Decoders.BASE64.decode("Xfd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8") - ); - - return Jwts - .builder() - .setSubject("anonymous") - .signWith(otherKey, SignatureAlgorithm.HS512) - .setExpiration(new Date(new Date().getTime() + ONE_MINUTE)) - .compact(); - } - - private double aggregate(Collection counters) { - return counters.stream().mapToDouble(Counter::count).sum(); - } -} diff --git a/src/test/java/io/github/jhipster/sample/security/jwt/TokenProviderTest.java b/src/test/java/io/github/jhipster/sample/security/jwt/TokenProviderTest.java deleted file mode 100644 index c282f69b2..000000000 --- a/src/test/java/io/github/jhipster/sample/security/jwt/TokenProviderTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package io.github.jhipster.sample.security.jwt; - -import static org.assertj.core.api.Assertions.assertThat; - -import io.github.jhipster.sample.management.SecurityMetersService; -import io.github.jhipster.sample.security.AuthoritiesConstants; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.nio.charset.StandardCharsets; -import java.security.Key; -import java.util.*; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.test.util.ReflectionTestUtils; -import tech.jhipster.config.JHipsterProperties; - -class TokenProviderTest { - - private static final long ONE_MINUTE = 60000; - - private Key key; - private TokenProvider tokenProvider; - - @BeforeEach - public void setup() { - JHipsterProperties jHipsterProperties = new JHipsterProperties(); - String base64Secret = "fd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8"; - jHipsterProperties.getSecurity().getAuthentication().getJwt().setBase64Secret(base64Secret); - - SecurityMetersService securityMetersService = new SecurityMetersService(new SimpleMeterRegistry()); - - tokenProvider = new TokenProvider(jHipsterProperties, securityMetersService); - key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)); - - ReflectionTestUtils.setField(tokenProvider, "key", key); - ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", ONE_MINUTE); - } - - @Test - void testReturnFalseWhenJWThasInvalidSignature() { - boolean isTokenValid = tokenProvider.validateToken(createTokenWithDifferentSignature()); - - assertThat(isTokenValid).isFalse(); - } - - @Test - void testReturnFalseWhenJWTisMalformed() { - Authentication authentication = createAuthentication(); - String token = tokenProvider.createToken(authentication, false); - String invalidToken = token.substring(1); - boolean isTokenValid = tokenProvider.validateToken(invalidToken); - - assertThat(isTokenValid).isFalse(); - } - - @Test - void testReturnFalseWhenJWTisExpired() { - ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", -ONE_MINUTE); - - Authentication authentication = createAuthentication(); - String token = tokenProvider.createToken(authentication, false); - - boolean isTokenValid = tokenProvider.validateToken(token); - - assertThat(isTokenValid).isFalse(); - } - - @Test - void testReturnFalseWhenJWTisUnsupported() { - String unsupportedToken = createUnsupportedToken(); - - boolean isTokenValid = tokenProvider.validateToken(unsupportedToken); - - assertThat(isTokenValid).isFalse(); - } - - @Test - void testReturnFalseWhenJWTisInvalid() { - boolean isTokenValid = tokenProvider.validateToken(""); - - assertThat(isTokenValid).isFalse(); - } - - @Test - void testKeyIsSetFromSecretWhenSecretIsNotEmpty() { - final String secret = "NwskoUmKHZtzGRKJKVjsJF7BtQMMxNWi"; - JHipsterProperties jHipsterProperties = new JHipsterProperties(); - jHipsterProperties.getSecurity().getAuthentication().getJwt().setSecret(secret); - - SecurityMetersService securityMetersService = new SecurityMetersService(new SimpleMeterRegistry()); - - TokenProvider tokenProvider = new TokenProvider(jHipsterProperties, securityMetersService); - - Key key = (Key) ReflectionTestUtils.getField(tokenProvider, "key"); - assertThat(key).isNotNull().isEqualTo(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))); - } - - @Test - void testKeyIsSetFromBase64SecretWhenSecretIsEmpty() { - final String base64Secret = "fd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8"; - JHipsterProperties jHipsterProperties = new JHipsterProperties(); - jHipsterProperties.getSecurity().getAuthentication().getJwt().setBase64Secret(base64Secret); - - SecurityMetersService securityMetersService = new SecurityMetersService(new SimpleMeterRegistry()); - - TokenProvider tokenProvider = new TokenProvider(jHipsterProperties, securityMetersService); - - Key key = (Key) ReflectionTestUtils.getField(tokenProvider, "key"); - assertThat(key).isNotNull().isEqualTo(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))); - } - - private Authentication createAuthentication() { - Collection authorities = new ArrayList<>(); - authorities.add(new SimpleGrantedAuthority(AuthoritiesConstants.ANONYMOUS)); - return new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities); - } - - private String createUnsupportedToken() { - return Jwts.builder().setPayload("payload").signWith(key, SignatureAlgorithm.HS512).compact(); - } - - private String createTokenWithDifferentSignature() { - Key otherKey = Keys.hmacShaKeyFor( - Decoders.BASE64.decode("Xfd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8") - ); - - return Jwts - .builder() - .setSubject("anonymous") - .signWith(otherKey, SignatureAlgorithm.HS512) - .setExpiration(new Date(new Date().getTime() + ONE_MINUTE)) - .compact(); - } -} diff --git a/src/test/java/io/github/jhipster/sample/service/MailServiceIT.java b/src/test/java/io/github/jhipster/sample/service/MailServiceIT.java index 983188254..f609113aa 100644 --- a/src/test/java/io/github/jhipster/sample/service/MailServiceIT.java +++ b/src/test/java/io/github/jhipster/sample/service/MailServiceIT.java @@ -7,6 +7,11 @@ import io.github.jhipster.sample.IntegrationTest; import io.github.jhipster.sample.config.Constants; import io.github.jhipster.sample.domain.User; +import jakarta.mail.Multipart; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeBodyPart; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; @@ -17,11 +22,6 @@ import java.util.Properties; import java.util.regex.Matcher; import java.util.regex.Pattern; -import javax.mail.Multipart; -import javax.mail.Session; -import javax.mail.internet.MimeBodyPart; -import javax.mail.internet.MimeMessage; -import javax.mail.internet.MimeMultipart; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -205,7 +205,7 @@ void testSendLocalizedEmailForAllSupportedLanguages() throws Exception { verify(javaMailSender, atLeastOnce()).send(messageCaptor.capture()); MimeMessage message = messageCaptor.getValue(); - String propertyFilePath = "i18n/messages_" + getJavaLocale(langKey) + ".properties"; + String propertyFilePath = "i18n/messages_" + getMessageSourceSuffixForLanguage(langKey) + ".properties"; URL resource = this.getClass().getClassLoader().getResource(propertyFilePath); File file = new File(new URI(resource.getFile()).getPath()); Properties properties = new Properties(); @@ -221,7 +221,7 @@ void testSendLocalizedEmailForAllSupportedLanguages() throws Exception { /** * Convert a lang key to the Java locale. */ - private String getJavaLocale(String langKey) { + private String getMessageSourceSuffixForLanguage(String langKey) { String javaLangKey = langKey; Matcher matcher2 = PATTERN_LOCALE_2.matcher(langKey); if (matcher2.matches()) { diff --git a/src/test/java/io/github/jhipster/sample/service/UserServiceIT.java b/src/test/java/io/github/jhipster/sample/service/UserServiceIT.java index 61e2acb60..6c979561f 100644 --- a/src/test/java/io/github/jhipster/sample/service/UserServiceIT.java +++ b/src/test/java/io/github/jhipster/sample/service/UserServiceIT.java @@ -4,10 +4,8 @@ import static org.mockito.Mockito.when; import io.github.jhipster.sample.IntegrationTest; -import io.github.jhipster.sample.config.Constants; import io.github.jhipster.sample.domain.User; import io.github.jhipster.sample.repository.UserRepository; -import io.github.jhipster.sample.service.dto.AdminUserDTO; import java.time.Instant; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; @@ -20,8 +18,6 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.auditing.AuditingHandler; import org.springframework.data.auditing.DateTimeProvider; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.transaction.annotation.Transactional; import tech.jhipster.security.RandomUtil; diff --git a/src/test/java/io/github/jhipster/sample/web/filter/SpaWebFilterIT.java b/src/test/java/io/github/jhipster/sample/web/filter/SpaWebFilterIT.java new file mode 100644 index 000000000..9e22ec459 --- /dev/null +++ b/src/test/java/io/github/jhipster/sample/web/filter/SpaWebFilterIT.java @@ -0,0 +1,88 @@ +package io.github.jhipster.sample.web.filter; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.github.jhipster.sample.IntegrationTest; +import io.github.jhipster.sample.security.AuthoritiesConstants; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@AutoConfigureMockMvc +@WithMockUser +@IntegrationTest +class SpaWebFilterIT { + + @Autowired + private MockMvc mockMvc; + + @Test + void testFilterForwardsToIndex() throws Exception { + mockMvc.perform(get("/")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void testFilterDoesNotForwardToIndexForApi() throws Exception { + mockMvc.perform(get("/api/authenticate")).andExpect(status().isOk()).andExpect(forwardedUrl(null)); + } + + @Test + @WithMockUser(authorities = AuthoritiesConstants.ADMIN) + void testFilterDoesNotForwardToIndexForV3ApiDocs() throws Exception { + mockMvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andExpect(forwardedUrl(null)); + } + + @Test + void testFilterDoesNotForwardToIndexForDotFile() throws Exception { + mockMvc.perform(get("/file.js")).andExpect(status().isNotFound()); + } + + @Test + void getBackendEndpoint() throws Exception { + mockMvc.perform(get("/test")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void forwardUnmappedFirstLevelMapping() throws Exception { + mockMvc.perform(get("/first-level")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void forwardUnmappedSecondLevelMapping() throws Exception { + mockMvc.perform(get("/first-level/second-level")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void forwardUnmappedThirdLevelMapping() throws Exception { + mockMvc.perform(get("/first-level/second-level/third-level")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void forwardUnmappedDeepMapping() throws Exception { + mockMvc.perform(get("/1/2/3/4/5/6/7/8/9/10")).andExpect(forwardedUrl("/index.html")); + } + + @Test + void getUnmappedFirstLevelFile() throws Exception { + mockMvc.perform(get("/foo.js")).andExpect(status().isNotFound()); + } + + /** + * This test verifies that any files that aren't permitted by Spring Security will be forbidden. + * If you want to change this to return isNotFound(), you need to add a request mapping that + * allows this file in SecurityConfiguration. + */ + @Test + void getUnmappedSecondLevelFile() throws Exception { + mockMvc.perform(get("/foo/bar.js")).andExpect(status().isForbidden()); + } + + @Test + void getUnmappedThirdLevelFile() throws Exception { + mockMvc.perform(get("/foo/another/bar.js")).andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/io/github/jhipster/sample/web/rest/AccountResourceIT.java b/src/test/java/io/github/jhipster/sample/web/rest/AccountResourceIT.java index eb6813ed4..c64544d1e 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/AccountResourceIT.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/AccountResourceIT.java @@ -1,6 +1,5 @@ package io.github.jhipster.sample.web.rest; -import static io.github.jhipster.sample.web.rest.AccountResourceIT.TEST_USER_LOGIN; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -14,7 +13,6 @@ import io.github.jhipster.sample.service.UserService; import io.github.jhipster.sample.service.dto.AdminUserDTO; import io.github.jhipster.sample.service.dto.PasswordChangeDTO; -import io.github.jhipster.sample.service.dto.UserDTO; import io.github.jhipster.sample.web.rest.vm.KeyAndPasswordVM; import io.github.jhipster.sample.web.rest.vm.ManagedUserVM; import java.time.Instant; @@ -33,7 +31,6 @@ * Integration tests for the {@link AccountResource} REST controller. */ @AutoConfigureMockMvc -@WithMockUser(value = TEST_USER_LOGIN) @IntegrationTest class AccountResourceIT { @@ -64,21 +61,16 @@ void testNonAuthenticatedUser() throws Exception { } @Test + @WithMockUser(TEST_USER_LOGIN) void testAuthenticatedUser() throws Exception { restAccountMockMvc - .perform( - get("/api/authenticate") - .with(request -> { - request.setRemoteUser(TEST_USER_LOGIN); - return request; - }) - .accept(MediaType.APPLICATION_JSON) - ) + .perform(get("/api/authenticate").with(request -> request).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().string(TEST_USER_LOGIN)); } @Test + @WithMockUser(TEST_USER_LOGIN) void testGetExistingAccount() throws Exception { Set authorities = new HashSet<>(); authorities.add(AuthoritiesConstants.ADMIN); @@ -108,9 +100,7 @@ void testGetExistingAccount() throws Exception { @Test void testGetUnknownAccount() throws Exception { - restAccountMockMvc - .perform(get("/api/account").accept(MediaType.APPLICATION_PROBLEM_JSON)) - .andExpect(status().isInternalServerError()); + restAccountMockMvc.perform(get("/api/account").accept(MediaType.APPLICATION_PROBLEM_JSON)).andExpect(status().isUnauthorized()); } @Test @@ -263,13 +253,13 @@ void testRegisterDuplicateLogin() throws Exception { Optional testUser = userRepository.findOneByEmailIgnoreCase("alice2@example.com"); assertThat(testUser).isPresent(); - testUser.get().setActivated(true); - userRepository.save(testUser.get()); + testUser.orElseThrow().setActivated(true); + userRepository.save(testUser.orElseThrow()); // Second (already activated) user restAccountMockMvc .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(secondUser))) - .andExpect(status().is4xxClientError()); + .andExpect(status().isCreated()); } @Test @@ -339,15 +329,15 @@ void testRegisterDuplicateEmail() throws Exception { Optional testUser4 = userRepository.findOneByLogin("test-register-duplicate-email-3"); assertThat(testUser4).isPresent(); - assertThat(testUser4.get().getEmail()).isEqualTo("test-register-duplicate-email@example.com"); + assertThat(testUser4.orElseThrow().getEmail()).isEqualTo("test-register-duplicate-email@example.com"); - testUser4.get().setActivated(true); - userService.updateUser((new AdminUserDTO(testUser4.get()))); + testUser4.orElseThrow().setActivated(true); + userService.updateUser((new AdminUserDTO(testUser4.orElseThrow()))); // Register 4th (already activated) user restAccountMockMvc .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(secondUser))) - .andExpect(status().is4xxClientError()); + .andExpect(status().isCreated()); } @Test @@ -370,9 +360,9 @@ void testRegisterAdminIsIgnored() throws Exception { Optional userDup = userRepository.findOneWithAuthoritiesByLogin("badguy"); assertThat(userDup).isPresent(); - assertThat(userDup.get().getAuthorities()) + assertThat(userDup.orElseThrow().getAuthorities()) .hasSize(1) - .containsExactly(authorityRepository.findById(AuthoritiesConstants.USER).get()); + .containsExactly(authorityRepository.findById(AuthoritiesConstants.USER).orElseThrow()); } @Test diff --git a/src/test/java/io/github/jhipster/sample/web/rest/UserJWTControllerIT.java b/src/test/java/io/github/jhipster/sample/web/rest/AuthenticateControllerIT.java similarity index 97% rename from src/test/java/io/github/jhipster/sample/web/rest/UserJWTControllerIT.java rename to src/test/java/io/github/jhipster/sample/web/rest/AuthenticateControllerIT.java index e65c51990..0b76c992f 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/UserJWTControllerIT.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/AuthenticateControllerIT.java @@ -22,11 +22,11 @@ import org.springframework.transaction.annotation.Transactional; /** - * Integration tests for the {@link UserJWTController} REST controller. + * Integration tests for the {@link AuthenticateController} REST controller. */ @AutoConfigureMockMvc @IntegrationTest -class UserJWTControllerIT { +class AuthenticateControllerIT { @Autowired private UserRepository userRepository; diff --git a/src/test/java/io/github/jhipster/sample/web/rest/BankAccountResourceIT.java b/src/test/java/io/github/jhipster/sample/web/rest/BankAccountResourceIT.java index 4c887d4af..9e691dd5c 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/BankAccountResourceIT.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/BankAccountResourceIT.java @@ -10,12 +10,12 @@ import io.github.jhipster.sample.IntegrationTest; import io.github.jhipster.sample.domain.BankAccount; import io.github.jhipster.sample.repository.BankAccountRepository; +import jakarta.persistence.EntityManager; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.concurrent.atomic.AtomicLong; -import javax.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,7 +24,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; @@ -227,7 +226,7 @@ void putExistingBankAccount() throws Exception { int databaseSizeBeforeUpdate = bankAccountRepository.findAll().size(); // Update the bankAccount - BankAccount updatedBankAccount = bankAccountRepository.findById(bankAccount.getId()).get(); + BankAccount updatedBankAccount = bankAccountRepository.findById(bankAccount.getId()).orElseThrow(); // Disconnect from session so that the updates on updatedBankAccount are not directly saved in db em.detach(updatedBankAccount); updatedBankAccount.name(UPDATED_NAME).balance(UPDATED_BALANCE); @@ -316,8 +315,6 @@ void partialUpdateBankAccountWithPatch() throws Exception { BankAccount partialUpdatedBankAccount = new BankAccount(); partialUpdatedBankAccount.setId(bankAccount.getId()); - partialUpdatedBankAccount.name(UPDATED_NAME).balance(UPDATED_BALANCE); - restBankAccountMockMvc .perform( patch(ENTITY_API_URL_ID, partialUpdatedBankAccount.getId()) @@ -330,8 +327,8 @@ void partialUpdateBankAccountWithPatch() throws Exception { List bankAccountList = bankAccountRepository.findAll(); assertThat(bankAccountList).hasSize(databaseSizeBeforeUpdate); BankAccount testBankAccount = bankAccountList.get(bankAccountList.size() - 1); - assertThat(testBankAccount.getName()).isEqualTo(UPDATED_NAME); - assertThat(testBankAccount.getBalance()).isEqualByComparingTo(UPDATED_BALANCE); + assertThat(testBankAccount.getName()).isEqualTo(DEFAULT_NAME); + assertThat(testBankAccount.getBalance()).isEqualByComparingTo(DEFAULT_BALANCE); } @Test diff --git a/src/test/java/io/github/jhipster/sample/web/rest/ClientForwardControllerTest.java b/src/test/java/io/github/jhipster/sample/web/rest/ClientForwardControllerTest.java deleted file mode 100644 index b5e4680e2..000000000 --- a/src/test/java/io/github/jhipster/sample/web/rest/ClientForwardControllerTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package io.github.jhipster.sample.web.rest; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * Unit tests for the {@link ClientForwardController} REST controller. - */ -class ClientForwardControllerTest { - - private MockMvc restMockMvc; - - @BeforeEach - public void setup() { - ClientForwardController clientForwardController = new ClientForwardController(); - this.restMockMvc = MockMvcBuilders.standaloneSetup(clientForwardController, new TestController()).build(); - } - - @Test - void getBackendEndpoint() throws Exception { - restMockMvc - .perform(get("/test")) - .andExpect(status().isOk()) - .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN_VALUE)) - .andExpect(content().string("test")); - } - - @Test - void getClientEndpoint() throws Exception { - ResultActions perform = restMockMvc.perform(get("/non-existant-mapping")); - perform.andExpect(status().isOk()).andExpect(forwardedUrl("/")); - } - - @Test - void getNestedClientEndpoint() throws Exception { - restMockMvc.perform(get("/admin/user-management")).andExpect(status().isOk()).andExpect(forwardedUrl("/")); - } - - @Test - void getUnmappedDottedEndpoint() throws Exception { - restMockMvc.perform(get("/foo.js")).andExpect(status().isNotFound()); - } - - @Test - void getUnmappedNestedDottedEndpoint() throws Exception { - restMockMvc.perform(get("/foo/bar.js")).andExpect(status().isNotFound()); - } - - @RestController - public static class TestController { - - @RequestMapping(value = "/test") - public String test() { - return "test"; - } - } -} diff --git a/src/test/java/io/github/jhipster/sample/web/rest/LabelResourceIT.java b/src/test/java/io/github/jhipster/sample/web/rest/LabelResourceIT.java index d32b164a0..89e012be5 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/LabelResourceIT.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/LabelResourceIT.java @@ -8,10 +8,10 @@ import io.github.jhipster.sample.IntegrationTest; import io.github.jhipster.sample.domain.Label; import io.github.jhipster.sample.repository.LabelRepository; +import jakarta.persistence.EntityManager; import java.util.List; import java.util.Random; import java.util.concurrent.atomic.AtomicLong; -import javax.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -173,7 +173,7 @@ void putExistingLabel() throws Exception { int databaseSizeBeforeUpdate = labelRepository.findAll().size(); // Update the label - Label updatedLabel = labelRepository.findById(label.getId()).get(); + Label updatedLabel = labelRepository.findById(label.getId()).orElseThrow(); // Disconnect from session so that the updates on updatedLabel are not directly saved in db em.detach(updatedLabel); updatedLabel.label(UPDATED_LABEL); diff --git a/src/test/java/io/github/jhipster/sample/web/rest/OperationResourceIT.java b/src/test/java/io/github/jhipster/sample/web/rest/OperationResourceIT.java index 8561aed6b..12b62f843 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/OperationResourceIT.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/OperationResourceIT.java @@ -10,6 +10,7 @@ import io.github.jhipster.sample.IntegrationTest; import io.github.jhipster.sample.domain.Operation; import io.github.jhipster.sample.repository.OperationRepository; +import jakarta.persistence.EntityManager; import java.math.BigDecimal; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -17,7 +18,6 @@ import java.util.List; import java.util.Random; import java.util.concurrent.atomic.AtomicLong; -import javax.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,7 +26,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; @@ -235,7 +234,7 @@ void putExistingOperation() throws Exception { int databaseSizeBeforeUpdate = operationRepository.findAll().size(); // Update the operation - Operation updatedOperation = operationRepository.findById(operation.getId()).get(); + Operation updatedOperation = operationRepository.findById(operation.getId()).orElseThrow(); // Disconnect from session so that the updates on updatedOperation are not directly saved in db em.detach(updatedOperation); updatedOperation.date(UPDATED_DATE).description(UPDATED_DESCRIPTION).amount(UPDATED_AMOUNT); @@ -325,6 +324,8 @@ void partialUpdateOperationWithPatch() throws Exception { Operation partialUpdatedOperation = new Operation(); partialUpdatedOperation.setId(operation.getId()); + partialUpdatedOperation.date(UPDATED_DATE); + restOperationMockMvc .perform( patch(ENTITY_API_URL_ID, partialUpdatedOperation.getId()) @@ -337,7 +338,7 @@ void partialUpdateOperationWithPatch() throws Exception { List operationList = operationRepository.findAll(); assertThat(operationList).hasSize(databaseSizeBeforeUpdate); Operation testOperation = operationList.get(operationList.size() - 1); - assertThat(testOperation.getDate()).isEqualTo(DEFAULT_DATE); + assertThat(testOperation.getDate()).isEqualTo(UPDATED_DATE); assertThat(testOperation.getDescription()).isEqualTo(DEFAULT_DESCRIPTION); assertThat(testOperation.getAmount()).isEqualByComparingTo(DEFAULT_AMOUNT); } diff --git a/src/test/java/io/github/jhipster/sample/web/rest/PublicUserResourceIT.java b/src/test/java/io/github/jhipster/sample/web/rest/PublicUserResourceIT.java index e34e66388..091fbaadb 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/PublicUserResourceIT.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/PublicUserResourceIT.java @@ -9,7 +9,7 @@ import io.github.jhipster.sample.domain.User; import io.github.jhipster.sample.repository.UserRepository; import io.github.jhipster.sample.security.AuthoritiesConstants; -import javax.persistence.EntityManager; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -21,7 +21,7 @@ import org.springframework.transaction.annotation.Transactional; /** - * Integration tests for the {@link UserResource} REST controller. + * Integration tests for the {@link PublicUserResource} REST controller. */ @AutoConfigureMockMvc @WithMockUser(authorities = AuthoritiesConstants.ADMIN) diff --git a/src/test/java/io/github/jhipster/sample/web/rest/TestUtil.java b/src/test/java/io/github/jhipster/sample/web/rest/TestUtil.java index 021f5c470..c6a7b58a9 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/TestUtil.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/TestUtil.java @@ -6,16 +6,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; import java.io.IOException; import java.math.BigDecimal; import java.time.ZonedDateTime; import java.time.format.DateTimeParseException; import java.util.List; -import javax.persistence.EntityManager; -import javax.persistence.TypedQuery; -import javax.persistence.criteria.CriteriaBuilder; -import javax.persistence.criteria.CriteriaQuery; -import javax.persistence.criteria.Root; import org.hamcrest.Description; import org.hamcrest.TypeSafeDiagnosingMatcher; import org.hamcrest.TypeSafeMatcher; @@ -187,16 +187,16 @@ public static FormattingConversionService createFormattingConversionService() { } /** - * Makes a an executes a query to the EntityManager finding all stored objects. + * Executes a query on the EntityManager finding all stored objects. * @param The type of objects to be searched * @param em The instance of the EntityManager - * @param clss The class type to be searched + * @param clazz The class type to be searched * @return A list of all found objects */ - public static List findAll(EntityManager em, Class clss) { + public static List findAll(EntityManager em, Class clazz) { CriteriaBuilder cb = em.getCriteriaBuilder(); - CriteriaQuery cq = cb.createQuery(clss); - Root rootEntry = cq.from(clss); + CriteriaQuery cq = cb.createQuery(clazz); + Root rootEntry = cq.from(clazz); CriteriaQuery all = cq.select(rootEntry); TypedQuery allQuery = em.createQuery(all); return allQuery.getResultList(); diff --git a/src/test/java/io/github/jhipster/sample/web/rest/UserResourceIT.java b/src/test/java/io/github/jhipster/sample/web/rest/UserResourceIT.java index 66ca5d8be..560724a03 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/UserResourceIT.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/UserResourceIT.java @@ -12,11 +12,10 @@ import io.github.jhipster.sample.security.AuthoritiesConstants; import io.github.jhipster.sample.service.dto.AdminUserDTO; import io.github.jhipster.sample.service.mapper.UserMapper; -import io.github.jhipster.sample.web.rest.vm.ManagedUserVM; +import jakarta.persistence.EntityManager; import java.time.Instant; import java.util.*; import java.util.function.Consumer; -import javax.persistence.EntityManager; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -123,21 +122,18 @@ void createUser() throws Exception { int databaseSizeBeforeCreate = userRepository.findAll().size(); // Create the User - ManagedUserVM managedUserVM = new ManagedUserVM(); - managedUserVM.setLogin(DEFAULT_LOGIN); - managedUserVM.setPassword(DEFAULT_PASSWORD); - managedUserVM.setFirstName(DEFAULT_FIRSTNAME); - managedUserVM.setLastName(DEFAULT_LASTNAME); - managedUserVM.setEmail(DEFAULT_EMAIL); - managedUserVM.setActivated(true); - managedUserVM.setImageUrl(DEFAULT_IMAGEURL); - managedUserVM.setLangKey(DEFAULT_LANGKEY); - managedUserVM.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); + AdminUserDTO user = new AdminUserDTO(); + user.setLogin(DEFAULT_LOGIN); + user.setFirstName(DEFAULT_FIRSTNAME); + user.setLastName(DEFAULT_LASTNAME); + user.setEmail(DEFAULT_EMAIL); + user.setActivated(true); + user.setImageUrl(DEFAULT_IMAGEURL); + user.setLangKey(DEFAULT_LANGKEY); + user.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); restUserMockMvc - .perform( - post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(managedUserVM)) - ) + .perform(post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(user))) .andExpect(status().isCreated()); // Validate the User in the database @@ -158,23 +154,20 @@ void createUser() throws Exception { void createUserWithExistingId() throws Exception { int databaseSizeBeforeCreate = userRepository.findAll().size(); - ManagedUserVM managedUserVM = new ManagedUserVM(); - managedUserVM.setId(DEFAULT_ID); - managedUserVM.setLogin(DEFAULT_LOGIN); - managedUserVM.setPassword(DEFAULT_PASSWORD); - managedUserVM.setFirstName(DEFAULT_FIRSTNAME); - managedUserVM.setLastName(DEFAULT_LASTNAME); - managedUserVM.setEmail(DEFAULT_EMAIL); - managedUserVM.setActivated(true); - managedUserVM.setImageUrl(DEFAULT_IMAGEURL); - managedUserVM.setLangKey(DEFAULT_LANGKEY); - managedUserVM.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); + AdminUserDTO user = new AdminUserDTO(); + user.setId(DEFAULT_ID); + user.setLogin(DEFAULT_LOGIN); + user.setFirstName(DEFAULT_FIRSTNAME); + user.setLastName(DEFAULT_LASTNAME); + user.setEmail(DEFAULT_EMAIL); + user.setActivated(true); + user.setImageUrl(DEFAULT_IMAGEURL); + user.setLangKey(DEFAULT_LANGKEY); + user.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); // An entity with an existing ID cannot be created, so this API call must fail restUserMockMvc - .perform( - post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(managedUserVM)) - ) + .perform(post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(user))) .andExpect(status().isBadRequest()); // Validate the User in the database @@ -188,22 +181,19 @@ void createUserWithExistingLogin() throws Exception { userRepository.saveAndFlush(user); int databaseSizeBeforeCreate = userRepository.findAll().size(); - ManagedUserVM managedUserVM = new ManagedUserVM(); - managedUserVM.setLogin(DEFAULT_LOGIN); // this login should already be used - managedUserVM.setPassword(DEFAULT_PASSWORD); - managedUserVM.setFirstName(DEFAULT_FIRSTNAME); - managedUserVM.setLastName(DEFAULT_LASTNAME); - managedUserVM.setEmail("anothermail@localhost"); - managedUserVM.setActivated(true); - managedUserVM.setImageUrl(DEFAULT_IMAGEURL); - managedUserVM.setLangKey(DEFAULT_LANGKEY); - managedUserVM.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); + AdminUserDTO user = new AdminUserDTO(); + user.setLogin(DEFAULT_LOGIN); // this login should already be used + user.setFirstName(DEFAULT_FIRSTNAME); + user.setLastName(DEFAULT_LASTNAME); + user.setEmail("anothermail@localhost"); + user.setActivated(true); + user.setImageUrl(DEFAULT_IMAGEURL); + user.setLangKey(DEFAULT_LANGKEY); + user.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); // Create the User restUserMockMvc - .perform( - post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(managedUserVM)) - ) + .perform(post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(user))) .andExpect(status().isBadRequest()); // Validate the User in the database @@ -217,22 +207,19 @@ void createUserWithExistingEmail() throws Exception { userRepository.saveAndFlush(user); int databaseSizeBeforeCreate = userRepository.findAll().size(); - ManagedUserVM managedUserVM = new ManagedUserVM(); - managedUserVM.setLogin("anotherlogin"); - managedUserVM.setPassword(DEFAULT_PASSWORD); - managedUserVM.setFirstName(DEFAULT_FIRSTNAME); - managedUserVM.setLastName(DEFAULT_LASTNAME); - managedUserVM.setEmail(DEFAULT_EMAIL); // this email should already be used - managedUserVM.setActivated(true); - managedUserVM.setImageUrl(DEFAULT_IMAGEURL); - managedUserVM.setLangKey(DEFAULT_LANGKEY); - managedUserVM.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); + AdminUserDTO user = new AdminUserDTO(); + user.setLogin("anotherlogin"); + user.setFirstName(DEFAULT_FIRSTNAME); + user.setLastName(DEFAULT_LASTNAME); + user.setEmail(DEFAULT_EMAIL); // this email should already be used + user.setActivated(true); + user.setImageUrl(DEFAULT_IMAGEURL); + user.setLangKey(DEFAULT_LANGKEY); + user.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); // Create the User restUserMockMvc - .perform( - post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(managedUserVM)) - ) + .perform(post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(user))) .andExpect(status().isBadRequest()); // Validate the User in the database @@ -295,34 +282,31 @@ void updateUser() throws Exception { int databaseSizeBeforeUpdate = userRepository.findAll().size(); // Update the user - User updatedUser = userRepository.findById(user.getId()).get(); - - ManagedUserVM managedUserVM = new ManagedUserVM(); - managedUserVM.setId(updatedUser.getId()); - managedUserVM.setLogin(updatedUser.getLogin()); - managedUserVM.setPassword(UPDATED_PASSWORD); - managedUserVM.setFirstName(UPDATED_FIRSTNAME); - managedUserVM.setLastName(UPDATED_LASTNAME); - managedUserVM.setEmail(UPDATED_EMAIL); - managedUserVM.setActivated(updatedUser.isActivated()); - managedUserVM.setImageUrl(UPDATED_IMAGEURL); - managedUserVM.setLangKey(UPDATED_LANGKEY); - managedUserVM.setCreatedBy(updatedUser.getCreatedBy()); - managedUserVM.setCreatedDate(updatedUser.getCreatedDate()); - managedUserVM.setLastModifiedBy(updatedUser.getLastModifiedBy()); - managedUserVM.setLastModifiedDate(updatedUser.getLastModifiedDate()); - managedUserVM.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); + User updatedUser = userRepository.findById(user.getId()).orElseThrow(); + + AdminUserDTO user = new AdminUserDTO(); + user.setId(updatedUser.getId()); + user.setLogin(updatedUser.getLogin()); + user.setFirstName(UPDATED_FIRSTNAME); + user.setLastName(UPDATED_LASTNAME); + user.setEmail(UPDATED_EMAIL); + user.setActivated(updatedUser.isActivated()); + user.setImageUrl(UPDATED_IMAGEURL); + user.setLangKey(UPDATED_LANGKEY); + user.setCreatedBy(updatedUser.getCreatedBy()); + user.setCreatedDate(updatedUser.getCreatedDate()); + user.setLastModifiedBy(updatedUser.getLastModifiedBy()); + user.setLastModifiedDate(updatedUser.getLastModifiedDate()); + user.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); restUserMockMvc - .perform( - put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(managedUserVM)) - ) + .perform(put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(user))) .andExpect(status().isOk()); // Validate the User in the database assertPersistedUsers(users -> { assertThat(users).hasSize(databaseSizeBeforeUpdate); - User testUser = users.stream().filter(usr -> usr.getId().equals(updatedUser.getId())).findFirst().get(); + User testUser = users.stream().filter(usr -> usr.getId().equals(updatedUser.getId())).findFirst().orElseThrow(); assertThat(testUser.getFirstName()).isEqualTo(UPDATED_FIRSTNAME); assertThat(testUser.getLastName()).isEqualTo(UPDATED_LASTNAME); assertThat(testUser.getEmail()).isEqualTo(UPDATED_EMAIL); @@ -339,34 +323,31 @@ void updateUserLogin() throws Exception { int databaseSizeBeforeUpdate = userRepository.findAll().size(); // Update the user - User updatedUser = userRepository.findById(user.getId()).get(); - - ManagedUserVM managedUserVM = new ManagedUserVM(); - managedUserVM.setId(updatedUser.getId()); - managedUserVM.setLogin(UPDATED_LOGIN); - managedUserVM.setPassword(UPDATED_PASSWORD); - managedUserVM.setFirstName(UPDATED_FIRSTNAME); - managedUserVM.setLastName(UPDATED_LASTNAME); - managedUserVM.setEmail(UPDATED_EMAIL); - managedUserVM.setActivated(updatedUser.isActivated()); - managedUserVM.setImageUrl(UPDATED_IMAGEURL); - managedUserVM.setLangKey(UPDATED_LANGKEY); - managedUserVM.setCreatedBy(updatedUser.getCreatedBy()); - managedUserVM.setCreatedDate(updatedUser.getCreatedDate()); - managedUserVM.setLastModifiedBy(updatedUser.getLastModifiedBy()); - managedUserVM.setLastModifiedDate(updatedUser.getLastModifiedDate()); - managedUserVM.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); + User updatedUser = userRepository.findById(user.getId()).orElseThrow(); + + AdminUserDTO user = new AdminUserDTO(); + user.setId(updatedUser.getId()); + user.setLogin(UPDATED_LOGIN); + user.setFirstName(UPDATED_FIRSTNAME); + user.setLastName(UPDATED_LASTNAME); + user.setEmail(UPDATED_EMAIL); + user.setActivated(updatedUser.isActivated()); + user.setImageUrl(UPDATED_IMAGEURL); + user.setLangKey(UPDATED_LANGKEY); + user.setCreatedBy(updatedUser.getCreatedBy()); + user.setCreatedDate(updatedUser.getCreatedDate()); + user.setLastModifiedBy(updatedUser.getLastModifiedBy()); + user.setLastModifiedDate(updatedUser.getLastModifiedDate()); + user.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); restUserMockMvc - .perform( - put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(managedUserVM)) - ) + .perform(put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(user))) .andExpect(status().isOk()); // Validate the User in the database assertPersistedUsers(users -> { assertThat(users).hasSize(databaseSizeBeforeUpdate); - User testUser = users.stream().filter(usr -> usr.getId().equals(updatedUser.getId())).findFirst().get(); + User testUser = users.stream().filter(usr -> usr.getId().equals(updatedUser.getId())).findFirst().orElseThrow(); assertThat(testUser.getLogin()).isEqualTo(UPDATED_LOGIN); assertThat(testUser.getFirstName()).isEqualTo(UPDATED_FIRSTNAME); assertThat(testUser.getLastName()).isEqualTo(UPDATED_LASTNAME); @@ -394,28 +375,25 @@ void updateUserExistingEmail() throws Exception { userRepository.saveAndFlush(anotherUser); // Update the user - User updatedUser = userRepository.findById(user.getId()).get(); - - ManagedUserVM managedUserVM = new ManagedUserVM(); - managedUserVM.setId(updatedUser.getId()); - managedUserVM.setLogin(updatedUser.getLogin()); - managedUserVM.setPassword(updatedUser.getPassword()); - managedUserVM.setFirstName(updatedUser.getFirstName()); - managedUserVM.setLastName(updatedUser.getLastName()); - managedUserVM.setEmail("jhipster@localhost"); // this email should already be used by anotherUser - managedUserVM.setActivated(updatedUser.isActivated()); - managedUserVM.setImageUrl(updatedUser.getImageUrl()); - managedUserVM.setLangKey(updatedUser.getLangKey()); - managedUserVM.setCreatedBy(updatedUser.getCreatedBy()); - managedUserVM.setCreatedDate(updatedUser.getCreatedDate()); - managedUserVM.setLastModifiedBy(updatedUser.getLastModifiedBy()); - managedUserVM.setLastModifiedDate(updatedUser.getLastModifiedDate()); - managedUserVM.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); + User updatedUser = userRepository.findById(user.getId()).orElseThrow(); + + AdminUserDTO user = new AdminUserDTO(); + user.setId(updatedUser.getId()); + user.setLogin(updatedUser.getLogin()); + user.setFirstName(updatedUser.getFirstName()); + user.setLastName(updatedUser.getLastName()); + user.setEmail("jhipster@localhost"); // this email should already be used by anotherUser + user.setActivated(updatedUser.isActivated()); + user.setImageUrl(updatedUser.getImageUrl()); + user.setLangKey(updatedUser.getLangKey()); + user.setCreatedBy(updatedUser.getCreatedBy()); + user.setCreatedDate(updatedUser.getCreatedDate()); + user.setLastModifiedBy(updatedUser.getLastModifiedBy()); + user.setLastModifiedDate(updatedUser.getLastModifiedDate()); + user.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); restUserMockMvc - .perform( - put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(managedUserVM)) - ) + .perform(put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(user))) .andExpect(status().isBadRequest()); } @@ -437,28 +415,25 @@ void updateUserExistingLogin() throws Exception { userRepository.saveAndFlush(anotherUser); // Update the user - User updatedUser = userRepository.findById(user.getId()).get(); - - ManagedUserVM managedUserVM = new ManagedUserVM(); - managedUserVM.setId(updatedUser.getId()); - managedUserVM.setLogin("jhipster"); // this login should already be used by anotherUser - managedUserVM.setPassword(updatedUser.getPassword()); - managedUserVM.setFirstName(updatedUser.getFirstName()); - managedUserVM.setLastName(updatedUser.getLastName()); - managedUserVM.setEmail(updatedUser.getEmail()); - managedUserVM.setActivated(updatedUser.isActivated()); - managedUserVM.setImageUrl(updatedUser.getImageUrl()); - managedUserVM.setLangKey(updatedUser.getLangKey()); - managedUserVM.setCreatedBy(updatedUser.getCreatedBy()); - managedUserVM.setCreatedDate(updatedUser.getCreatedDate()); - managedUserVM.setLastModifiedBy(updatedUser.getLastModifiedBy()); - managedUserVM.setLastModifiedDate(updatedUser.getLastModifiedDate()); - managedUserVM.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); + User updatedUser = userRepository.findById(user.getId()).orElseThrow(); + + AdminUserDTO user = new AdminUserDTO(); + user.setId(updatedUser.getId()); + user.setLogin("jhipster"); // this login should already be used by anotherUser + user.setFirstName(updatedUser.getFirstName()); + user.setLastName(updatedUser.getLastName()); + user.setEmail(updatedUser.getEmail()); + user.setActivated(updatedUser.isActivated()); + user.setImageUrl(updatedUser.getImageUrl()); + user.setLangKey(updatedUser.getLangKey()); + user.setCreatedBy(updatedUser.getCreatedBy()); + user.setCreatedDate(updatedUser.getCreatedDate()); + user.setLastModifiedBy(updatedUser.getLastModifiedBy()); + user.setLastModifiedDate(updatedUser.getLastModifiedDate()); + user.setAuthorities(Collections.singleton(AuthoritiesConstants.USER)); restUserMockMvc - .perform( - put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(managedUserVM)) - ) + .perform(put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(TestUtil.convertObjectToJsonBytes(user))) .andExpect(status().isBadRequest()); } diff --git a/src/test/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslatorIT.java b/src/test/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslatorIT.java index ff08b20e8..dce25ffee 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslatorIT.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslatorIT.java @@ -10,7 +10,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; @@ -93,7 +92,7 @@ void testMethodNotSupported() throws Exception { .andExpect(status().isMethodNotAllowed()) .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.message").value("error.http.405")) - .andExpect(jsonPath("$.detail").value("Request method 'POST' not supported")); + .andExpect(jsonPath("$.detail").value("Request method 'POST' is not supported")); } @Test diff --git a/src/test/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslatorTestController.java b/src/test/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslatorTestController.java index 78f39cabb..29cbea8c2 100644 --- a/src/test/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslatorTestController.java +++ b/src/test/java/io/github/jhipster/sample/web/rest/errors/ExceptionTranslatorTestController.java @@ -1,7 +1,7 @@ package io.github.jhipster.sample.web.rest.errors; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import org.springframework.dao.ConcurrencyFailureException; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; diff --git a/src/test/javascript/cypress/.eslintrc.json b/src/test/javascript/cypress/.eslintrc.json index 16012997a..6e8814c8b 100644 --- a/src/test/javascript/cypress/.eslintrc.json +++ b/src/test/javascript/cypress/.eslintrc.json @@ -7,6 +7,7 @@ }, "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:cypress/recommended"], "rules": { - "@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }] + "@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }], + "@typescript-eslint/no-unused-vars": "off" } } diff --git a/src/test/javascript/cypress/e2e/account/password-page.cy.ts b/src/test/javascript/cypress/e2e/account/password-page.cy.ts index ed62edfa5..f84eee602 100644 --- a/src/test/javascript/cypress/e2e/account/password-page.cy.ts +++ b/src/test/javascript/cypress/e2e/account/password-page.cy.ts @@ -27,20 +27,25 @@ describe('/account/password', () => { }); it('requires current password', () => { - cy.get(currentPasswordSelector) - .should('have.class', classInvalid) - .type('wrong-current-password') - .blur() - .should('have.class', classValid); + cy.get(currentPasswordSelector).should('have.class', classInvalid); + cy.get(currentPasswordSelector).type('wrong-current-password'); + cy.get(currentPasswordSelector).blur(); + cy.get(currentPasswordSelector).should('have.class', classValid); }); it('requires new password', () => { - cy.get(newPasswordSelector).should('have.class', classInvalid).type('jhipster').blur().should('have.class', classValid); + cy.get(newPasswordSelector).should('have.class', classInvalid); + cy.get(newPasswordSelector).type('jhipster'); + cy.get(newPasswordSelector).blur(); + cy.get(newPasswordSelector).should('have.class', classValid); }); it('requires confirm new password', () => { cy.get(newPasswordSelector).type('jhipster'); - cy.get(confirmPasswordSelector).should('have.class', classInvalid).type('jhipster').blur().should('have.class', classValid); + cy.get(confirmPasswordSelector).should('have.class', classInvalid); + cy.get(confirmPasswordSelector).type('jhipster'); + cy.get(confirmPasswordSelector).blur(); + cy.get(confirmPasswordSelector).should('have.class', classValid); }); it('should fail to update password when using incorrect current password', () => { diff --git a/src/test/javascript/cypress/e2e/account/register-page.cy.ts b/src/test/javascript/cypress/e2e/account/register-page.cy.ts index 19fc45bd1..704d3638e 100644 --- a/src/test/javascript/cypress/e2e/account/register-page.cy.ts +++ b/src/test/javascript/cypress/e2e/account/register-page.cy.ts @@ -28,38 +28,57 @@ describe('/account/register', () => { }); it('requires username', () => { - cy.get(usernameRegisterSelector).should('have.class', classInvalid).type('test').blur().should('have.class', classValid); + cy.get(usernameRegisterSelector).should('have.class', classInvalid); + cy.get(usernameRegisterSelector).type('test'); + cy.get(usernameRegisterSelector).blur(); + cy.get(usernameRegisterSelector).should('have.class', classValid); }); it('should not accept invalid email', () => { - cy.get(emailRegisterSelector).should('have.class', classInvalid).type('testtest.fr').blur().should('have.class', classInvalid); + cy.get(emailRegisterSelector).should('have.class', classInvalid); + cy.get(emailRegisterSelector).type('testtest.fr'); + cy.get(emailRegisterSelector).blur(); + cy.get(emailRegisterSelector).should('have.class', classInvalid); }); it('requires email in correct format', () => { - cy.get(emailRegisterSelector).should('have.class', classInvalid).type('test@test.fr').blur().should('have.class', classValid); + cy.get(emailRegisterSelector).should('have.class', classInvalid); + cy.get(emailRegisterSelector).type('test@test.fr'); + cy.get(emailRegisterSelector).blur(); + cy.get(emailRegisterSelector).should('have.class', classValid); }); it('requires first password', () => { - cy.get(firstPasswordRegisterSelector).should('have.class', classInvalid).type('test@test.fr').blur().should('have.class', classValid); + cy.get(firstPasswordRegisterSelector).should('have.class', classInvalid); + cy.get(firstPasswordRegisterSelector).type('test@test.fr'); + cy.get(firstPasswordRegisterSelector).blur(); + cy.get(firstPasswordRegisterSelector).should('have.class', classValid); }); it('requires password and confirm password to be same', () => { - cy.get(firstPasswordRegisterSelector).should('have.class', classInvalid).type('test').blur().should('have.class', classValid); - cy.get(secondPasswordRegisterSelector).should('have.class', classInvalid).type('test').blur().should('have.class', classValid); + cy.get(firstPasswordRegisterSelector).should('have.class', classInvalid); + cy.get(firstPasswordRegisterSelector).type('test'); + cy.get(firstPasswordRegisterSelector).blur(); + cy.get(firstPasswordRegisterSelector).should('have.class', classValid); + cy.get(secondPasswordRegisterSelector).should('have.class', classInvalid); + cy.get(secondPasswordRegisterSelector).type('test'); + cy.get(secondPasswordRegisterSelector).blur(); + cy.get(secondPasswordRegisterSelector).should('have.class', classValid); }); it('requires password and confirm password have not the same value', () => { - cy.get(firstPasswordRegisterSelector).should('have.class', classInvalid).type('test').blur().should('have.class', classValid); - cy.get(secondPasswordRegisterSelector) - .should('have.class', classInvalid) - .type('otherPassword') - .blur() - .should('have.class', classInvalid); + cy.get(firstPasswordRegisterSelector).should('have.class', classInvalid); + cy.get(firstPasswordRegisterSelector).type('test'); + cy.get(firstPasswordRegisterSelector).blur(); + cy.get(firstPasswordRegisterSelector).should('have.class', classValid); + cy.get(secondPasswordRegisterSelector).should('have.class', classInvalid); + cy.get(secondPasswordRegisterSelector).type('otherPassword'); + cy.get(submitRegisterSelector).should('be.disabled'); }); it('register a valid user', () => { - const randomEmail = 'Aliza.Heaney44@hotmail.com'; - const randomUsername = 'Arvid_Balistreri64'; + const randomEmail = 'Mariah44@hotmail.com'; + const randomUsername = 'Arvel_Osinski55'; cy.get(usernameRegisterSelector).type(randomUsername); cy.get(emailRegisterSelector).type(randomEmail); cy.get(firstPasswordRegisterSelector).type('jondoe'); diff --git a/src/test/javascript/cypress/e2e/account/reset-password-page.cy.ts b/src/test/javascript/cypress/e2e/account/reset-password-page.cy.ts index ee64cb245..dbe3ef2e5 100644 --- a/src/test/javascript/cypress/e2e/account/reset-password-page.cy.ts +++ b/src/test/javascript/cypress/e2e/account/reset-password-page.cy.ts @@ -22,7 +22,8 @@ describe('forgot your password', () => { }); it('requires email', () => { - cy.get(emailResetPasswordSelector).should('have.class', classInvalid).type('user@gmail.com'); + cy.get(emailResetPasswordSelector).should('have.class', classInvalid); + cy.get(emailResetPasswordSelector).type('user@gmail.com'); cy.get(emailResetPasswordSelector).should('have.class', classValid); }); diff --git a/src/test/javascript/cypress/e2e/account/settings-page.cy.ts b/src/test/javascript/cypress/e2e/account/settings-page.cy.ts index c41e16f26..45edf7d92 100644 --- a/src/test/javascript/cypress/e2e/account/settings-page.cy.ts +++ b/src/test/javascript/cypress/e2e/account/settings-page.cy.ts @@ -1,4 +1,5 @@ import { firstNameSettingsSelector, lastNameSettingsSelector, submitSettingsSelector, emailSettingsSelector } from '../../support/commands'; +import type { Account } from '../../support/account'; describe('/account/settings', () => { const adminUsername = Cypress.env('E2E_USERNAME') ?? 'admin'; @@ -6,15 +7,40 @@ describe('/account/settings', () => { const username = Cypress.env('E2E_USERNAME') ?? 'user'; const password = Cypress.env('E2E_PASSWORD') ?? 'user'; - beforeEach(() => { + const testUserEmail = 'user@localhost.fr'; + let originalUserAccount: Account; + let testUserAccount: Account; + + before(() => { cy.login(username, password); - cy.visit('/account/settings'); + + cy.getAccount().then(account => { + originalUserAccount = account; + testUserAccount = { ...account, email: testUserEmail }; + + // need to modify email because default email does not match regex in some frameworks + cy.saveAccount(testUserAccount).its('status').should('eq', 200); + }); }); beforeEach(() => { + cy.login(username, password); + cy.visit('/account/settings'); + cy.get(emailSettingsSelector).should('have.value', testUserEmail); + cy.intercept('POST', '/api/account').as('settingsSave'); }); + afterEach(() => { + cy.login(username, password); + cy.saveAccount(testUserAccount).its('status').should('eq', 200); + }); + + after(() => { + cy.login(username, password); + cy.saveAccount(originalUserAccount).its('status').should('eq', 200); + }); + it('should be accessible through menu', () => { cy.visit(''); cy.clickOnSettingsItem(); @@ -22,45 +48,52 @@ describe('/account/settings', () => { }); it("should be able to change 'user' firstname settings", () => { - cy.get(firstNameSettingsSelector).clear().type('jhipster'); - // need to modify email because default email does not match regex in vue - cy.get(emailSettingsSelector).clear().type('user@localhost.fr'); + cy.get(firstNameSettingsSelector).clear(); + cy.get(firstNameSettingsSelector).type('jhipster'); cy.get(submitSettingsSelector).click(); - cy.wait('@settingsSave').then(({ response }) => expect(response.statusCode).to.equal(200)); + cy.wait('@settingsSave').then(({ response }) => expect(response?.statusCode).to.equal(200)); }); it("should be able to change 'user' lastname settings", () => { - cy.get(lastNameSettingsSelector).clear().type('retspihj'); - // need to modify email because default email does not match regex in vue - cy.get(emailSettingsSelector).clear().type('user@localhost.fr'); + cy.get(lastNameSettingsSelector).clear(); + cy.get(lastNameSettingsSelector).type('retspihj'); cy.get(submitSettingsSelector).click(); - cy.wait('@settingsSave').then(({ response }) => expect(response.statusCode).to.equal(200)); + cy.wait('@settingsSave').then(({ response }) => expect(response?.statusCode).to.equal(200)); }); it("should be able to change 'user' email settings", () => { - cy.get(emailSettingsSelector).clear().type('user@localhost.fr'); + cy.get(emailSettingsSelector).clear(); + cy.get(emailSettingsSelector).type('user@localhost.fr'); cy.get(submitSettingsSelector).click(); - cy.wait('@settingsSave').then(({ response }) => expect(response.statusCode).to.equal(200)); + cy.wait('@settingsSave').then(({ response }) => expect(response?.statusCode).to.equal(200)); }); describe('if there is another user with an email', () => { + let originalAdminAccount: Account; + const testAdminEmail = 'admin@localhost.fr'; + before(() => { cy.login(adminUsername, adminPassword); - cy.visit('/account/settings'); - cy.get(emailSettingsSelector).clear().type('admin@localhost.fr'); - cy.intercept({ - method: 'POST', - url: '/api/account', - times: 1, - }).as('settingsSave'); - cy.get(submitSettingsSelector).click(); - cy.wait('@settingsSave'); + cy.getAccount().then(account => { + originalAdminAccount = account; + + // need to modify email because default email does not match regex in some frameworks + cy.saveAccount({ ...account, email: testAdminEmail }) + .its('status') + .should('eq', 200); + }); + }); + + after(() => { + cy.login(adminUsername, adminPassword); + cy.saveAccount(originalAdminAccount).its('status').should('eq', 200); }); it("should not be able to change 'user' email to same value", () => { - cy.get(emailSettingsSelector).clear().type('admin@localhost.fr'); + cy.get(emailSettingsSelector).clear(); + cy.get(emailSettingsSelector).type(testAdminEmail); cy.get(submitSettingsSelector).click(); - cy.wait('@settingsSave').then(({ response }) => expect(response.statusCode).to.equal(400)); + cy.wait('@settingsSave').then(({ response }) => expect(response?.statusCode).to.equal(400)); }); }); }); diff --git a/src/test/javascript/cypress/e2e/entity/bank-account.cy.ts b/src/test/javascript/cypress/e2e/entity/bank-account.cy.ts index 8ec50f943..dd8763aa5 100644 --- a/src/test/javascript/cypress/e2e/entity/bank-account.cy.ts +++ b/src/test/javascript/cypress/e2e/entity/bank-account.cy.ts @@ -15,7 +15,7 @@ describe('BankAccount e2e test', () => { const bankAccountPageUrlPattern = new RegExp('/bank-account(\\?.*)?$'); const username = Cypress.env('E2E_USERNAME') ?? 'user'; const password = Cypress.env('E2E_PASSWORD') ?? 'user'; - const bankAccountSample = { name: 'Refined Visionary', balance: 14517 }; + const bankAccountSample = { name: 'stamp gah', balance: 31518.71 }; let bankAccount; @@ -92,7 +92,7 @@ describe('BankAccount e2e test', () => { { statusCode: 200, body: [bankAccount], - } + }, ).as('entitiesRequestInternal'); }); @@ -157,9 +157,11 @@ describe('BankAccount e2e test', () => { }); it('should create an instance of BankAccount', () => { - cy.get(`[data-cy="name"]`).type('experiences withdrawal').should('have.value', 'experiences withdrawal'); + cy.get(`[data-cy="name"]`).type('quintessential lightly excited'); + cy.get(`[data-cy="name"]`).should('have.value', 'quintessential lightly excited'); - cy.get(`[data-cy="balance"]`).type('93119').should('have.value', '93119'); + cy.get(`[data-cy="balance"]`).type('24759.29'); + cy.get(`[data-cy="balance"]`).should('have.value', '24759.29'); cy.get(entityCreateSaveButtonSelector).click(); diff --git a/src/test/javascript/cypress/e2e/entity/label.cy.ts b/src/test/javascript/cypress/e2e/entity/label.cy.ts index 7a3c24198..392ae43c5 100644 --- a/src/test/javascript/cypress/e2e/entity/label.cy.ts +++ b/src/test/javascript/cypress/e2e/entity/label.cy.ts @@ -15,7 +15,7 @@ describe('Label e2e test', () => { const labelPageUrlPattern = new RegExp('/label(\\?.*)?$'); const username = Cypress.env('E2E_USERNAME') ?? 'user'; const password = Cypress.env('E2E_PASSWORD') ?? 'user'; - const labelSample = { label: 'input bypass' }; + const labelSample = { label: 'accomplished along following' }; let label; @@ -92,7 +92,7 @@ describe('Label e2e test', () => { { statusCode: 200, body: [label], - } + }, ).as('entitiesRequestInternal'); }); @@ -157,7 +157,8 @@ describe('Label e2e test', () => { }); it('should create an instance of Label', () => { - cy.get(`[data-cy="label"]`).type('Synergized').should('have.value', 'Synergized'); + cy.get(`[data-cy="label"]`).type('soX'); + cy.get(`[data-cy="label"]`).should('have.value', 'soX'); cy.get(entityCreateSaveButtonSelector).click(); diff --git a/src/test/javascript/cypress/e2e/entity/operation.cy.ts b/src/test/javascript/cypress/e2e/entity/operation.cy.ts index 6280e8b74..d4cb88e75 100644 --- a/src/test/javascript/cypress/e2e/entity/operation.cy.ts +++ b/src/test/javascript/cypress/e2e/entity/operation.cy.ts @@ -15,7 +15,7 @@ describe('Operation e2e test', () => { const operationPageUrlPattern = new RegExp('/operation(\\?.*)?$'); const username = Cypress.env('E2E_USERNAME') ?? 'user'; const password = Cypress.env('E2E_PASSWORD') ?? 'user'; - const operationSample = { date: '2015-08-04T18:04:46.481Z', amount: 40149 }; + const operationSample = { date: '2015-08-05T00:49:48.690Z', amount: 32417.83 }; let operation; @@ -95,7 +95,7 @@ describe('Operation e2e test', () => { link: '; rel="last",; rel="first"', }, body: [operation], - } + }, ).as('entitiesRequestInternal'); }); @@ -160,11 +160,15 @@ describe('Operation e2e test', () => { }); it('should create an instance of Operation', () => { - cy.get(`[data-cy="date"]`).type('2015-08-05T09:35').blur().should('have.value', '2015-08-05T09:35'); + cy.get(`[data-cy="date"]`).type('2015-08-04T18:29'); + cy.get(`[data-cy="date"]`).blur(); + cy.get(`[data-cy="date"]`).should('have.value', '2015-08-04T18:29'); - cy.get(`[data-cy="description"]`).type('back-end iterate').should('have.value', 'back-end iterate'); + cy.get(`[data-cy="description"]`).type('phew shrilly'); + cy.get(`[data-cy="description"]`).should('have.value', 'phew shrilly'); - cy.get(`[data-cy="amount"]`).type('10331').should('have.value', '10331'); + cy.get(`[data-cy="amount"]`).type('18114.57'); + cy.get(`[data-cy="amount"]`).should('have.value', '18114.57'); cy.get(entityCreateSaveButtonSelector).click(); diff --git a/src/test/javascript/cypress/plugins/index.ts b/src/test/javascript/cypress/plugins/index.ts index b0f502018..b88c9928b 100644 --- a/src/test/javascript/cypress/plugins/index.ts +++ b/src/test/javascript/cypress/plugins/index.ts @@ -12,7 +12,6 @@ // the project's config changing) import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { lighthouse, pa11y, prepareAudit } from 'cypress-audit'; -import ReportGenerator from 'lighthouse/report/generator/report-generator'; export default async (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) => { on('before:browser:launch', (browser, launchOptions) => { @@ -23,9 +22,22 @@ export default async (on: Cypress.PluginEvents, config: Cypress.PluginConfigOpti } }); + // Allows logging with cy.task('log', 'message') or cy.task('table', object) on('task', { - lighthouse: lighthouse(lighthouseReport => { - !existsSync('target/cypress') && mkdirSync('target/cypress', { recursive: true }); + log(message) { + console.log(message); + return null; + }, + table(message) { + console.table(message); + return null; + }, + }); + + on('task', { + lighthouse: lighthouse(async lighthouseReport => { + const { default: ReportGenerator } = await import('lighthouse/report/generator/report-generator'); + !existsSync('target/cypress/') && mkdirSync('target/cypress/', { recursive: true }); writeFileSync('target/cypress/lhreport.html', ReportGenerator.generateReport(lighthouseReport.lhr, 'html')); }), pa11y: pa11y(), diff --git a/src/test/javascript/cypress/support/account.ts b/src/test/javascript/cypress/support/account.ts new file mode 100644 index 000000000..da5d3682b --- /dev/null +++ b/src/test/javascript/cypress/support/account.ts @@ -0,0 +1,27 @@ +export type Account = Record; + +Cypress.Commands.add('getAccount', () => { + return cy + .authenticatedRequest({ + method: 'GET', + url: '/api/account', + }) + .then(response => response.body as Account); +}); + +Cypress.Commands.add('saveAccount', (account: Account) => { + return cy.authenticatedRequest({ + method: 'POST', + url: '/api/account', + body: account, + }); +}); + +declare global { + namespace Cypress { + interface Chainable { + getAccount(): Cypress.Chainable; + saveAccount(account: Account): Cypress.Chainable>; + } + } +} diff --git a/src/test/javascript/cypress/support/commands.ts b/src/test/javascript/cypress/support/commands.ts index 3a540af23..63f6454a5 100644 --- a/src/test/javascript/cypress/support/commands.ts +++ b/src/test/javascript/cypress/support/commands.ts @@ -74,16 +74,22 @@ export const configurationPageHeadingSelector = '[data-cy="configurationPageHead // End Specific Selector Attributes for Cypress // *********************************************** -export const classInvalid = 'invalid'; -export const classValid = 'valid'; +export const classInvalid = 'ng-invalid'; + +export const classValid = 'ng-valid'; + Cypress.Commands.add('authenticatedRequest', data => { - const bearerToken = sessionStorage.getItem(Cypress.env('jwtStorageName')); - return cy.request({ - ...data, - auth: { - bearer: bearerToken, - }, - }); + const jwtToken = sessionStorage.getItem(Cypress.env('jwtStorageName')); + const bearerToken = jwtToken && JSON.parse(jwtToken); + if (bearerToken) { + return cy.request({ + ...data, + auth: { + bearer: bearerToken, + }, + }); + } + return cy.request(data); }); Cypress.Commands.add('login', (username: string, password: string) => { @@ -107,7 +113,7 @@ Cypress.Commands.add('login', (username: string, password: string) => { validate() { cy.authenticatedRequest({ url: '/api/account' }).its('status').should('eq', 200); }, - } + }, ); }); diff --git a/src/test/javascript/cypress/support/entity.ts b/src/test/javascript/cypress/support/entity.ts index e944772dd..904458425 100644 --- a/src/test/javascript/cypress/support/entity.ts +++ b/src/test/javascript/cypress/support/entity.ts @@ -62,7 +62,8 @@ Cypress.Commands.add('setFieldSelectToLastOfEntity', (fieldName: string) => { return cy.get(`[data-cy="${fieldName}"] option`).then((options: JQuery) => { const elements = [...options].map((o: HTMLElement) => (o as HTMLOptionElement).label); const lastElement = elements.length - 1; - cy.get(`[data-cy="${fieldName}"]`).select(lastElement).type('{downarrow}'); + cy.get(`[data-cy="${fieldName}"]`).select(lastElement); + cy.get(`[data-cy="${fieldName}"]`).type('{downarrow}'); }); } else { return cy.get(`[data-cy="${fieldName}"]`).type('{downarrow}'); diff --git a/src/test/javascript/cypress/support/index.ts b/src/test/javascript/cypress/support/index.ts index 1a4430051..96bd01cc2 100644 --- a/src/test/javascript/cypress/support/index.ts +++ b/src/test/javascript/cypress/support/index.ts @@ -13,6 +13,7 @@ // https://on.cypress.io/configuration // *********************************************************** +import './account'; import './commands'; import './navbar'; import './entity'; diff --git a/src/test/javascript/cypress/support/navbar.ts b/src/test/javascript/cypress/support/navbar.ts index 6c1b656db..8a06068e8 100644 --- a/src/test/javascript/cypress/support/navbar.ts +++ b/src/test/javascript/cypress/support/navbar.ts @@ -14,31 +14,38 @@ import { } from './commands'; Cypress.Commands.add('clickOnLoginItem', () => { - return cy.get(navbarSelector).get(accountMenuSelector).click().get(loginItemSelector).click(); + cy.get(navbarSelector).get(accountMenuSelector).click(); + return cy.get(navbarSelector).get(accountMenuSelector).get(loginItemSelector).click(); }); Cypress.Commands.add('clickOnLogoutItem', () => { - return cy.get(navbarSelector).get(accountMenuSelector).click().get(logoutItemSelector).click(); + cy.get(navbarSelector).get(accountMenuSelector).click(); + return cy.get(navbarSelector).get(accountMenuSelector).get(logoutItemSelector).click(); }); Cypress.Commands.add('clickOnRegisterItem', () => { - return cy.get(navbarSelector).get(accountMenuSelector).click().get(registerItemSelector).click(); + cy.get(navbarSelector).get(accountMenuSelector).click(); + return cy.get(navbarSelector).get(accountMenuSelector).get(registerItemSelector).click(); }); Cypress.Commands.add('clickOnSettingsItem', () => { - return cy.get(navbarSelector).get(accountMenuSelector).click().get(settingsItemSelector).click(); + cy.get(navbarSelector).get(accountMenuSelector).click(); + return cy.get(navbarSelector).get(accountMenuSelector).get(settingsItemSelector).click(); }); Cypress.Commands.add('clickOnPasswordItem', () => { - return cy.get(navbarSelector).get(accountMenuSelector).click().get(passwordItemSelector).click(); + cy.get(navbarSelector).get(accountMenuSelector).click(); + return cy.get(navbarSelector).get(accountMenuSelector).get(passwordItemSelector).click(); }); Cypress.Commands.add('clickOnAdminMenuItem', (item: string) => { - return cy.get(navbarSelector).get(adminMenuSelector).click().get(`.dropdown-item[href="/admin/${item}"]`).click(); + cy.get(navbarSelector).get(adminMenuSelector).click(); + return cy.get(navbarSelector).get(adminMenuSelector).get(`.dropdown-item[href="/admin/${item}"]`).click(); }); Cypress.Commands.add('clickOnEntityMenuItem', (entityName: string) => { - return cy.get(navbarSelector).get(entityItemSelector).click().get(`.dropdown-item[href="/${entityName}"]`).click(); + cy.get(navbarSelector).get(entityItemSelector).click(); + return cy.get(navbarSelector).get(entityItemSelector).get(`.dropdown-item[href="/${entityName}"]`).click({ force: true }); }); declare global { diff --git a/src/test/javascript/cypress/tsconfig.json b/src/test/javascript/cypress/tsconfig.json index c0f1ddc11..bf2d9fa84 100644 --- a/src/test/javascript/cypress/tsconfig.json +++ b/src/test/javascript/cypress/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../../tsconfig.json", "compilerOptions": { "baseUrl": "./", - "outDir": "../../../../target/out-tsc/cypress", + "sourceMap": false, + "outDir": "../../../../target/cypress/out-tsc", "target": "es2018", "types": ["cypress", "node"] }, diff --git a/src/test/resources/META-INF/spring.factories b/src/test/resources/META-INF/spring.factories index a2d487413..d23f3042e 100644 --- a/src/test/resources/META-INF/spring.factories +++ b/src/test/resources/META-INF/spring.factories @@ -1 +1,2 @@ -org.springframework.test.context.ContextCustomizerFactory = io.github.jhipster.sample.config.TestContainersSpringContextCustomizerFactory +org.springframework.test.context.ContextCustomizerFactory = io.github.jhipster.\ + sample.config.SqlTestContainersSpringContextCustomizerFactory \ No newline at end of file diff --git a/src/test/resources/config/application-testdev.yml b/src/test/resources/config/application-testdev.yml index 98d28d0f2..f6afbb9da 100644 --- a/src/test/resources/config/application-testdev.yml +++ b/src/test/resources/config/application-testdev.yml @@ -11,6 +11,9 @@ spring: datasource: type: com.zaxxer.hikari.HikariDataSource + url: jdbc:h2:mem:jhipstersampleapplication:12344;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: jhipsterSampleApplication + password: hikari: auto-commit: false jpa: @@ -18,7 +21,7 @@ spring: hibernate: ddl-auto: none naming: - physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy properties: hibernate.id.new_generator_mappings: true @@ -26,6 +29,8 @@ spring: hibernate.cache.use_second_level_cache: false hibernate.cache.use_query_cache: false hibernate.generate_statistics: false - hibernate.hbm2ddl.auto: validate + hibernate.hbm2ddl.auto: none #TODO: temp relief for integration tests, revisit required + hibernate.type.preferred_instant_jdbc_type: TIMESTAMP hibernate.jdbc.time_zone: UTC + hibernate.timezone.default_storage: NORMALIZE hibernate.query.fail_on_pagination_over_collection_fetch: true diff --git a/src/test/resources/config/application-testprod.yml b/src/test/resources/config/application-testprod.yml index 484eda81f..c12aead42 100644 --- a/src/test/resources/config/application-testprod.yml +++ b/src/test/resources/config/application-testprod.yml @@ -16,12 +16,11 @@ spring: auto-commit: false maximum-pool-size: 1 jpa: - database-platform: tech.jhipster.domain.util.FixedPostgreSQL10Dialect open-in-view: false hibernate: ddl-auto: none naming: - physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy properties: hibernate.id.new_generator_mappings: true @@ -29,6 +28,8 @@ spring: hibernate.cache.use_second_level_cache: false hibernate.cache.use_query_cache: false hibernate.generate_statistics: false - hibernate.hbm2ddl.auto: validate + hibernate.hbm2ddl.auto: none #TODO: temp relief for integration tests, revisit required + hibernate.type.preferred_instant_jdbc_type: TIMESTAMP hibernate.jdbc.time_zone: UTC + hibernate.timezone.default_storage: NORMALIZE hibernate.query.fail_on_pagination_over_collection_fetch: true diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml index 826d3c687..df98fc359 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -26,9 +26,6 @@ spring: host: localhost main: allow-bean-definition-overriding: true - mvc: - pathmatch: - matching-strategy: ant_path_matcher messages: basename: i18n/messages task: @@ -67,7 +64,7 @@ jhipster: enabled: false host: localhost port: 5000 - queue-size: 512 + ring-buffer-size: 512 security: authentication: jwt: @@ -75,6 +72,7 @@ jhipster: base64-secret: bXktc2VjcmV0LWtleS13aGljaC1zaG91bGQtYmUtY2hhbmdlZC1pbi1wcm9kdWN0aW9uLWFuZC1iZS1iYXNlNjQtZW5jb2RlZAo= # Token is valid 24 hours token-validity-in-seconds: 86400 + token-validity-in-seconds-for-remember-me: 86400 # =================================================================== # Application specific properties diff --git a/src/test/resources/i18n/messages_en.properties b/src/test/resources/i18n/messages_en.properties old mode 100644 new mode 100755 diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties old mode 100644 new mode 100755 diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml index 948a5c356..90e8e4d86 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -12,9 +12,9 @@ - - - + + + @@ -38,14 +38,10 @@ - - - - - WARN - - + + + diff --git a/src/test/resources/templates/mail/activationEmail.html b/src/test/resources/templates/mail/activationEmail.html old mode 100644 new mode 100755 index 01ad64c79..6db87f68d --- a/src/test/resources/templates/mail/activationEmail.html +++ b/src/test/resources/templates/mail/activationEmail.html @@ -1,4 +1,4 @@ - + JHipster activation diff --git a/src/test/resources/templates/mail/creationEmail.html b/src/test/resources/templates/mail/creationEmail.html old mode 100644 new mode 100755 index e96075f4c..07075e0e4 --- a/src/test/resources/templates/mail/creationEmail.html +++ b/src/test/resources/templates/mail/creationEmail.html @@ -1,4 +1,4 @@ - + JHipster creation diff --git a/src/test/resources/templates/mail/passwordResetEmail.html b/src/test/resources/templates/mail/passwordResetEmail.html old mode 100644 new mode 100755 index ca7727a8e..6ddc5d29f --- a/src/test/resources/templates/mail/passwordResetEmail.html +++ b/src/test/resources/templates/mail/passwordResetEmail.html @@ -1,4 +1,4 @@ - + JHipster password reset diff --git a/src/test/resources/templates/mail/testEmail.html b/src/test/resources/templates/mail/testEmail.html old mode 100644 new mode 100755 diff --git a/src/test/resources/testcontainers.properties b/src/test/resources/testcontainers.properties deleted file mode 100644 index f0e6e42c2..000000000 --- a/src/test/resources/testcontainers.properties +++ /dev/null @@ -1 +0,0 @@ -testcontainers.reuse.enable=true diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 000000000..85b73341c --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./target/classes/static/app", + "types": ["@angular/localize"] + }, + "files": ["src/main/webapp/main.ts"], + "include": ["src/main/webapp/**/*.d.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..e4460c9ae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "baseUrl": "src/main/webapp/", + "outDir": "./target/classes/static/", + "forceConsistentCasingInFileNames": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "useDefineForClassFields": false, + "target": "es2022", + "module": "es2020", + "types": [], + "lib": ["es2018", "es2020", "dom"] + }, + "references": [ + { + "path": "tsconfig.spec.json" + } + ], + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true, + "preserveWhitespaces": true + } +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 000000000..d00c68763 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/main/webapp/**/*.ts"], + "compilerOptions": { + "composite": true, + "outDir": "target/out-tsc/spec", + "types": ["jest", "node"] + } +} diff --git a/webpack/proxy.conf.js b/webpack/proxy.conf.js index 5daa7c2e8..642725dcd 100644 --- a/webpack/proxy.conf.js +++ b/webpack/proxy.conf.js @@ -1,7 +1,8 @@ function setupProxy({ tls }) { + const serverResources = ['/api', '/services', '/management', '/v3/api-docs', '/h2-console', '/auth', '/health']; const conf = [ { - context: ['/api', '/services', '/management', '/v3/api-docs', '/h2-console', '/auth', '/health'], + context: serverResources, target: `http${tls ? 's' : ''}://localhost:8080`, secure: false, changeOrigin: tls, diff --git a/webpack/webpack.custom.js b/webpack/webpack.custom.js index fcbdf867b..6d22012a4 100644 --- a/webpack/webpack.custom.js +++ b/webpack/webpack.custom.js @@ -1,6 +1,6 @@ +const path = require('path'); const webpack = require('webpack'); const { merge } = require('webpack-merge'); -const path = require('path'); const { hashElement } = require('folder-hash'); const MergeJsonWebpackPlugin = require('merge-jsons-webpack-plugin'); const BrowserSyncPlugin = require('browser-sync-webpack-plugin'); @@ -32,7 +32,7 @@ module.exports = async (config, options, targetOptions) => { new WebpackNotifierPlugin({ title: 'Jhipster Sample Application', contentImage: path.join(__dirname, 'logo-jhipster.png'), - }) + }), ); } @@ -50,7 +50,7 @@ module.exports = async (config, options, targetOptions) => { port: 9000, https: tls, proxy: { - target: `http${tls ? 's' : ''}://localhost:${targetOptions.target === 'serve' ? '4200' : '8080'}`, + target: `http${tls ? 's' : ''}://localhost:${targetOptions.target === 'serve' ? '9060' : '8080'}`, ws: true, proxyOptions: { changeOrigin: false, //pass the Host header to the backend unchanged https://github.com/Browsersync/browser-sync/issues/430 @@ -72,8 +72,8 @@ module.exports = async (config, options, targetOptions) => { }, { reload: targetOptions.target === 'build', // enabled for build --watch - } - ) + }, + ), ); } @@ -84,7 +84,7 @@ module.exports = async (config, options, targetOptions) => { openAnalyzer: false, // Webpack statistics in target folder reportFilename: '../stats.html', - }) + }), ); } @@ -127,11 +127,11 @@ module.exports = async (config, options, targetOptions) => { // jhipster-needle-i18n-language-webpack - JHipster will add/remove languages in this array ], }, - }) + }), ); config = merge( - config + config, // jhipster-needle-add-webpack-config - JHipster will add custom config );