Repository: wiringbits/scala-webapp-template Branch: master Commit: 8089a40f739d Files: 247 Total size: 401.7 KB Directory structure: gitextract_jae30_9x/ ├── .github/ │ └── workflows/ │ ├── codepreview.yml │ └── pull_request.yml ├── .gitignore ├── .nvmrc ├── .sbtopts ├── .scalafmt.conf ├── .sdkmanrc ├── LICENSE ├── README.md ├── build.sbt ├── custom.webpack.config.js ├── docs/ │ ├── README.md │ ├── architecture.md │ ├── design-decisions.md │ ├── diagram-sources/ │ │ ├── architecture-infra.puml │ │ ├── architecture-modules.puml │ │ ├── architecture-server-actions.puml │ │ ├── architecture-server-controllers.puml │ │ ├── architecture-server-daos.puml │ │ ├── architecture-server-external-apis.puml │ │ ├── architecture-server-repositories.puml │ │ ├── architecture-server-services.puml │ │ └── architecture-server.puml │ ├── learning-material.md │ ├── setup-dev-environment.md │ └── swagger-integration.md ├── infra/ │ ├── .gitignore │ ├── README.md │ ├── admin.yml │ ├── config/ │ │ ├── nginx/ │ │ │ ├── admin-app-htpasswd │ │ │ ├── admin_app_site.j2 │ │ │ ├── mime.types │ │ │ ├── nginx.conf │ │ │ ├── preview_admin_app_site.j2 │ │ │ ├── preview_web_app_site.j2 │ │ │ └── web_app_site.j2 │ │ └── server/ │ │ ├── dev.env.j2 │ │ └── server.service.j2 │ ├── demo-hosts.ini │ ├── nginx.yml │ ├── nginx_site_admin.yml │ ├── nginx_site_web.yml │ ├── preview_nginx_site_admin.yml │ ├── preview_nginx_site_web.yml │ ├── scripts/ │ │ ├── build-admin.sh │ │ ├── build-server.sh │ │ └── build-web.sh │ ├── server.yml │ ├── setup-postgres.md │ ├── test-hosts.ini │ └── web.yml ├── lib/ │ ├── api/ │ │ └── shared/ │ │ └── src/ │ │ └── main/ │ │ └── scala/ │ │ └── net/ │ │ └── wiringbits/ │ │ └── api/ │ │ ├── ApiClient.scala │ │ ├── endpoints/ │ │ │ ├── AdminEndpoints.scala │ │ │ ├── AuthEndpoints.scala │ │ │ ├── EnvironmentConfigEndpoints.scala │ │ │ ├── HealthEndpoints.scala │ │ │ ├── UsersEndpoints.scala │ │ │ └── package.scala │ │ ├── models/ │ │ │ ├── PlayErrorResponse.scala │ │ │ ├── admin/ │ │ │ │ ├── AdminGetUserLogs.scala │ │ │ │ └── AdminGetUsers.scala │ │ │ ├── auth/ │ │ │ │ ├── GetCurrentUser.scala │ │ │ │ ├── Login.scala │ │ │ │ └── Logout.scala │ │ │ ├── environmentconfig/ │ │ │ │ └── GetEnvironmentConfig.scala │ │ │ ├── package.scala │ │ │ └── users/ │ │ │ ├── CreateUser.scala │ │ │ ├── ForgotPassword.scala │ │ │ ├── GetUserLogs.scala │ │ │ ├── ResetPassword.scala │ │ │ ├── SendEmailVerificationToken.scala │ │ │ ├── UpdatePassword.scala │ │ │ ├── UpdateUser.scala │ │ │ └── VerifyEmail.scala │ │ └── utils/ │ │ └── Formatter.scala │ ├── common/ │ │ ├── js/ │ │ │ └── src/ │ │ │ └── test/ │ │ │ └── scala/ │ │ │ └── java/ │ │ │ └── security/ │ │ │ └── SecureRandom.scala │ │ └── shared/ │ │ └── src/ │ │ ├── main/ │ │ │ └── scala/ │ │ │ └── net/ │ │ │ └── wiringbits/ │ │ │ └── common/ │ │ │ ├── ErrorMessages.scala │ │ │ └── models/ │ │ │ ├── Captcha.scala │ │ │ ├── Email.scala │ │ │ ├── Name.scala │ │ │ ├── Password.scala │ │ │ └── UserToken.scala │ │ └── test/ │ │ └── scala/ │ │ └── net/ │ │ └── wiringbits/ │ │ └── common/ │ │ └── models/ │ │ ├── EmailSpec.scala │ │ ├── NameSpec.scala │ │ ├── PasswordSpec.scala │ │ └── UserTokenSpec.scala │ └── ui/ │ └── src/ │ └── main/ │ └── scala/ │ └── net/ │ └── wiringbits/ │ └── ui/ │ └── components/ │ ├── core/ │ │ └── widgets/ │ │ └── ValidatedTextInput.scala │ └── inputs/ │ └── inputs.scala ├── project/ │ ├── build.properties │ └── plugins.sbt ├── server/ │ └── src/ │ ├── main/ │ │ ├── resources/ │ │ │ ├── application.conf │ │ │ ├── evolutions/ │ │ │ │ └── default/ │ │ │ │ ├── 1.sql │ │ │ │ └── 2.sql │ │ │ ├── logback.xml │ │ │ ├── messages │ │ │ └── routes │ │ └── scala/ │ │ ├── PekkoStream.scala │ │ ├── controllers/ │ │ │ ├── AdminController.scala │ │ │ ├── ApiRouter.scala │ │ │ ├── AuthController.scala │ │ │ ├── EnvironmentConfigController.scala │ │ │ ├── HealthController.scala │ │ │ ├── UsersController.scala │ │ │ └── package.scala │ │ └── net/ │ │ └── wiringbits/ │ │ ├── actions/ │ │ │ ├── auth/ │ │ │ │ ├── GetUserAction.scala │ │ │ │ └── LoginAction.scala │ │ │ ├── environmentconfig/ │ │ │ │ └── GetEnvironmentConfigAction.scala │ │ │ ├── internal/ │ │ │ │ └── StreamPendingBackgroundJobsForeverAction.scala │ │ │ └── users/ │ │ │ ├── CreateUserAction.scala │ │ │ ├── ForgotPasswordAction.scala │ │ │ ├── GetUserLogsAction.scala │ │ │ ├── ResetPasswordAction.scala │ │ │ ├── SendEmailVerificationTokenAction.scala │ │ │ ├── UpdatePasswordAction.scala │ │ │ ├── UpdateUserAction.scala │ │ │ └── VerifyUserEmailAction.scala │ │ ├── apis/ │ │ │ ├── EmailApi.scala │ │ │ ├── EmailApiAWSImpl.scala │ │ │ ├── ReCaptchaApi.scala │ │ │ └── models/ │ │ │ └── EmailRequest.scala │ │ ├── config/ │ │ │ ├── AWSConfig.scala │ │ │ ├── BackgroundJobsExecutorConfig.scala │ │ │ ├── EmailConfig.scala │ │ │ ├── ReCaptchaConfig.scala │ │ │ ├── SwaggerConfig.scala │ │ │ ├── UserTokensConfig.scala │ │ │ └── WebAppConfig.scala │ │ ├── executors/ │ │ │ └── DatabaseExecutionContext.scala │ │ ├── models/ │ │ │ ├── AWSAccessKeyId.scala │ │ │ ├── AWSSecretAccessKey.scala │ │ │ ├── ReCaptchaSecret.scala │ │ │ ├── ReCaptchaSiteKey.scala │ │ │ ├── SecretValue.scala │ │ │ └── jobs/ │ │ │ ├── BackgroundJobPayload.scala │ │ │ ├── BackgroundJobStatus.scala │ │ │ └── BackgroundJobType.scala │ │ ├── modules/ │ │ │ ├── ApisModule.scala │ │ │ ├── ClockModule.scala │ │ │ ├── ConfigModule.scala │ │ │ ├── ExecutorsModule.scala │ │ │ └── TasksModule.scala │ │ ├── repositories/ │ │ │ ├── BackgroundJobsRepository.scala │ │ │ ├── UserLogsRepository.scala │ │ │ ├── UserTokensRepository.scala │ │ │ ├── UsersRepository.scala │ │ │ ├── daos/ │ │ │ │ ├── BackgroundJobDAO.scala │ │ │ │ ├── UserLogsDAO.scala │ │ │ │ ├── UserTokensDAO.scala │ │ │ │ ├── UsersDAO.scala │ │ │ │ └── package.scala │ │ │ └── models/ │ │ │ ├── BackgroundJobData.scala │ │ │ ├── User.scala │ │ │ ├── UserLog.scala │ │ │ ├── UserToken.scala │ │ │ └── UserTokenType.scala │ │ ├── services/ │ │ │ └── AdminService.scala │ │ ├── tasks/ │ │ │ └── BackgroundJobsExecutorTask.scala │ │ ├── util/ │ │ │ ├── DelayGenerator.scala │ │ │ ├── EmailMessage.scala │ │ │ ├── EmailsHelper.scala │ │ │ ├── StringUtils.scala │ │ │ ├── TokenGenerator.scala │ │ │ └── TokensHelper.scala │ │ └── validations/ │ │ ├── ValidateCaptcha.scala │ │ ├── ValidateEmailIsAvailable.scala │ │ ├── ValidateEmailIsRegistered.scala │ │ ├── ValidatePasswordMatches.scala │ │ ├── ValidateUserIsNotVerified.scala │ │ ├── ValidateUserToken.scala │ │ └── ValidateVerifiedUser.scala │ └── test/ │ └── scala/ │ ├── controllers/ │ │ ├── AdminControllerSpec.scala │ │ ├── AuthControllerSpec.scala │ │ ├── EnvironmentConfigControllerSpec.scala │ │ ├── UsersControllerSpec.scala │ │ └── common/ │ │ ├── PlayAPISpec.scala │ │ └── PlayPostgresSpec.scala │ ├── net/ │ │ └── wiringbits/ │ │ ├── apis/ │ │ │ └── ReCaptchaApiSpec.scala │ │ ├── core/ │ │ │ ├── PostgresSpec.scala │ │ │ ├── RepositoryComponents.scala │ │ │ └── RepositorySpec.scala │ │ ├── repositories/ │ │ │ ├── BackgroundJobsRepositorySpec.scala │ │ │ ├── UserLogsRepositorySpec.scala │ │ │ ├── UserTokensRepositorySpec.scala │ │ │ └── UsersRepositorySpec.scala │ │ └── util/ │ │ ├── DelayGeneratorSpec.scala │ │ └── TokensHelperSpec.scala │ └── utils/ │ ├── Executors.scala │ ├── LoginUtils.scala │ └── RepositoryUtils.scala └── web/ └── src/ ├── main/ │ ├── js/ │ │ ├── index.css │ │ └── index.html │ └── scala/ │ └── net/ │ └── wiringbits/ │ ├── API.scala │ ├── App.scala │ ├── AppContext.scala │ ├── AppRouter.scala │ ├── AppTheme.scala │ ├── I18nMessages.scala │ ├── Main.scala │ ├── components/ │ │ ├── AppSplash.scala │ │ ├── pages/ │ │ │ ├── AboutPage.scala │ │ │ ├── DashboardPage.scala │ │ │ ├── ForgotPasswordPage.scala │ │ │ ├── HomePage.scala │ │ │ ├── ResendVerifyEmailPage.scala │ │ │ ├── ResetPasswordPage.scala │ │ │ ├── SignInPage.scala │ │ │ ├── SignUpPage.scala │ │ │ ├── UserEditPage.scala │ │ │ ├── VerifyEmailPage.scala │ │ │ └── VerifyEmailWithTokenPage.scala │ │ └── widgets/ │ │ ├── AppBar.scala │ │ ├── AppCard.scala │ │ ├── EditPasswordForm.scala │ │ ├── EditUserForm.scala │ │ ├── Footer.scala │ │ ├── ForgotPasswordForm.scala │ │ ├── Loader.scala │ │ ├── LogList.scala │ │ ├── Logs.scala │ │ ├── ReCaptcha.scala │ │ ├── ResendVerifyEmailForm.scala │ │ ├── ResetPasswordForm.scala │ │ ├── SignInForm.scala │ │ ├── SignUpForm.scala │ │ └── UserInfo.scala │ ├── core/ │ │ ├── I18nHooks.scala │ │ ├── I18nLang.scala │ │ └── ReactiveHooks.scala │ ├── forms/ │ │ ├── ForgotPasswordFormData.scala │ │ ├── ResendVerifyEmailFormData.scala │ │ ├── ResetPasswordFormData.scala │ │ ├── SignInFormData.scala │ │ ├── SignUpFormData.scala │ │ ├── UpdateInfoFormData.scala │ │ └── UpdatePasswordFormData.scala │ ├── models/ │ │ ├── AuthState.scala │ │ ├── User.scala │ │ └── UserMenuOption.scala │ └── services/ │ └── StorageService.scala └── test/ └── scala/ ├── java/ │ └── security/ │ └── SecureRandom.scala └── net/ └── wiringbits/ └── forms/ ├── ForgotPasswordFormDataSpec.scala ├── ResendVerifyEmailFormDataSpec.scala ├── ResetPasswordFormDataSpec.scala ├── SignInFormDataSpec.scala ├── SignUpFormDataSpec.scala ├── UpdateInfoFormDataSpec.scala └── UpdatePasswordFormDataSpec.scala ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/codepreview.yml ================================================ name: Create preview environment on: pull_request: branches: [ master ] push: branches: [ master, scala3 ] concurrency: # The preview script can't handle concurrent deploys group: codepreview cancel-in-progress: false # TODO: Define minimal permissions, I haven't found which one is necessary to allow writing comments on commits # see https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs #permissions: # contents: read # for checkout jobs: preview: runs-on: ubuntu-latest steps: - name: Install ansible run: python3 -m pip install --user ansible - uses: coursier/cache-action@v6 - uses: VirtusLab/scala-cli-setup@main - name: checkout uses: actions/checkout@v2 - name: Setup Scala uses: japgolly/setup-everything-scala@v3.1 with: java-version: 'adopt:1.11.0-11' node-version: '16.7.0' - name: Cache compiled code uses: actions/cache@v3 with: path: | **/target/ /home/runner/.ivy2/local key: compiled-code-preview-cache-${{ hashFiles('**/build.sbt') }} restore-keys: compiled-code-preview-cache- - name: Compile run: sbt compile - name: Create SSH key run: | mkdir -p ~/.ssh/ echo "$CODEPREVIEW_PRIVATE_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa echo "StrictHostKeyChecking=no" > ~/.ssh/config shell: bash env: CODEPREVIEW_PRIVATE_KEY: ${{ secrets.CODEPREVIEW_PRIVATE_KEY }} - name: Create codepreview scripts run: | rm -rf ./infra curl -u "github:$CODEPREVIEW_TOKEN" -O https://sssppa.wiringbits.dev/sssppa.zip unzip sssppa.zip -d . chmod +x ./infra/scripts/*.sh shell: bash env: CODEPREVIEW_TOKEN: ${{ secrets.CODEPREVIEW_TOKEN }} - name: Create preview env run: cd infra && ./scripts/deploy-preview.sh env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.number }} ================================================ FILE: .github/workflows/pull_request.yml ================================================ name: Build the app on: push: branches: [ master ] pull_request: branches: [ master ] concurrency: # Only run once for latest commit per ref and cancel other (previous) runs. group: ci-${{ github.ref }} cancel-in-progress: true permissions: contents: read # for checkout jobs: build: runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@v2 - name: Setup Scala uses: japgolly/setup-everything-scala@v3.1 with: java-version: 'adopt:1.11.0-11' node-version: '16.7.0' - name: Cache compiled code uses: actions/cache@v3 with: path: | **/target/ /home/runner/.ivy2/local key: compiled-code-cache-${{ hashFiles('**/build.sbt') }} restore-keys: compiled-code-cache- - name: Check code format run: sbt scalafmtCheckAll - name: Compile run: CI=true sbt compile - name: Run tests run: CI=true sbt test - name: Test summary if: always() # Always run, even if previous steps failed uses: test-summary/action@v2 with: paths: "**/target/test-reports/*.xml" - name: Prepare web build run: sbt web/build - name: Prepare server build run: sbt server/dist ================================================ FILE: .gitignore ================================================ target/ .idea/ .bsp/ .vscode/ logs/ admin/build/ web/build/ local.env # https://scalameta.org/metals/docs/editors/vscode/#files-and-directories-to-include-in-your-gitignore .metals/ .bloop/ .ammonite/ metals.sbt ================================================ FILE: .nvmrc ================================================ 16.7.0 ================================================ FILE: .sbtopts ================================================ -J-Xmx4G -J-XX:MaxMetaspaceSize=4G -J-XX:+CMSClassUnloadingEnabled ================================================ FILE: .scalafmt.conf ================================================ version = 3.7.3 project.git = true project.excludeFilters = [ ] runner.dialect=scala3 maxColumn = 120 assumeStandardLibraryStripMargin = false continuationIndent.callSite = 2 continuationIndent.defnSite = 4 align.preset = none onTestFailure = "To fix this, run 'sbt scalafmt' from the project directory, to avoid this issue, ensure you set up IntelliJ to format the code using scalafmt, see https://scalameta.org/scalafmt/docs/installation.html#intellij" ================================================ FILE: .sdkmanrc ================================================ java=11.0.16-tem ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 wiringbits Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Wiringbits Web Application Template ![wiringbits](https://github.com/wiringbits/scala-webapp-template/workflows/Build%20the%20server%20app/badge.svg) [![Scala.js](https://www.scala-js.org/assets/badges/scalajs-1.6.0.svg)](https://www.scala-js.org) This is the skeleton used by Wiringbits when creating new web applications in Scala/Scala.js, so far, we have created ~10 projects from this template, back-porting useful details to improve it. If you require building a Web Application in Scala while you do not have the time to do many technical choices, this template could be a reasonable choice. ## Why? Scala has a common misconception, many people believe that it is hard to get productive with it, at Wiringbits, we have proven the contrary with this template. Engineers with no previous Scala experience tend to start contributing simple bug fixes at their first week (including undergrad interns). Our template provides all the necessary boilerplate to get started fast when building a traditional web application. Don't waste your time evaluating every library required to build your web app, pick this template and go from there. Using Scala.js not only save us considerable time, it also allows us to avoid many common issues, for example, all frontend/backend validations are in sync just because the code is the same. ## Demo We have a live demo so that you can get a taste on what our template provides. - [Web App](https://template-demo.wiringbits.net) showcases the web application intended for the general user, explore it and create an account to get an idea on what your users will experience. - [API Docs](https://template-demo.wiringbits.net/api/docs/index.html) showcases the [Swagger UI](https://swagger.io/tools/swagger-ui/) which can help to explore the API directly. ### Short videos Users app 1m demo: [![Users app 1m demo](./docs/assets/demo-video-01.png)](https://youtu.be/hURUK4NCGBk "Users app 1m demo") Deployment 2m demo: [![Deployment 2m demo](./docs/assets/demo-video-02.png)](https://youtu.be/cN599dMa9EA "Deployment 2m demo") ## What's included? 1. User registration and authentication; Including email verification, profile updates, password recovery, and, captcha for spam prevention. 2. Integration with the React ecosystem, most libraries/components will work right away, while we use [Material UI](https://v3.mui.com/), you can switch to your preferred component library. 3. PostgreSQL as the data store layer, which is a reasonable choice for most web applications. 4. Practical components for testing your server-side code, writing tests for the Data/Api layer is real simple, no excuses accepted. 5. Practical frontend utilities, for example, test your frontend forms easily, consistent UI when performing asynchronous actions (fetching/submitting data), etc. 6. Typed data inputs, don't bother running simple validations to form data at the backend, accepted requests are already validated. 7. Reasonable Continuous-Integration workflows, don't waste time reviewing code format, asking whether tests are passing or looking at jobs logs finding which test failed (thanks to [TestForest Dashboard](https://github.com/marketplace/actions/testforest-dashboard) action), Github Actions do this for you. 8. A simple to follow architecture, including short-guides for doing common tasks. 9. Deployment scripts to cloud instances, we believe in simplicity and most projects are fine with simple managed servers instead of containers/K8s/etc. ## Get started Read the [docs](./docs/README.md) or watch our [onboarding videos](http://onboarding.wiringbits.net). ## Presentations There have been some presentations involing this project: - Jan 2023; [ScaLatin](https://scalac.io/scalatin/); [Creando aplicaciones web con Scala/Scala.js](https://www.youtube.com/watch?v=PqI8brUxCRg); [slides](http://scalatin2023.wiringbits.net) - Oct 2022; [ScalaCon](https://www.scalacon.org/); [A Practical Skeleton for Your Next Scala Scala js Web Application](https://www.youtube.com/watch?v=xWGMr0AsAMU) ## Scala.js bundle size These are code-size measurements from the deployed version at 5/Feb/2023, overall, Scala.js core is minimal, gzipped versions are usually less than 1Mb. ### Web app ![sssppa-web-code-size](./docs/assets/images/sssppa-web-code-size.png) ## Hire us The open source work we do is funded by our Scala/Scala.js consulting and development services, [schedule a call](http://alexis.wiringbits.net/) to hire us for your project. ================================================ FILE: build.sbt ================================================ import java.nio.file.Files import java.nio.file.StandardCopyOption.REPLACE_EXISTING ThisBuild / scalaVersion := "3.3.0" ThisBuild / organization := "net.wiringbits" val playJson = "3.0.1" val sttp = "3.8.15" val webappUtils = "0.7.2" val anorm = "2.7.0" val enumeratum = "1.7.2" val scalaJavaTime = "2.5.0" val tapir = "1.8.5" val chimney = "0.8.0-RC1" val consoleDisabledOptions = Seq("-Werror", "-Ywarn-unused", "-Ywarn-unused-import") /** Say just `build` or `sbt build` to make a production bundle in `build` */ lazy val build = TaskKey[File]("build") lazy val commonSettings: Project => Project = { _.settings( // Enable fatal warnings only when running in the CI scalacOptions ++= { sys.env .get("CI") .filter(_.nonEmpty) .map(_ => Seq("-Werror")) .getOrElse(Seq.empty[String]) }, Compile / compile / wartremoverErrors ++= List( Wart.ArrayEquals, // Wart.Any, // Wart.AsInstanceOf, // Wart.ExplicitImplicitTypes, Wart.IsInstanceOf, Wart.JavaConversions, // Wart.JavaSerializable, Wart.MutableDataStructures, // Wart.NonUnitStatements, // Wart.Nothing, Wart.Null, Wart.OptionPartial, // Wart.Overloading, // Wart.Product, // Wart.PublicInference, Wart.Return, // Wart.Serializable, // Wart.StringPlusAny, // Wart.ToString, Wart.TryPartial ) ) } // TODO: Reuse it in all projects lazy val baseServerSettings: Project => Project = { _.settings( scalacOptions ++= Seq( "-unchecked", "-deprecation", "-feature" ), Compile / doc / scalacOptions ++= Seq("-no-link-warnings"), // Some options are very noisy when using the console and prevent us using it smoothly, let's disable them Compile / console / scalacOptions ~= (_.filterNot(consoleDisabledOptions.contains)) ) } // Used only by web projects lazy val baseWebSettings: Project => Project = _.enablePlugins(ScalaJSPlugin) .settings( scalacOptions ++= Seq( "-deprecation", // Emit warning and location for usages of deprecated APIs. "-explaintypes", // Explain type errors in more detail. "-feature", // Emit warning and location for usages of features that should be imported explicitly. "-unchecked" // Enable additional warnings where generated code depends on assumptions. ), scalaJSUseMainModuleInitializer := true, /* disabled because it somehow triggers many warnings */ scalaJSLinkerConfig := scalaJSLinkerConfig.value.withSourceMap(false), /* for slinky */ libraryDependencies ++= Seq("me.shadaj" %%% "slinky-hot" % "0.7.3"), libraryDependencies ++= Seq( "io.github.cquiroz" %%% "scala-java-time" % scalaJavaTime, "io.github.cquiroz" %%% "scala-java-time-tzdb" % scalaJavaTime ), Test / fork := false, // sjs needs this to run tests Test / requireJsDomEnv := true ) // Used only by the lib projects // TODO: This should go to commonSettings instead lazy val baseLibSettings: Project => Project = _.settings( scalacOptions ++= Seq( "-deprecation", // Emit warning and location for usages of deprecated APIs. "-encoding", "utf-8", // Specify character encoding used by source files. "-explaintypes", // Explain type errors in more detail. "-feature", // Emit warning and location for usages of features that should be imported explicitly. "-unchecked" // Enable additional warnings where generated code depends on assumptions. ) ) /** Implement the `build` task define above. Most of this is really just to copy the index.html file around. */ lazy val browserProject: Project => Project = _.settings( build := { val artifacts = (Compile / fullOptJS / webpack).value val artifactFolder = (Compile / fullOptJS / crossTarget).value val jsFolder = baseDirectory.value / "src" / "main" / "js" val distFolder = baseDirectory.value / "build" distFolder.mkdirs() artifacts.foreach { artifact => val target = artifact.data.relativeTo(artifactFolder) match { case None => distFolder / artifact.data.name case Some(relFile) => distFolder / relFile.toString } Files.copy(artifact.data.toPath, target.toPath, REPLACE_EXISTING) } // copy public resources Files .walk(jsFolder.toPath) .filter(x => !Files.isDirectory(x)) .forEach(source => { source.toFile.relativeTo(jsFolder).foreach { relativeSource => val dest = distFolder / relativeSource.toString dest.getParentFile.mkdirs() Files.copy(source, dest.toPath, REPLACE_EXISTING) } }) // link the proper js bundle val indexFrom = baseDirectory.value / "src/main/js/index.html" val indexTo = distFolder / "index.html" val indexPatchedContent = { import collection.JavaConverters._ Files .readAllLines(indexFrom.toPath, IO.utf8) .asScala .map(_.replaceAllLiterally("-fastopt", "-opt")) .mkString("\n") } Files.write(indexTo.toPath, indexPatchedContent.getBytes(IO.utf8)) distFolder } ) // specify versions for all of reacts dependencies lazy val reactNpmDeps: Project => Project = _.settings( stTypescriptVersion := "3.9.3", stIgnore += "react-proxy", Compile / npmDependencies ++= Seq( "react" -> "18.2.0", "@types/react" -> "18.0.33", "react-dom" -> "18.2.0", "@types/react-dom" -> "18.0.11", "csstype" -> "2.6.11", "react-proxy" -> "1.1.8", "@types/prop-types" -> "15.7.3" ) ) lazy val withCssLoading: Project => Project = _.settings( /* custom webpack file to include css */ Compile / webpackConfigFile := Some((ThisBuild / baseDirectory).value / "custom.webpack.config.js"), Test / webpackConfigFile := None, // it is important to avoid the custom webpack config in tests to get them passing Compile / npmDevDependencies ++= Seq( "webpack-merge" -> "4.2.2", "css-loader" -> "3.4.2", "style-loader" -> "1.1.3", "file-loader" -> "5.1.0", "url-loader" -> "3.0.0" ) ) lazy val bundlerSettings: Project => Project = _.settings( Compile / fastOptJS / webpackExtraArgs += "--mode=development", Compile / fullOptJS / webpackExtraArgs += "--mode=production", Compile / fastOptJS / webpackDevServerExtraArgs += "--mode=development", Compile / fullOptJS / webpackDevServerExtraArgs += "--mode=production" ) // Used only by play-based projects lazy val playSettings: Project => Project = { _.enablePlugins(PlayScala) .disablePlugins(PlayLayoutPlugin) .settings( // docs are huge and unnecessary Compile / doc / sources := Nil, Compile / doc / scalacOptions ++= Seq( "-no-link-warnings" ), // remove play noisy warnings play.sbt.routes.RoutesKeys.routesImport := Seq.empty, libraryDependencies ++= Seq( guice, evolutions, jdbc, ws, "com.google.inject" % "guice" % "5.1.0" ), // test libraryDependencies ++= Seq( "org.scalatestplus.play" %% "scalatestplus-play" % "6.0.0-M6" % Test, "org.scalatestplus" %% "mockito-4-6" % "3.2.15.0" % Test ) ) } lazy val common = (crossProject(JSPlatform, JVMPlatform) in file("lib/common")) .configure(baseLibSettings, commonSettings) .jsConfigure(_.enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin, ScalablyTypedConverterPlugin)) .settings( libraryDependencies ++= Seq() ) .jvmSettings( libraryDependencies ++= Seq( "org.playframework" %% "play-json" % playJson, "net.wiringbits" %% "webapp-common" % webappUtils, "org.scalatest" %% "scalatest" % "3.2.16" % Test ) ) .jsSettings( useYarn := true, Test / fork := false, // sjs needs this to run tests stUseScalaJsDom := true, Compile / stMinimize := Selection.All, libraryDependencies ++= Seq( "io.github.cquiroz" %%% "scala-java-time" % scalaJavaTime, "org.playframework" %%% "play-json" % playJson, "net.wiringbits" %%% "webapp-common" % webappUtils, "org.scalatest" %%% "scalatest" % "3.2.16" % Test, "com.beachape" %%% "enumeratum" % enumeratum ) ) // shared apis lazy val api = (crossProject(JSPlatform, JVMPlatform) in file("lib/api")) .dependsOn(common) .configure(baseLibSettings, commonSettings) .jsConfigure(_.enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin, ScalablyTypedConverterPlugin)) .jvmSettings( libraryDependencies ++= Seq( "org.playframework" %% "play-json" % playJson, "com.softwaremill.sttp.client3" %% "core" % sttp, "com.softwaremill.sttp.tapir" %% "tapir-json-play" % tapir, "com.softwaremill.sttp.tapir" %% "tapir-sttp-client" % tapir ) ) .jsSettings( useYarn := true, Test / fork := false, // sjs needs this to run tests stUseScalaJsDom := true, Compile / stMinimize := Selection.All, libraryDependencies ++= Seq( "org.playframework" %%% "play-json" % playJson, "org.scalatest" %%% "scalatest" % "3.2.16" % Test, "com.beachape" %%% "enumeratum" % enumeratum, "com.softwaremill.sttp.client3" %%% "core" % sttp, "com.softwaremill.sttp.tapir" %%% "tapir-json-play" % tapir, "com.softwaremill.sttp.tapir" %%% "tapir-sttp-client" % tapir ) ) // shared on the ui only lazy val ui = (project in file("lib/ui")) .configure(baseLibSettings, commonSettings) .configure(_.enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin, ScalablyTypedConverterPlugin)) .dependsOn(api.js, common.js) .settings( name := "wiringbits-lib-ui", useYarn := true, Test / requireJsDomEnv := true, Test / fork := false, // sjs needs this to run tests stTypescriptVersion := "3.9.3", // material-ui is provided by a pre-packaged library stIgnore ++= List( "@mui/material", "@mui/icons-material", "@mui/joy", "@emotion/react", "@emotion/styled", "react-router", "react-router-dom" ), Compile / npmDependencies ++= Seq( "@mui/material" -> "5.11.16", "@mui/icons-material" -> "5.11.16", "@mui/joy" -> "5.0.0-alpha.74", "@emotion/react" -> "11.10.6", "@emotion/styled" -> "11.10.6", "react-router" -> "5.1.2", "react-router-dom" -> "5.1.2" ), stFlavour := Flavour.Slinky, stReactEnableTreeShaking := Selection.All, stUseScalaJsDom := true, Compile / stMinimize := Selection.All, libraryDependencies ++= Seq( "io.github.cquiroz" %%% "scala-java-time" % scalaJavaTime, "org.scala-js" %%% "scala-js-macrotask-executor" % "1.1.1", "com.olvind.st-material-ui" %%% "st-material-ui-icons-slinky" % "5.11.16", "net.wiringbits" %%% "slinky-utils" % webappUtils, "org.scalatest" %%% "scalatest" % "3.2.16" % Test, "com.beachape" %%% "enumeratum" % enumeratum ) ) lazy val server = (project in file("server")) .dependsOn(common.jvm, api.jvm) .configure(baseServerSettings, commonSettings, playSettings) .settings( name := "wiringbits-server", fork := true, Test / fork := true, // allows for graceful shutdown of containers once the tests have finished running libraryDependencies ++= Seq( "org.playframework.anorm" %% "anorm" % anorm, "org.playframework.anorm" %% "anorm-postgres" % anorm, "org.playframework" %% "play-json" % playJson, "org.postgresql" % "postgresql" % "42.6.0", "de.svenkubiak" % "jBCrypt" % "0.4.3", "commons-validator" % "commons-validator" % "1.7", "com.dimafeng" %% "testcontainers-scala-scalatest" % "0.40.16" % "test", "com.dimafeng" %% "testcontainers-scala-postgresql" % "0.40.16" % "test", "com.softwaremill.sttp.client3" %% "core" % sttp % "test", "com.softwaremill.sttp.client3" %% "async-http-client-backend-future" % sttp % "test", // "net.wiringbits" %% "admin-data-explorer-play-server" % webappUtils, "software.amazon.awssdk" % "ses" % "2.17.141", "jakarta.xml.bind" % "jakarta.xml.bind-api" % "4.0.0", "org.apache.commons" % "commons-text" % "1.10.0", // JAX-B dependencies for JDK 9+, required to use play sessions "javax.xml.bind" % "jaxb-api" % "2.3.1", "javax.annotation" % "javax.annotation-api" % "1.3.2", "javax.el" % "javax.el-api" % "3.0.0", "org.glassfish" % "javax.el" % "3.0.0", "com.beachape" %% "enumeratum" % enumeratum, "io.scalaland" %% "chimney" % chimney, "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapir, "com.softwaremill.sttp.tapir" %% "tapir-json-play" % tapir, "com.softwaremill.sttp.tapir" %% "tapir-play-server" % tapir, "org.apache.pekko" %% "pekko-stream" % "1.0.1" ) ) lazy val webBuildInfoSettings: Project => Project = _.enablePlugins(BuildInfoPlugin) .settings( buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion), buildInfoKeys ++= { val apiUrl = sys.env.get("API_URL") val values = Seq( "apiUrl" -> apiUrl ) // Logging these values is useful to make sure that the necessary settings // are being overriden when packaging the app. sLog.value.info(s"BuildInfo settings:\n${values.mkString("\n")}") values.map(t => BuildInfoKey(t._1, t._2)) }, buildInfoPackage := "net.wiringbits", buildInfoUsePackageAsPath := true ) lazy val web = (project in file("web")) .dependsOn(common.js, api.js, ui) .enablePlugins(ScalablyTypedConverterPlugin) .configure( baseWebSettings, browserProject, commonSettings, reactNpmDeps, withCssLoading, bundlerSettings, webBuildInfoSettings ) .settings( name := "wiringbits-web", useYarn := true, webpackDevServerPort := 8080, stFlavour := Flavour.Slinky, stReactEnableTreeShaking := Selection.All, stUseScalaJsDom := true, Compile / stMinimize := Selection.All, // material-ui is provided by a pre-packaged library stIgnore ++= List("@mui/material", "@mui/icons-material", "@mui/joy", "react-router", "react-router-dom"), Compile / npmDependencies ++= Seq( "@mui/material" -> "5.11.16", "@mui/icons-material" -> "5.11.16", "@mui/joy" -> "5.0.0-alpha.74", "@emotion/styled" -> "11.10.6", "@emotion/react" -> "11.10.6", "react-router" -> "5.1.2", "react-router-dom" -> "5.1.2", "react-google-recaptcha" -> "2.1.0", "@types/react-google-recaptcha" -> "2.1.0" ), libraryDependencies ++= Seq( "org.playframework" %%% "play-json" % playJson, "com.softwaremill.sttp.client3" %%% "core" % sttp, "org.scala-js" %%% "scala-js-macrotask-executor" % "1.1.1", "com.olvind.st-material-ui" %%% "st-material-ui-icons-slinky" % "5.11.16", "io.monix" %%% "monix-reactive" % "3.4.1", "com.softwaremill.sttp.tapir" %%% "tapir-sttp-client" % tapir ), libraryDependencies ++= Seq( "org.scalatest" %%% "scalatest" % "3.2.16" % Test ) ) lazy val root = (project in file(".")) .aggregate( common.jvm, common.js, api.jvm, api.js, ui, server, web ) .settings( publish := {}, publishLocal := {} ) addCommandAlias("dev-web", ";web/fastOptJS::startWebpackDevServer;~web/fastOptJS") ================================================ FILE: custom.webpack.config.js ================================================ var path = require("path"); var merge = require('webpack-merge'); var generated = require('./scalajs.webpack.config'); var local = { devServer: { // the historyAPIFallback allows react-router to work historyApiFallback: true, proxy: { // when a request to /api is done, we want to apply a proxy '/api': { changeOrigin: true, cookieDomainRewrite: 'localhost', target: 'http://localhost:9000', pathRewrite: { '^/api': '/'}, onProxyReq: (req) => { if (req.getHeader('origin')) { req.setHeader('origin', 'http://localhost:9000') } } } } }, resolve: { alias: { "js": path.resolve(__dirname, "../../../../src/main/js"), } }, module: { rules: [ { test: /\.css$/, use: ['style-loader', 'css-loader'] }, { test: /\.(ttf|eot|woff|png|jpg|glb|svg)$/, use: 'file-loader' }, { test: /\.(eot)$/, use: 'url-loader' } ] } } module.exports = merge(generated, local); ================================================ FILE: docs/README.md ================================================ # Wiringbits Scala WebApp template - Docs - [Setup development environment](./setup-dev-environment.md) - [Architecture](./architecture.md) - [Design desicions](./design-decisions.md) - [Learning material](./learning-material.md) - [Swagger integration](./swagger-integration.md). ## Diagrams The docs include diagrams created with plantuml, you will need to compile them after they are updated. Before you can compile those, you will require some dependencies: 1. `graphviz` which can be installed with `apt install graphviz` 2. Download `plantuml.jar` from the official [site](https://plantuml.com/starting). Then, you can execute this to generate them all: - `java -jar ~/Downloads/plantuml.jar ./diagram-sources -o ../assets/diagrams/` Consider using the [plantuml plugin for IntelliJ](https://plugins.jetbrains.com/plugin/7017-plantuml-integration/) when editing diagrams, this way, you can preview the changes. ================================================ FILE: docs/architecture.md ================================================ # Architecture The following diagrams illustrate the overall project architecture. **Disclaimer** There are some parts of the code (specially `server`) that do not fit the architecture completely, we plan to get there. ## Infrastructure ![Infrastructure architecture diagram](./assets/diagrams/architecture-infra.png) The [infra](../infra) project includes Ansible scripts to configure your own Server, be sure to check it out. Summary: - The app is usually hosted on a cloud server, let it be a DigitalOcean Droplet, an AWS EC2 instance, etc. - Ubuntu 20.04 is the target OS, newer versions would likely work without issues. - We recommend a managed database for Postgres. - While the diagram shows all components in a single server, we can easily separate nginx as a load balancer + many server instances. - Frontend apps are composed by static assets, stored at the server filesystem. - `nginx` is the entry point handling user requests, which has a TLS certificate (thanks to LetsEncrypt/CertBot), depending on the domain, it serves the files for the regular app. - When the server API is being invoked, `nginx` will route the traffic to the server app. - The server app connects to postgres and external services when necessary (like AWS SES). - AWS SES is being used to send emails. ## Modules ![Modules architecture diagram](./assets/diagrams/architecture-modules.png) The application modules make sure to share code when possible, some of them cross-compile to Scala.js: - [LibCommon](../lib/common) has code shared over the whole application (Scala/Scala.js), it consists mostly of models. - [LibAPI](../lib/api) has code shared to the `server` (Scala) and the web apps (Scala.js), it includes all the models defining the request/response for the `server` endpoints, it also includes the API client that web apps use to invoke the `server` app. - [LibUI](../lib/ui) has code shared between the webapps (Scala.js), while it is mostly empty, it can include reusable components for the UI. - [Web](../web) has the web app code for the regular user application (Scala.js). - [Server](../server) has the code for the server app (Scala). ## Server ![Server architecture diagram](./assets/diagrams/architecture-server.png) The server architecture is a mix taking advantage from Domain Driven Design (DDD), Hexagonal Architecture, and, Clean Architecture. **NOTE** While current diagrams do not display it, there is are some core models that are shared on all layers. Let's visit the layers from the top to the bottom. ### Controllers ![Controllers architecture diagram](./assets/diagrams/architecture-server-controllers.png) Controllers is the entry point layer for the user requests, it is tied to the http-framework, it has these responsibilities: - Decode requests into typed models. - Authenticate requests. - Delegate the work to the actions layer. - Encode responses. ### Actions ![Actions architecture diagram](./assets/diagrams/architecture-server-actions.png) Actions is the non-framework entrypoint, for example, if the http-layer gets another implementation, there shouldn't be a need to update Actions, it has these responsibilities: - Actions have 0 knowledge about anything on the `Controllers` layer. - Each action is represented by a class/file, which has a single public method, commonly called `apply` so that Scala syntactic sugar can be used, like `getCurrentUserAction()`. - An action can’t depend on another action, when we get to such need, we would usually extract the common functionality into a service which can be invoked from many actions. - Checks authorization rules. - Runs complex validations (like the ones requiring interactions with another layers, checking whether an email is already registered could be one example). - Usually, an action would combine work from other layers, like reading data from a Repository and submitting it to an External API. ### Services ![Services architecture diagram](./assets/diagrams/architecture-server-services.png) The Services layer is composed of business rules Combines the work from Repositories and ExternalApis Expose functions handling business rules when they get complex enough to fit in Actions: - Services have 0 knowledge about Actions/Controllers. - Combines the work from Repositories and ExternalApis ### External APIs ![External APIs architecture diagram](./assets/diagrams/architecture-server-external-apis.png) External APIs layer holds anything necessary to communicate with external services, for example, AWS SES. When working on this layer, it is a good idea to consider that any changes should be extractable into an isolated library, hence, business rules must not go here. Of course, this layer won't know anything about Controllers/Actions/Services/Repositories. ### Repositories ![Repositories architecture diagram](./assets/diagrams/architecture-server-repositories.png) Repositories is the entry point to the storage layer, given that we use Postgres, this layer is in charge of choosing when an operation requires a transaction. Given the transactional capabilities, there are times when a repository could deal with small business rules, the ones that matter for data integrity. For example, creating a user could require to store a token used to verify the user's email. Details: - The work done by Repositories is mostly composing DAOs. - Repositories do not know any other layer besides DAOs. - This layer is **NOT** responsible of validating input format. - This layer could validate whether an item already exists. ### DAOs ![DAOs architecture diagram](./assets/diagrams/architecture-server-daos.png) The DAOs (Data-Access-Object) layer is the one that knows how to deal with database tables/rows, transforming operations into SQL statements, as well as parsing results into data models. DAOs don't know about any other layer. ================================================ FILE: docs/design-decisions.md ================================================ # Design decisions This document explains why we took certain design decisions on how the project is built/structured. ## 2022/Aug - Avoid default parameter in most cases We commonly deal with models that are similar but belong to a different domain, [chimney](https://scalalandio.github.io/chimney) help us to transform those models from one domain to another, while this tool is handy, it does not play well with default values in arguments. Take this snippet as an example: ```scala case class CreateUserApiRequest(name: String, age: Option[Int]) case class CreateUserData(name: String, yearsOld: Option[Int] = None) def transform(request: CreateUserApiRequest): CreateUserData = request.into[CreateUserData].transform ``` While the `transform` function would succeed, the `age` value will never become the `yearsOld` value, if there wasn't a default value, we'd get a compile error which would give us a chance to fix the problem (`request.into[CreateUserData].withFieldRenamed(_.age, _.yearsOld).transform`). Still, there can be exceptions: - The http API layer usually gets default values when adding a new parameter to an API method, this way, we keep backwards compatibility to support old API clients. ## 2022/Apr - Naming conventions for api/data models The project follows some principles from DDD (Domain Driven Design), we use different models for different layers even if they look quite similar. For example, when creating an endpoint that creates a user, we'd end up with models like `models.api.CreateUser` and `models.data.CreateUser`, in theory, we could be disciplined enough to follow the conventions and refer to the models with the package from the domain we are interested in, like: ```scala import models._ def createUserApi(model: api.CreateUser) def createUserData(model: data.CreateUser) ``` Unfortunately, IDE's automatically import the models from specific packages, which commonly causes conflicts because IDE imported the data model while we require the api one, there are pieces where we even need to deal with both. Then, it seems more practical to just include the domain name at the model name instea, like `models.data.CreateUserData`, this way, IDE's won't have ambiguous choices. ## 2022/Jan/23 - Avoid creating postgres extensions in evolution scripts While it is very handy to keep all the necessary sql operations at the evolution scripts, it is a good practice to limit the permissions for the database user, in fact, in AWS RDS, the default user won't be able to create some extensions. Solving such an issue can be annoying, hence, the pain is being shifted to the local environments instead. In short, when creating a local database, you will see yourself creating extensions manually like `CREATE EXTENSION CITEXT;` Ref: 0439d7b3159e01f886ceeb3f0ff0d2d471f5e304 ## Undated - Do not use `Downs` in evolutions From experience, downs can become annoying, imagine that you are trying branch `A` while another developer pushes a change to an existing evolution which includes downs, your data will be destroyed without any confirmation, on the other hand, when there are no downs, you will be required to throw away the data manually, in theory, we could get this behavior by updating `application.conf` but we have found that avoiding downs tend to work better. ================================================ FILE: docs/diagram-sources/architecture-infra.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Infrastructure Diagram skinparam { ArrowColor Red linetype ortho } cloud Cloud { database Postgres rectangle UbuntuServer { node ScalaServerApp node nginx folder FileSystem { file WebAssets file AdminAssets } } component EmailService ScalaServerApp -> EmailService nginx -> WebAssets nginx --> AdminAssets nginx --> ScalaServerApp ScalaServerApp --> Postgres } person RegularUser person AdminUser RegularUser -> nginx AdminUser --> nginx @enduml ================================================ FILE: docs/diagram-sources/architecture-modules.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Module Diagram skinparam { ArrowColor Red } package LibCommon { [Typed models shared everywhere\n* Scala/Scala.js] } package LibUI { [Code shared on UI apps (web/admin)\n* Scala.js] } package LibAPI { [REST API client and models\n* Scala/Scala.js] } package WebApp { [The main web app\n* Scala.js] } package AdminApp { [The admin web app\n* Scala.js] } package ServerApp { [The server side app\n* Scala] } WebApp .left....> LibUI : uses WebApp .left....> LibAPI : uses AdminApp .right.> LibUI : uses AdminApp .right.> LibAPI : uses ServerApp .> LibAPI : uses LibUI .up..> LibCommon : uses LibAPI .down.> LibCommon : uses @enduml ================================================ FILE: docs/diagram-sources/architecture-server-actions.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Server Architecture - Actions skinparam { linetype ortho } skinparam component { BackgroundColor LightBlue } skinparam rectangle { BackgroundColor White } rectangle Controllers { component Actions { rectangle Services { rectangle ExternalApis { rectangle ExternalApiClients { rectangle ExternalApiModels } } rectangle Repositories { rectangle DAOs { rectangle DataModels } } } } } @enduml ================================================ FILE: docs/diagram-sources/architecture-server-controllers.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Server Architecture - Controllers skinparam { linetype ortho } skinparam component { BackgroundColor LightBlue } skinparam rectangle { BackgroundColor White } component Controllers { rectangle Actions { rectangle Services { rectangle ExternalApis { rectangle ExternalApiClients { rectangle ExternalApiModels } } rectangle Repositories { rectangle DAOs { rectangle DataModels } } } } } @enduml ================================================ FILE: docs/diagram-sources/architecture-server-daos.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Server Architecture - DAOs skinparam { linetype ortho } skinparam component { BackgroundColor LightBlue } skinparam rectangle { BackgroundColor White } rectangle Controllers { rectangle Actions { rectangle Services { rectangle ExternalApis { rectangle ExternalApiClients { rectangle ExternalApiModels } } rectangle Repositories { component DAOs { rectangle DataModels } } } } } @enduml ================================================ FILE: docs/diagram-sources/architecture-server-external-apis.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Server Architecture - External APIs skinparam { linetype ortho } skinparam component { BackgroundColor LightBlue } skinparam rectangle { BackgroundColor White } rectangle Controllers { rectangle Actions { rectangle Services { component ExternalApis { rectangle ExternalApiClients { rectangle ExternalApiModels } } rectangle Repositories { rectangle DAOs { rectangle DataModels } } } } } @enduml ================================================ FILE: docs/diagram-sources/architecture-server-repositories.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Server Architecture - Repositories skinparam { linetype ortho } skinparam component { BackgroundColor LightBlue } skinparam rectangle { BackgroundColor White } rectangle Controllers { rectangle Actions { rectangle Services { rectangle ExternalApis { rectangle ExternalApiClients { rectangle ExternalApiModels } } component Repositories { rectangle DAOs { rectangle DataModels } } } } } @enduml ================================================ FILE: docs/diagram-sources/architecture-server-services.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Server Architecture - Services skinparam { linetype ortho } skinparam component { BackgroundColor LightBlue } skinparam rectangle { BackgroundColor White } rectangle Controllers { rectangle Actions { component Services { rectangle ExternalApis { rectangle ExternalApiClients { rectangle ExternalApiModels } } rectangle Repositories { rectangle DAOs { rectangle DataModels } } } } } @enduml ================================================ FILE: docs/diagram-sources/architecture-server.puml ================================================ @startuml Title Wiringbits Scala WebApp Template - Server Architecture skinparam { linetype ortho } rectangle Controllers { rectangle Actions { rectangle Services { rectangle ExternalApis { rectangle ExternalApiClients { rectangle ExternalApiModels } } rectangle Repositories { rectangle DAOs { rectangle DataModels } } } } } @enduml ================================================ FILE: docs/learning-material.md ================================================ # Learning material These are the tools used by the template, you don't need to master them all but being familiar with them definitely helps: - [Scala](https://scala-lang.org/), we use Scala 2.13 because it has great tooling support, we'll eventually upgrade to Scala 3. - [Scala.js](https://www.scala-js.org/) powers the frontend side. - [Scalablytyped](https://scalablytyped.org/) generates the Scala facades to interact with JavaScript libraries by converting TypeScript definitions to Scala.js facades. - [yarn](https://yarnpkg.com) (v1) as the JavaScript package manager. - [React](https://reactjs.org/) as the view library. - [Slinky](https://slinky.dev/) being the Scala wrapper for React. - [Webpack](https://webpack.js.org) to bundle the web apps. - [Scalajs bundler](https://scalacenter.github.io/scalajs-bundler/) being the Scala wrapper for Webpack. - [Material UI v3](https://v3.material-ui.com/) as the Material UI framework on top of React (hoping to upgrade to v5 when Scalablytyped supports it). - [Play Framework](https://playframework.com/) as the backend framework, used for the REST API. - [sttp](https://github.com/softwaremill/sttp/) as the REST API client. - [react-router](https://www.npmjs.com/package/react-router) is the frontend routing library. - [play-json](https://github.com/playframework/play-json/) is the JSON library. - [ansible](https://ansible.com/) as the tool for deploying everything to a VM. - [nginx](https://nginx.org/en/) as the reverse proxy for handling the internet traffic, as well as the authentication mechanism for admin endpoints. - [GitHub](https://github.com/features/actions) actions integration so that you have a way to get every commit tested. ================================================ FILE: docs/setup-dev-environment.md ================================================ # Setup development environment Let's get started setting up your development environment. **NOTE** The instructions will work better on Linux/Mac, there could be some details that do not work on Windows (help wanted). There are demo videos while configuring a local environment in Ubuntu 22.04, covering everything, from the JDK install step until the application runs: http://onboarding.wiringbits.net **[Table of Contents](http://tableofcontent.eu)** - [Compile-time dependencies](compile-time-dependencies) - [Runtime dependencies](#runtime-dependencies) - [Postgres](#postgres) - [AWS Email Service](#aws-email-service) - [direnv](#direnv) - [Custom config](#custom-config) - [Run](#run) - [Test dependencies](#test-dependencies) - [Deployment setup](#deployment-setup) ## Compile-time dependencies 1. Clone the repository ```shell git clone git@github.com:wiringbits/scala-webapp-template.git ``` 2. JDK setup, we highly recommend [SDKMAN](https://sdkman.io/) due to its simplicity to switch between different jdk versions, run `sdk env` to pick the project's suggested jdk or edit sdkman config (`~/.sdkman/etc/config`) to set `sdkman_auto_env=true` which picks the project's jdk automatically: ```shell # sdkman_auto_env=true would pick the right jdk when moving into the project's directory $ cd scala-webapp-template Using java version 11.0.16-tem in this shell. # otherwise, you can set the jdk manually with `sdk env` $ sdk env Using java version 11.0.16-tem in this shell. # verify your version $ java -version openjdk version "11.0.16" 2022-07-19 OpenJDK Runtime Environment Temurin-11.0.16+8 (build 11.0.16+8) OpenJDK 64-Bit Server VM Temurin-11.0.16+8 (build 11.0.16+8, mixed mode) ``` **Hint**: [.sdkmanrc](../.sdkmanrc) defines our suggested jdk. 3. Install sbt, run `sdk install sbt` or follow the official [instructions](https://www.scala-sbt.org/download.html). 4. Node setup, we highly recommend [nvm](https://github.com/nvm-sh/nvm) due to its simplicity to switch between different node versions, run `nvm use` to pick the project's suggested node version, or follow [nvm-instructions](https://github.com/nvm-sh/nvm#automatically-call-nvm-use) to pick the right version automatically: ```shell # nvm can pick the right node version when moving into the project's directory $ cd scala-webapp-template Found '~/scala-webapp-template/.nvmrc' with version <16.7.0> Now using node v16.7.0 (npm v7.20.3) # otherwise, you can set the node version manually with `nvm use` $ nvm use Found '~/scala-webapp-template/.nvmrc' with version <16.7.0> Now using node v16.7.0 (npm v7.20.3) # verify your version $ node --version v16.7.0 ``` **Hint**: [.nvmrc](../.nvmrc) defines our suggested node version. 5. Install [yarn](https://classic.yarnpkg.com/en/docs/install), most times, `npm install --global yarn` should be enough (we have tested this with yarn v1), be aware that this must installed at the node version you set in the previous step: ```shell # yarn -version 1.22.11 ``` That's it, now just run `sbt compile` to compile the project (the first time it could take several minutes). ## Runtime dependencies ### Postgres PostgreSQL is the only required runtime dependency, it can be installed by following the official [docs](https://www.postgresql.org/download/), it can also be run with docker. What matters is that you can connect to it with `psql -U postgres -h 127.0.0.1` (`postgres` is the default username, if you changed it, you must update the command too). **Hint**: We use `127.0.0.1` to force `psql` to use a TCP connection instead of a unix socket which (`localhost`), this happens because the app connects to postgres through TCP. Once you are connected into postgres, we'll create a database for our app, and, any necessary dependencies: ```postgres-sql -- create a database for the app CREATE DATABASE wiringbits_db; -- connect to it \c wiringbits_db; -- create an extension used by the app CREATE EXTENSION IF NOT EXISTS CITEXT; ``` ### AWS Email Service We are using [SES](https://aws.amazon.com/ses/) to send emails (like the account verification email, password recovery, etc), what matters is to get AWS keys with access to SES. This is an optional requirement, if you decide to ignore it, everything should work fine. ### direnv [direnv](https://direnv.net/) is super handy to define your custom app settings without modifying any of the application files tracked by git (sorry windows). It is optional but highly recommended. In short, it will allow you to create a `.envrc` file with all your custom settings, which will be loaded when moving into the project's directory (don't forget the [hook](https://direnv.net/docs/hook.html)) ### Custom config It is very likely that the default settings won't work for you, at least, you will be expected to update the settings to match your postgres credentials (and SES if used). There are two ways: 1. Update [application.conf](../server/src/main/resources/application.conf) Update application.conf to set your environment specific values (just avoid committing these). 2. Use `direnv`, create `.envrc` to export environment variables for your custom settings (don't forget to run `direnv allow` after that), get inspired by this example, it is unlikely that you will need to change any other settings: ```shell # postgres settings export POSTGRES_HOST="127.0.0.1" export POSTGRES_DATABASE="wiringbits_db" export POSTGRES_USERNAME="postgres" export POSTGRES_PASSWORD="postgres" # emails export EMAIL_SENDER_ADDRESS="test@wiringbits.net" export EMAIL_PROVIDER=none # aws, required only if the email provider is AWS export AWS_REGION="us-west-2" export AWS_ACCESS_KEY_ID="REPLACE_ME" export AWS_SECRET_ACCESS_KEY="REPLACE_ME" ``` ### Run Time to run the app: 1. `sbt server/run` launches the backend, which is started once you launch a request (like `curl localhost:9000`), swagger-ui available at `http://localhost:9000/docs/index.html`. 2. `sbt dev-web` launches the main web app at `localhost:8080` **Hints**: - All these apps are automatically reloaded on code changes. - The server app prints the settings after starting, double check that they match your custom settings. - By default, outgoing emails are logged, be sure to check those to look for the email verification links. ## Test dependencies Docker is the only required dependency to run the integration tests. Each integration test mounts its own clean database through docker, which removes the need to worry about tests polluting data. Check the official [docs](https://docs.docker.com/engine/install/) to get it installed, it is ideal that docker can be executed without `sudo`, when this command works, tests must run too: `docker run hello-world` Commands: 1. `sbt test` runs all the tests. 2. `sbt server/test` runs the server tests only. **Hint** IntelliJ allows running all tests in a file, or a single test through its UI, which is very handy. ## Deployment setup Check the [infra](../infra/README.md) project. ================================================ FILE: docs/swagger-integration.md ================================================ # Swagger integration We have a swagger integration so that users can explore the server API through swagger-ui. Some highlights: - We are using [tapir](https://tapir.softwaremill.com/) which integrates an [open-api](https://tapir.softwaremill.com/en/latest/docs/openapi.html) module for swagger. - Swagger-ui is exposed locally at [http://localhost:9000/docs](http://localhost:9000/docs). - Be sure to check existing [endpoints](../lib/api/shared/src/main/scala/net/wiringbits/api/endpoints) to see real examples. - `Option[T]` values are supported, sending a json without the key and value will be interpreted as `None`, otherwise, `Some(value)` will be sent. ## Creating an endpoint definition We have to define our endpoints at the [endpoints](../lib/api/shared/src/main/scala/net/wiringbits/api/endpoints) package, for example: ```scala val basicPostEndpoint = endpoint .post("basic") // points to POST http://localhost:9000/basic .tag("Misc") // tags the endpoint as "Misc" on swagger-ui .in( jsonBody[Basic.Request].example( // expects a JSON body of type BasicGet.Request with example values BasicGet.Request( name = "Alexis", email = "alexis@wiringbits.net" ) ) ) .out( jsonBody[Basic.Response].example( // returns a JSON body of type BasicGet.Response with example values BasicGet.Response( message = "Hello Alexis!" ) ) ) ``` Api models must have an `implicit Schema` defined, for example: ```scala Schema .derived[Response] .name(Schema.SName("BasicResponse")) .description("Says hello to the user") ``` And then integrate the endpoint to the [ApiRouter](../server/src/main/scala/controllers/ApiRouter.scala) file: ```scala object ApiRouter { private def routes(implicit ec: ExecutionContext): List[AnyEndpoint] = List( basicPostEndpoint ) } ``` ## Endpoint user authentication details We use Play Session cookie for user authentication, this is a cookie that's stored securely and is sent on every request, this cookie is used to identify the user and to check if the user is authenticated. Any endpoint that requieres user authentication must include our implicit [userAuth](../lib/api/shared/src/main/scala/net/wiringbits/api/endpoints/package.scala) handler and convert the endpoint `val` to `def` that receives an implicit handler `implicit authHandler: ServerRequest => Future[UUID]`, for example: [//]: # (TODO: change Future[UUID] to Future[UserId] after mergin typo) ```scala def basicEndpoint(implicit authHandler: ServerRequest => Future[UUID]) = endpoint.get .in(userAuth) ``` For more information about creating endpoints, please check the [tapir documentation](https://tapir.softwaremill.com/en/latest/). ================================================ FILE: infra/.gitignore ================================================ apps/ .vault prod-hosts.ini config/server/demo.env.j2 .scala-build/ ================================================ FILE: infra/README.md ================================================ # Infra This project includes the necessary scripts and configuration files to deploy the applications to cloud servers (like a DigitalOcean Droplet, or an Amazon EC2 instance). ## Requirements The scripts work with [Ansible](https://www.ansible.com/) `2.9.23`, it is likely that other versions would work too. There [test-hosts.ini](./test-hosts.ini) inventory file is an example configuration that is used to deploy the demo apps, it includes the necessary comments for adapting it to your own environment. A postgres database is required, you can use either a managed database or set up a local one by following these [instructions](./setup-postgres.md). Modify the [server](./config/server/dev.env.j2) configuration that are required while deploying it. The scripts are tested in Ubuntu 20.04 with paswordless sudo (meaning that `sudo ls` works without a password), they likely works in other Ubuntu based operating systems. ## Playbooks There are many playbooks involved to let you deploy the necessary pieces only: - [server.yml](./server.yml) deploys the [server](../server) application to the cloud server. - [web.yml](./web.yml) deploys the [web](../web) application to the cloud server. - [admin.yml](./admin.yml) deploys the [admin](../admin) application to the cloud server. - [nginx.yml](./nginx.yml) installs nginx in the cloud server, which is used to serve the requests from the public internet. - [nginx_site_admin.yml](./nginx_site_admin.yml) exposes the [admin](../admin) application to the internet, also, it gets and configures a SSL certificate to access it using https, to run this, nginx should be already deployed, also, a domain should be linked to your cloud server. - [nginx_site_web.yml](./nginx_site_web.yml) exposes the [web](../web) application to the internet, also, it gets and configures a SSL certificate to access it using https, to run this, nginx should be already deployed, also, a domain should be linked to your cloud server. **NOTE** You will likely run the nginx stuff only once. After setting up everything: 1. Deploy nginx: `ansible-playbook -i test-hosts.ini nginx.yml` 2. Deploy the apps with: `ansible-playbook -i test-hosts.ini server.yml web.yml admin.yml` 3. Expose the apps to the internet with: `ansible-playbook -i test-hosts.ini nginx_site_admin.yml nginx_site_web.yml` Once everything is ready, run the first step to deploy the apps again (or use a single playbook to deploy a single app instead). ================================================ FILE: infra/admin.yml ================================================ --- - hosts: frontend gather_facts: no vars: - webapp_source_zip: "apps/admin.zip" - webapp_remote_file: "admin.zip" tasks: - name: Build the application shell: ./scripts/build-admin.sh {{ admin_api_url }} delegate_to: 127.0.0.1 - name: Install unzip become: yes apt: name: unzip state: latest update_cache: yes - name: Upload the application synchronize: src: "{{ webapp_source_zip }}" dest: "{{ webapp_remote_file }}" - name: Create the admin data directory become: yes file: path: "{{ webapp_admin_assets_directory }}" state: directory owner: www-data group: www-data - name: Unpack the application become: yes unarchive: remote_src: yes src: admin.zip dest: "{{ webapp_admin_assets_directory }}" - name: Set the permissions become: yes file: dest: "{{ webapp_admin_assets_directory }}" owner: www-data group: www-data recurse: yes - name: Reload nginx config become: yes service: name: nginx state: reloaded ================================================ FILE: infra/config/nginx/admin-app-htpasswd ================================================ demo:$apr1$8bJ.PWGf$3IxfPeFYxWRQkCw3yvRfp0 ================================================ FILE: infra/config/nginx/admin_app_site.j2 ================================================ server { listen 80; auth_basic "Administrators only"; auth_basic_user_file {{ nginx_admin_settings_file }}; server_name {{ admin_app_domain }}; root {{ webapp_admin_assets_directory }}; index index.html; set_real_ip_from 10.0.0.0/8; real_ip_header X-Real-IP; real_ip_recursive on; client_body_buffer_size 128k; proxy_connect_timeout 60; proxy_send_timeout 180s; proxy_read_timeout 180s; proxy_buffer_size 64k; proxy_busy_buffers_size 128k; proxy_buffers 64 16k; location /api { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Host $remote_addr; proxy_set_header X-Forwarded-User $remote_user; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Connection ""; rewrite ^/api/(.*) /$1 break; proxy_pass http://localhost:{{ server_app_port }}; } # caching static assets location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ { expires 7d; } location / { try_files $uri $uri/ /index.html; } } ================================================ FILE: infra/config/nginx/mime.types ================================================ types { text/html html htm shtml; text/css css; text/xml xml; image/gif gif; image/jpeg jpeg jpg; application/javascript js; application/atom+xml atom; application/rss+xml rss; text/mathml mml; text/plain txt; text/vnd.sun.j2me.app-descriptor jad; text/vnd.wap.wml wml; text/x-component htc; image/png png; image/tiff tif tiff; image/vnd.wap.wbmp wbmp; image/x-icon ico; image/x-jng jng; image/x-ms-bmp bmp; image/svg+xml svg svgz; image/webp webp; application/font-woff woff; application/java-archive jar war ear; application/json json; application/mac-binhex40 hqx; application/msword doc; application/pdf pdf; application/postscript ps eps ai; application/rtf rtf; application/vnd.apple.mpegurl m3u8; application/vnd.ms-excel xls; application/vnd.ms-fontobject eot; application/vnd.ms-powerpoint ppt; application/vnd.wap.wmlc wmlc; application/vnd.google-earth.kml+xml kml; application/vnd.google-earth.kmz kmz; application/x-7z-compressed 7z; application/x-cocoa cco; application/x-java-archive-diff jardiff; application/x-java-jnlp-file jnlp; application/x-makeself run; application/x-perl pl pm; application/x-pilot prc pdb; application/x-rar-compressed rar; application/x-redhat-package-manager rpm; application/x-sea sea; application/x-shockwave-flash swf; application/x-stuffit sit; application/x-tcl tcl tk; application/x-x509-ca-cert der pem crt; application/x-xpinstall xpi; application/xhtml+xml xhtml; application/xspf+xml xspf; application/zip zip; application/octet-stream bin exe dll; application/octet-stream deb; application/octet-stream dmg; application/octet-stream iso img; application/octet-stream msi msp msm; application/vnd.openxmlformats-officedocument.wordprocessingml.document docx; application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx; application/vnd.openxmlformats-officedocument.presentationml.presentation pptx; audio/midi mid midi kar; audio/mpeg mp3; audio/ogg ogg; audio/x-m4a m4a; audio/x-realaudio ra; video/3gpp 3gpp 3gp; video/mp2t ts; video/mp4 mp4; video/mpeg mpeg mpg; video/quicktime mov; video/webm webm; video/x-flv flv; video/x-m4v m4v; video/x-mng mng; video/x-ms-asf asx asf; video/x-ms-wmv wmv; video/x-msvideo avi; } ================================================ FILE: infra/config/nginx/nginx.conf ================================================ user www-data; worker_processes auto; pid /run/nginx.pid; events { worker_connections 4096; # multi_accept on; } http { ## # Basic Settings ## sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 300s; keepalive_requests 2000; types_hash_max_size 2048; # server_tokens off; # server_names_hash_bucket_size 64; # server_name_in_redirect off; include /etc/nginx/mime.types; default_type application/octet-stream; ## # SSL Settings ## ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE ssl_prefer_server_ciphers on; ## # Logging Settings ## access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; ## # Gzip Settings ## gzip on; gzip_disable "msie6"; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_buffers 16 8k; gzip_http_version 1.1; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_min_length 512; ## # Virtual Host Configs ## include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*; } ================================================ FILE: infra/config/nginx/preview_admin_app_site.j2 ================================================ server { # listen [::]:443 ssl ipv6only=on; # managed by Certbot listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/dev.wiringbits.mx/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/dev.wiringbits.mx/privkey.pem; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot auth_basic "Administrators only"; auth_basic_user_file {{ nginx_admin_settings_file }}; server_name {{ admin_app_domain }}; root {{ webapp_admin_assets_directory }}; index index.html; set_real_ip_from 10.0.0.0/8; real_ip_header X-Real-IP; real_ip_recursive on; client_body_buffer_size 128k; proxy_connect_timeout 60; proxy_send_timeout 180s; proxy_read_timeout 180s; proxy_buffer_size 64k; proxy_busy_buffers_size 128k; proxy_buffers 64 16k; location /api { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Host $remote_addr; proxy_set_header X-Forwarded-User $remote_user; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Connection ""; rewrite ^/api/(.*) /$1 break; proxy_pass http://localhost:{{ server_app_port }}; } # caching static assets location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ { expires 7d; } location / { try_files $uri $uri/ /index.html; } } server { if ($host = {{ admin_app_domain }}) { return 301 https://$host$request_uri; } # managed by Certbot listen 80; server_name {{ admin_app_domain }}; return 404; # managed by Certbot } ================================================ FILE: infra/config/nginx/preview_web_app_site.j2 ================================================ server { # listen [::]:443 ssl ipv6only=on; # managed by Certbot listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/dev.wiringbits.mx/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/dev.wiringbits.mx/privkey.pem; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot server_name {{ web_app_domain }}; root {{ webapp_assets_directory }}; index index.html; set_real_ip_from 10.0.0.0/8; real_ip_header X-Real-IP; real_ip_recursive on; client_body_buffer_size 128k; proxy_connect_timeout 60; proxy_send_timeout 180s; proxy_read_timeout 180s; proxy_buffer_size 64k; proxy_busy_buffers_size 128k; proxy_buffers 64 16k; # swagger docs location /swagger.json { proxy_pass http://localhost:{{ server_app_port }}; } # the admin api is only reachable when providing the necessary credentials location /api/admin { auth_basic "Administrators only"; auth_basic_user_file {{ nginx_admin_settings_file }}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Host $remote_addr; proxy_set_header X-Forwarded-User $remote_user; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Connection ""; rewrite ^/api/(.*) /$1 break; proxy_pass http://localhost:{{ server_app_port }}; } location /api { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Host $remote_addr; proxy_set_header X-Forwarded-User $remote_user; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Connection ""; rewrite ^/api/(.*) /$1 break; proxy_pass http://localhost:{{ server_app_port }}; } # FIXME: This prevents returning the static resources from the app, like /api/swagger.json # caching static assets # location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ { # expires 7d; # } location / { try_files $uri $uri/ /index.html; } } server { if ($host = {{ web_app_domain }}) { return 301 https://$host$request_uri; } # managed by Certbot listen 80; server_name {{ web_app_domain }}; return 404; # managed by Certbot } ================================================ FILE: infra/config/nginx/web_app_site.j2 ================================================ server { listen 80; server_name {{ web_app_domain }}; root {{ webapp_assets_directory }}; index index.html; set_real_ip_from 10.0.0.0/8; real_ip_header X-Real-IP; real_ip_recursive on; client_body_buffer_size 128k; proxy_connect_timeout 60; proxy_send_timeout 180s; proxy_read_timeout 180s; proxy_buffer_size 64k; proxy_busy_buffers_size 128k; proxy_buffers 64 16k; # swagger docs location /swagger.json { proxy_pass http://localhost:{{ server_app_port }}; } # the admin api is only reachable when providing the necessary credentials location /api/admin { auth_basic "Administrators only"; auth_basic_user_file {{ nginx_admin_settings_file }}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Host $remote_addr; proxy_set_header X-Forwarded-User $remote_user; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Connection ""; rewrite ^/api/(.*) /$1 break; proxy_pass http://localhost:{{ server_app_port }}; } location /api { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Host $remote_addr; proxy_set_header X-Forwarded-User $remote_user; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Connection ""; rewrite ^/api/(.*) /$1 break; proxy_pass http://localhost:{{ server_app_port }}; } # FIXME: This prevents returning the static resources from the app, like /api/swagger.json # caching static assets # location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ { # expires 7d; # } location / { try_files $uri $uri/ /index.html; } } ================================================ FILE: infra/config/server/dev.env.j2 ================================================ POSTGRES_DATABASE="server_db" POSTGRES_USERNAME="db_user" POSTGRES_PASSWORD="REPLACE_ME" POSTGRES_HOST="127.0.0.1" PLAY_APPLICATION_SECRET="REPLACE_ME" JWT_SECRET="REPLACE_ME" JWT_ENFORCED=false PLAY_SESSION_SECURE=true PLAY_SESSION_DOMAIN="{{ web_app_domain }}" APP_ALLOWED_HOST_1="{{ web_app_domain }}" APP_ALLOWED_HOST_2="{{ admin_app_domain }}" AWS_REGION="us-east-1" AWS_ACCESS_KEY_ID="REPLACE_ME" AWS_SECRET_ACCESS_KEY="REPLACE_ME" EMAIL_SENDER_ADDRESS="template@wiringbits.net" WEBAPP_HOST="https://template-demo.wiringbits.net" USER_TOKENS_HMAC_SECRET="REPLACE_ME" ================================================ FILE: infra/config/server/server.service.j2 ================================================ [Unit] Description={{ app_systemd_service_name }} [Service] Type=simple WorkingDirectory={{ app_home }}/{{ app_directory_name }} StandardOutput=tty StandardError=tty EnvironmentFile={{ app_env_config_file }} LimitNOFILE=65535 User={{ app_user }} ExecStart={{ app_home }}/{{ app_directory_name }}/bin/{{ app_startup_script }} -Dpidfile.path=/dev/null -Dhttp.port={{ server_app_port }} Restart=on-failure [Install] WantedBy=multi-user.target ================================================ FILE: infra/demo-hosts.ini ================================================ [webapp] [webapp:vars] ansible_user=ubuntu ansible_ssh_extra_args='-o StrictHostKeyChecking=no' server_app_port=9000 # the domain where the main app is going to run # you are expected to have already created a DNS "A" record pointing to the server that will host the app web_app_domain="template-demo.wiringbits.net" # the domain where the admin app is going to run # you are expected to have already created a DNS "A" record pointing to the server that will host the app admin_app_domain="template-demo-admin.wiringbits.net" [webapp:children] backend frontend [backend] backend-server ansible_host=64.227.100.33 [backend:vars] # this is where the environment variables required by the app are defined # it could be kept encrypted by using ansible-vault app_env_config_source=config/server/demo.env.j2 # defines the systemd service used to run the app app_systemd_service_source=config/server/server.service.j2 # the service name used to register the service in systemd, for example, # restarting the app would be done by invoking: service app-server restart app_systemd_service_name="wiringbits-server" # the directory name where the app is stored after building it # this depends on your app name, get it by running "sbt server/dist" in the app's source # the last logs will display a line like: # [info] Your package is ready in .../server/target/universal/wiringbits-server-0.1.0-SNAPSHOT.zip # the last part is the source name, remove the zip extension and that's the directory name, # remove the version from the directory name and that's the startup script app_source_name="wiringbits-server-0.1.0-SNAPSHOT.zip" app_directory_name="wiringbits-server-0.1.0-SNAPSHOT" app_startup_script="wiringbits-server" # user/group/home used to store the app app_user="play" app_group="play" app_home="/home/play/app" app_env_config_file="/home/play/app/.env" [frontend] frontend-server ansible_host=64.227.100.33 [frontend:vars] # this is necessary to get SSL certificates, it is the email for receiving notifications from letsencrypt letsencrypt_notifications_email=certbot@wiringbits.net # the url where the server/backend api is exposed # this depends on the nginx settings web_api_url="https://template-demo.wiringbits.net/api" # the url where the server/backend api is exposed (for the admin website) # this depends on the nginx settings admin_api_url="https://template-demo-admin.wiringbits.net/api" # the settings to enable http basic authorization with nginx while accessing the admin app # it can be generated by running `htpasswd`, for defining user called "demo" this could be run: # - htpasswd -n demo > config/nginx/admin-app-htpasswd nginx_admin_password_file=config/nginx/admin-app-htpasswd webapp_assets_directory=/var/www/html webapp_admin_assets_directory=/var/www/admin ================================================ FILE: infra/nginx.yml ================================================ --- - hosts: frontend gather_facts: no vars: - nginx_config_file: "config/nginx/nginx.conf" - nginx_mime_types_file: "config/nginx/mime.types" tasks: - name: Install nginx become: yes apt: name: nginx state: latest update_cache: yes - name: Disable nginx default site become: yes file: path: /etc/nginx/sites-enabled/default state: absent - name: Copy the nginx config become: yes copy: src: "{{ nginx_config_file }}" dest: /etc/nginx/nginx.conf - name: Copy mime.types become: yes copy: src: "{{ nginx_mime_types_file }}" dest: /etc/nginx/mime.types - name: Restart nginx become: yes service: name: nginx state: restarted - name: Enable nginx to run on system startup become: yes systemd: name: nginx enabled: yes ================================================ FILE: infra/nginx_site_admin.yml ================================================ --- - hosts: frontend gather_facts: no vars: - nginx_site_file: "config/nginx/admin_app_site.j2" - nginx_admin_settings_directory: "/etc/nginx/admin-config" - nginx_admin_settings_file: "/etc/nginx/admin-config/htpasswd" tasks: - name: Create the custom config directory become: yes file: path: "{{ nginx_admin_settings_directory }}" state: directory # file generated with `htpasswd -n demo`, user = demo, pass = wiringbits - name: Copy the site password become: yes copy: src: "{{ nginx_admin_password_file }}" dest: "{{ nginx_admin_settings_file }}" - name: Create the sites-available directory become: yes file: path: /etc/nginx/sites-available state: directory - name: Copy the site config become: yes template: src: "{{ nginx_site_file }}" dest: /etc/nginx/sites-available/{{ admin_app_domain }} - name: Create the sites-enabled directory become: yes file: path: /etc/nginx/sites-enabled state: directory - name: Enable the site become: yes file: src: /etc/nginx/sites-available/{{ admin_app_domain }} dest: /etc/nginx/sites-enabled/{{ admin_app_domain }} state: link - name: Install snapd become: yes apt: name: snapd state: latest update_cache: yes # Get SSL certificate # Source: https://certbot.eff.org/lets-encrypt/ubuntufocal-nginx - name: Install certbot become: yes snap: name: certbot classic: yes - name: Get SSL certificate become: yes shell: certbot -n --agree-tos --nginx -m "{{ letsencrypt_notifications_email }}" --domains "{{ admin_app_domain }}" ================================================ FILE: infra/nginx_site_web.yml ================================================ --- - hosts: frontend gather_facts: no vars: - nginx_site_file: "config/nginx/web_app_site.j2" - nginx_admin_settings_file: "/etc/nginx/admin-config/htpasswd" tasks: - name: Create the sites-available directory become: yes file: path: /etc/nginx/sites-available state: directory - name: Copy the site config become: yes template: src: "{{ nginx_site_file }}" dest: /etc/nginx/sites-available/{{ web_app_domain }} - name: Create the sites-enabled directory become: yes file: path: /etc/nginx/sites-enabled state: directory - name: Enable the site become: yes file: src: /etc/nginx/sites-available/{{ web_app_domain }} dest: /etc/nginx/sites-enabled/{{ web_app_domain }} state: link - name: Install snapd become: yes apt: name: snapd state: latest update_cache: yes # Get SSL certificate # Source: https://certbot.eff.org/lets-encrypt/ubuntufocal-nginx - name: Install certbot become: yes snap: name: certbot classic: yes - name: Get SSL certificate become: yes shell: certbot -n --agree-tos --nginx -m "{{ letsencrypt_notifications_email }}" --domains "{{ web_app_domain }}" ================================================ FILE: infra/preview_nginx_site_admin.yml ================================================ --- - hosts: frontend gather_facts: no vars: - nginx_site_file: "config/nginx/preview_admin_app_site.j2" - nginx_admin_settings_directory: "/etc/nginx/admin-config" - nginx_admin_settings_file: "/etc/nginx/admin-config/htpasswd" tasks: - name: Create the custom config directory become: yes file: path: "{{ nginx_admin_settings_directory }}" state: directory # file generated with `htpasswd -n demo`, user = demo, pass = wiringbits - name: Copy the site password become: yes copy: src: "{{ nginx_admin_password_file }}" dest: "{{ nginx_admin_settings_file }}" - name: Create the sites-available directory become: yes file: path: /etc/nginx/sites-available state: directory - name: Copy the site config become: yes template: src: "{{ nginx_site_file }}" dest: /etc/nginx/sites-available/{{ admin_app_domain }} - name: Create the sites-enabled directory become: yes file: path: /etc/nginx/sites-enabled state: directory - name: Enable the site become: yes file: src: /etc/nginx/sites-available/{{ admin_app_domain }} dest: /etc/nginx/sites-enabled/{{ admin_app_domain }} state: link - name: Reload nginx config become: yes service: name: nginx state: reloaded ================================================ FILE: infra/preview_nginx_site_web.yml ================================================ --- - hosts: frontend gather_facts: no vars: - nginx_site_file: "config/nginx/preview_web_app_site.j2" - nginx_admin_settings_file: "/etc/nginx/admin-config/htpasswd" tasks: - name: Create the sites-available directory become: yes file: path: /etc/nginx/sites-available state: directory - name: Copy the site config become: yes template: src: "{{ nginx_site_file }}" dest: /etc/nginx/sites-available/{{ web_app_domain }} - name: Create the sites-enabled directory become: yes file: path: /etc/nginx/sites-enabled state: directory - name: Enable the site become: yes file: src: /etc/nginx/sites-available/{{ web_app_domain }} dest: /etc/nginx/sites-enabled/{{ web_app_domain }} state: link - name: Reload nginx config become: yes service: name: nginx state: reloaded ================================================ FILE: infra/scripts/build-admin.sh ================================================ #!/bin/bash set -e API_URL=$1 echo "API_URL=$API_URL" \ && cd ../ \ && API_URL=$API_URL sbt admin/build \ && cd - cd ../admin/build && zip -r admin.zip * && cd - mkdir -p apps && mv ../admin/build/admin.zip apps/admin.zip ================================================ FILE: infra/scripts/build-server.sh ================================================ #!/bin/bash set -e APP_SOURCE_ZIP=$1 echo "APP_SOURCE_ZIP=$APP_SOURCE_ZIP" cd ../ && SWAGGER_API_BASEPATH="/api" sbt server/dist && cd - mkdir -p apps && cp ../server/target/universal/$APP_SOURCE_ZIP apps/server.zip ================================================ FILE: infra/scripts/build-web.sh ================================================ #!/bin/bash set -e API_URL=$1 echo "API_URL=$API_URL" \ && cd ../ \ && API_URL=$API_URL sbt web/build \ && cd - cd ../web/build && zip -r web.zip * && cd - mkdir -p apps && mv ../web/build/web.zip apps/web.zip ================================================ FILE: infra/server.yml ================================================ --- - hosts: backend gather_facts: no vars: - app_source_zip: "apps/server.zip" - app_source_zip_remote: "server.zip" tasks: - name: Install java11 become: yes apt: name: default-jre state: latest - name: Install unzip become: yes apt: name: unzip state: latest - name: Build the application retries: 10 delay: 5 shell: "./scripts/build-server.sh {{ app_source_name }}" delegate_to: 127.0.0.1 - name: Upload the application synchronize: src: "{{ app_source_zip }}" dest: "{{ app_source_zip_remote }}" # Registering the service before unpacking the app allows to make sure the app is stopped # on the first deployment. - name: Add the systemd service become: yes template: src: "{{ app_systemd_service_source }}" dest: "/etc/systemd/system/{{ app_systemd_service_name }}.service" - name: Pick up systemd changes become: yes systemd: daemon_reload: yes - name: Make sure the application is stopped become: yes systemd: name: "{{ app_systemd_service_name }}" state: stopped # This is crucial to avoid polluting the classpath with old jars after upgrading dependencies - name: Delete old application (important!) become: yes file: path: "{{ app_home }}/{{ app_directory_name }}" state: absent - name: Create the app group become: yes group: name: "{{ app_group }}" state: present - name: Create the app user become: yes user: name: "{{ app_user }}" group: "{{ app_group }}" state: present system: yes - name: Create the app directory become: yes file: path: "{{ app_home }}" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" - name: Unpack the application become: yes unarchive: remote_src: yes src: "{{ app_source_zip_remote }}" dest: "{{ app_home }}" owner: "{{ app_user }}" group: "{{ app_group }}" - name: Set the application config become: yes template: src: "{{ app_env_config_source }}" dest: "{{ app_env_config_file }}" owner: "{{ app_user }}" group: "{{ app_group }}" - name: Set the application files permissions become: yes file: dest: "{{ app_home }}" owner: "{{ app_user }}" group: "{{ app_group }}" recurse: yes - name: Make sure the application is started become: yes systemd: name: "{{ app_systemd_service_name }}" state: started - name: Enable the application to run on system startup become: yes systemd: name: "{{ app_systemd_service_name }}" enabled: yes # TODO: Check service is healthy by querying localhost:9000/health ================================================ FILE: infra/setup-postgres.md ================================================ # Setup postgres This is the manual way to set up postgres for a test server, for production it is recommended to use a managed database instead. Either follow these [instructions](https://postgreshelp.com/postgresql-13-install-in-ubuntu/) or just run these commands: ```bash # Create the file repository configuration sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' # Import the repository signing key wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - # Update the package lists sudo apt-get update # Install the latest version of PostgreSQL. # If you want a specific version, use 'postgresql-12' or similar instead of 'postgresql': sudo apt-get -y install postgresql-15 ``` Then, connect to the database (`sudo -u postgres psql`) and create the necessary user/database: ```shell CREATE DATABASE server_db; \c server_db; CREATE EXTENSION IF NOT EXISTS CITEXT; ALTER DATABASE server_db SET statement_timeout = '60s'; ALTER DATABASE server_db SET idle_in_transaction_session_timeout TO '5min'; CREATE USER db_user WITH SUPERUSER PASSWORD 'useYourOwnPasswordInstead'; GRANT ALL PRIVILEGES ON DATABASE "server_db" to db_user; ``` Test the connection to the new database with the custom user: `psql -h 127.0.0.1 -U db_user server_db` That's it! ================================================ FILE: infra/test-hosts.ini ================================================ [webapp] [webapp:vars] ansible_user=ubuntu ansible_ssh_extra_args='-o StrictHostKeyChecking=no' server_app_port=9000 # the domain where the main app is going to run # you are expected to have already created a DNS "A" record pointing to the server that will host the app web_app_domain="template-demo.wiringbits.net" # the domain where the admin app is going to run # you are expected to have already created a DNS "A" record pointing to the server that will host the app admin_app_domain="template-demo-admin.wiringbits.net" [webapp:children] backend frontend [backend] backend-server ansible_host=64.227.100.33 [backend:vars] # this is where the environment variables required by the app are defined # it could be kept encrypted by using ansible-vault app_env_config_source=config/server/dev.env.j2 # defines the systemd service used to run the app app_systemd_service_source=config/server/server.service.j2 # the service name used to register the service in systemd, for example, # restarting the app would be done by invoking: service app-server restart app_systemd_service_name="wiringbits-server" # the directory name where the app is stored after building it # this depends on your app name, get it by running "sbt server/dist" in the app's source # the last logs will display a line like: # [info] Your package is ready in .../server/target/universal/wiringbits-server-0.1.0-SNAPSHOT.zip # the last part is the source name, remove the zip extension and that's the directory name, # remove the version from the directory name and that's the startup script app_source_name="wiringbits-server-0.1.0-SNAPSHOT.zip" app_directory_name="wiringbits-server-0.1.0-SNAPSHOT" app_startup_script="wiringbits-server" # user/group/home used to store the app app_user="play" app_group="play" app_home="/home/play/app" app_env_config_file="/home/play/app/.env" [frontend] frontend-server ansible_host=64.227.100.33 [frontend:vars] # this is necessary to get SSL certificates, it is the email for receiving notifications from letsencrypt letsencrypt_notifications_email=certbot@wiringbits.net # the url where the server/backend api is exposed # this depends on the nginx settings web_api_url="https://template-demo.wiringbits.net/api" # the url where the server/backend api is exposed (for the admin website) # this depends on the nginx settings admin_api_url="https://template-demo-admin.wiringbits.net/api" # the settings to enable http basic authorization with nginx while accessing the admin app # it can be generated by running `htpasswd`, for defining user called "demo" this could be run: # - htpasswd -n demo > config/nginx/admin-app-htpasswd nginx_admin_password_file=config/nginx/admin-app-htpasswd webapp_assets_directory=/var/www/html webapp_admin_assets_directory=/var/www/admin ================================================ FILE: infra/web.yml ================================================ --- - hosts: frontend gather_facts: no vars: - webapp_source_zip: "apps/web.zip" - webapp_remote_file: "web.zip" tasks: - name: Build the application shell: ./scripts/build-web.sh {{ web_api_url }} delegate_to: 127.0.0.1 - name: Install unzip become: yes apt: name: unzip state: latest update_cache: yes - name: Upload the application synchronize: src: "{{ webapp_source_zip }}" dest: "{{ webapp_remote_file }}" - name: Create the web data directory become: yes file: path: "{{ webapp_assets_directory }}" state: directory owner: www-data group: www-data - name: Unpack the application become: yes unarchive: remote_src: yes src: "{{ webapp_remote_file }}" dest: "{{ webapp_assets_directory }}" - name: Set the permissions become: yes file: dest: "{{ webapp_assets_directory }}" owner: www-data group: www-data recurse: yes - name: Reload nginx config become: yes service: name: nginx state: reloaded ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/ApiClient.scala ================================================ package net.wiringbits.api import net.wiringbits.api.endpoints.* import net.wiringbits.api.models.* import net.wiringbits.api.models.admin.{AdminGetUserLogs, AdminGetUsers} import net.wiringbits.api.models.auth.{GetCurrentUser, Login, Logout} import net.wiringbits.api.models.environmentconfig.GetEnvironmentConfig import net.wiringbits.api.models.users.* import play.api.libs.json.{Json, Reads} import sttp.client3.* import sttp.tapir.PublicEndpoint import sttp.tapir.client.sttp.SttpClientInterpreter import sttp.tapir.model.ServerRequest import java.util.UUID import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} object ApiClient { case class Config(serverUrl: String) } class ApiClient(config: ApiClient.Config)(implicit ex: ExecutionContext, sttpBackend: SttpBackend[Future, _] ) { // While the server requires a userId, it is extracted from the Session cookie, we need a dummy value just to // fulfill the method signatures private val dummyUserId = Future.successful(UUID.fromString("887a5d77-cb5d-4d9c-b4dc-539c8aae3977")) // Similarly to the dummy userId, we need a way to derive the userId from a request, which is used only on the // server-side code, this function is helpful to fulfill the method signatures private implicit val handleDummyUserId: ServerRequest => Future[UUID] = _ => dummyUserId private def asJson[R: Reads](strBody: String) = { Try { Json.parse(strBody).as[ErrorResponse] } match { case Success(error) => throw new RuntimeException(error.error) case Failure(_) => Try { Json.parse(strBody).as[R] } match { case Success(response) => response case Failure(error) => throw new RuntimeException(s"Unexpected response ${error.getMessage}") } } } private val ServerAPI = sttp.model.Uri .parse(config.serverUrl) .getOrElse(throw new RuntimeException("Invalid server url")) private val client = SttpClientInterpreter() /** This is necessary for non-browser clients, this way, the cookies from the last authentication response are * propagated to the next requests */ private var lastAuthResponse = Option.empty[Response[_]] private def unsafeSetLoginResponse(response: Response[_]): Unit = synchronized { lastAuthResponse = Some(response) } private def unsafeRemoveLoginResponse(): Unit = synchronized { lastAuthResponse = None } private def handleRequest[I, O](endpoint: PublicEndpoint[I, ErrorResponse, O, Any], request: I): Future[O] = { val savedCookies = lastAuthResponse.map(_.unsafeCookies).getOrElse(Seq.empty) client .toRequestThrowDecodeFailures(endpoint, Some(ServerAPI)) .apply(request) .cookies(savedCookies) .send(sttpBackend) .map(_.body) .map { case Left(error) => throw new RuntimeException(error.error) case Right(response) => response } } def createUser(request: CreateUser.Request): Future[CreateUser.Response] = handleRequest(UsersEndpoints.create, request) def verifyEmail(request: VerifyEmail.Request): Future[VerifyEmail.Response] = handleRequest(UsersEndpoints.verifyEmail, request) def forgotPassword(request: ForgotPassword.Request): Future[ForgotPassword.Response] = handleRequest(UsersEndpoints.forgotPassword, request) def resetPassword(request: ResetPassword.Request): Future[ResetPassword.Response] = handleRequest(UsersEndpoints.resetPassword, request) def currentUser: Future[GetCurrentUser.Response] = handleRequest(AuthEndpoints.getCurrentUser, dummyUserId) def updateUser(request: UpdateUser.Request): Future[UpdateUser.Response] = handleRequest(UsersEndpoints.update, (request, dummyUserId)) def updatePassword(request: UpdatePassword.Request): Future[UpdatePassword.Response] = handleRequest(UsersEndpoints.updatePassword, (request, dummyUserId)) def getUserLogs: Future[GetUserLogs.Response] = handleRequest(UsersEndpoints.getLogs, dummyUserId) def adminGetUserLogs(userId: UUID): Future[AdminGetUserLogs.Response] = handleRequest(AdminEndpoints.getUserLogsEndpoint, ("_", userId, "")) def adminGetUsers: Future[AdminGetUsers.Response] = handleRequest(AdminEndpoints.getUsersEndpoint, ("_", "")) def getEnvironmentConfig: Future[GetEnvironmentConfig.Response] = handleRequest(EnvironmentConfigEndpoints.getEnvironmentConfig, ()) def sendEmailVerificationToken( request: SendEmailVerificationToken.Request ): Future[SendEmailVerificationToken.Response] = handleRequest(UsersEndpoints.sendEmailVerificationToken, request) // login and logout are special cases, since they return a cookie, sttp-client can not decode them correctly, so we have // to do it manually def login(request: Login.Request): Future[Login.Response] = client .toRequestThrowDecodeFailures(AuthEndpoints.login, Some(ServerAPI)) .apply(request) .response(asStringAlways) .send(sttpBackend) .map { response => unsafeSetLoginResponse(response) response.body } .map(asJson[Login.Response]) def logout: Future[Logout.Response] = client .toRequestThrowDecodeFailures(AuthEndpoints.logout, Some(ServerAPI)) .apply(dummyUserId) .response(asStringAlways) .send(sttpBackend) .map { response => unsafeRemoveLoginResponse() response.body } .map(asJson[Logout.Response]) } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/endpoints/AdminEndpoints.scala ================================================ package net.wiringbits.api.endpoints import net.wiringbits.api.models import net.wiringbits.api.models.admin.{AdminGetUserLogs, AdminGetUsers} import net.wiringbits.api.models.ErrorResponse import net.wiringbits.common.models.{Email, Name} import sttp.tapir.* import sttp.tapir.json.play.* import java.time.Instant import java.util.UUID object AdminEndpoints { private val baseEndpoint = endpoint .in("admin") .tag("Admin") .in(adminAuth) .errorOut(errorResponseErrorOut) val getUserLogsEndpoint: Endpoint[Unit, (String, UUID, String), ErrorResponse, AdminGetUserLogs.Response, Any] = baseEndpoint.get .in("users" / path[UUID]("userId") / "logs") .in(adminHeader) .out( jsonBody[AdminGetUserLogs.Response].example( AdminGetUserLogs.Response( List( AdminGetUserLogs.Response .UserLog( userLogId = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), message = "Message", createdAt = Instant.parse("2021-01-01T00:00:00Z") ) ) ) ) ) .errorOut(oneOf(HttpErrors.badRequest, HttpErrors.unauthorized)) .summary("Get the logs for a specific user") val getUsersEndpoint: Endpoint[Unit, (String, String), ErrorResponse, AdminGetUsers.Response, Any] = baseEndpoint.get .in("users") .in(adminHeader) .out( jsonBody[AdminGetUsers.Response].example( AdminGetUsers.Response( List( AdminGetUsers.Response.User( id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), name = Name.trusted("Alexis"), email = Email.trusted("alexis@wiringbits.net"), createdAt = Instant.parse("2021-01-01T00:00:00Z") ) ) ) ) ) .errorOut(oneOf(HttpErrors.badRequest, HttpErrors.unauthorized)) .summary("Get the registered users") val routes: List[AnyEndpoint] = List( getUserLogsEndpoint, getUsersEndpoint ) } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/endpoints/AuthEndpoints.scala ================================================ package net.wiringbits.api.endpoints import net.wiringbits.api.models import net.wiringbits.api.models.auth.{GetCurrentUser, Login, Logout} import net.wiringbits.api.models.ErrorResponse import net.wiringbits.common.models.{Captcha, Email, Name, Password} import sttp.tapir.* import sttp.tapir.json.play.* import sttp.tapir.model.ServerRequest import java.time.Instant import java.util.UUID import scala.concurrent.Future object AuthEndpoints { private val baseEndpoint = endpoint .in("auth") .tag("Auth") .errorOut(errorResponseErrorOut) val login: Endpoint[Unit, Login.Request, ErrorResponse, (Login.Response, String), Any] = baseEndpoint.post .in("login") .in( jsonBody[Login.Request].example( Login.Request( email = Email.trusted("alexis@wiringbits.net"), password = Password.trusted("notSoWeakPassword"), captcha = Captcha.trusted("captcha") ) ) ) .out( jsonBody[Login.Response] .description("Successful login") .example( Login.Response( id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), name = Name.trusted("Alexis"), email = Email.trusted("alexis@wiringbits.net") ) ) ) .out(setSessionHeader) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Log into the app") .description("Sets a session cookie to authenticate the following requests") def logout(implicit authHandler: ServerRequest => Future[UUID] ): Endpoint[Unit, Future[UUID], ErrorResponse, (Logout.Response, String), Any] = baseEndpoint.post .in("logout") .in(userAuth) .out(jsonBody[Logout.Response].description("Successful logout").example(Logout.Response())) .out(setSessionHeader) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Logout from the app") .description("Clears the session cookie that's stored securely") def getCurrentUser(implicit authHandler: ServerRequest => Future[UUID] ): Endpoint[Unit, Future[UUID], ErrorResponse, GetCurrentUser.Response, Any] = baseEndpoint.get .in("me") .in(userAuth) .out( jsonBody[GetCurrentUser.Response] .description("Got user details") .example( GetCurrentUser.Response( id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), name = Name.trusted("Alexis"), email = Email.trusted("alexis@wiringbits.net"), createdAt = Instant.parse("2021-01-01T00:00:00Z") ) ) ) .summary("Get the details for the authenticated user") def routes(implicit authHandler: ServerRequest => Future[UUID]): List[AnyEndpoint] = List( login, logout, getCurrentUser ) } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/endpoints/EnvironmentConfigEndpoints.scala ================================================ package net.wiringbits.api.endpoints import net.wiringbits.api.models import net.wiringbits.api.models.ErrorResponse import net.wiringbits.api.models.environmentconfig.GetEnvironmentConfig import sttp.tapir.* import sttp.tapir.json.play.* object EnvironmentConfigEndpoints { private val baseEndpoint = endpoint .in("environment-config") .tag("Misc") .errorOut(errorResponseErrorOut) val getEnvironmentConfig: Endpoint[Unit, Unit, ErrorResponse, GetEnvironmentConfig.Response, Any] = baseEndpoint.get .out( jsonBody[GetEnvironmentConfig.Response] .description("Got the config values") .example(GetEnvironmentConfig.Response("siteKey")) ) .summary("Get the config values for the current environment") .description("These values are required by the frontend app to interact with the backend") val routes: List[AnyEndpoint] = List( getEnvironmentConfig ) } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/endpoints/HealthEndpoints.scala ================================================ package net.wiringbits.api.endpoints import sttp.tapir.* object HealthEndpoints { private val baseEndpoint = endpoint .tag("Misc") .in("health") val check: Endpoint[Unit, Unit, Unit, Unit, Any] = baseEndpoint.get .out(emptyOutput.description("The app is healthy")) .summary("Queries the application's health") val routes: List[AnyEndpoint] = List( check ) } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/endpoints/UsersEndpoints.scala ================================================ package net.wiringbits.api.endpoints import net.wiringbits.api.models.* import net.wiringbits.api.models.users.* import net.wiringbits.common.models.* import sttp.tapir.* import sttp.tapir.json.play.* import sttp.tapir.model.ServerRequest import java.time.Instant import java.util.UUID import scala.concurrent.Future object UsersEndpoints { private val baseEndpoint = endpoint .in("users") .tag("Users") .errorOut(errorResponseErrorOut) val create: Endpoint[Unit, CreateUser.Request, ErrorResponse, CreateUser.Response, Any] = baseEndpoint.post .in( jsonBody[CreateUser.Request].example( CreateUser.Request( name = Name.trusted("Alexis"), email = Email.trusted("alexis@wiringbits.net"), password = Password.trusted("notSoWeakPassword"), captcha = Captcha.trusted("captcha") ) ) ) .out( jsonBody[CreateUser.Response] .description("The account was created") .example( CreateUser.Response( id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), name = Name.trusted("Alexis"), email = Email.trusted("alexis@wiringbits.net") ) ) ) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Creates a new account") .description("Requires a captcha") val verifyEmail: Endpoint[Unit, VerifyEmail.Request, ErrorResponse, VerifyEmail.Response, Any] = baseEndpoint.post .in("verify-email") .in( jsonBody[VerifyEmail.Request].example( VerifyEmail.Request( UserToken( userId = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), token = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") ) ) ) ) .out(jsonBody[VerifyEmail.Response].description("The account's email was verified").example(VerifyEmail.Response())) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Verify the user's email") .description( "When an account is created, a verification code is sent to the registered email, this operations take such code and marks the email as verified" ) val forgotPassword: Endpoint[Unit, ForgotPassword.Request, ErrorResponse, ForgotPassword.Response, Any] = baseEndpoint.post .in("forgot-password") .in( jsonBody[ForgotPassword.Request].example( ForgotPassword.Request( email = Email.trusted("alexis@wirngbits.net"), captcha = Captcha.trusted("captcha") ) ) ) .out( jsonBody[ForgotPassword.Response] .description("The email to recover the password was sent") .example(ForgotPassword.Response()) ) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Requests an email to reset a user password") val resetPassword: Endpoint[Unit, ResetPassword.Request, ErrorResponse, ResetPassword.Response, Any] = baseEndpoint.post .in("reset-password") .in( jsonBody[ResetPassword.Request] .example( ResetPassword.Request( token = UserToken( userId = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), token = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") ), password = Password.trusted("notSoWeakPassword") ) ) ) .out( jsonBody[ResetPassword.Response] .description("The password was updated") .example( ResetPassword.Response( name = Name.trusted("Alexis"), email = Email.trusted("alexis@wiringbits.net") ) ) ) .errorOut(oneOf[Unit](HttpErrors.badRequest)) .summary("Resets a user password") val sendEmailVerificationToken : Endpoint[Unit, SendEmailVerificationToken.Request, ErrorResponse, SendEmailVerificationToken.Response, Any] = baseEndpoint.post .in("email-verification-token") .in( jsonBody[SendEmailVerificationToken.Request].example( SendEmailVerificationToken.Request( email = Email.trusted("alexis@wiringbits.net"), captcha = Captcha.trusted("captcha") ) ) ) .out( jsonBody[SendEmailVerificationToken.Response] .description("The account's email was verified") .example( SendEmailVerificationToken.Response( expiresAt = Instant.parse("2021-01-01T00:00:00Z") ) ) ) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Sends the email verification token") .description( "The user's email should be unconfirmed, this is intended to re-send a token in case the previous one did not arrive" ) def update(implicit authHandler: ServerRequest => Future[UUID] ): Endpoint[Unit, (UpdateUser.Request, Future[UUID]), ErrorResponse, UpdateUser.Response, Any] = baseEndpoint.put .in("me") .in( jsonBody[UpdateUser.Request].example( UpdateUser.Request( name = Name.trusted("Alexis") ) ) ) .in(userAuth) .out(jsonBody[UpdateUser.Response].description("The user details were updated").example(UpdateUser.Response())) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Updates the authenticated user details") def updatePassword(implicit authHandler: ServerRequest => Future[UUID] ): Endpoint[Unit, (UpdatePassword.Request, Future[UUID]), ErrorResponse, UpdatePassword.Response, Any] = baseEndpoint.put .in("me" / "password") .in( jsonBody[UpdatePassword.Request] .description("The user password was updated") .example( UpdatePassword.Request( oldPassword = Password.trusted("oldWeakPassword"), newPassword = Password.trusted("newNotSoWeakPassword") ) ) ) .in(userAuth) .out(jsonBody[UpdatePassword.Response]) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Updates the authenticated user password") def getLogs(implicit authHandler: ServerRequest => Future[UUID] ): Endpoint[Unit, Future[UUID], ErrorResponse, GetUserLogs.Response, Any] = baseEndpoint.get .in("me" / "logs") .in(userAuth) .out( jsonBody[GetUserLogs.Response] .description("Got user logs") .example( GetUserLogs.Response( List( GetUserLogs.Response.UserLog( userLogId = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), message = "Message", createdAt = Instant.parse("2021-01-01T00:00:00Z") ) ) ) ) ) .errorOut(oneOf(HttpErrors.badRequest)) .summary("Get the logs for the authenticated user") def routes(implicit authHandler: ServerRequest => Future[UUID]): List[AnyEndpoint] = List( create, verifyEmail, forgotPassword, resetPassword, sendEmailVerificationToken, update, updatePassword, getLogs ) } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/endpoints/package.scala ================================================ package net.wiringbits.api import net.wiringbits.api.models.{ErrorResponse, errorResponseFormat} import sttp.model.StatusCode import sttp.tapir.* import sttp.tapir.EndpointInput.AuthType import sttp.tapir.generic.auto.* import sttp.tapir.json.play.* import sttp.tapir.model.ServerRequest import java.util.UUID import scala.concurrent.Future package object endpoints { // TODO: better name? object HttpErrors { val badRequest: EndpointOutput.OneOfVariant[Unit] = oneOfVariant( statusCode(StatusCode.BadRequest).description("Invalid or missing arguments") ) val unauthorized: EndpointOutput.OneOfVariant[Unit] = oneOfVariant( statusCode(StatusCode.Unauthorized).description("Invalid or missing authentication") ) } val adminHeader: EndpointIO.Header[String] = header[String]("X-Forwarded-User") .default("Unknown") .schema(_.hidden(true)) val adminAuth: EndpointInput.Auth[String, AuthType.Http] = auth .basic[String]() .securitySchemeName("Basic authorization") .description("Admin credentials") val setSessionHeader: EndpointIO.Header[String] = header[String]("Set-Cookie") .description("Set user session") .schema(_.hidden(true)) val errorResponseErrorOut: EndpointIO.Body[String, ErrorResponse] = jsonBody[ErrorResponse] .description("Error response") .example(ErrorResponse("Unauthorized: Invalid or missing authentication")) .schema(_.hidden(true)) def userAuth(implicit handleAuth: ServerRequest => Future[UUID]): EndpointInput.ExtractFromRequest[Future[UUID]] = extractFromRequest(handleAuth) } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/PlayErrorResponse.scala ================================================ package net.wiringbits.api.models import play.api.libs.json.{Format, Json} import sttp.tapir.Schema // play json errors are like: // {"error":{"requestId":2,"message":"Invalid Json: ..."}} case class PlayErrorResponse(error: PlayErrorResponse.PlayError) object PlayErrorResponse { case class PlayError(message: String) implicit val playErrorResponseErrorFormat: Format[PlayError] = Json.format[PlayError] implicit val playErrorResponseFormat: Format[PlayErrorResponse] = Json.format[PlayErrorResponse] implicit val playErrorResponseErrorSchema: Schema[PlayError] = Schema.derived[PlayError].name(Schema.SName("PlayError")) implicit val playErrorResponseSchema: Schema[PlayErrorResponse] = Schema .derived[PlayErrorResponse] .name(Schema.SName("PlayErrorResponse")) .description("Response with an application error") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/admin/AdminGetUserLogs.scala ================================================ package net.wiringbits.api.models.admin import net.wiringbits.api.models.* import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import sttp.tapir.generic.auto.* import java.time.Instant import java.util.UUID object AdminGetUserLogs { case class Response(data: List[Response.UserLog]) implicit val adminGetUserLogsResponseFormat: Format[Response] = Json.format[Response] implicit val adminGetUserLogsResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("AdminGetUserLogsResponse")) .description("Includes the logs for a single user") object Response { case class UserLog(userLogId: UUID, message: String, createdAt: Instant) implicit val adminGetUserLogsResponseUserLogFormat: Format[UserLog] = Json.format[UserLog] implicit val adminGetUserLogsResponseUserLogSchema: Schema[UserLog] = Schema .derived[UserLog] .name(Schema.SName("AdminGetUserLogsResponseUserLog")) } } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/admin/AdminGetUsers.scala ================================================ package net.wiringbits.api.models.admin import net.wiringbits.api.models.* import net.wiringbits.common.models.{Email, Name} import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import sttp.tapir.generic.auto.* import java.time.Instant import java.util.UUID object AdminGetUsers { case class Response(data: List[Response.User]) implicit val adminGetUsersResponseFormat: Format[Response] = Json.format[Response] implicit val adminGetUsersResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("AdminGetUsersResponse")) .description("Includes the user list") object Response { case class User(id: UUID, name: Name, email: Email, createdAt: Instant) implicit val adminGetUsersResponseUserFormat: Format[User] = Json.format[User] implicit val adminGetUsersResponseUserSchema: Schema[User] = Schema .derived[User] .name(Schema.SName("AdminGetUsersResponseUser")) } } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/auth/GetCurrentUser.scala ================================================ package net.wiringbits.api.models.auth import net.wiringbits.api.models.* import net.wiringbits.common.models.{Email, Name} import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import java.time.Instant import java.util.UUID object GetCurrentUser { case class Response(id: UUID, name: Name, email: Email, createdAt: Instant) implicit val getUserResponseFormat: Format[Response] = Json.format[Response] implicit val getUserResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("GetCurrentUserResponse")) .description("Response to find the authenticated user details") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/auth/Login.scala ================================================ package net.wiringbits.api.models.auth import net.wiringbits.api.models.* import net.wiringbits.common.models.{Captcha, Email, Name, Password} import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import sttp.tapir.generic.auto.* import java.util.UUID object Login { case class Request(email: Email, password: Password, captcha: Captcha) case class Response(id: UUID, name: Name, email: Email) implicit val loginRequestFormat: Format[Request] = Json.format[Request] implicit val loginResponseFormat: Format[Response] = Json.format[Response] implicit val loginRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("LoginRequest")) .description("Request to log into the app") implicit val loginResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("LoginResponse")) .description("Response after logging into the app") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/auth/Logout.scala ================================================ package net.wiringbits.api.models.auth import play.api.libs.json.{Format, Json} import sttp.tapir.Schema object Logout { case class Request(noData: String = "") case class Response(noData: String = "") implicit val logoutRequestFormat: Format[Request] = Json.format[Request] implicit val logoutResponseFormat: Format[Response] = Json.format[Response] implicit val logoutRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("LogoutRequest")) .description("Request to log out of the app") implicit val logoutResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("LogoutResponse")) .description("Response after logging out of the app") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/environmentconfig/GetEnvironmentConfig.scala ================================================ package net.wiringbits.api.models.environmentconfig import play.api.libs.json.{Format, Json} import sttp.tapir.Schema object GetEnvironmentConfig { case class Response(recaptchaSiteKey: String) implicit val configResponseFormat: Format[Response] = Json.format[Response] implicit val configResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("GetEnvironmentConfigResponse")) .description("Request to fetch the environment config") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/package.scala ================================================ package net.wiringbits.api import net.wiringbits.webapp.common.models.WrappedString import play.api.libs.json.* import sttp.tapir.generic.auto.* import sttp.tapir.{Schema, SchemaType} import java.time.Instant package object models { /** For some reason, play-json doesn't provide support for Instant in the scalajs version, grabbing the jvm values * seems to work: * - https://github.com/playframework/play-json/blob/master/play-json/jvm/src/main/scala/play/api/libs/json/EnvReads.scala * - https://github.com/playframework/play-json/blob/master/play-json/jvm/src/main/scala/play/api/libs/json/EnvWrites.scala */ implicit val instantFormat: Format[Instant] = Format[Instant]( fjs = implicitly[Reads[String]].map(string => Instant.parse(string)), tjs = Writes[Instant](i => JsString(i.toString)) ) case class ErrorResponse(error: String) implicit val errorResponseFormat: Format[ErrorResponse] = Json.format[ErrorResponse] implicit val errorResponseSchema: Schema[ErrorResponse] = Schema .derived[ErrorResponse] .name(Schema.SName("ErrorResponse")) implicit def wrappedStringSchema[T <: WrappedString]: Schema[T] = Schema(SchemaType.SString()) } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/users/CreateUser.scala ================================================ package net.wiringbits.api.models.users import net.wiringbits.api.models.* import net.wiringbits.common.models.{Captcha, Email, Name, Password} import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import java.util.UUID object CreateUser { case class Request(name: Name, email: Email, password: Password, captcha: Captcha) case class Response(id: UUID, name: Name, email: Email) implicit val createUserRequestFormat: Format[Request] = Json.format[Request] implicit val createUserResponseFormat: Format[Response] = Json.format[Response] implicit val createUserRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("CreateUserRequest")) .description("Request for the create user API") implicit val createUserResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("CreateUserResponse")) .description("Response for the create user API") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/users/ForgotPassword.scala ================================================ package net.wiringbits.api.models.users import net.wiringbits.api.models.* import net.wiringbits.common.models.{Captcha, Email} import play.api.libs.json.{Format, Json} import sttp.tapir.Schema object ForgotPassword { case class Request(email: Email, captcha: Captcha) case class Response(noData: String = "") implicit val forgotPasswordRequestFormat: Format[Request] = Json.format[Request] implicit val forgotPasswordResponseFormat: Format[Response] = Json.format[Response] implicit val forgotPasswordRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("ForgotPasswordRequest")) .description("Request to reset a forgotten password") implicit val forgotPasswordResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("ForgotPasswordResponse")) .description("Response to the ForgotPasswordRequest") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/users/GetUserLogs.scala ================================================ package net.wiringbits.api.models.users import net.wiringbits.api.models.* import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import sttp.tapir.generic.auto.* import java.time.Instant import java.util.UUID object GetUserLogs { case class Response(data: List[Response.UserLog]) object Response { case class UserLog(userLogId: UUID, message: String, createdAt: Instant) implicit val getUserLogsResponseFormat: Format[UserLog] = Json.format[UserLog] implicit val getUserLogsResponseSchema: Schema[UserLog] = Schema.derived[UserLog].name(Schema.SName("GetUserLogsResponseUserLog")) } implicit val getUserLogsResponseFormat: Format[Response] = Json.format[Response] implicit val getUserLogsResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("GetUserLogsResponse")) .description("Includes the authenticated user logs") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/users/ResetPassword.scala ================================================ package net.wiringbits.api.models.users import net.wiringbits.api.models.* import net.wiringbits.common.models.{Email, Name, Password, UserToken} import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import sttp.tapir.generic.auto.* object ResetPassword { case class Request(token: UserToken, password: Password) case class Response(name: Name, email: Email) implicit val resetPasswordRequestFormat: Format[Request] = Json.format[Request] implicit val resetPasswordResponseFormat: Format[Response] = Json.format[Response] implicit val resetPasswordRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("ResetPasswordRequest")) .description("Request to reset a user password") implicit val resetPasswordResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("ResetPasswordResponse")) .description("Response after resetting a user password") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/users/SendEmailVerificationToken.scala ================================================ package net.wiringbits.api.models.users import net.wiringbits.api.models.* import net.wiringbits.common.models.{Captcha, Email} import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import java.time.Instant object SendEmailVerificationToken { case class Request(email: Email, captcha: Captcha) case class Response(expiresAt: Instant) implicit val sendEmailVerificationTokenRequestFormat: Format[Request] = Json.format[Request] implicit val sendEmailVerificationTokenResponseFormat: Format[Response] = Json.format[Response] implicit val sendEmailVerificationTokenRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("SendEmailVerificationTokenRequest")) .description("Request to re-send the token to verify an email") implicit val sendEmailVerificationTokenResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("SendEmailVerificationTokenResponse")) .description("Response after sending the token to verify an email") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/users/UpdatePassword.scala ================================================ package net.wiringbits.api.models.users import net.wiringbits.api.models.* import net.wiringbits.common.models.Password import play.api.libs.json.{Format, Json} import sttp.tapir.Schema object UpdatePassword { case class Request(oldPassword: Password, newPassword: Password) case class Response(noData: String = "") implicit val updatePasswordRequestFormat: Format[Request] = Json.format[Request] implicit val updatePasswordResponseFormat: Format[Response] = Json.format[Response] implicit val updatePasswordRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("UpdatePasswordRequest")) .description("Request to change the user's password") implicit val updatePasswordResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("UpdatePasswordResponse")) .description("Response after updating the user's password") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/users/UpdateUser.scala ================================================ package net.wiringbits.api.models.users import net.wiringbits.api.models.* import net.wiringbits.common.models.Name import play.api.libs.json.{Format, Json} import sttp.tapir.Schema object UpdateUser { case class Request(name: Name) case class Response(noData: String = "") implicit val updateUserRequestFormat: Format[Request] = Json.format[Request] implicit val updateUserResponseFormat: Format[Response] = Json.format[Response] implicit val updateUserRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("UpdateUserRequest")) .description("Request to update user details") implicit val updateUserResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("UpdateUserResponse")) .description("Response after updating the user details") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/models/users/VerifyEmail.scala ================================================ package net.wiringbits.api.models.users import net.wiringbits.common.models.UserToken import play.api.libs.json.{Format, Json} import sttp.tapir.Schema import sttp.tapir.generic.auto.* object VerifyEmail { case class Request(token: UserToken) case class Response(noData: String = "") implicit val verifyEmailRequestFormat: Format[Request] = Json.format[Request] implicit val verifyEmailResponseFormat: Format[Response] = Json.format[Response] implicit val verifyEmailRequestSchema: Schema[Request] = Schema .derived[Request] .name(Schema.SName("VerifyEmailRequest")) .description("Request to verify an email") implicit val verifyEmailResponseSchema: Schema[Response] = Schema .derived[Response] .name(Schema.SName("VerifyEmailResponse")) .description("Response after verifying an email") } ================================================ FILE: lib/api/shared/src/main/scala/net/wiringbits/api/utils/Formatter.scala ================================================ package net.wiringbits.api.utils import java.time.Instant object Formatter { def instant(item: Instant): String = { try { java.time.ZonedDateTime .ofInstant(item, java.time.ZoneId.systemDefault()) .format(java.time.format.DateTimeFormatter.ofPattern("dd/MMM/uuuu hh:mm a")) } catch { // if for any reason the locale is not available in the sjs libraries, the operation will fail // this shouldn't happen in the jvm case _: Throwable => item.toString } } } ================================================ FILE: lib/common/js/src/test/scala/java/security/SecureRandom.scala ================================================ package java.security import scala.scalajs.js import scala.scalajs.js.typedarray.* // DISCLAIMER: This is almost identical to the official library https://github.com/scala-js/scala-js-fake-insecure-java-securerandom // There was the need to apply a patch that won't be accepted by the upstream library, given that this is used // only for tests, it shouldn't be a problem to keep the patch. // // The seed in java.util.Random will be unused, so set to 0L instead of having to generate one class SecureRandom() extends java.util.Random(0L) { // Make sure to resolve the appropriate function no later than the first instantiation private val getRandomValuesFun = SecureRandom.getRandomValuesFun /* setSeed has no effect. For cryptographically secure PRNGs, giving a seed * can only ever increase the entropy. It is never allowed to decrease it. * Given that we don't have access to an API to strengthen the entropy of the * underlying PRNG, it's fine to ignore it instead. * * Note that the doc of `SecureRandom` says that it will seed itself upon * first call to `nextBytes` or `next`, if it has not been seeded yet. This * suggests that an *initial* call to `setSeed` would make a `SecureRandom` * instance deterministic. Experimentally, this does not seem to be the case, * however, so we don't spend extra effort to make that happen. */ override def setSeed(x: Long): Unit = () override def nextBytes(bytes: Array[Byte]): Unit = { val len = bytes.length val buffer = new Int8Array(len) getRandomValuesFun(buffer) var i = 0 while (i != len) { bytes(i) = buffer(i) i += 1 } } override protected final def next(numBits: Int): Int = { if (numBits <= 0) { 0 // special case because the formula on the last line is incorrect for numBits == 0 } else { val buffer = new Int32Array(1) getRandomValuesFun(buffer) val rand32 = buffer(0) rand32 & (-1 >>> (32 - numBits)) // Clear the (32 - numBits) higher order bits } } } object SecureRandom { private lazy val getRandomValuesFun: js.Function1[ArrayBufferView, Unit] = { if ( js.typeOf(js.Dynamic.global.crypto) != "undefined" && js.typeOf(js.Dynamic.global.crypto.getRandomValues) == "function" ) { { (buffer: ArrayBufferView) => js.Dynamic.global.crypto.getRandomValues(buffer) () } } else if (js.typeOf(js.Dynamic.global.require) == "function") { try { val crypto = js.Dynamic.global.require("crypto") if (js.typeOf(crypto.randomFillSync) == "function") { { (buffer: ArrayBufferView) => /** This part differs from the official implementation because it catches runtime exceptions * * This was necessary because webpack seems to be polluting our runtime libraries with one that breaks in * the tests. */ try { crypto.randomFillSync(buffer) } catch { case _: Throwable => insecureDefault(buffer) } () } } else { insecureDefault } } catch { case _: Throwable => insecureDefault } } else { insecureDefault } } private def insecureDefault: js.Function1[ArrayBufferView, Unit] = { val insecureRandom = new java.util.Random() { (buffer: ArrayBufferView) => val asInt8Array = new Int8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) val len = asInt8Array.length val arrayBuffer = new Array[Byte](len) insecureRandom.nextBytes(arrayBuffer) var i = 0 while (i != len) { asInt8Array(i) = arrayBuffer(i) i += 1 } } } } ================================================ FILE: lib/common/shared/src/main/scala/net/wiringbits/common/ErrorMessages.scala ================================================ package net.wiringbits.common object ErrorMessages { val emailNotVerified = "The email is not verified, check your spam folder if you don't see the email." } ================================================ FILE: lib/common/shared/src/main/scala/net/wiringbits/common/models/Captcha.scala ================================================ package net.wiringbits.common.models import net.wiringbits.webapp.common.models.WrappedString import net.wiringbits.webapp.common.validators.ValidationResult class Captcha private (val string: String) extends WrappedString object Captcha extends WrappedString.Companion[Captcha] { override def validate(string: String): ValidationResult[Captcha] = { Option(string.trim) .filter(_.nonEmpty) .map(ValidationResult.Valid(_, new Captcha(string))) .getOrElse { ValidationResult.Invalid(string, "Invalid recaptcha") } } override def trusted(string: String): Captcha = new Captcha(string) } ================================================ FILE: lib/common/shared/src/main/scala/net/wiringbits/common/models/Email.scala ================================================ package net.wiringbits.common.models import net.wiringbits.webapp.common.models.WrappedString import net.wiringbits.webapp.common.validators.ValidationResult class Email private (val string: String) extends WrappedString object Email extends WrappedString.Companion[Email] { private val emailRegex = """^[\w.!#$%&'*+/=?^_`{|}~-]+@([\w-]+\.)+[\w-]{2,7}$""".r override def validate(string: String): ValidationResult[Email] = { val valid = emailRegex.findAllMatchIn(string).length == 1 Option .when(valid)(ValidationResult.Valid(string, new Email(string))) .getOrElse { ValidationResult.Invalid(string, "Invalid email") } } override def trusted(string: String): Email = new Email(string) } ================================================ FILE: lib/common/shared/src/main/scala/net/wiringbits/common/models/Name.scala ================================================ package net.wiringbits.common.models import net.wiringbits.webapp.common.models.WrappedString import net.wiringbits.webapp.common.validators.ValidationResult class Name private (val string: String) extends WrappedString object Name extends WrappedString.Companion[Name] { private val minNameLength: Int = 2 // we do have people named like `Jo` override def validate(string: String): ValidationResult[Name] = { val isValid = string.length >= minNameLength Option .when(isValid)(ValidationResult.Valid(string, new Name(string))) .getOrElse { ValidationResult.Invalid(string, "Invalid name") } } override def trusted(string: String): Name = new Name(string) } ================================================ FILE: lib/common/shared/src/main/scala/net/wiringbits/common/models/Password.scala ================================================ package net.wiringbits.common.models import net.wiringbits.webapp.common.models.WrappedString import net.wiringbits.webapp.common.validators.ValidationResult class Password private (val string: String) extends WrappedString object Password extends WrappedString.Companion[Password] { private val minPasswordLength: Int = 8 override def validate(string: String): ValidationResult[Password] = { val isValid = string.length >= minPasswordLength Option .when(isValid)(ValidationResult.Valid(string, new Password(string))) .getOrElse { ValidationResult.Invalid(string, "Invalid password") } } override def trusted(string: String): Password = new Password(string) } ================================================ FILE: lib/common/shared/src/main/scala/net/wiringbits/common/models/UserToken.scala ================================================ package net.wiringbits.common.models import play.api.libs.json.{Format, Json} import java.util.UUID import scala.util.Try case class UserToken(userId: UUID, token: UUID) object UserToken { def validate(tokenStr: String): Option[UserToken] = { val splittedToken = tokenStr.split("_") val isValid = splittedToken.length == 2 // TODO: Improve this impl Try( Option.when(isValid)(UserToken(UUID.fromString(splittedToken(0)), UUID.fromString(splittedToken(1)))) ).toOption.flatten } implicit val userTokenFormat: Format[UserToken] = Json.format[UserToken] } ================================================ FILE: lib/common/shared/src/test/scala/net/wiringbits/common/models/EmailSpec.scala ================================================ package net.wiringbits.common.models import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec class EmailSpec extends AnyWordSpec { val valid = List( "alexis@wiringbits.net", "a@xe.com", "ejemplo@goo.gl", "ejemplo+aqui@e.io", "one_mail@test.com", "valid.mail@test.xs", "valid_@gf.com", "test@gmail.co.au", "test@gmail.space" ) val invalid = List( "alexis@wiringbits.net.", "alexis@wiringbits.net a@xe.net", "esto,noes@unemail", "esto tampoco@es", "@xe.com", "hello@", "ejemplo@goo", ".", "" ) "validate" should { valid.foreach { input => s"accept valid values: $input" in { Email.validate(input).isValid must be(true) } } invalid.foreach { input => s"reject invalid values: $input" in { Email.validate(input).isValid must be(false) } } } } ================================================ FILE: lib/common/shared/src/test/scala/net/wiringbits/common/models/NameSpec.scala ================================================ package net.wiringbits.common.models import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec class NameSpec extends AnyWordSpec { val valid = List( "ale", "jo", "jorge julian" ) val invalid = List( ".", "", "a" ) "validate" should { valid.foreach { input => s"accept valid values: $input" in { Name.validate(input).isValid must be(true) } } invalid.foreach { input => s"reject invalid values: $input" in { Name.validate(input).isValid must be(false) } } } } ================================================ FILE: lib/common/shared/src/test/scala/net/wiringbits/common/models/PasswordSpec.scala ================================================ package net.wiringbits.common.models import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec class PasswordSpec extends AnyWordSpec { val valid = List( "12345678", "aaabbbcc", "..121l2.1.2o9z9n23 voi109" ) val invalid = List( "...11..", "", "1j190u" ) "validate" should { valid.foreach { input => s"accept valid values: $input" in { Password.validate(input).isValid must be(true) } } invalid.foreach { input => s"reject invalid values: $input" in { Password.validate(input).isValid must be(false) } } } } ================================================ FILE: lib/common/shared/src/test/scala/net/wiringbits/common/models/UserTokenSpec.scala ================================================ package net.wiringbits.common.models import org.scalatest.matchers.must.Matchers.{be, must} import org.scalatest.wordspec.AnyWordSpec import java.util.UUID class UserTokenSpec extends AnyWordSpec { "validate" should { "succeed when there's two valid UUIDs and one underscore" in { val valid = s"${UUID.randomUUID()}_${UUID.randomUUID()}" UserToken.validate(valid).isDefined must be(true) } s"fail when the string is not a valid UUID" in { val invalid = "wiringbits" UserToken.validate(invalid).isDefined must be(false) } s"fail when the string is not a valid UUID and there's an underscore" in { val invalid = "wiringbits_wiringbits" UserToken.validate(invalid).isDefined must be(false) } s"fail when there's zero underscores in the string" in { val invalid = UUID.randomUUID.toString UserToken.validate(invalid).isDefined must be(false) } s"fail when there's more than two underscores in the string" in { val invalid = s"${UUID.randomUUID()}_${UUID.randomUUID()}_${UUID.randomUUID()}" UserToken.validate(invalid).isDefined must be(false) } s"fail when there's an underscore after the UUID" in { val invalid = s"${UUID.randomUUID()}_" UserToken.validate(invalid).isDefined must be(false) } } } ================================================ FILE: lib/ui/src/main/scala/net/wiringbits/ui/components/core/widgets/ValidatedTextInput.scala ================================================ package net.wiringbits.ui.components.core.widgets import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.webapp.common.validators.{TextValidator, ValidationResult} import net.wiringbits.webapp.utils.slinkyUtils.forms.FormField import org.scalajs.dom import slinky.core.FunctionalComponent abstract class ValidatedTextInput[T: TextValidator] { private val validator = implicitly[TextValidator[T]] case class Props( field: FormField[T], disabled: Boolean = false, onChange: ValidationResult[T] => Unit, margin: "dense" = "dense" ) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => def onChange(text: String): Unit = { val validation = validator(text) props.onChange(validation) } val helperText = props.field.value.flatMap(_.errorMessage).getOrElse("") val value = props.field.value.map(_.input).getOrElse("") val hasError = props.field.value.exists(_.hasError) mui.TextField .outlined() .id(s"ExperimentalTextInput-${props.field.name}") .name(s"ExperimentalTextInput-${props.field.name}") .label(props.field.label) .`type`(props.field.`type`) .required(props.field.required) .fullWidth(true) .disabled(props.disabled) .margin(props.margin) .error(hasError) .helperText(helperText) .value(value) .onChange(e => onChange(e.target.asInstanceOf[dom.HTMLInputElement].value)) } } ================================================ FILE: lib/ui/src/main/scala/net/wiringbits/ui/components/inputs/inputs.scala ================================================ package net.wiringbits.ui.components import net.wiringbits.common.models.{Email, Name, Password} import net.wiringbits.ui.components.core.widgets.ValidatedTextInput package object inputs { object NameInput extends ValidatedTextInput[Name] object EmailInput extends ValidatedTextInput[Email] object PasswordInput extends ValidatedTextInput[Password] } ================================================ FILE: project/build.properties ================================================ sbt.version = 1.7.3 ================================================ FILE: project/plugins.sbt ================================================ // while there are some eviction errors, plugins seem to be compatible so far evictionErrorLevel := sbt.util.Level.Warn addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.0") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.1") addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.20.0") addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta39") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") addSbtPlugin("org.wartremover" % "sbt-wartremover" % "3.1.3") ================================================ FILE: server/src/main/resources/application.conf ================================================ # https://www.playframework.com/documentation/latest/Configuration # Swagger - be aware these are used at compile time swagger { api { basePath = "" basePath = ${?SWAGGER_API_BASEPATH} info = { version = "beta" contact = "template@wiringbits.net" title = "Scala webapp template's API" description = "The API for the Scala webapp template app" } } } play.i18n.langs = ["en"] play.filters.hosts { allowed = [host.docker.internal, "localhost", "localhost:9000", "127.0.0.1:9000"] allowed += ${?APP_ALLOWED_HOST_1} allowed += ${?APP_ALLOWED_HOST_2} allowed += ${?APP_ALLOWED_HOST_3} } play.http { # Important for production, it is used to sign sessions secret.key = "changeme" secret.key = ${?PLAY_APPLICATION_SECRET} errorHandler = "play.api.http.JsonHttpErrorHandler" session { cookieName = "__APP_SESSION__" # false by default because we use http locally, must be true in prod secure = false secure = ${?PLAY_SESSION_SECURE} # to secure the cookie, this value should be set in prod domain = null domain = ${?PLAY_SESSION_DOMAIN} # The session path # Must start with /. path = ${play.http.context} path = ${?PLAY_SESSION_DOMAIN_PATH} } } play.filters.disabled += "play.filters.csrf.CSRFFilter" play.filters.enabled += "play.filters.cors.CORSFilter" db.default { driver = "org.postgresql.Driver" host = "localhost:5432" database = "wiringbits_db" username = "postgres" password = "postgres" host = ${?POSTGRES_HOST} database = ${?POSTGRES_DATABASE} username = ${?POSTGRES_USERNAME} password = ${?POSTGRES_PASSWORD} url = "jdbc:postgresql://"${db.default.host}"/"${db.default.database} } play.evolutions { autoApply = true db.default { enabled = true # Important because when this is false, failed migrations won't get to the play_evolutions table # preventing us to fix them manually autocommit = true } } # Number of database connections # See https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing fixedConnectionPool = 9 play.db { prototype { hikaricp.minimumIdle = ${fixedConnectionPool} hikaricp.maximumPoolSize = ${fixedConnectionPool} } } # Job queue sized to HikariCP connection pool database.dispatcher { executor = "thread-pool-executor" throughput = 1 thread-pool-executor { fixed-pool-size = ${fixedConnectionPool} } } blocking.dispatcher { executor = "thread-pool-executor" throughput = 1 thread-pool-executor { // very high bound to process lots of blocking operations concurrently fixed-pool-size = 5000 } } play.modules.enabled += "net.wiringbits.modules.ApisModule" play.modules.enabled += "net.wiringbits.modules.ConfigModule" play.modules.enabled += "net.wiringbits.modules.ExecutorsModule" play.modules.enabled += "net.wiringbits.modules.ClockModule" play.modules.enabled += "net.wiringbits.modules.TasksModule" email { senderAddress = "replace@replace.net" senderAddress = ${?EMAIL_SENDER_ADDRESS} # defines the provider used to send emails, valid values being "aws" or "none" provider = "none" provider = ${?EMAIL_PROVIDER} } userTokens { hmacSecret = "REPLACE ME" hmacSecret = ${?USER_TOKENS_HMAC_SECRET} emailVerification { # expiration time for email verification expirationTime = "24 hours" expirationTime = ${?USER_TOKENS_EMAIL_VERIFICATION_EXPIRATION_TIME} } resetPassword { # expiration time for email reset password expirationTime = "24 hours" expirationTime = ${?USER_TOKENS_RESET_PASSWORD_EXPIRATION_TIME} } } aws { region = "us-west-2" region = ${?AWS_REGION} accessKeyId = REPLACE_ME accessKeyId = ${?AWS_ACCESS_KEY_ID} secretAccessKey = REPLACE_ME secretAccessKey = ${?AWS_SECRET_ACCESS_KEY} } webapp { host = "http://localhost:8080" host = ${?WEBAPP_HOST} } recaptcha { # secret key only used for test purposes secretKey = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" secretKey = ${?RECAPTCHA_SECRET_KEY} siteKey = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" siteKey = ${?RECAPTCHA_SITE_KEY} } backgroundJobsExecutorTask { # the task will run every time the period is fullfilled interval = 1 minutes interval = ${?NOTIFICATIONS_TASK_INTERVAL} } ================================================ FILE: server/src/main/resources/evolutions/default/1.sql ================================================ -- !Ups -- The users table has the minimum necessary data CREATE TABLE users( user_id UUID NOT NULL, name TEXT NOT NULL, last_name TEXT NULL, email CITEXT NOT NULL, password TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), verified_on TIMESTAMPTZ NULL, CONSTRAINT users_user_id_pk PRIMARY KEY (user_id), CONSTRAINT users_email_unique UNIQUE (email) ); CREATE INDEX users_email_index ON users USING BTREE (email); -- create the table to store the user logs CREATE TABLE user_logs ( user_log_id UUID NOT NULL, user_id UUID NOT NULL, message TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT user_logs_pk PRIMARY KEY (user_log_id), CONSTRAINT user_logs_users_fk FOREIGN KEY (user_id) REFERENCES users(user_id) ); CREATE INDEX user_logs_user_id_index ON user_logs USING BTREE (user_id); CREATE TABLE user_tokens ( user_token_id UUID NOT NULL, token TEXT NOT NULL, token_type TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, user_id UUID NOT NULL, CONSTRAINT user_tokens_id_pk PRIMARY KEY (user_token_id), CONSTRAINT user_tokens_user_id_fk FOREIGN KEY (user_id) REFERENCES users (user_id) ); CREATE INDEX user_tokens_user_id_index ON user_tokens USING BTREE (user_id); -- Stores the notifications we are sending to the user from a background job CREATE TABLE user_notifications ( user_notification_id UUID NOT NULL, user_id UUID NOT NULL, notification_type TEXT NOT NULL, subject TEXT NOT NULL, message TEXT NOT NULL, status TEXT NOT NULL, -- pending/success/failed, status_details TEXT NULL, -- if failed, what was the reason error_count INT DEFAULT 0, execute_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT user_notifications_user_notification_id_pk PRIMARY KEY (user_notification_id), CONSTRAINT user_notifications_user_id_fk FOREIGN KEY (user_id) REFERENCES users(user_id) ); CREATE INDEX user_notifications_user_id_index ON user_notifications USING BTREE (user_id); CREATE INDEX user_notifications_execute_at_index ON user_notifications USING BTREE (execute_at); ================================================ FILE: server/src/main/resources/evolutions/default/2.sql ================================================ -- !Ups -- Stores the background jobs from the app CREATE TABLE background_jobs ( background_job_id UUID NOT NULL, type TEXT NOT NULL, payload JSONB NOT NULL, status TEXT NOT NULL, -- pending/success/failed, status_details TEXT NULL, -- if failed, what was the reason error_count INT DEFAULT 0, execute_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT background_jobs_id_pk PRIMARY KEY (background_job_id) ); CREATE INDEX background_jobs_execute_at_index ON background_jobs USING BTREE (execute_at); -- these are now handled by background_jobs DROP TABLE user_notifications; ================================================ FILE: server/src/main/resources/logback.xml ================================================ ${user.home:-.}/logs/application.log ${user.home:-.}/logs/application.%d{yyyy-MM-dd}.log 30 3GB %date [%level] from %logger in %thread - %message%n%rEx%xException %coloredLevel %logger{15} - %message%n%rEx%xException{10} ================================================ FILE: server/src/main/resources/messages ================================================ # https://www.playframework.com/documentation/latest/ScalaI18N ================================================ FILE: server/src/main/resources/routes ================================================ # Routes # This file defines all application routes (Higher priority routes first) # https://www.playframework.com/documentation/latest/ScalaRouting # ~~~~ -> / controllers.ApiRouter # routes for admin tables (GET, POST, PUT and DELETE) #-> / net.wiringbits.webapp.utils.admin.AppRouter ================================================ FILE: server/src/main/scala/PekkoStream.scala ================================================ /* * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. */ package anorm import java.sql.Connection import scala.util.control.NonFatal import scala.concurrent.{Future, Promise} import org.apache.pekko.stream.scaladsl.Source import scala.annotation.nowarn /** Anorm companion for the Pekko Streams. * * @define materialization * It materializes a [[scala.concurrent.Future]] of [[scala.Int]] containing the number of rows read from the source * upon completion, and a possible exception if row parsing failed. * @define sqlParam * the SQL query * @define connectionParam * the JDBC connection, which must not be closed until the source is materialized. * @define columnAliaserParam * the column aliaser */ // From https://github.com/playframework/anorm/blob/main/pekko/src/main/scala/anorm/PekkoStream.scala // We are copying this because the anorm.pekko isn't published yet // TODO: remove after anorm.pekko is published object PekkoStream { /** Returns the rows parsed from the `sql` query as a reactive source. * * $materialization * * @tparam T * the type of the result elements * @param sql * $sqlParam * @param parser * the result (row) parser * @param as * $columnAliaserParam * @param connection * $connectionParam * * {{{ * import java.sql.Connection * * import scala.concurrent.Future * * import org.apache.pekko.stream.scaladsl.Source * * import anorm._ * * def resultSource(implicit con: Connection): Source[String, Future[Int]] = PekkoStream.source(SQL"SELECT * FROM Test", SqlParser.scalar[String], ColumnAliaser.empty) * }}} */ @SuppressWarnings(Array("UnusedMethodParameter")) def source[T](sql: => Sql, parser: RowParser[T], as: ColumnAliaser)(implicit con: Connection ): Source[T, Future[Int]] = Source.fromGraph(new ResultSource[T](con, sql, as, parser)) /** Returns the rows parsed from the `sql` query as a reactive source. * * $materialization * * @tparam T * the type of the result elements * @param sql * $sqlParam * @param parser * the result (row) parser * @param connection * $connectionParam */ @SuppressWarnings(Array("UnusedMethodParameter")) def source[T](sql: => Sql, parser: RowParser[T])(implicit con: Connection): Source[T, Future[Int]] = source[T](sql, parser, ColumnAliaser.empty) /** Returns the result rows from the `sql` query as an enumerator. This is equivalent to `source[Row](sql, * RowParser.successful, as)`. * * $materialization * * @param sql * $sqlParam * @param as * $columnAliaserParam * @param connection * $connectionParam */ def source(sql: => Sql, as: ColumnAliaser)(implicit connection: Connection): Source[Row, Future[Int]] = source(sql, RowParser.successful, as) /** Returns the result rows from the `sql` query as an enumerator. This is equivalent to `source[Row](sql, * RowParser.successful, ColumnAliaser.empty)`. * * $materialization * * @param sql * $sqlParam * @param connection * $connectionParam */ def source(sql: => Sql)(implicit connnection: Connection): Source[Row, Future[Int]] = source(sql, RowParser.successful, ColumnAliaser.empty) // Internal stages import org.apache.pekko.stream.stage.{GraphStageLogic, GraphStageWithMaterializedValue, OutHandler} import org.apache.pekko.stream.{Attributes, Outlet, SourceShape} import java.sql.ResultSet import scala.util.{Failure, Success} private[anorm] class ResultSource[T](connection: Connection, sql: Sql, as: ColumnAliaser, parser: RowParser[T]) extends GraphStageWithMaterializedValue[SourceShape[T], Future[Int]] { @SuppressWarnings(Array("org.wartremover.warts.Null")) private[anorm] var resultSet: ResultSet = _ override val toString = "AnormQueryResult" val out: Outlet[T] = Outlet(s"${toString}.out") val shape: SourceShape[T] = SourceShape(out) override def createLogicAndMaterializedValue(inheritedAttributes: Attributes): (GraphStageLogic, Future[Int]) = { val result = Promise[Int]() val logic = new GraphStageLogic(shape) with OutHandler { private var cursor: Option[Cursor] = None private var counter: Int = 0 private def failWith(cause: Throwable): Unit = { result.failure(cause) fail(out, cause) () } override def preStart(): Unit = { try { resultSet = sql.unsafeResultSet(connection) nextCursor() } catch { case NonFatal(cause) => failWith(cause) } } override def postStop() = release() private def release(): Unit = { val stmt: Option[java.sql.Statement] = { if (resultSet != null && !resultSet.isClosed) { val s = resultSet.getStatement resultSet.close() Option(s) } else None } stmt.foreach { s => if (!s.isClosed) s.close() } } private def nextCursor(): Unit = { cursor = Sql.unsafeCursor(resultSet, sql.resultSetOnFirstRow, as) } def onPull(): Unit = cursor match { case Some(c) => c.row.as(parser) match { case Success(parsed) => { counter += 1 push(out, parsed) nextCursor() } case Failure(cause) => failWith(cause) } case _ => { result.success(counter) complete(out) } } @nowarn override def onDownstreamFinish() = { result.tryFailure(new InterruptedException("Downstream finished")) release() super.onDownstreamFinish() } setHandler(out, this) } logic -> result.future } } } ================================================ FILE: server/src/main/scala/controllers/AdminController.scala ================================================ package controllers import net.wiringbits.api.endpoints.AdminEndpoints import net.wiringbits.api.models.ErrorResponse import net.wiringbits.api.models.admin.{AdminGetUserLogs, AdminGetUsers} import net.wiringbits.common.models.Email import net.wiringbits.services.AdminService import org.slf4j.LoggerFactory import sttp.capabilities.WebSockets import sttp.capabilities.pekko.PekkoStreams import sttp.tapir.server.ServerEndpoint import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class AdminController @Inject() ( adminService: AdminService )(implicit ec: ExecutionContext) { private val logger = LoggerFactory.getLogger(this.getClass) private def getUserLogs( authBasic: String, userId: UUID, adminCookie: String ): Future[Either[ErrorResponse, AdminGetUserLogs.Response]] = handleRequest { logger.info(s"Get user logs: $userId") for { response <- adminService.userLogs(userId) } yield Right(response) } private def getUsers( authBasic: String, adminCookie: String ): Future[Either[ErrorResponse, AdminGetUsers.Response]] = handleRequest { logger.info(s"Get users") for { response <- adminService.users() // TODO: Avoid masking data when this the admin website is not public maskedResponse = response.copy(data = response.data.map(_.copy(email = Email.trusted("email@wiringbits.net")))) } yield Right(maskedResponse) } def routes: List[ServerEndpoint[PekkoStreams with WebSockets, Future]] = { List( AdminEndpoints.getUserLogsEndpoint.serverLogic(getUserLogs), AdminEndpoints.getUsersEndpoint.serverLogic(getUsers) ) } } ================================================ FILE: server/src/main/scala/controllers/ApiRouter.scala ================================================ package controllers import net.wiringbits.api.endpoints.* import net.wiringbits.config.SwaggerConfig import org.apache.pekko.actor.ActorSystem import org.apache.pekko.stream.Materializer import play.api.routing.Router.Routes import play.api.routing.SimpleRouter import sttp.apispec.openapi.Info import sttp.tapir.AnyEndpoint import sttp.tapir.server.play.PlayServerInterpreter import sttp.tapir.swagger.SwaggerUIOptions import sttp.tapir.swagger.bundle.SwaggerInterpreter import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class ApiRouter @Inject() ( adminController: AdminController, authController: AuthController, healthController: HealthController, usersController: UsersController, environmentConfigController: EnvironmentConfigController, swaggerConfig: SwaggerConfig )(using ExecutionContext) extends SimpleRouter { given ActorSystem = ActorSystem("ApiRouter") private val swagger = SwaggerInterpreter( swaggerUIOptions = SwaggerUIOptions.default.copy(contextPath = List(swaggerConfig.basePath)) ) .fromEndpoints[Future]( ApiRouter.routes, Info( title = swaggerConfig.info.title, version = swaggerConfig.info.version, description = Some(swaggerConfig.info.description) ) ) override def routes: Routes = PlayServerInterpreter() .toRoutes( List( swagger, usersController.routes, authController.routes, healthController.routes, adminController.routes, environmentConfigController.routes ).flatten ) } object ApiRouter { private def routes(implicit ec: ExecutionContext): List[AnyEndpoint] = List( HealthEndpoints.routes, AdminEndpoints.routes, AuthEndpoints.routes, UsersEndpoints.routes, EnvironmentConfigEndpoints.routes ).flatten } ================================================ FILE: server/src/main/scala/controllers/AuthController.scala ================================================ package controllers import net.wiringbits.actions.auth.{GetUserAction, LoginAction} import net.wiringbits.api.endpoints.AuthEndpoints import net.wiringbits.api.models.* import net.wiringbits.api.models.auth.{GetCurrentUser, Login, Logout} import org.slf4j.LoggerFactory import sttp.capabilities.WebSockets import sttp.capabilities.pekko.PekkoStreams import sttp.tapir.server.ServerEndpoint import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class AuthController @Inject() ( loginAction: LoginAction, getUserAction: GetUserAction, playTapirBridge: PlayTapirBridge )(implicit ec: ExecutionContext) { private val logger = LoggerFactory.getLogger(this.getClass) private def login(body: Login.Request): Future[Either[ErrorResponse, (Login.Response, String)]] = handleRequest { logger.info(s"Login API: ${body.email}") for { response <- loginAction(body) cookieEncoded <- playTapirBridge.setSession(response.id) } yield Right(response, cookieEncoded) } private def me(userIdF: Future[UUID]): Future[Either[ErrorResponse, GetCurrentUser.Response]] = handleRequest { for { userId <- userIdF _ = logger.info(s"Get user info: $userId") response <- getUserAction(userId) } yield Right(response) } private def logout(userIdF: Future[UUID]): Future[Either[ErrorResponse, (Logout.Response, String)]] = handleRequest { for { _ <- userIdF _ = logger.info("Logout") header <- playTapirBridge.clearSession() } yield Right(Logout.Response(), header) } def routes: List[ServerEndpoint[PekkoStreams with WebSockets, Future]] = { List( AuthEndpoints.login.serverLogic(login), AuthEndpoints.getCurrentUser.serverLogic(me), AuthEndpoints.logout.serverLogic(logout) ) } } ================================================ FILE: server/src/main/scala/controllers/EnvironmentConfigController.scala ================================================ package controllers import net.wiringbits.actions.environmentconfig.GetEnvironmentConfigAction import net.wiringbits.api.endpoints.EnvironmentConfigEndpoints import net.wiringbits.api.models.ErrorResponse import net.wiringbits.api.models.environmentconfig.GetEnvironmentConfig import org.slf4j.LoggerFactory import sttp.capabilities.WebSockets import sttp.capabilities.pekko.PekkoStreams import sttp.tapir.server.ServerEndpoint import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class EnvironmentConfigController @Inject() ( getEnvironmentConfigAction: GetEnvironmentConfigAction )(implicit ec: ExecutionContext) { private val logger = LoggerFactory.getLogger(this.getClass) private def getEnvironmentConfig: Future[Either[ErrorResponse, GetEnvironmentConfig.Response]] = handleRequest { logger.info("Get frontend config") for { response <- getEnvironmentConfigAction() } yield Right(response) } def routes: List[ServerEndpoint[PekkoStreams with WebSockets, Future]] = { List(EnvironmentConfigEndpoints.getEnvironmentConfig.serverLogic(_ => getEnvironmentConfig)) } } ================================================ FILE: server/src/main/scala/controllers/HealthController.scala ================================================ package controllers import net.wiringbits.api.endpoints.HealthEndpoints import sttp.capabilities.WebSockets import sttp.capabilities.pekko.PekkoStreams import sttp.model.headers.{Cookie, CookieValueWithMeta, CookieWithMeta} import sttp.tapir.server.ServerEndpoint import java.time.Instant import java.time.temporal.ChronoUnit import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class HealthController @Inject() (implicit ec: ExecutionContext) { private def check: Future[Either[Unit, Unit]] = Future.successful(Right(())) def routes: List[ServerEndpoint[PekkoStreams with WebSockets, Future]] = { List(HealthEndpoints.check.serverLogic(_ => check)) } } ================================================ FILE: server/src/main/scala/controllers/UsersController.scala ================================================ package controllers import net.wiringbits.actions.* import net.wiringbits.actions.users.* import net.wiringbits.api.endpoints.UsersEndpoints import net.wiringbits.api.models.* import net.wiringbits.api.models.users.* import org.slf4j.LoggerFactory import sttp.capabilities.WebSockets import sttp.capabilities.pekko.PekkoStreams import sttp.tapir.server.ServerEndpoint import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class UsersController @Inject() ( createUserAction: CreateUserAction, verifyUserEmailAction: VerifyUserEmailAction, forgotPasswordAction: ForgotPasswordAction, resetPasswordAction: ResetPasswordAction, updateUserAction: UpdateUserAction, updatePasswordAction: UpdatePasswordAction, getUserLogsAction: GetUserLogsAction, sendEmailVerificationTokenAction: SendEmailVerificationTokenAction )(implicit ec: ExecutionContext) { private val logger = LoggerFactory.getLogger(this.getClass) private def create(request: CreateUser.Request): Future[Either[ErrorResponse, CreateUser.Response]] = handleRequest { logger.info(s"Create user: ${request.email.string}") for { response <- createUserAction(request) } yield Right(response) } private def verifyEmail(request: VerifyEmail.Request) = handleRequest { val token = request.token logger.info(s"Verify user's email: ${token.userId}") for { response <- verifyUserEmailAction(token.userId, token.token) } yield Right(response) } private def forgotPassword(request: ForgotPassword.Request): Future[Either[ErrorResponse, ForgotPassword.Response]] = handleRequest { logger.info(s"Send a link to reset password for user with email: ${request.email}") for { response <- forgotPasswordAction(request) } yield Right(response) } private def resetPassword(request: ResetPassword.Request): Future[Either[ErrorResponse, ResetPassword.Response]] = handleRequest { logger.info(s"Reset user's password: ${request.token.userId}") for { response <- resetPasswordAction(request.token.userId, request.token.token, request.password) } yield Right(response) } private def sendEmailVerificationToken( request: SendEmailVerificationToken.Request ): Future[Either[ErrorResponse, SendEmailVerificationToken.Response]] = handleRequest { logger.info(s"Send email to: ${request.email}") for { response <- sendEmailVerificationTokenAction(request) } yield Right(response) } private def update( request: UpdateUser.Request, userIdF: Future[UUID] ): Future[Either[ErrorResponse, UpdateUser.Response]] = handleRequest { logger.info(s"Update user: $request") for { userId <- userIdF _ <- updateUserAction(userId, request) response = UpdateUser.Response() } yield Right(response) } private def updatePassword( request: UpdatePassword.Request, userIdF: Future[UUID] ): Future[Either[ErrorResponse, UpdatePassword.Response]] = handleRequest { for { userId <- userIdF _ = logger.info(s"Update password for: $userId") _ <- updatePasswordAction(userId, request) response = UpdatePassword.Response() } yield Right(response) } private def getLogs(userIdF: Future[UUID]): Future[Either[ErrorResponse, GetUserLogs.Response]] = handleRequest { for { userId <- userIdF _ = logger.info(s"Get user logs: $userId") response <- getUserLogsAction(userId) } yield Right(response) } def routes: List[ServerEndpoint[PekkoStreams with WebSockets, Future]] = { List( UsersEndpoints.create.serverLogic(create), UsersEndpoints.verifyEmail.serverLogic(verifyEmail), UsersEndpoints.forgotPassword.serverLogic(forgotPassword), UsersEndpoints.resetPassword.serverLogic(resetPassword), UsersEndpoints.sendEmailVerificationToken.serverLogic(sendEmailVerificationToken), UsersEndpoints.update.serverLogic(update), UsersEndpoints.updatePassword.serverLogic(updatePassword), UsersEndpoints.getLogs.serverLogic(getLogs) ) } } ================================================ FILE: server/src/main/scala/controllers/package.scala ================================================ import net.wiringbits.api.models.{ErrorResponse, errorResponseFormat} import org.slf4j.LoggerFactory import play.api.mvc.request.DefaultRequestFactory import play.api.mvc.{CookieHeaderEncoding, RequestHeader, Session} import sttp.tapir.model.ServerRequest import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} import scala.language.implicitConversions import scala.util.Try import scala.util.control.NonFatal package object controllers { private val logger = LoggerFactory.getLogger(this.getClass) class PlayTapirBridge @Inject() ( requestFactory: DefaultRequestFactory, cookieHeaderEncoding: CookieHeaderEncoding )(implicit ec: ExecutionContext) { def setSession(userId: UUID): Future[String] = Future { val session = Session(Map("id" -> userId.toString)) val playCookie = requestFactory.sessionBaker.encodeAsCookie(session) cookieHeaderEncoding.encodeSetCookieHeader(List(playCookie)) } def clearSession(): Future[String] = Future { val encoded = requestFactory.sessionBaker.discard.toCookie cookieHeaderEncoding.encodeSetCookieHeader(List(encoded)) } } def handleRequest[R]( block: Future[Right[ErrorResponse, R]] )(implicit ec: ExecutionContext): Future[Either[ErrorResponse, R]] = { block.recover(errorHandler) } def errorHandler[R]: PartialFunction[Throwable, Left[ErrorResponse, R]] = { // rendering any error this way should be enough for a while case NonFatal(ex) => // debug level used because this includes any validation error as well as server errors logger.debug(s"Error response while handling a request: ${ex.getMessage}", ex) Left(ErrorResponse(ex.getMessage)) } // This is the way to access the play request from tapir, we need it to extract the play session // UUID has to be future, because we want to handle the exception in the controllers implicit def authHandler(serverRequest: ServerRequest)(implicit ec: ExecutionContext): Future[UUID] = val session = serverRequest.underlying .asInstanceOf[RequestHeader] .session def userIdFromSession = Future { session .get("id") .flatMap(str => Try(UUID.fromString(str)).toOption) .getOrElse(throw new RuntimeException("Invalid or missing authentication")) } userIdFromSession .recover { case NonFatal(_) => throw new RuntimeException("Unauthorized: Invalid or missing authentication") } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/auth/GetUserAction.scala ================================================ package net.wiringbits.actions.auth import io.scalaland.chimney.dsl.transformInto import net.wiringbits.api.models.auth.GetCurrentUser import net.wiringbits.repositories.UsersRepository import net.wiringbits.repositories.models.User import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class GetUserAction @Inject() ( usersRepository: UsersRepository )(implicit ec: ExecutionContext) { def apply(userId: UUID): Future[GetCurrentUser.Response] = { for { user <- unsafeUser(userId) } yield user.transformInto[GetCurrentUser.Response] } private def unsafeUser(userId: UUID): Future[User] = { usersRepository .find(userId) .map { maybe => maybe.getOrElse( throw new RuntimeException( s"Unexpected error because the user wasn't found: $userId" ) ) } } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/auth/LoginAction.scala ================================================ package net.wiringbits.actions.auth import io.scalaland.chimney.dsl.transformInto import net.wiringbits.api.models.auth.Login import net.wiringbits.apis.ReCaptchaApi import net.wiringbits.repositories.{UserLogsRepository, UsersRepository} import net.wiringbits.validations.{ValidateCaptcha, ValidatePasswordMatches, ValidateVerifiedUser} import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class LoginAction @Inject() ( captchaApi: ReCaptchaApi, usersRepository: UsersRepository, userLogsRepository: UserLogsRepository )(implicit ec: ExecutionContext ) { // returns the token to use for authenticating requests def apply(request: Login.Request): Future[Login.Response] = { for { _ <- ValidateCaptcha(captchaApi, request.captcha) // the user is verified maybe <- usersRepository.find(request.email) _ = maybe.foreach(ValidateVerifiedUser.apply) // The password matches user = ValidatePasswordMatches(maybe, request.password) // A login token is created _ <- userLogsRepository.create(user.id, "Logged in successfully") } yield user.transformInto[Login.Response] } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/environmentconfig/GetEnvironmentConfigAction.scala ================================================ package net.wiringbits.actions.environmentconfig import net.wiringbits.api.models.environmentconfig.GetEnvironmentConfig import net.wiringbits.config.ReCaptchaConfig import javax.inject.Inject import scala.concurrent.Future class GetEnvironmentConfigAction @Inject() ( reCaptchaConfig: ReCaptchaConfig )() { def apply(): Future[GetEnvironmentConfig.Response] = Future.successful { GetEnvironmentConfig.Response(reCaptchaConfig.siteKey.string) } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/internal/StreamPendingBackgroundJobsForeverAction.scala ================================================ package net.wiringbits.actions.internal import org.apache.pekko.actor.ActorSystem import org.apache.pekko.stream.scaladsl.* import net.wiringbits.repositories.BackgroundJobsRepository import net.wiringbits.repositories.models.BackgroundJobData import org.slf4j.LoggerFactory import javax.inject.Inject import scala.concurrent.duration.{DurationInt, FiniteDuration} import scala.concurrent.{ExecutionContext, Future} class StreamPendingBackgroundJobsForeverAction @Inject() (backgroundJobsRepository: BackgroundJobsRepository)(implicit ec: ExecutionContext, system: ActorSystem ) { private val logger = LoggerFactory.getLogger(this.getClass) def apply(reconnectionDelay: FiniteDuration = 10.seconds): Source[BackgroundJobData, org.apache.pekko.NotUsed] = { // Let's use unfoldAsync to continuously fetch items from database // First execution doesn't involve a delay Source .unfoldAsync[Boolean, Source[BackgroundJobData, Future[Int]]](false) { delay => logger.trace(s"Looking for pending background jobs") org.apache.pekko.pattern .after(if (delay) reconnectionDelay else 0.seconds) { backgroundJobsRepository.streamPendingJobs } .map(source => Some(true -> source)) } .flatMapConcat(identity) } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/users/CreateUserAction.scala ================================================ package net.wiringbits.actions.users import io.scalaland.chimney.dsl.transformInto import net.wiringbits.api.models.users.CreateUser import net.wiringbits.apis.ReCaptchaApi import net.wiringbits.config.UserTokensConfig import net.wiringbits.repositories import net.wiringbits.repositories.UsersRepository import net.wiringbits.repositories.models.User import net.wiringbits.util.{EmailsHelper, TokenGenerator, TokensHelper} import net.wiringbits.validations.{ValidateCaptcha, ValidateEmailIsAvailable} import org.mindrot.jbcrypt.BCrypt import java.time.Instant import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class CreateUserAction @Inject() ( usersRepository: UsersRepository, reCaptchaApi: ReCaptchaApi, tokenGenerator: TokenGenerator, userTokensConfig: UserTokensConfig, emailsHelper: EmailsHelper )(implicit ec: ExecutionContext ) { def apply(request: CreateUser.Request): Future[CreateUser.Response] = { for { _ <- validations(request) hashedPassword = BCrypt.hashpw(request.password.string, BCrypt.gensalt()) token = tokenGenerator.next() hmacToken = TokensHelper.doHMACSHA1(token.toString.getBytes(), userTokensConfig.hmacSecret) // create the user createUser = repositories.models.User .CreateUser( id = UUID.randomUUID(), name = request.name, email = request.email, hashedPassword = hashedPassword, verifyEmailToken = hmacToken ) _ <- usersRepository.create(createUser) // then, send the verification email _ <- emailsHelper.sendRegistrationEmailWithVerificationToken( User( id = createUser.id, name = request.name, email = request.email, hashedPassword = hashedPassword, createdAt = Instant.now, verifiedOn = None ), token ) } yield createUser.transformInto[CreateUser.Response] } private def validations(request: CreateUser.Request) = { for { _ <- ValidateCaptcha(reCaptchaApi, request.captcha) _ <- ValidateEmailIsAvailable(usersRepository, request.email) } yield () } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/users/ForgotPasswordAction.scala ================================================ package net.wiringbits.actions.users import net.wiringbits.api.models.users.ForgotPassword import net.wiringbits.apis.ReCaptchaApi import net.wiringbits.repositories.UsersRepository import net.wiringbits.repositories.models.User import net.wiringbits.util.EmailsHelper import net.wiringbits.validations.{ValidateCaptcha, ValidateVerifiedUser} import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class ForgotPasswordAction @Inject() ( captchaApi: ReCaptchaApi, usersRepository: UsersRepository, emailsHelper: EmailsHelper )(implicit ec: ExecutionContext) { def apply(request: ForgotPassword.Request): Future[ForgotPassword.Response] = { for { _ <- ValidateCaptcha(captchaApi, request.captcha) userMaybe <- usersRepository.find(request.email) // submit the email only when the user exists, otherwise, ignore the request _ <- userMaybe.map(whenExists).getOrElse(Future.unit) } yield ForgotPassword.Response() } private def whenExists(user: User) = { for { _ <- Future { ValidateVerifiedUser(user) } _ <- emailsHelper.sendPasswordRecoveryEmail(user) } yield () } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/users/GetUserLogsAction.scala ================================================ package net.wiringbits.actions.users import io.scalaland.chimney.dsl.transformInto import net.wiringbits.api.models.users.GetUserLogs import net.wiringbits.repositories.UserLogsRepository import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class GetUserLogsAction @Inject() ( userLogsRepository: UserLogsRepository )(implicit ec: ExecutionContext) { def apply(userId: UUID): Future[GetUserLogs.Response] = { for { logs <- userLogsRepository.logs(userId) items = logs.map(_.transformInto[GetUserLogs.Response.UserLog]) } yield GetUserLogs.Response(items) } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/users/ResetPasswordAction.scala ================================================ package net.wiringbits.actions.users import net.wiringbits.api.models.users.ResetPassword import net.wiringbits.common.models.Password import net.wiringbits.config.UserTokensConfig import net.wiringbits.repositories.{UserTokensRepository, UsersRepository} import net.wiringbits.util.{EmailMessage, TokensHelper} import net.wiringbits.validations.ValidateUserToken import org.mindrot.jbcrypt.BCrypt import java.time.Clock import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class ResetPasswordAction @Inject() ( userTokensConfig: UserTokensConfig, usersRepository: UsersRepository, userTokensRepository: UserTokensRepository )(implicit ec: ExecutionContext, clock: Clock ) { def apply(userId: UUID, token: UUID, password: Password): Future[ResetPassword.Response] = { val hashedPassword = BCrypt.hashpw(password.string, BCrypt.gensalt()) val hmacToken = TokensHelper.doHMACSHA1(token.toString.getBytes, userTokensConfig.hmacSecret) for { // When the token valid tokenMaybe <- userTokensRepository.find(userId, hmacToken) token = tokenMaybe.getOrElse(throw new RuntimeException(s"Token for user $userId wasn't found")) _ = ValidateUserToken(token) // We trigger the reset password flow userMaybe <- usersRepository.find(userId) user = userMaybe.getOrElse(throw new RuntimeException(s"User with id $userId wasn't found")) emailMessage = EmailMessage.resetPassword(user.name) _ <- usersRepository.resetPassword(userId, hashedPassword, emailMessage) } yield ResetPassword.Response(name = user.name, email = user.email) } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/users/SendEmailVerificationTokenAction.scala ================================================ package net.wiringbits.actions.users import net.wiringbits.api.models.users.SendEmailVerificationToken import net.wiringbits.apis.ReCaptchaApi import net.wiringbits.repositories.UsersRepository import net.wiringbits.util.EmailsHelper import net.wiringbits.validations.{ValidateCaptcha, ValidateEmailIsRegistered, ValidateUserIsNotVerified} import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class SendEmailVerificationTokenAction @Inject() ( usersRepository: UsersRepository, emailsHelper: EmailsHelper, reCaptchaApi: ReCaptchaApi )(implicit ec: ExecutionContext) { def apply(request: SendEmailVerificationToken.Request): Future[SendEmailVerificationToken.Response] = { for { _ <- validations(request) userMaybe <- usersRepository.find(request.email) user = userMaybe.getOrElse(throw new RuntimeException(s"User with email ${request.email} wasn't found")) _ = ValidateUserIsNotVerified(user) expiresAt <- emailsHelper.sendEmailVerificationToken(user) } yield SendEmailVerificationToken.Response(expiresAt = expiresAt) } private def validations(request: SendEmailVerificationToken.Request) = { for { _ <- ValidateCaptcha(reCaptchaApi, request.captcha) _ <- ValidateEmailIsRegistered(usersRepository, request.email) } yield () } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/users/UpdatePasswordAction.scala ================================================ package net.wiringbits.actions.users import net.wiringbits.api.models.users.UpdatePassword import net.wiringbits.repositories.UsersRepository import net.wiringbits.util.EmailMessage import net.wiringbits.validations.ValidatePasswordMatches import org.mindrot.jbcrypt.BCrypt import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class UpdatePasswordAction @Inject() ( usersRepository: UsersRepository )(implicit ec: ExecutionContext) { def apply(userId: UUID, request: UpdatePassword.Request): Future[Unit] = { for { maybe <- usersRepository.find(userId) user = ValidatePasswordMatches(maybe, request.oldPassword) hashedPassword = BCrypt.hashpw(request.newPassword.string, BCrypt.gensalt()) emailMessage = EmailMessage.updatePassword(user.name) _ <- usersRepository.updatePassword(userId, hashedPassword, emailMessage) } yield () } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/users/UpdateUserAction.scala ================================================ package net.wiringbits.actions.users import net.wiringbits.api.models.users.UpdateUser import net.wiringbits.repositories.UsersRepository import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class UpdateUserAction @Inject() ( usersRepository: UsersRepository )(implicit ec: ExecutionContext) { def apply(userId: UUID, request: UpdateUser.Request): Future[Unit] = { val validate = Future { if (request.name.string.isEmpty) new RuntimeException(s"The name is required") else () } for { _ <- validate _ <- usersRepository.update(userId, request.name) } yield () } } ================================================ FILE: server/src/main/scala/net/wiringbits/actions/users/VerifyUserEmailAction.scala ================================================ package net.wiringbits.actions.users import net.wiringbits.api.models.users.VerifyEmail import net.wiringbits.config.UserTokensConfig import net.wiringbits.repositories.{UserTokensRepository, UsersRepository} import net.wiringbits.util.{EmailMessage, TokensHelper} import net.wiringbits.validations.{ValidateUserIsNotVerified, ValidateUserToken} import java.time.Clock import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class VerifyUserEmailAction @Inject() ( usersRepository: UsersRepository, userTokensRepository: UserTokensRepository, userTokensConfig: UserTokensConfig )(implicit ec: ExecutionContext, clock: Clock ) { def apply(userId: UUID, token: UUID): Future[VerifyEmail.Response] = for { // when the user is not verified userMaybe <- usersRepository.find(userId) user = userMaybe.getOrElse(throw new RuntimeException(s"User wasn't found")) _ = ValidateUserIsNotVerified(user) // the token is validated hmacToken = TokensHelper.doHMACSHA1(token.toString.getBytes, userTokensConfig.hmacSecret) tokenMaybe <- userTokensRepository.find(userId, hmacToken) userToken = tokenMaybe.getOrElse(throw new RuntimeException(s"Token for user $userId wasn't found")) _ = ValidateUserToken(userToken) // then, the user is marked as verified emailMessage = EmailMessage.confirm(user.name) _ <- usersRepository.verify(userId = userId, tokenId = userToken.id, emailMessage) } yield VerifyEmail.Response() } ================================================ FILE: server/src/main/scala/net/wiringbits/apis/EmailApi.scala ================================================ package net.wiringbits.apis import net.wiringbits.apis.models.EmailRequest import org.slf4j.LoggerFactory import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} trait EmailApi { def sendEmail(emailRequest: EmailRequest): Future[Unit] } object EmailApi { class LogImpl @Inject() (implicit ec: ExecutionContext) extends EmailApi { private val logger = LoggerFactory.getLogger(this.getClass) override def sendEmail(request: EmailRequest): Future[Unit] = Future { logger.info( s"Sending email, to = ${request.destination}, subject = ${request.message.subject}, body = ${request.message.body}" ) } } } ================================================ FILE: server/src/main/scala/net/wiringbits/apis/EmailApiAWSImpl.scala ================================================ package net.wiringbits.apis import net.wiringbits.apis.models.EmailRequest import net.wiringbits.config.{AWSConfig, EmailConfig} import org.slf4j.LoggerFactory import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} import software.amazon.awssdk.services.ses.SesAsyncClient import software.amazon.awssdk.services.ses.model.* import javax.inject.Inject import scala.jdk.FutureConverters.CompletionStageOps import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.{Future, blocking} class EmailApiAWSImpl @Inject() ( emailConfig: EmailConfig, awsConfig: AWSConfig ) extends EmailApi { private val logger = LoggerFactory.getLogger(this.getClass) override def sendEmail(emailRequest: EmailRequest): Future[Unit] = { val from = emailConfig.senderAddress val htmlBody = s"""

${emailRequest.message.body}

""".stripMargin def unsafe: Future[Unit] = try { val credentials = AwsBasicCredentials.create(awsConfig.accessKeyId.string, awsConfig.secretAccessKey.string) val credentialsProvider = StaticCredentialsProvider.create(credentials) val client = SesAsyncClient.builder.region(awsConfig.region).credentialsProvider(credentialsProvider).build() val destination = Destination.builder.toAddresses(emailRequest.destination.string).build() val body = Body.builder .html(Content.builder.charset("UTF-8").data(htmlBody).build()) .text(Content.builder.charset("UTF-8").data(emailRequest.message.body).build()) .build() val subject = Content.builder.charset("UTF-8").data(emailRequest.message.subject).build val message = Message.builder.body(body).subject(subject).build() val request = SendEmailRequest.builder .source(from) .destination(destination) .message(message) .build() for { response <- blocking { client.sendEmail(request) }.asScala _ = logger.info( s"Email sent, to: ${emailRequest.destination}, subject = ${emailRequest.message.subject}, messageId = ${response.messageId()}" ) } yield () } catch { case ex: Exception => throw new RuntimeException( s"Email was not sent, to: ${emailRequest.destination}, subject = ${emailRequest.message.subject}", ex ) } Future { blocking(unsafe) }.flatten } } ================================================ FILE: server/src/main/scala/net/wiringbits/apis/ReCaptchaApi.scala ================================================ package net.wiringbits.apis import net.wiringbits.common.models.Captcha import net.wiringbits.config.ReCaptchaConfig import play.api.libs.json.Json import play.api.libs.ws.DefaultBodyWritables.writeableOf_String import play.api.libs.ws.WSClient import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class ReCaptchaApi @Inject() (reCaptchaConfig: ReCaptchaConfig, ws: WSClient)(implicit ec: ExecutionContext ) { private val url = "https://www.google.com/recaptcha/api/siteverify" def verify(captcha: Captcha): Future[Boolean] = { ws.url(url) .addQueryStringParameters("secret" -> reCaptchaConfig.secret.string, "response" -> captcha.string) .post("{}") .map { response => (response.json \ "success") .as[Boolean] } } } ================================================ FILE: server/src/main/scala/net/wiringbits/apis/models/EmailRequest.scala ================================================ package net.wiringbits.apis.models import net.wiringbits.common.models.Email import net.wiringbits.util.EmailMessage case class EmailRequest(destination: Email, message: EmailMessage) ================================================ FILE: server/src/main/scala/net/wiringbits/config/AWSConfig.scala ================================================ package net.wiringbits.config import net.wiringbits.models.{AWSAccessKeyId, AWSSecretAccessKey} import play.api.Configuration import software.amazon.awssdk.regions.Region case class AWSConfig(accessKeyId: AWSAccessKeyId, secretAccessKey: AWSSecretAccessKey, region: Region) { override def toString: String = { s"AwsConfig(region = $region, accessKeyId = ${accessKeyId.toString}, secretAccessKey = ${secretAccessKey.toString})" } } object AWSConfig { def apply(config: Configuration): AWSConfig = { val accessKeyId = config.get[String]("accessKeyId") val secretAccessKey = config.get[String]("secretAccessKey") val region = config.get[String]("region") AWSConfig(AWSAccessKeyId(accessKeyId), AWSSecretAccessKey(secretAccessKey), Region.of(region)) } } ================================================ FILE: server/src/main/scala/net/wiringbits/config/BackgroundJobsExecutorConfig.scala ================================================ package net.wiringbits.config import play.api.Configuration import scala.concurrent.duration.FiniteDuration case class BackgroundJobsExecutorConfig(interval: FiniteDuration) { override def toString: String = { s"BackgroundJobsExecutorConfig(interval = $interval)" } } object BackgroundJobsExecutorConfig { def apply(config: Configuration): BackgroundJobsExecutorConfig = { val interval = config.get[FiniteDuration]("interval") BackgroundJobsExecutorConfig(interval) } } ================================================ FILE: server/src/main/scala/net/wiringbits/config/EmailConfig.scala ================================================ package net.wiringbits.config import play.api.Configuration case class EmailConfig(senderAddress: String, provider: String) { override def toString: String = { s"EmailConfig(senderAddress = $senderAddress, provider = $provider)" } } object EmailConfig { def apply(config: Configuration): EmailConfig = { val senderAddress = config.get[String]("senderAddress") val provider = config.get[String]("provider") new EmailConfig(senderAddress = senderAddress, provider = provider) } } ================================================ FILE: server/src/main/scala/net/wiringbits/config/ReCaptchaConfig.scala ================================================ package net.wiringbits.config import net.wiringbits.models.{ReCaptchaSecret, ReCaptchaSiteKey} import play.api.Configuration case class ReCaptchaConfig(secret: ReCaptchaSecret, siteKey: ReCaptchaSiteKey) { override def toString: String = { s"ReCaptchaConfig(secret = ${secret.toString}, siteKey = ${siteKey})" } } object ReCaptchaConfig { def apply(config: Configuration): ReCaptchaConfig = { val secret = config.get[String]("secretKey") val siteKey = config.get[String]("siteKey") ReCaptchaConfig(ReCaptchaSecret(secret), ReCaptchaSiteKey(siteKey)) } } ================================================ FILE: server/src/main/scala/net/wiringbits/config/SwaggerConfig.scala ================================================ package net.wiringbits.config import play.api.Configuration case class SwaggerConfig(basePath: String, info: SwaggerConfig.Info) { override def toString: String = s"SwaggerConfig($basePath, $info)" } object SwaggerConfig { case class Info(version: String, contact: String, title: String, description: String) { override def toString: String = s"Info($version, $contact, $title, $description)" } def apply(config: Configuration): SwaggerConfig = { val apiConfig = config.get[Configuration]("api") val apiInfoConfig = apiConfig.get[Configuration]("info") val basePath = apiConfig.get[String]("basePath") val version = apiInfoConfig.get[String]("version") val contact = apiInfoConfig.get[String]("contact") val title = apiInfoConfig.get[String]("title") val description = apiInfoConfig.get[String]("description") SwaggerConfig(basePath, Info(version, contact, title, description)) } } ================================================ FILE: server/src/main/scala/net/wiringbits/config/UserTokensConfig.scala ================================================ package net.wiringbits.config import play.api.Configuration import scala.concurrent.duration.FiniteDuration case class UserTokensConfig( emailVerificationExp: FiniteDuration, resetPasswordExp: FiniteDuration, hmacSecret: String ) { override def toString: String = { import net.wiringbits.util.StringUtils.Implicits.* s"UserTokensConfig(emailVerificationExp = $emailVerificationExp, resetPasswordExp = $resetPasswordExp, hmacSecret = ${hmacSecret.mask()})" } } object UserTokensConfig { def apply(conf: Configuration): UserTokensConfig = { val emailVerificationExp = conf.get[FiniteDuration]("emailVerification.expirationTime") val resetPasswordExp = conf.get[FiniteDuration]("resetPassword.expirationTime") val hmacSecret = conf.get[String]("hmacSecret") UserTokensConfig(emailVerificationExp, resetPasswordExp, hmacSecret) } } ================================================ FILE: server/src/main/scala/net/wiringbits/config/WebAppConfig.scala ================================================ package net.wiringbits.config import play.api.Configuration case class WebAppConfig(host: String) { override def toString: String = { s"WebAppConfig(host = $host)" } } object WebAppConfig { def apply(config: Configuration): WebAppConfig = { val url = config.get[String]("host") WebAppConfig(url) } } ================================================ FILE: server/src/main/scala/net/wiringbits/executors/DatabaseExecutionContext.scala ================================================ package net.wiringbits.executors import org.apache.pekko.actor.ActorSystem import play.api.libs.concurrent.CustomExecutionContext import javax.inject.{Inject, Singleton} import scala.concurrent.ExecutionContext trait DatabaseExecutionContext extends ExecutionContext object DatabaseExecutionContext { @Singleton class AkkaBased @Inject() (system: ActorSystem) extends CustomExecutionContext(system, "database.dispatcher") with DatabaseExecutionContext } ================================================ FILE: server/src/main/scala/net/wiringbits/models/AWSAccessKeyId.scala ================================================ package net.wiringbits.models import com.typesafe.config.Config import play.api.ConfigLoader case class AWSAccessKeyId(string: String) extends SecretValue(string) object AWSAccessKeyId { implicit val configLoader: ConfigLoader[AWSAccessKeyId] = (config: Config, path: String) => { AWSAccessKeyId(string = config.getString(path)) } } ================================================ FILE: server/src/main/scala/net/wiringbits/models/AWSSecretAccessKey.scala ================================================ package net.wiringbits.models import com.typesafe.config.Config import play.api.ConfigLoader case class AWSSecretAccessKey(string: String) extends SecretValue(string) object AWSSecretAccessKey { implicit val configLoader: ConfigLoader[AWSSecretAccessKey] = (config: Config, path: String) => { AWSSecretAccessKey(string = config.getString(path)) } } ================================================ FILE: server/src/main/scala/net/wiringbits/models/ReCaptchaSecret.scala ================================================ package net.wiringbits.models import com.typesafe.config.Config import play.api.ConfigLoader case class ReCaptchaSecret(string: String) extends SecretValue(string) object ReCaptchaSecret { implicit val configLoader: ConfigLoader[ReCaptchaSecret] = (config: Config, path: String) => { ReCaptchaSecret(string = config.getString(path)) } } ================================================ FILE: server/src/main/scala/net/wiringbits/models/ReCaptchaSiteKey.scala ================================================ package net.wiringbits.models import com.typesafe.config.Config import play.api.ConfigLoader case class ReCaptchaSiteKey(string: String) object ReCaptchaSiteKey { implicit val configLoader: ConfigLoader[ReCaptchaSiteKey] = (config: Config, path: String) => { ReCaptchaSiteKey(string = config.getString(path)) } } ================================================ FILE: server/src/main/scala/net/wiringbits/models/SecretValue.scala ================================================ package net.wiringbits.models import net.wiringbits.util.StringUtils.Implicits.StringUtilsExt abstract class SecretValue(string: String) { override def toString: String = string.mask() } ================================================ FILE: server/src/main/scala/net/wiringbits/models/jobs/BackgroundJobPayload.scala ================================================ package net.wiringbits.models.jobs import net.wiringbits.common.models.Email import play.api.libs.json.{Format, Json, Writes} sealed trait BackgroundJobPayload extends Product with Serializable /** NOTE: Updating these models can cause tasks to fail, for example, adding an extra argument to SendEmail would cause * the json parsing to fail when we already have jobs in the database */ object BackgroundJobPayload { case class SendEmail(email: Email, subject: String, body: String) extends BackgroundJobPayload object SendEmail { implicit val sendEmailFormat: Format[SendEmail] = Json.format } implicit val backgroundJobPayloadWrites: Writes[BackgroundJobPayload] = { case payload: SendEmail => Json.toJson(payload) } } ================================================ FILE: server/src/main/scala/net/wiringbits/models/jobs/BackgroundJobStatus.scala ================================================ package net.wiringbits.models.jobs import enumeratum.EnumEntry.Uppercase import enumeratum.{Enum, EnumEntry} sealed trait BackgroundJobStatus extends EnumEntry with Uppercase object BackgroundJobStatus extends Enum[BackgroundJobStatus] { case object Success extends BackgroundJobStatus case object Pending extends BackgroundJobStatus case object Failed extends BackgroundJobStatus val values = findValues } ================================================ FILE: server/src/main/scala/net/wiringbits/models/jobs/BackgroundJobType.scala ================================================ package net.wiringbits.models.jobs import enumeratum.EnumEntry.Uppercase import enumeratum.{Enum, EnumEntry} sealed trait BackgroundJobType extends EnumEntry with Uppercase /** NOTE: Updating this model can cause tasks to fail, for example, if SendEmail is removed while there are pending * SendEmail tasks stored at the database */ object BackgroundJobType extends Enum[BackgroundJobType] { case object SendEmail extends BackgroundJobType val values = findValues } ================================================ FILE: server/src/main/scala/net/wiringbits/modules/ApisModule.scala ================================================ package net.wiringbits.modules import com.google.inject.{AbstractModule, Provider} import net.wiringbits.apis.{EmailApi, EmailApiAWSImpl} import net.wiringbits.config.EmailConfig import org.slf4j.LoggerFactory import javax.inject.Inject class ApisModule extends AbstractModule { override def configure(): Unit = { val _ = bind(classOf[EmailApi]) .toProvider(classOf[ApisModule.EmailApiProvider]) .asEagerSingleton() } } object ApisModule { class EmailApiProvider @Inject() (config: EmailConfig, logImpl: EmailApi.LogImpl, awsImpl: EmailApiAWSImpl) extends Provider[EmailApi] { private val logger = LoggerFactory.getLogger(this.getClass) override def get(): EmailApi = { if (config.provider equalsIgnoreCase "aws") { logger.info("Mail provider set to AWS") awsImpl } else { logger.info("Mail provider set to none, emails will be printed as logs") logImpl } } } } ================================================ FILE: server/src/main/scala/net/wiringbits/modules/ClockModule.scala ================================================ package net.wiringbits.modules import com.google.inject.AbstractModule import java.time.Clock class ClockModule extends AbstractModule { override def configure(): Unit = { bind(classOf[Clock]).toInstance(Clock.systemUTC()) } } ================================================ FILE: server/src/main/scala/net/wiringbits/modules/ConfigModule.scala ================================================ package net.wiringbits.modules import com.google.inject.{AbstractModule, Provides} import net.wiringbits.config.* import org.slf4j.LoggerFactory import play.api.Configuration import javax.inject.Singleton class ConfigModule extends AbstractModule { private val logger = LoggerFactory.getLogger(this.getClass) @Provides @Singleton def recaptchaConfig(global: Configuration): ReCaptchaConfig = { val config = ReCaptchaConfig(global.get[Configuration]("recaptcha")) logger.info(s"Config loaded: $config") config } @Provides @Singleton def emailConfig(global: Configuration): EmailConfig = { val config = EmailConfig(global.get[Configuration]("email")) logger.info(s"Config loaded: $config") config } @Provides @Singleton def webAppConfig(global: Configuration): WebAppConfig = { val config = WebAppConfig(global.get[Configuration]("webapp")) logger.info(s"Config loaded: $config") config } @Provides @Singleton def userTokensConfig(global: Configuration): UserTokensConfig = { val config = UserTokensConfig(global.get[Configuration]("userTokens")) logger.info(s"Config loaded: $config") config } @Provides @Singleton def awsConfig(global: Configuration): AWSConfig = { val config = AWSConfig(global.get[Configuration]("aws")) logger.info(s"Config loaded: $config") config } @Provides @Singleton def backgroundJobsExecutorConfig(global: Configuration): BackgroundJobsExecutorConfig = { val config = BackgroundJobsExecutorConfig(global.get[Configuration]("backgroundJobsExecutorTask")) logger.info(s"Config loaded: $config") config } @Provides @Singleton def swaggerConfig(global: Configuration): SwaggerConfig = { val config = SwaggerConfig(global.get[Configuration]("swagger")) logger.info(s"Config loaded: $config") config } } ================================================ FILE: server/src/main/scala/net/wiringbits/modules/ExecutorsModule.scala ================================================ package net.wiringbits.modules import com.google.inject.AbstractModule import net.wiringbits.executors.DatabaseExecutionContext class ExecutorsModule extends AbstractModule { override def configure(): Unit = { val _ = bind(classOf[DatabaseExecutionContext]).to(classOf[DatabaseExecutionContext.AkkaBased]).asEagerSingleton() } } ================================================ FILE: server/src/main/scala/net/wiringbits/modules/TasksModule.scala ================================================ package net.wiringbits.modules import com.google.inject.AbstractModule import net.wiringbits.tasks.BackgroundJobsExecutorTask class TasksModule extends AbstractModule { override def configure(): Unit = { bind(classOf[BackgroundJobsExecutorTask]).asEagerSingleton() } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/BackgroundJobsRepository.scala ================================================ package net.wiringbits.repositories import net.wiringbits.executors.DatabaseExecutionContext import net.wiringbits.repositories.daos.BackgroundJobDAO import net.wiringbits.repositories.models.BackgroundJobData import play.api.db.Database import java.time.{Clock, Instant} import java.util.UUID import javax.inject.Inject import scala.concurrent.Future import scala.util.control.NonFatal class BackgroundJobsRepository @Inject() (database: Database)(implicit ec: DatabaseExecutionContext, clock: Clock) { def streamPendingJobs: Future[org.apache.pekko.stream.scaladsl.Source[BackgroundJobData, Future[Int]]] = Future { // autocommit=false is necessary to avoid loading the whole result into memory implicit val conn = database.getConnection(autocommit = false) try { val stream = BackgroundJobDAO.streamPendingJobs() // make sure to close the connection when it isn't required anymore stream.mapMaterializedValue { result => result.onComplete { t => conn.close() t } result } } catch { case NonFatal(ex) => conn.close() throw new RuntimeException("Failed to stream pending background jobs", ex) } } def setStatusToFailed(backgroundJobId: UUID, executeAt: Instant, failReason: String): Future[Unit] = Future { database.withConnection { implicit conn => BackgroundJobDAO.setStatusToFailed(backgroundJobId, executeAt, failReason) } } def setStatusToSuccess(backgroundJobId: UUID): Future[Unit] = Future { database.withConnection { implicit conn => BackgroundJobDAO.setStatusToSuccess(backgroundJobId) } } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/UserLogsRepository.scala ================================================ package net.wiringbits.repositories import net.wiringbits.executors.DatabaseExecutionContext import net.wiringbits.repositories.daos.UserLogsDAO import net.wiringbits.repositories.models.UserLog import play.api.db.Database import java.util.UUID import javax.inject.Inject import scala.concurrent.Future class UserLogsRepository @Inject() (database: Database)(implicit ec: DatabaseExecutionContext) { def create(request: UserLog.CreateUserLog): Future[Unit] = Future { database.withConnection { implicit conn => UserLogsDAO.create(request) } } def create(userId: UUID, message: String): Future[Unit] = Future { database.withConnection { implicit conn => val request = UserLog.CreateUserLog(UUID.randomUUID(), userId, message) UserLogsDAO.create(request) } } def logs(userId: UUID): Future[List[UserLog]] = Future { database.withConnection { implicit conn => UserLogsDAO.logs(userId) } } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/UserTokensRepository.scala ================================================ package net.wiringbits.repositories import net.wiringbits.executors.DatabaseExecutionContext import net.wiringbits.repositories.daos.UserTokensDAO import net.wiringbits.repositories.models.UserToken import play.api.db.Database import java.util.UUID import javax.inject.Inject import scala.concurrent.Future class UserTokensRepository @Inject() ( database: Database )(implicit ec: DatabaseExecutionContext ) { def create(request: UserToken.Create): Future[Unit] = Future { database.withConnection { implicit conn => UserTokensDAO.create(request) } } def find(userId: UUID, token: String): Future[Option[UserToken]] = Future { database.withConnection { implicit conn => UserTokensDAO.find(userId, token) } } def find(userId: UUID): Future[List[UserToken]] = Future { database.withConnection { implicit conn => UserTokensDAO.find(userId) } } def delete(tokenId: UUID, userId: UUID): Future[Unit] = Future { database.withConnection { implicit conn => UserTokensDAO.delete(tokenId, userId: UUID) } } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/UsersRepository.scala ================================================ package net.wiringbits.repositories import net.wiringbits.common.models.{Email, Name} import net.wiringbits.config.UserTokensConfig import net.wiringbits.executors.DatabaseExecutionContext import net.wiringbits.models.jobs.{BackgroundJobPayload, BackgroundJobStatus, BackgroundJobType} import net.wiringbits.repositories.daos.{BackgroundJobDAO, UserLogsDAO, UserTokensDAO, UsersDAO} import net.wiringbits.repositories.models.* import net.wiringbits.util.EmailMessage import play.api.db.Database import java.sql.Connection import java.time.Clock import java.time.temporal.ChronoUnit import java.util.UUID import javax.inject.Inject import scala.concurrent.Future class UsersRepository @Inject() ( database: Database, userTokensConfig: UserTokensConfig )(implicit ec: DatabaseExecutionContext, clock: Clock ) { def create(request: User.CreateUser): Future[Unit] = Future { val createToken = UserToken.Create( id = UUID.randomUUID(), token = request.verifyEmailToken, tokenType = UserTokenType.EmailVerification, createdAt = clock.instant(), expiresAt = clock.instant().plus(userTokensConfig.emailVerificationExp.toHours, ChronoUnit.HOURS), userId = request.id ) database.withTransaction { implicit conn => UsersDAO.create(request) UserTokensDAO.create(createToken) UserLogsDAO.create( UserLog.CreateUserLog( UUID.randomUUID(), request.id, s"Account created, name = ${request.name}, email = ${request.email}" ) ) } } def all(): Future[List[User]] = Future { database.withConnection { implicit conn => UsersDAO.all() } } def find(email: Email): Future[Option[User]] = Future { database.withConnection { implicit conn => UsersDAO.find(email) } } def find(userId: UUID): Future[Option[User]] = Future { database.withConnection { implicit conn => UsersDAO.find(userId) } } def update(userId: UUID, name: Name): Future[Unit] = Future { database.withTransaction { implicit conn => UsersDAO.updateName(userId, name) UserLogsDAO.create( UserLog.CreateUserLog( UUID.randomUUID(), userId = userId, "Profile updated" ) ) } } def updatePassword(userId: UUID, password: String, emailMessage: EmailMessage): Future[Unit] = Future { database.withTransaction { implicit conn => UsersDAO.resetPassword(userId, password) val request = UserLog.CreateUserLog(UUID.randomUUID(), userId, "Password was updated") UserLogsDAO.create(request) sendEmailLater(userId, emailMessage) } } def verify(userId: UUID, tokenId: UUID, emailMessage: EmailMessage): Future[Unit] = Future { database.withTransaction { implicit conn => UsersDAO.verify(userId) UserLogsDAO.create( UserLog.CreateUserLog( UUID.randomUUID(), userId = userId, "Email verified" ) ) UserTokensDAO.delete(tokenId = tokenId, userId = userId) sendEmailLater(userId, emailMessage) } } def resetPassword(userId: UUID, password: String, emailMessage: EmailMessage): Future[Unit] = Future { database.withTransaction { implicit conn => UsersDAO.resetPassword(userId, password) val request = UserLog.CreateUserLog(UUID.randomUUID(), userId, "Password was reset") UserLogsDAO.create(request) sendEmailLater(userId, emailMessage) } } private def sendEmailLater(userId: UUID, emailMessage: EmailMessage)(implicit conn: Connection): Unit = { val userOpt = UsersDAO.find(userId) userOpt.foreach { user => val payload = BackgroundJobPayload.SendEmail( email = user.email, subject = emailMessage.subject, body = emailMessage.body ) val createNotification = BackgroundJobData.Create( id = UUID.randomUUID(), `type` = BackgroundJobType.SendEmail, payload = payload, status = BackgroundJobStatus.Pending, executeAt = clock.instant(), createdAt = clock.instant(), updatedAt = clock.instant() ) BackgroundJobDAO.create(createNotification) } } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/daos/BackgroundJobDAO.scala ================================================ package net.wiringbits.repositories.daos import anorm.postgresql.* import net.wiringbits.models.jobs.BackgroundJobStatus import net.wiringbits.repositories.models.BackgroundJobData import play.api.libs.json.Json import java.sql.Connection import java.time.{Clock, Instant} import java.util.UUID import scala.concurrent.Future object BackgroundJobDAO { import anorm.* def create(request: BackgroundJobData.Create)(implicit conn: Connection): Unit = { val _ = SQL""" INSERT INTO background_jobs (background_job_id, type, payload, status, execute_at, created_at, updated_at) VALUES ( ${request.id}, ${request.`type`.toString}, ${Json.toJson(request.payload)}, ${request.status.toString}, ${request.executeAt}, ${request.createdAt}, ${request.updatedAt} ) """ .execute() } def streamPendingJobs( allowedErrors: Int = 10, fetchSize: Int = 1000 )(implicit conn: Connection, clock: Clock ): org.apache.pekko.stream.scaladsl.Source[BackgroundJobData, Future[Int]] = { val query = SQL""" SELECT background_job_id, type, payload, status, status_details, error_count, execute_at, created_at, updated_at FROM background_jobs WHERE status != ${BackgroundJobStatus.Success.toString} AND execute_at <= ${clock.instant()} AND error_count < $allowedErrors ORDER BY execute_at, background_job_id """.withFetchSize(Some(fetchSize)) // without this, all data is loaded into memory PekkoStream.source(query, backgroundJobParser)(conn) } def setStatusToFailed(backgroundJobId: UUID, executeAt: Instant, failReason: String)(implicit conn: Connection ): Unit = { val _ = SQL""" UPDATE background_jobs SET status = ${BackgroundJobStatus.Failed.toString}::TEXT, status_details = $failReason, error_count = error_count + 1, execute_at = $executeAt::TIMESTAMPTZ, updated_at = ${Instant.now()}::TIMESTAMPTZ WHERE background_job_id = ${backgroundJobId.toString}::UUID """ .execute() } def setStatusToSuccess(backgroundJobId: UUID)(implicit conn: Connection ): Unit = { val _ = SQL""" UPDATE background_jobs SET status = ${BackgroundJobStatus.Success.toString}::TEXT, updated_at = ${Instant.now()} WHERE background_job_id = ${backgroundJobId.toString}::UUID """ .execute() } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/daos/UserLogsDAO.scala ================================================ package net.wiringbits.repositories.daos import net.wiringbits.repositories.models.UserLog import java.sql.Connection import java.util.UUID object UserLogsDAO { import anorm.* def create(request: UserLog.CreateUserLog)(implicit conn: Connection): Unit = { val _ = SQL""" INSERT INTO user_logs (user_log_id, user_id, message, created_at) VALUES ( ${request.userLogId.toString}::UUID, ${request.userId.toString}::UUID, ${request.message}, NOW() ) """ .execute() } def logs(userId: UUID)(implicit conn: Connection): List[UserLog] = { SQL""" SELECT user_log_id, user_id, message, created_at FROM user_logs WHERE user_id = ${userId.toString}::UUID ORDER BY created_at DESC, user_log_id """.as(userLogParser.*) } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/daos/UserTokensDAO.scala ================================================ package net.wiringbits.repositories.daos import anorm.SqlStringInterpolation import net.wiringbits.repositories.models.UserToken import java.sql.Connection import java.util.UUID object UserTokensDAO { def create(request: UserToken.Create)(implicit conn: Connection ): Unit = { val _ = SQL""" INSERT INTO user_tokens (user_token_id, token, token_type, created_at, expires_at, user_id) VALUES ( ${request.id.toString}::UUID, ${request.token}::TEXT, ${request.tokenType.toString}::TEXT, ${request.createdAt}::TIMESTAMPTZ, ${request.expiresAt}::TIMESTAMPTZ, ${request.userId.toString}::UUID ) """ .execute() } def find(userId: UUID, token: String)(implicit conn: Connection): Option[UserToken] = { SQL""" SELECT user_token_id, token, token_type, created_at, expires_at, user_id FROM user_tokens WHERE user_id = ${userId.toString}::UUID AND token = $token::TEXT """.as(tokenParser.singleOpt) } def find(userId: UUID)(implicit conn: Connection): List[UserToken] = { SQL""" SELECT user_token_id, token, token_type, created_at, expires_at, user_id FROM user_tokens WHERE user_id = ${userId.toString}::UUID ORDER BY created_at DESC, user_token_id """.as(tokenParser.*) } def delete(tokenId: UUID, userId: UUID)(implicit conn: Connection): Unit = { val _ = SQL""" DELETE FROM user_tokens WHERE user_id = ${userId.toString}::UUID AND user_token_id = ${tokenId.toString}::UUID """ .executeUpdate() } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/daos/UsersDAO.scala ================================================ package net.wiringbits.repositories.daos import net.wiringbits.common.models.{Email, Name} import net.wiringbits.repositories.models.User import java.sql.Connection import java.util.UUID object UsersDAO { import anorm.* def create(request: User.CreateUser)(implicit conn: Connection): Unit = { val _ = SQL""" INSERT INTO users (user_id, name, email, password, created_at) VALUES ( ${request.id.toString}::UUID, ${request.name.string}, ${request.email.string}, ${request.hashedPassword}, NOW() ) """ .execute() } def all()(implicit conn: Connection): List[User] = { SQL""" SELECT user_id, name, email, password, created_at, verified_on FROM users """.as(userParser.*) } def find(email: Email)(implicit conn: Connection): Option[User] = { SQL""" SELECT user_id, name, email, password, created_at, verified_on FROM users WHERE email = ${email.string}::CITEXT """.as(userParser.singleOpt) } def find(userId: UUID)(implicit conn: Connection): Option[User] = { SQL""" SELECT user_id, name, email, password, created_at, verified_on FROM users WHERE user_id = ${userId.toString}::UUID """.as(userParser.singleOpt) } def updateName(userId: UUID, name: Name)(implicit conn: Connection): Unit = { val _ = SQL""" UPDATE users SET name = ${name.string} WHERE user_id = ${userId.toString}::UUID """.execute() } def verify(userId: UUID)(implicit conn: Connection): Unit = { val _ = SQL""" UPDATE users SET verified_on = NOW() WHERE user_id = ${userId.toString}::UUID """.execute() } def resetPassword(userId: UUID, password: String)(implicit conn: Connection): Unit = { val _ = SQL""" UPDATE users SET password = $password WHERE user_id = ${userId.toString}::UUID """.execute() } def findUserForUpdate(userId: UUID)(implicit conn: Connection): Option[User] = { SQL""" SELECT user_id, name, email, password, created_at, verified_on FROM users WHERE user_id = ${userId.toString}::UUID FOR UPDATE NOWAIT """.as(userParser.singleOpt) } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/daos/package.scala ================================================ package net.wiringbits.repositories import anorm.* import anorm.SqlParser.* import anorm.postgresql.* import net.wiringbits.common.models.{Email, Name} import net.wiringbits.models.jobs.{BackgroundJobStatus, BackgroundJobType} import net.wiringbits.repositories.models.* import java.time.Instant import java.util.UUID package object daos { import anorm.{Column, MetaDataItem, TypeDoesNotMatch} import org.postgresql.util.PGobject implicit val citextToString: Column[String] = Column.nonNull { case (value, meta) => val MetaDataItem(qualified, _, clazz) = meta value match { case str: String => Right(str) case obj: PGobject if "citext" equalsIgnoreCase obj.getType => Right(obj.getValue) case _ => Left( TypeDoesNotMatch( s"Cannot convert $value: ${value.asInstanceOf[AnyRef].getClass} to String for column $qualified, class = $clazz" ) ) } } implicit val nameParser: Column[Name] = Column.columnToString.map(Name.trusted) implicit val emailParser: Column[Email] = citextToString.map(Email.trusted) val userParser: RowParser[User] = { Macro.parser[User]( "user_id", "name", "email", "password", "created_at", "verified_on" ) } val userLogParser: RowParser[UserLog] = { Macro.parser[UserLog]("user_log_id", "user_id", "message", "created_at") } def enumColumn[A](f: String => Option[A]): Column[A] = Column.columnToString.mapResult { string => f(string) .toRight(SqlRequestError(new RuntimeException(s"The value $string doesn't exists"))) } implicit val tokenTypeColumn: Column[UserTokenType] = enumColumn( UserTokenType.withNameInsensitiveOption ) // TODO: Use Macro.parser, for some reason it doesn't work so we have to parse it manually implicit val tokenParser: RowParser[UserToken] = { get[UUID]("user_token_id") ~ str("token") ~ get[UserTokenType]("token_type") ~ get[Instant]("created_at") ~ get[Instant]("expires_at") ~ get[UUID]("user_id") map { case tokenId ~ token ~ tokenType ~ createdAt ~ expiresAt ~ userId => UserToken( id = tokenId, tokenType = tokenType, token = token, createdAt = createdAt, expiresAt = expiresAt, userId = userId ) } } implicit val backgroundJobStatusColumn: Column[BackgroundJobStatus] = enumColumn( BackgroundJobStatus.withNameInsensitiveOption ) implicit val backgroundJobTypeColumn: Column[BackgroundJobType] = enumColumn( BackgroundJobType.withNameInsensitiveOption ) implicit val backgroundJobParser: RowParser[BackgroundJobData] = { Macro.parser[BackgroundJobData]( "background_job_id", "type", "payload", "status", "status_details", "error_count", "execute_at", "created_at", "updated_at" ) } } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/models/BackgroundJobData.scala ================================================ package net.wiringbits.repositories.models import net.wiringbits.models.jobs.{BackgroundJobPayload, BackgroundJobStatus, BackgroundJobType} import play.api.libs.json.JsValue import java.time.Instant import java.util.UUID case class BackgroundJobData( id: UUID, `type`: BackgroundJobType, payload: JsValue, status: BackgroundJobStatus, statusDetails: Option[String], errorCount: Int, executeAt: Instant, createdAt: Instant, updatedAt: Instant ) object BackgroundJobData { case class Create( id: UUID, `type`: BackgroundJobType, payload: BackgroundJobPayload, status: BackgroundJobStatus, executeAt: Instant, createdAt: Instant, updatedAt: Instant ) } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/models/User.scala ================================================ package net.wiringbits.repositories.models import net.wiringbits.common.models.{Email, Name} import java.time.Instant import java.util.UUID case class User( id: UUID, name: Name, email: Email, hashedPassword: String, createdAt: Instant, verifiedOn: Option[Instant] ) object User { case class CreateUser(id: UUID, name: Name, email: Email, hashedPassword: String, verifyEmailToken: String) } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/models/UserLog.scala ================================================ package net.wiringbits.repositories.models import java.time.Instant import java.util.UUID case class UserLog(userLogId: UUID, userId: UUID, message: String, createdAt: Instant) object UserLog { case class CreateUserLog(userLogId: UUID, userId: UUID, message: String) } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/models/UserToken.scala ================================================ package net.wiringbits.repositories.models import java.time.Instant import java.util.UUID case class UserToken( id: UUID, token: String, tokenType: UserTokenType, createdAt: Instant, expiresAt: Instant, userId: UUID ) object UserToken { case class Create( id: UUID, token: String, tokenType: UserTokenType, createdAt: Instant, expiresAt: Instant, userId: UUID ) } ================================================ FILE: server/src/main/scala/net/wiringbits/repositories/models/UserTokenType.scala ================================================ package net.wiringbits.repositories.models import enumeratum.EnumEntry.Uppercase import enumeratum.{Enum, EnumEntry} sealed trait UserTokenType extends EnumEntry with Uppercase object UserTokenType extends Enum[UserTokenType] { case object EmailVerification extends UserTokenType case object ResetPassword extends UserTokenType val values = findValues } ================================================ FILE: server/src/main/scala/net/wiringbits/services/AdminService.scala ================================================ package net.wiringbits.services import io.scalaland.chimney.dsl.transformInto import net.wiringbits.api.models.admin.{AdminGetUserLogs, AdminGetUsers} import net.wiringbits.repositories.{UserLogsRepository, UsersRepository} import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class AdminService @Inject() (userLogsRepository: UserLogsRepository, usersRepository: UsersRepository)(implicit ec: ExecutionContext ) { def userLogs(userId: UUID): Future[AdminGetUserLogs.Response] = { for { logs <- userLogsRepository.logs(userId) items = logs.map(_.transformInto[AdminGetUserLogs.Response.UserLog]) } yield AdminGetUserLogs.Response(items) } def users(): Future[AdminGetUsers.Response] = { for { users <- usersRepository.all() items = users.map(_.transformInto[AdminGetUsers.Response.User]) } yield AdminGetUsers.Response(items) } } ================================================ FILE: server/src/main/scala/net/wiringbits/tasks/BackgroundJobsExecutorTask.scala ================================================ package net.wiringbits.tasks import org.apache.pekko.actor.ActorSystem import com.google.inject.Inject import net.wiringbits.actions.internal.StreamPendingBackgroundJobsForeverAction import net.wiringbits.apis.EmailApi import net.wiringbits.apis.models.EmailRequest import net.wiringbits.config.BackgroundJobsExecutorConfig import net.wiringbits.models.jobs.{BackgroundJobPayload, BackgroundJobType} import net.wiringbits.repositories.BackgroundJobsRepository import net.wiringbits.repositories.models.BackgroundJobData import net.wiringbits.util.{DelayGenerator, EmailMessage} import org.slf4j.LoggerFactory import java.time.Clock import java.time.temporal.ChronoUnit import scala.concurrent.duration.DurationInt import scala.concurrent.{ExecutionContext, Future} import scala.util.control.NonFatal import scala.util.{Failure, Success} class BackgroundJobsExecutorTask @Inject() ( config: BackgroundJobsExecutorConfig, streamPendingBackgroundJobsForeverAction: StreamPendingBackgroundJobsForeverAction, emailApi: EmailApi, backgroundJobsRepository: BackgroundJobsRepository )(implicit ec: ExecutionContext, actorSystem: ActorSystem, clock: Clock ) { private val logger = LoggerFactory.getLogger(this.getClass) logger.info("Starting the background jobs executor task") actorSystem.scheduler.scheduleOnce(config.interval) { run() } private def execute(job: BackgroundJobData): Future[Unit] = { val executionResult = job.`type` match { case BackgroundJobType.SendEmail => job.payload.asOpt[BackgroundJobPayload.SendEmail] match { case Some(typedPayload) => sendEmail(typedPayload) case None => Future.failed( new RuntimeException( s"The given payload is not supported by the SendEmail task, please double check, job id = ${job.id}" ) ) } } executionResult .flatMap { _ => backgroundJobsRepository.setStatusToSuccess(job.id) } .recoverWith { case NonFatal(ex) => val minutesUntilExecute = DelayGenerator.createDelay(job.errorCount) val executeAt = clock.instant().plus(minutesUntilExecute, ChronoUnit.MINUTES) logger.warn(s"Job with id ${job.id} failed: ${ex.getMessage}", ex) backgroundJobsRepository.setStatusToFailed(job.id, executeAt, ex.getMessage) } } // TODO: Move to another file private def sendEmail(payload: BackgroundJobPayload.SendEmail): Future[Unit] = { val emailRequest = EmailRequest(payload.email, EmailMessage(subject = payload.subject, body = payload.body)) emailApi.sendEmail(emailRequest) } def run(): Unit = { // TODO: Allow configuring the throttling mechanism // the reason to throttle and handle 1 background job concurrently is to avoid overloading the app val result = streamPendingBackgroundJobsForeverAction() .throttle(100, 1.minute) .runWith(org.apache.pekko.stream.scaladsl.Sink.foreachAsync(1)(execute)) result.onComplete { case Failure(ex) => logger.error( s"Failed to process pending background jobs, retrying after ${config.interval}: ${ex.getMessage}", ex ) actorSystem.scheduler.scheduleOnce(config.interval) { run() } case Success(_) => actorSystem.scheduler.scheduleOnce(config.interval) { run() } } } } ================================================ FILE: server/src/main/scala/net/wiringbits/util/DelayGenerator.scala ================================================ package net.wiringbits.util object DelayGenerator { def createDelay( retry: Int, factor: Int = 2 ): Long = { Math .pow( factor.toDouble, retry.toDouble ) .longValue } } ================================================ FILE: server/src/main/scala/net/wiringbits/util/EmailMessage.scala ================================================ package net.wiringbits.util import net.wiringbits.common.models.Name import org.apache.commons.text.StringEscapeUtils case class EmailMessage(subject: String, body: String) object EmailMessage { implicit class EmailBodyStringExt(val str: String) extends AnyVal { def htmlEscape: String = StringEscapeUtils.escapeHtml4(str) } def registration(name: Name, url: String, emailParameter: String): EmailMessage = { val subject = "Registration Confirmation" val body = s"""Hi ${name.string.htmlEscape}, |Thanks for creating an account. |To continue, please confirm your email address by clicking the button below. |Confirm email address |""".stripMargin EmailMessage(subject, body) } def confirm(name: Name): EmailMessage = { val subject = "Your email has been confirmed" val body = s"Hi ${name.string.htmlEscape}, Thanks for confirming your email.".stripMargin EmailMessage(subject, body) } def forgotPassword(name: Name, url: String, emailParameter: String): EmailMessage = { val subject = "Password Reset" val body = s"""

Password Reset Instructions

|Hi ${name.string.htmlEscape}, |Here is the link to reset your password. |To continue, please click the button below. |Reset your password |If you did not perform this request, you can safely ignore this email. |""".stripMargin EmailMessage(subject, body) } def resetPassword(name: Name): EmailMessage = { val subject = "Your password has been reset" val body = s"""Hi ${name.string.htmlEscape}, |

Your password has been changed.

|If this was not you, click the 'Forgot password' link on the sign in page and follow the steps to reset your password. |""".stripMargin EmailMessage(subject, body) } def updatePassword(name: Name): EmailMessage = { val subject = "Your password has been updated" val body = s"""Hi ${name.string.htmlEscape}, |

Your password has been changed.

|If this was not you, click the 'Forgot password' link on the sign in page and follow the steps to reset your password. |""".stripMargin EmailMessage(subject, body) } } ================================================ FILE: server/src/main/scala/net/wiringbits/util/EmailsHelper.scala ================================================ package net.wiringbits.util import net.wiringbits.apis.EmailApi import net.wiringbits.apis.models.EmailRequest import net.wiringbits.config.{UserTokensConfig, WebAppConfig} import net.wiringbits.repositories.UserTokensRepository import net.wiringbits.repositories.models.{User, UserToken, UserTokenType} import java.time.temporal.ChronoUnit import java.time.{Clock, Instant} import java.util.UUID import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class EmailsHelper @Inject() ( emailApi: EmailApi, webAppConfig: WebAppConfig, userTokensRepository: UserTokensRepository, tokenGenerator: TokenGenerator, userTokensConfig: UserTokensConfig, clock: Clock )(implicit ec: ExecutionContext) { def sendEmailVerificationToken(user: User): Future[Instant] = { // we can't retrieve the plain text token, hence, we generate another one val token = tokenGenerator.next() val hmacToken = TokensHelper.doHMACSHA1(token.toString.getBytes(), userTokensConfig.hmacSecret) val createToken = UserToken .Create( id = UUID.randomUUID(), token = hmacToken, tokenType = UserTokenType.EmailVerification, createdAt = Instant.now(clock), userId = user.id, expiresAt = Instant.now(clock).plus(userTokensConfig.emailVerificationExp.toSeconds, ChronoUnit.SECONDS) ) for { _ <- userTokensRepository.create(createToken) _ <- sendRegistrationEmailWithVerificationToken(user, token) } yield createToken.expiresAt } // we don't save emails in the queue when user tokens are involved def sendRegistrationEmailWithVerificationToken(user: User, token: UUID): Future[Unit] = { val emailParameter = s"${user.id}_$token" val emailMessage = EmailMessage.registration( name = user.name, url = webAppConfig.host, emailParameter = emailParameter ) val request = EmailRequest(user.email, emailMessage) emailApi.sendEmail(request) } // we don't save emails in the queue when user tokens are involved def sendPasswordRecoveryEmail(user: User): Future[Unit] = { val token = tokenGenerator.next() val emailParameter = s"${user.id}_$token" val hmacToken = TokensHelper.doHMACSHA1(token.toString.getBytes, userTokensConfig.hmacSecret) val createToken = UserToken .Create( id = UUID.randomUUID(), token = hmacToken, tokenType = UserTokenType.ResetPassword, createdAt = Instant.now(clock), userId = user.id, expiresAt = Instant.now(clock).plus(userTokensConfig.resetPasswordExp.toHours, ChronoUnit.HOURS) ) val message = EmailMessage.forgotPassword(user.name, webAppConfig.host, emailParameter) for { _ <- userTokensRepository.create(createToken) _ <- emailApi.sendEmail(EmailRequest(user.email, message)) } yield () } } ================================================ FILE: server/src/main/scala/net/wiringbits/util/StringUtils.scala ================================================ package net.wiringbits.util object StringUtils { def mask(value: String, prefixSize: Int, suffixSize: Int): String = { if (value.length <= prefixSize + suffixSize + 4) { "..." } else { s"${value.take(prefixSize)}...${value.takeRight(suffixSize)}" } } object Implicits { implicit class StringUtilsExt(val string: String) extends AnyVal { def mask(prefix: Int = 2, suffix: Int = 2): String = StringUtils.mask(string, prefix, suffix) } } } ================================================ FILE: server/src/main/scala/net/wiringbits/util/TokenGenerator.scala ================================================ package net.wiringbits.util import java.util.UUID import javax.inject.Inject class TokenGenerator @Inject() () { def next(): UUID = UUID.randomUUID() } ================================================ FILE: server/src/main/scala/net/wiringbits/util/TokensHelper.scala ================================================ package net.wiringbits.util import jakarta.xml.bind.DatatypeConverter object TokensHelper { def doHMACSHA1(value: Array[Byte], secretKey: String): String = { import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec val signingKey = new SecretKeySpec(secretKey.getBytes, "HmacSHA1") val mac = Mac.getInstance("HmacSHA1") mac.init(signingKey) val rawHmac = mac.doFinal(value) DatatypeConverter.printHexBinary(rawHmac) } def isSignatureValid(tokensSecret: String, digest: String, data: Array[Byte]): Boolean = { val ourDigest = doHMACSHA1(data, tokensSecret) ourDigest equalsIgnoreCase digest } } ================================================ FILE: server/src/main/scala/net/wiringbits/validations/ValidateCaptcha.scala ================================================ package net.wiringbits.validations import net.wiringbits.apis.ReCaptchaApi import net.wiringbits.common.models.Captcha import scala.concurrent.{ExecutionContext, Future} object ValidateCaptcha { def apply(captchaApi: ReCaptchaApi, captcha: Captcha)(implicit ec: ExecutionContext): Future[Unit] = { captchaApi .verify(captcha) .map { case true => () case false => throw new RuntimeException(s"Invalid captcha, try again") } } } ================================================ FILE: server/src/main/scala/net/wiringbits/validations/ValidateEmailIsAvailable.scala ================================================ package net.wiringbits.validations import net.wiringbits.common.models.Email import net.wiringbits.repositories.UsersRepository import scala.concurrent.{ExecutionContext, Future} object ValidateEmailIsAvailable { def apply(repository: UsersRepository, email: Email)(implicit ec: ExecutionContext): Future[Unit] = { for { maybe <- repository.find(email) } yield { if (maybe.isDefined) throw new RuntimeException(s"The email is not available") else () } } } ================================================ FILE: server/src/main/scala/net/wiringbits/validations/ValidateEmailIsRegistered.scala ================================================ package net.wiringbits.validations import net.wiringbits.common.models.Email import net.wiringbits.repositories.UsersRepository import scala.concurrent.{ExecutionContext, Future} object ValidateEmailIsRegistered { def apply(repository: UsersRepository, email: Email)(implicit ec: ExecutionContext): Future[Unit] = { for { maybe <- repository.find(email) } yield { if (maybe.isEmpty) throw new RuntimeException(s"The email is not registered") else () } } } ================================================ FILE: server/src/main/scala/net/wiringbits/validations/ValidatePasswordMatches.scala ================================================ package net.wiringbits.validations import net.wiringbits.common.models.Password import net.wiringbits.repositories.models.User import org.mindrot.jbcrypt.BCrypt object ValidatePasswordMatches { def apply(maybe: Option[User], password: Password): User = { maybe .filter(user => BCrypt.checkpw(password.string, user.hashedPassword)) .getOrElse(throw new RuntimeException("The given email/password doesn't match")) } } ================================================ FILE: server/src/main/scala/net/wiringbits/validations/ValidateUserIsNotVerified.scala ================================================ package net.wiringbits.validations import net.wiringbits.repositories.models.User object ValidateUserIsNotVerified { def apply(user: User): Unit = { if (user.verifiedOn.isDefined) throw new RuntimeException(s"User email is already verified") else () } } ================================================ FILE: server/src/main/scala/net/wiringbits/validations/ValidateUserToken.scala ================================================ package net.wiringbits.validations import net.wiringbits.repositories.models.UserToken import java.time.Clock object ValidateUserToken { def apply(token: UserToken)(implicit clock: Clock): Unit = { if (token.expiresAt.isBefore(clock.instant())) throw new RuntimeException("Token is expired") else () } } ================================================ FILE: server/src/main/scala/net/wiringbits/validations/ValidateVerifiedUser.scala ================================================ package net.wiringbits.validations import net.wiringbits.common.ErrorMessages import net.wiringbits.repositories.models.User object ValidateVerifiedUser { def apply(user: User): Unit = { if (user.verifiedOn.isDefined) () else throw new RuntimeException(ErrorMessages.emailNotVerified) } } ================================================ FILE: server/src/test/scala/controllers/AdminControllerSpec.scala ================================================ package controllers import com.dimafeng.testcontainers.PostgreSQLContainer import controllers.common.PlayPostgresSpec import net.wiringbits.apis.models.EmailRequest import net.wiringbits.apis.{EmailApi, ReCaptchaApi} import net.wiringbits.common.models.* import net.wiringbits.util.TokenGenerator import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.* import org.scalatestplus.mockito.MockitoSugar import play.api.inject import play.api.inject.guice.GuiceApplicationBuilder import utils.LoginUtils import java.time.{Clock, Instant} import java.util.UUID import scala.concurrent.Future class AdminControllerSpec extends PlayPostgresSpec with LoginUtils with MockitoSugar { private val tokenGenerator = mock[TokenGenerator] private val clock = mock[Clock] when(clock.instant).thenReturn(Instant.now()) private val emailApi = mock[EmailApi] when(emailApi.sendEmail(any[EmailRequest]())).thenReturn(Future.unit) private val captchaApi = mock[ReCaptchaApi] when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(true)) override def guiceApplicationBuilder(container: PostgreSQLContainer): GuiceApplicationBuilder = super .guiceApplicationBuilder(container) .overrides( inject.bind[TokenGenerator].to(tokenGenerator), inject.bind[EmailApi].to(emailApi), inject.bind[ReCaptchaApi].to(captchaApi), inject.bind[Clock].to(clock) ) "GET /admin/users" should { "get every user" in withApiClient { implicit client => val expected = 3 (1 to expected).foreach { _ => createVerifyLoginUser( tokenGenerator ).futureValue } val response = client.adminGetUsers.futureValue response.data.length must be(expected) } "return no results" in withApiClient { client => val response = client.adminGetUsers.futureValue response.data.isEmpty must be(true) } } "GET /admin/users/:userId/logs" should { "get user logs" in withApiClient { implicit client => val user = createVerifyLoginUser(tokenGenerator).futureValue val response = client.adminGetUserLogs(user.id).futureValue response.data.isEmpty must be(false) } "return no results" in withApiClient { client => val response = client.adminGetUserLogs(UUID.randomUUID()).futureValue response.data.isEmpty must be(true) } } } ================================================ FILE: server/src/test/scala/controllers/AuthControllerSpec.scala ================================================ package controllers import com.dimafeng.testcontainers.PostgreSQLContainer import controllers.common.PlayPostgresSpec import net.wiringbits.api.models.auth.Login import net.wiringbits.api.models.users.VerifyEmail import net.wiringbits.apis.models.EmailRequest import net.wiringbits.apis.{EmailApi, ReCaptchaApi} import net.wiringbits.common.models.* import net.wiringbits.config.UserTokensConfig import net.wiringbits.repositories.UserTokensRepository import net.wiringbits.util.TokenGenerator import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.* import org.scalatestplus.mockito.MockitoSugar import play.api.inject import play.api.inject.guice.GuiceApplicationBuilder import utils.LoginUtils import java.time.temporal.ChronoUnit import java.time.{Clock, Instant} import java.util.UUID import scala.concurrent.Future class AuthControllerSpec extends PlayPostgresSpec with LoginUtils with MockitoSugar { def userTokensRepository: UserTokensRepository = app.injector.instanceOf(classOf[UserTokensRepository]) private val clock = mock[Clock] when(clock.instant).thenReturn(Instant.now()) private val tokenGenerator = mock[TokenGenerator] private val emailApi = mock[EmailApi] when(emailApi.sendEmail(any[EmailRequest]())).thenReturn(Future.unit) private val captchaApi = mock[ReCaptchaApi] when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(true)) override def guiceApplicationBuilder(container: PostgreSQLContainer): GuiceApplicationBuilder = super .guiceApplicationBuilder(container) .overrides( inject.bind[EmailApi].to(emailApi), inject.bind[ReCaptchaApi].to(captchaApi), inject.bind[Clock].to(clock), inject.bind[TokenGenerator].to(tokenGenerator) ) def userTokensConfig: UserTokensConfig = app.injector.instanceOf(classOf[UserTokensConfig]) "POST /auth/login" should { "return the response from a correct user" in withApiClient { implicit client => val name = Name.trusted("wiringbits") val email = Email.trusted("test1@email.com") val loginResponse = createVerifyLoginUser(tokenGenerator, nameMaybe = Some(name), emailMaybe = Some(email)).futureValue loginResponse.name must be(name) loginResponse.email must be(email) } "fail when the user tries to login without an email verification" in withApiClient { implicit client => val password = Password.trusted("test123...") val user = createUser(passwordMaybe = Some(password)).futureValue val loginRequest = Login.Request( email = user.email, password = password, captcha = Captcha.trusted("test") ) val error = client .login(loginRequest) .expectError error must be("The email is not verified, check your spam folder if you don't see the email.") } "fail when the user tries to verify with a wrong token" in withApiClient { implicit client => val user = createUser().futureValue val error = client .verifyEmail(VerifyEmail.Request(UserToken(user.id, UUID.randomUUID()))) .expectError error must be(s"Token for user ${user.id} wasn't found") } "fail when the user tries to verify with an expired token" in withApiClient { implicit client => val verificationToken = UUID.randomUUID() when(tokenGenerator.next()).thenReturn(verificationToken) val user = createUser().futureValue when(clock.instant).thenReturn(Instant.now().plus(2, ChronoUnit.DAYS)) val error = client .verifyEmail(VerifyEmail.Request(UserToken(user.id, verificationToken))) .expectError error must be("Token is expired") } "login after successful email confirmation" in withApiClient { implicit client => val email = Email.trusted("test1@email.com") val response = createVerifyLoginUser(tokenGenerator, emailMaybe = Some(email)).futureValue response.email must be(email) } "fail when password is incorrect" in withApiClient { implicit client => val verificationToken = UUID.randomUUID() when(tokenGenerator.next()).thenReturn(verificationToken) val user = createUser().futureValue client.verifyEmail(VerifyEmail.Request(UserToken(user.id, verificationToken))).futureValue val loginRequest = Login.Request( email = user.email, password = Password.trusted("Incorrect password"), captcha = Captcha.trusted("test") ) val error = client .login(loginRequest) .expectError error must be("The given email/password doesn't match") } "fail when the captcha isn't valid" in withApiClient { implicit client => val password = Password.trusted("test123...") val user = createUser(passwordMaybe = Some(password)).futureValue val loginRequest = Login.Request( email = user.email, password = password, captcha = Captcha.trusted("test") ) when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(false)) val error = client .login(loginRequest) .expectError error must be("Invalid captcha, try again") when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(true)) } "fail when user isn't email confirmed" in withApiClient { implicit client => val password = Password.trusted("test123...") val user = createUser(passwordMaybe = Some(password)).futureValue val loginRequest = Login.Request( email = user.email, password = password, captcha = Captcha.trusted("test") ) val error = client .login(loginRequest) .expectError error must be("The email is not verified, check your spam folder if you don't see the email.") } } "GET /auth/me" should { "return current logged user" in withApiClient { implicit client => val name = Name.trusted("wiringbits") val email = Email.trusted("test1@email.com") createVerifyLoginUser(tokenGenerator, nameMaybe = Some(name), emailMaybe = Some(email)).futureValue val currentUser = client.currentUser.futureValue currentUser.name must be(name) currentUser.email must be(email) } "fail if user isn't logged in" in withApiClient { client => val error = client.currentUser.expectError error must be("Unauthorized: Invalid or missing authentication") } } } ================================================ FILE: server/src/test/scala/controllers/EnvironmentConfigControllerSpec.scala ================================================ package controllers import controllers.common.PlayPostgresSpec import net.wiringbits.config.ReCaptchaConfig import org.scalatest.BeforeAndAfterAll class EnvironmentConfigControllerSpec extends PlayPostgresSpec { def reCaptchaConfig: ReCaptchaConfig = app.injector.instanceOf(classOf[ReCaptchaConfig]) "GET /environment-config" should { "return the frontend configuration" in withApiClient { client => val response = client.getEnvironmentConfig.futureValue response.recaptchaSiteKey must be(reCaptchaConfig.siteKey.string) } } } ================================================ FILE: server/src/test/scala/controllers/UsersControllerSpec.scala ================================================ package controllers import com.dimafeng.testcontainers.PostgreSQLContainer import controllers.common.PlayPostgresSpec import net.wiringbits.api.models.auth.Login import net.wiringbits.api.models.users.{ForgotPassword, ResetPassword, SendEmailVerificationToken, VerifyEmail} import net.wiringbits.apis.models.EmailRequest import net.wiringbits.apis.{EmailApi, ReCaptchaApi} import net.wiringbits.common.models.* import net.wiringbits.config.UserTokensConfig import net.wiringbits.repositories.UserTokensRepository import net.wiringbits.repositories.models.UserTokenType import net.wiringbits.util.{TokenGenerator, TokensHelper} import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.* import org.scalatestplus.mockito.MockitoSugar import play.api.inject import play.api.inject.guice.GuiceApplicationBuilder import utils.LoginUtils import java.time.{Clock, Instant} import java.util.UUID import scala.concurrent.Future class UsersControllerSpec extends PlayPostgresSpec with LoginUtils with MockitoSugar { def userTokensRepository: UserTokensRepository = app.injector.instanceOf(classOf[UserTokensRepository]) private val clock = mock[Clock] when(clock.instant).thenReturn(Instant.now()) private val tokenGenerator = mock[TokenGenerator] private val emailApi = mock[EmailApi] when(emailApi.sendEmail(any[EmailRequest]())).thenReturn(Future.unit) private val captchaApi = mock[ReCaptchaApi] when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(true)) def userTokensConfig: UserTokensConfig = app.injector.instanceOf(classOf[UserTokensConfig]) override def guiceApplicationBuilder(container: PostgreSQLContainer): GuiceApplicationBuilder = super .guiceApplicationBuilder(container) .overrides( inject.bind[EmailApi].to(emailApi), inject.bind[ReCaptchaApi].to(captchaApi), inject.bind[Clock].to(clock), inject.bind[TokenGenerator].to(tokenGenerator) ) private def createHMACToken(token: UUID): String = { TokensHelper.doHMACSHA1(token.toString.getBytes, app.injector.instanceOf[UserTokensConfig].hmacSecret) } "POST /users" should { "return the email verification token after creating a user" in withApiClient { implicit client => val name = Name.trusted("wiringbits") val email = Email.trusted("test1@email.com") val verificationToken = UUID.randomUUID() when(tokenGenerator.next()).thenReturn(verificationToken) val response = createUser(nameMaybe = Some(name), emailMaybe = Some(email)).futureValue val token = userTokensRepository .find(response.id) .futureValue .find(_.tokenType == UserTokenType.EmailVerification) .value response.name must be(name) response.email must be(email) token.token must be(createHMACToken(verificationToken)) } "fail when the email is already taken" in withApiClient { implicit client => val email = Email.trusted("test@wiringbits.net") createUser(emailMaybe = Some(email)).futureValue val error = createUser(emailMaybe = Some(email)).expectError error must be("The email is not available") } "fail when the captcha isn't valid" in withApiClient { implicit client => when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(false)) val error = createUser().expectError error must be("Invalid captcha, try again") when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(true)) } } "POST /users/verify-email" should { "success on verifying user's email" in withApiClient { implicit client => val name = Name.trusted("wiringbits") val email = Email.trusted("test1@email.com") val response = createVerifyLoginUser(tokenGenerator, nameMaybe = Some(name), emailMaybe = Some(email)).futureValue response.name must be(name) response.email must be(email) } "delete the verification token after successful email confirmation" in withApiClient { implicit client => val user = createVerifyLoginUser(tokenGenerator).futureValue userTokensRepository.find(user.id).futureValue must be(empty) } "fail when trying to verify an already verified user's email" in withApiClient { implicit client => val user = createVerifyLoginUser(tokenGenerator).futureValue val token = UUID.randomUUID() val error = client .verifyEmail(VerifyEmail.Request(UserToken(user.id, token))) .expectError error must be(s"User email is already verified") } } "POST /forgot-password" should { "create the reset password token after the user's request to reset their password" in withApiClient { implicit client => val email = Email.trusted("test1@email.com") createVerifyLoginUser(tokenGenerator, emailMaybe = Some(email)).futureValue val forgotPasswordRequest = ForgotPassword.Request(email, Captcha.trusted("test")) val response = client.forgotPassword(forgotPasswordRequest).futureValue response must be(ForgotPassword.Response()) } "ignore the request when the user tries to reset a password for nonexistent email" in withApiClient { client => val email = Email.trusted("test@email.com") val forgotPasswordRequest = ForgotPassword.Request(email, Captcha.trusted("test")) val response = client.forgotPassword(forgotPasswordRequest).futureValue response must be(ForgotPassword.Response()) } "fail when the user tries to reset a password without their email verification step" in withApiClient { implicit client => val email = Email.trusted("test1@email.com") createUser(emailMaybe = Some(email)).futureValue val forgotPasswordRequest = ForgotPassword.Request(email, Captcha.trusted("test")) val error = client .forgotPassword(forgotPasswordRequest) .expectError error must be(s"The email is not verified, check your spam folder if you don't see the email.") } "fail when the captcha isn't valid" in withApiClient { implicit client => val email = Email.trusted("test@email.com") createVerifyLoginUser(tokenGenerator, emailMaybe = Some(email)).futureValue val forgotPasswordRequest = ForgotPassword.Request(email, Captcha.trusted("test")) when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(false)) val error = client .forgotPassword(forgotPasswordRequest) .expectError error must be("Invalid captcha, try again") when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(true)) } } "POST /reset-password" should { "reset a password for a given user" in withApiClient { implicit client => val name = Name.trusted("wiringbits") val email = Email.trusted("test@email.com") val user = createVerifyLoginUser(tokenGenerator, nameMaybe = Some(name), emailMaybe = Some(email)).futureValue val verificationToken = UUID.randomUUID() when(tokenGenerator.next()).thenReturn(verificationToken) val forgotPasswordRequest = ForgotPassword.Request(email, Captcha.trusted("test")) client.forgotPassword(forgotPasswordRequest).futureValue val resetPasswordRequest = ResetPassword.Request(UserToken(user.id, verificationToken), Password.trusted("test456...")) client.resetPassword(resetPasswordRequest).futureValue val loginRequest = Login.Request( email = email, password = Password.trusted("test456..."), captcha = Captcha.trusted("test") ) val loginResponse = client.login(loginRequest).futureValue loginResponse.name must be(name) loginResponse.email must be(email) } "return a email when a user tries to reset a password" in withApiClient { implicit client => val name = Name.trusted("wiringbits") val email = Email.trusted("test1@email.com") val userId = createVerifyLoginUser(tokenGenerator, nameMaybe = Some(name), emailMaybe = Some(email)).futureValue.id val verificationToken = tokenGenerator.next() val forgotPasswordRequest = ForgotPassword.Request(email, Captcha.trusted("test")) client.forgotPassword(forgotPasswordRequest).futureValue val resetPasswordRequest = ResetPassword.Request(UserToken(userId, verificationToken), Password.trusted("test456...")) val response = client .resetPassword(resetPasswordRequest) .futureValue response.email must be(email) } "fail when the user tries to login with their old password after the password resetting" in withApiClient { implicit client => val email = Email.trusted("test1@email.com") val user = createVerifyLoginUser(tokenGenerator, emailMaybe = Some(email)).futureValue val verificationToken = UUID.randomUUID() when(tokenGenerator.next()).thenReturn(verificationToken) val forgotPasswordRequest = ForgotPassword.Request(email, Captcha.trusted("test")) client.forgotPassword(forgotPasswordRequest).futureValue val resetPasswordRequest = ResetPassword.Request(UserToken(user.id, verificationToken), Password.trusted("test456...")) client.resetPassword(resetPasswordRequest).futureValue val loginRequest = Login.Request( email = email, password = Password.trusted("test123..."), captcha = Captcha.trusted("test") ) val error = client .login(loginRequest) .expectError error must be("The given email/password doesn't match") } } "POST /users/email-verification-token" should { "success on send verifying token user's email" in withApiClient { implicit client => val name = Name.trusted("wiringbits") val email = Email.trusted("test@email.com") val request = SendEmailVerificationToken.Request( email = email, captcha = Captcha.trusted("test") ) val verificationToken = UUID.randomUUID() when(tokenGenerator.next()).thenReturn(verificationToken) val userCreated = createUser(nameMaybe = Some(name), emailMaybe = Some(email)).futureValue val response = client.sendEmailVerificationToken(request).futureValue val token = userTokensRepository .find(userCreated.id) .futureValue .find(_.tokenType == UserTokenType.EmailVerification) .value response.expiresAt must be(token.expiresAt) } "success on verifying email and login" in withApiClient { implicit client => val name = Name.trusted("wiringbits") val email = Email.trusted("test@email.com") val response = createVerifyLoginUser(tokenGenerator, nameMaybe = Some(name), emailMaybe = Some(email)).futureValue response.name must be(name) response.email must be(email) } "fail when user's email is not registered" in withApiClient { client => val email = Email.trusted("test@email.com") val request = SendEmailVerificationToken.Request( email = email, captcha = Captcha.trusted("test") ) val error = client.sendEmailVerificationToken(request).expectError error must be(s"The email is not registered") } "fail when the captcha isn't valid" in withApiClient { client => val email = Email.trusted("test@email.com") val request = SendEmailVerificationToken.Request( email = email, captcha = Captcha.trusted("test") ) when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(false)) val error = client .sendEmailVerificationToken(request) .expectError error must be("Invalid captcha, try again") when(captchaApi.verify(any[Captcha]())).thenReturn(Future.successful(true)) } "fail if the user is already verified" in withApiClient { implicit client => val email = Email.trusted("test@email.com") val request = SendEmailVerificationToken.Request( email = email, captcha = Captcha.trusted("test") ) val verificationToken = UUID.randomUUID() when(tokenGenerator.next()).thenReturn(verificationToken) createVerifyLoginUser(tokenGenerator, emailMaybe = Some(email)).futureValue val error = client .sendEmailVerificationToken(request) .expectError error must be(s"User email is already verified") } } } ================================================ FILE: server/src/test/scala/controllers/common/PlayAPISpec.scala ================================================ package controllers.common import org.scalatest.concurrent.ScalaFutures import org.scalatestplus.play.PlaySpec import org.slf4j.LoggerFactory import play.api.inject.guice.GuiceApplicationBuilder import play.api.mvc.Result import play.api.test.FakeRequest import play.api.test.Helpers.* import play.api.{Application, Mode} import java.net.URLEncoder import scala.concurrent.Future /** A PlayAPISpec allow us to write tests for the API calls. */ trait PlayAPISpec extends PlaySpec with ScalaFutures { protected def defaultGuiceApplicationBuilder: GuiceApplicationBuilder = GuiceApplicationBuilder() .in(Mode.Test) private val JsonHeader = CONTENT_TYPE -> "application/json" private val EmptyJson = "{}" protected val logger = LoggerFactory.getLogger(this.getClass) def log[T](request: FakeRequest[T], response: Future[Result]): Unit = { logger.info( s"REQUEST > $request, headers = ${request.headers}; RESPONSE < status = ${status(response)}, body = ${contentAsString(response)}" ) } /** Syntactic sugar for calling APIs * */ def GET(url: String, extraHeaders: (String, String)*)(implicit application: Application): Future[Result] = { val headers = JsonHeader :: extraHeaders.toList val request = FakeRequest("GET", url) .withHeaders(headers: _*) val response = route(application, request).value log(request, response) response } def POST(url: String, extraHeaders: (String, String)*)(implicit application: Application): Future[Result] = { POST(url, EmptyJson, extraHeaders: _*) } def POST(url: String, jsonBody: String, extraHeaders: (String, String)*)(implicit application: Application ): Future[Result] = { val headers = JsonHeader :: extraHeaders.toList val request = FakeRequest("POST", url) .withHeaders(headers: _*) .withBody(jsonBody) val response = route(application, request).value log(request, response) response } def PUT(url: String, extraHeaders: (String, String)*)(implicit application: Application): Future[Result] = { PUT(url, EmptyJson, extraHeaders: _*) } def PUT(url: String, jsonBody: String, extraHeaders: (String, String)*)(implicit application: Application ): Future[Result] = { val headers = JsonHeader :: extraHeaders.toList val request = FakeRequest("PUT", url) .withHeaders(headers: _*) .withBody(jsonBody) val response = route(application, request).value log(request, response) response } def DELETE(url: String, extraHeaders: (String, String)*)(implicit application: Application): Future[Result] = { val headers = JsonHeader :: extraHeaders.toList val request = FakeRequest("DELETE", url) .withHeaders(headers: _*) val response = route(application, request).value log(request, response) response } } object PlayAPISpec { object Implicits { implicit class HttpExt(val params: List[(String, String)]) extends AnyVal { def toQueryString: String = { params .map { case (key, value) => val encodedKey = URLEncoder.encode(key, "UTF-8") val encodedValue = URLEncoder.encode(value, "UTF-8") List(encodedKey, encodedValue).mkString("=") } .mkString("&") } } implicit class StringUrlExt(val url: String) extends AnyVal { def withQueryParams(params: (String, String)*): String = { List(url, params.toList.toQueryString).mkString("?") } } } } ================================================ FILE: server/src/test/scala/controllers/common/PlayPostgresSpec.scala ================================================ package controllers.common import com.dimafeng.testcontainers.PostgreSQLContainer import com.dimafeng.testcontainers.scalatest.TestContainerForEach import net.wiringbits.api.ApiClient import org.scalatest.TestData import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.scalatestplus.play.guice.GuiceOneServerPerTest import org.testcontainers.utility.DockerImageName import play.api.inject.guice.GuiceApplicationBuilder import play.api.{Application, Configuration, Environment, Mode} import sttp.client3.HttpClientFutureBackend import java.sql.DriverManager import scala.concurrent.{ExecutionContext, Future} import scala.util.control.NonFatal trait PlayPostgresSpec extends PlayAPISpec with TestContainerForEach with GuiceOneServerPerTest { implicit val executionContext: ExecutionContext = ExecutionContext.Implicits.global override implicit val patienceConfig: PatienceConfig = PatienceConfig(30.seconds, 1.second) private val postgresImage = DockerImageName.parse("postgres:13") override val containerDef: PostgreSQLContainer.Def = PostgreSQLContainer.Def(dockerImageName = postgresImage) /** Loads configuration disabling evolutions on default database. * * This allows to not write a custom application.conf for testing and ensure play evolutions are disabled. */ private def loadConfigWithoutEvolutions(env: Environment, container: PostgreSQLContainer): Configuration = { val map = Map( "db.default.username" -> container.username, "db.default.password" -> container.password, "db.default.url" -> container.jdbcUrl ) Configuration.from(map).withFallback(Configuration.load(env)) } def guiceApplicationBuilder(container: PostgreSQLContainer): GuiceApplicationBuilder = GuiceApplicationBuilder(loadConfiguration = env => loadConfigWithoutEvolutions(env, container)) .in(Mode.Test) override def newAppForTest(testData: TestData): Application = { withContainers { postgres => val conn = DriverManager.getConnection( postgres.container.getJdbcUrl, postgres.container.getUsername, postgres.container.getPassword ) conn.createStatement().execute("CREATE EXTENSION CITEXT;") conn.close() guiceApplicationBuilder(postgres).build() } } def withApiClient[A](runTest: ApiClient => A): A = { implicit val sttpBackend: sttp.client3.SttpBackend[concurrent.Future, _] = HttpClientFutureBackend() val config = ApiClient.Config(s"http://localhost:$port") val client = new ApiClient(config) runTest(client) } implicit class RichFutureExt[T](val future: Future[T]) { def expectError: String = { future .map(_ => "Success when failure expected") .recover { case NonFatal(ex) => ex.getMessage } .futureValue } } } ================================================ FILE: server/src/test/scala/net/wiringbits/apis/ReCaptchaApiSpec.scala ================================================ package net.wiringbits.apis import net.wiringbits.common.models.Captcha import net.wiringbits.config.ReCaptchaConfig import net.wiringbits.models.{ReCaptchaSecret, ReCaptchaSiteKey} import org.mockito.ArgumentMatchers import org.mockito.Mockito.* import org.scalatest.concurrent.ScalaFutures.* import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec import org.scalatestplus.mockito.MockitoSugar import play.api.libs.json.{JsValue, Json} import play.api.libs.ws.DefaultBodyWritables.* import play.api.libs.ws.{BodyWritable, WSClient, WSRequest, WSResponse} import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future class ReCaptchaApiSpec extends AnyWordSpec with MockitoSugar { private val ws = mock[WSClient] private val request = mock[WSRequest] private val response = mock[WSResponse] private val config = ReCaptchaConfig(ReCaptchaSecret("test"), ReCaptchaSiteKey("test")) private val api = new ReCaptchaApi(config, ws) "verify" should { "detect successful responses" in { mockRequest(request, response)(Json.obj("success" -> true)) val result = api.verify(Captcha.trusted("example")) result.futureValue must be(true) } "detect unsuccessful responses" in { mockRequest(request, response)(Json.obj("success" -> false)) val result = api.verify(Captcha.trusted("example")) result.futureValue must be(false) } "fail when getting an unknown response" in { mockRequest(request, response)(Json.obj("other" -> false)) val result = api.verify(Captcha.trusted("example")) intercept[Throwable](result.futureValue) } } private def mockRequest(request: WSRequest, response: WSResponse)(body: JsValue): Unit = { when(ws.url(ArgumentMatchers.anyString)).thenReturn(request) when(request.addQueryStringParameters(ArgumentMatchers.any[(String, String)])).thenReturn(request) when(response.json).thenReturn(body) val _ = when(request.post[String](ArgumentMatchers.anyString())(ArgumentMatchers.eq(implicitly[BodyWritable[String]]))) .thenReturn(Future.successful(response)) } } ================================================ FILE: server/src/test/scala/net/wiringbits/core/PostgresSpec.scala ================================================ package net.wiringbits.core import com.dimafeng.testcontainers.PostgreSQLContainer import com.dimafeng.testcontainers.scalatest.TestContainerForEach import org.scalatest.Suite import org.testcontainers.utility.DockerImageName import play.api.db.evolutions.Evolutions import play.api.db.{Database, Databases} import java.sql.DriverManager trait PostgresSpec extends TestContainerForEach { self: Suite => private val postgresImage = DockerImageName.parse("postgres:13") override val containerDef: PostgreSQLContainer.Def = PostgreSQLContainer.Def(dockerImageName = postgresImage) def initDatabase(postgres: Containers): Unit = { val conn = DriverManager.getConnection( postgres.container.getJdbcUrl, postgres.container.getUsername, postgres.container.getPassword ) conn.createStatement().execute("CREATE EXTENSION CITEXT;") conn.close() } def withDatabase[T](runTest: Database => T): T = withContainers { postgres => initDatabase(postgres) val database = Databases( driver = "org.postgresql.Driver", url = postgres.jdbcUrl, name = "default", config = Map( "username" -> postgres.container.getUsername, "password" -> postgres.container.getPassword ) ) Evolutions.applyEvolutions(database) runTest(database) } } ================================================ FILE: server/src/test/scala/net/wiringbits/core/RepositoryComponents.scala ================================================ package net.wiringbits.core import net.wiringbits.repositories.* import play.api.db.Database case class RepositoryComponents( database: Database, users: UsersRepository, userTokens: UserTokensRepository, userLogs: UserLogsRepository, backgroundJobs: BackgroundJobsRepository ) ================================================ FILE: server/src/test/scala/net/wiringbits/core/RepositorySpec.scala ================================================ package net.wiringbits.core import net.wiringbits.config.UserTokensConfig import net.wiringbits.repositories.* import org.scalatest.concurrent.ScalaFutures.* import org.scalatest.wordspec.AnyWordSpec import utils.Executors import java.time.Clock import scala.concurrent.ExecutionContext import scala.concurrent.duration.DurationInt trait RepositorySpec extends AnyWordSpec with PostgresSpec { implicit val patienceConfig: PatienceConfig = PatienceConfig(30.seconds, 1.second) implicit val executionContext: ExecutionContext = Executors.globalEC def withRepositories[T](clock: Clock = Clock.systemUTC)(runTest: RepositoryComponents => T): T = withDatabase { db => val users = new UsersRepository(db, UserTokensConfig(1.hour, 1.hour, "secret"))(Executors.databaseEC, clock) val userTokens = new UserTokensRepository(db)(Executors.databaseEC) val userLogs = new UserLogsRepository(db)(Executors.databaseEC) val backgroundJobs = new BackgroundJobsRepository(db)(Executors.databaseEC, clock) val components = RepositoryComponents( db, users, userTokens, userLogs, backgroundJobs ) runTest(components) } } ================================================ FILE: server/src/test/scala/net/wiringbits/repositories/BackgroundJobsRepositorySpec.scala ================================================ package net.wiringbits.repositories import org.apache.pekko.actor.ActorSystem import org.apache.pekko.stream.scaladsl.* import net.wiringbits.common.models.Email import net.wiringbits.core.RepositorySpec import net.wiringbits.models.jobs.{BackgroundJobPayload, BackgroundJobStatus, BackgroundJobType} import net.wiringbits.repositories.daos.{BackgroundJobDAO, backgroundJobParser} import net.wiringbits.repositories.models.BackgroundJobData import org.scalatest.BeforeAndAfterAll import org.scalatest.OptionValues.* import org.scalatest.concurrent.ScalaFutures.* import org.scalatest.matchers.must.Matchers.* import play.api.libs.json.Json import utils.RepositoryUtils import java.time.Instant import java.util.UUID class BackgroundJobsRepositorySpec extends RepositorySpec with BeforeAndAfterAll with RepositoryUtils { // required to test the streaming operations private implicit lazy val system: ActorSystem = ActorSystem("BackgroundJobsRepositorySpec") override def afterAll(): Unit = { system.terminate().futureValue super.afterAll() } "streamPendingJobs" should { "work (simple case)" in withRepositories() { implicit repositories => val createRequest = createBackgroundJobData() val result = repositories.backgroundJobs.streamPendingJobs.futureValue .runWith(Sink.seq) .futureValue result.size must be(1) val item = result.headOption.value item.status must be(createRequest.status) item.`type` must be(createRequest.`type`) item.payload must be(Json.toJson(createRequest.payload)) } "only return pending jobs" in withRepositories() { implicit repositories => val backgroundJobType = BackgroundJobType.SendEmail val payload = backgroundJobPayload val limit = 6 for (i <- 1 to limit) { createBackgroundJobData( backgroundJobType = backgroundJobType, payload = payload, status = if (i % 2) == 0 then BackgroundJobStatus.Success else BackgroundJobStatus.Pending ) } val response = repositories.backgroundJobs.streamPendingJobs.futureValue .runWith(Sink.seq) .futureValue response.length must be(limit / 2) response.foreach { x => x.status must be(BackgroundJobStatus.Pending) x.`type` must be(backgroundJobType) x.payload must be(Json.toJson(payload)) } } "return no results" in withRepositories() { repositories => val response = repositories.backgroundJobs.streamPendingJobs.futureValue .runWith(Sink.seq) .futureValue response.isEmpty must be(true) } } "setStatusToFailed" should { "work" in withRepositories() { implicit repositories => val createRequest = createBackgroundJobData() val failReason = "test" repositories.backgroundJobs .setStatusToFailed(createRequest.id, executeAt = Instant.now(), failReason = failReason) .futureValue val result = repositories.backgroundJobs.streamPendingJobs.futureValue .runWith(Sink.seq) .futureValue result.size must be(1) val item = result.headOption.value item.id must be(createRequest.id) item.status must be(BackgroundJobStatus.Failed) item.statusDetails must be(Some(failReason)) } "fail if the job doesn't exists" in withRepositories() { repositories => pending // TODO: setStatusToFailed must actually return an error because right now it succeeds repositories.backgroundJobs .setStatusToFailed(UUID.randomUUID(), executeAt = Instant.now(), failReason = "test") .futureValue } } "setStatusToSuccess" should { "work" in withRepositories() { implicit repositories => val createRequest = createBackgroundJobData() repositories.backgroundJobs.setStatusToSuccess(createRequest.id).futureValue val result = repositories.backgroundJobs.streamPendingJobs.futureValue .runWith(Sink.seq) .futureValue result.isEmpty must be(true) } "fail if the notification doesn't exists" in withRepositories() { repositories => pending // TODO: setStatusToFailed must actually return an error because right now it succeeds repositories.backgroundJobs.setStatusToSuccess(UUID.randomUUID()).futureValue } } } ================================================ FILE: server/src/test/scala/net/wiringbits/repositories/UserLogsRepositorySpec.scala ================================================ package net.wiringbits.repositories import net.wiringbits.core.RepositorySpec import org.scalatest.concurrent.ScalaFutures.* import org.scalatest.matchers.must.Matchers.* import utils.RepositoryUtils import java.util.UUID class UserLogsRepositorySpec extends RepositorySpec with RepositoryUtils { "create" should { "work" in withRepositories() { implicit repositories => val request = createUser().futureValue createUserLog(request.id).futureValue } "fail if the user doesn't exists" in withRepositories() { implicit repositories => val ex = intercept[RuntimeException] { createUserLog(UUID.randomUUID()).futureValue } ex.getCause.getMessage must startWith( s"""ERROR: insert or update on table "user_logs" violates foreign key constraint "user_logs_users_fk"""" ) } } "create(userId, message)" should { "work" in withRepositories() { implicit repositories => val request = createUser().futureValue createUserLog(request.id, "test").futureValue } "fail if the user doesn't exists" in withRepositories() { implicit repositories => val ex = intercept[RuntimeException] { createUserLog(UUID.randomUUID(), "test").futureValue } ex.getCause.getMessage must startWith( s"""ERROR: insert or update on table "user_logs" violates foreign key constraint "user_logs_users_fk"""" ) } } "logs" should { "return every log" in withRepositories() { implicit repositories => val request = createUser().futureValue val message = "test" val expected = 3 (1 to expected).foreach { _ => createUserLog(request.id, message).futureValue } val response = repositories.userLogs.logs(request.id).futureValue // Creating a user generates a user log. 3 + 1 response.length must be(expected + 1) } "return no results" in withRepositories() { repositories => val response = repositories.userLogs.logs(UUID.randomUUID()).futureValue response.isEmpty must be(true) } } } ================================================ FILE: server/src/test/scala/net/wiringbits/repositories/UserTokensRepositorySpec.scala ================================================ package net.wiringbits.repositories import net.wiringbits.core.RepositorySpec import org.scalatest.OptionValues.convertOptionToValuable import org.scalatest.concurrent.ScalaFutures.* import org.scalatest.matchers.must.Matchers.* import utils.RepositoryUtils import java.util.UUID class UserTokensRepositorySpec extends RepositorySpec with RepositoryUtils { "create" should { "work" in withRepositories() { implicit repositories => val request = createUser().futureValue createToken(request.id).futureValue } "fail when the user doesn't exists" in withRepositories() { implicit repositories => val ex = intercept[RuntimeException] { createToken(UUID.randomUUID()).futureValue } ex.getCause.getMessage must startWith( s"""ERROR: insert or update on table "user_tokens" violates foreign key constraint "user_tokens_user_id_fk"""" ) } } "find(userId)" should { "return the user token" in withRepositories() { implicit repositories => val request = createUser().futureValue val tokenRequest = createToken(request.id).futureValue val maybe = repositories.userTokens.find(request.id).futureValue val response = maybe.headOption.value response.token must be(tokenRequest.token) response.tokenType must be(tokenRequest.tokenType) response.id must be(tokenRequest.id) } "return no results when the user doesn't exists" in withRepositories() { repositories => val response = repositories.userTokens.find(UUID.randomUUID()).futureValue response.isEmpty must be(true) } } "find(userId, token)" should { "return the user token" in withRepositories() { implicit repositories => val request = createUser().futureValue val tokenRequest = createToken(request.id).futureValue val response = repositories.userTokens.find(request.id, tokenRequest.token).futureValue response.isDefined must be(true) } "return no results when the user doesn't exists" in withRepositories() { repositories => val response = repositories.userTokens.find(UUID.randomUUID(), "test").futureValue response.isEmpty must be(true) } } "delete" should { "work" in withRepositories() { implicit repositories => val request = createUser().futureValue val maybe = repositories.userTokens.find(request.id).futureValue val tokenId = maybe.headOption.value.id repositories.userTokens.delete(tokenId = tokenId, userId = request.id).futureValue val response = repositories.userTokens.find(request.id).futureValue response.isEmpty must be(true) } } } ================================================ FILE: server/src/test/scala/net/wiringbits/repositories/UsersRepositorySpec.scala ================================================ package net.wiringbits.repositories import org.apache.pekko.actor.ActorSystem import org.apache.pekko.stream.scaladsl.Sink import net.wiringbits.common.models.{Email, Name} import net.wiringbits.core.RepositorySpec import net.wiringbits.repositories.models.User import net.wiringbits.util.EmailMessage import org.scalatest.BeforeAndAfterAll import org.scalatest.OptionValues.* import org.scalatest.concurrent.ScalaFutures.* import org.scalatest.matchers.must.Matchers.* import utils.RepositoryUtils import java.util.UUID class UsersRepositorySpec extends RepositorySpec with BeforeAndAfterAll with RepositoryUtils { // required to test the streaming operations private implicit lazy val system: ActorSystem = ActorSystem("UserRepositorySpec") override def afterAll(): Unit = { system.terminate().futureValue super.afterAll() } "create" should { "work" in withRepositories() { implicit repositories => createUser().futureValue } "create a token for verifying the email" in withRepositories() { implicit repositories => val request = createUser().futureValue val response = repositories.userTokens.find(request.id).futureValue response.nonEmpty must be(true) } "fail when the id already exists" in withRepositories() { implicit repositories => val request = createUser().futureValue val ex = intercept[RuntimeException] { repositories.users.create(request.copy(email = Email.trusted("email2@wiringbits.net"))).futureValue } // TODO: This should be a better message ex.getCause.getMessage must startWith( """ERROR: duplicate key value violates unique constraint "users_user_id_pk"""" ) } "fail when the email already exists" in withRepositories() { implicit repositories => val request = createUser().futureValue val ex = intercept[RuntimeException] { repositories.users.create(request.copy(id = UUID.randomUUID())).futureValue } // TODO: This should be a better message ex.getCause.getMessage must startWith( """ERROR: duplicate key value violates unique constraint "users_email_unique"""" ) } } "all" should { "return the existing users" in withRepositories() { implicit repositories => val expected = 3 for (i <- 1 to expected) { createUser(email = Email.trusted(s"test$i@wiringbits.net")).futureValue } val response = repositories.users.all().futureValue response.length must be(3) } "return no users" in withRepositories() { repositories => val response = repositories.users.all().futureValue response.isEmpty must be(true) } } "find(email)" should { "return a user when the email exists" in withRepositories() { implicit repositories => val request = createUser().futureValue val response = repositories.users.find(request.email).futureValue response.value.email must be(request.email) response.value.id must be(request.id) response.value.hashedPassword must be(request.hashedPassword) } "return a user when the email exists (case insensitive match)" in withRepositories() { implicit repositories => val request = createUser().futureValue val email = Email.trusted(request.email.string.toUpperCase) val response = repositories.users.find(email).futureValue response.isDefined must be(true) } "return no result when the email doesn't exists" in withRepositories() { repositories => val email = Email.trusted("hello@wiringbits.net") val response = repositories.users.find(email).futureValue response.isEmpty must be(true) } } "find(id)" should { "return a user when the id exists" in withRepositories() { implicit repositories => val request = createUser().futureValue val response = repositories.users.find(request.id).futureValue response.value.email must be(request.email) response.value.id must be(request.id) response.value.hashedPassword must be(request.hashedPassword) } "return no result when the id doesn't exists" in withRepositories() { repositories => val id = UUID.randomUUID() val response = repositories.users.find(id).futureValue response.isEmpty must be(true) } } "update" should { "update an existing user" in withRepositories() { implicit repositories => val request = createUser().futureValue val newName = Name.trusted("Test") repositories.users.update(request.id, newName).futureValue val response = repositories.users.find(request.id).futureValue response.value.name must be(newName) response.value.email must be(request.email) } "fail when the user doesn't exist" in withRepositories() { repositories => val id = UUID.randomUUID() val newName = Name.trusted("Test") val ex = intercept[RuntimeException] { repositories.users.update(id, newName).futureValue } ex.getCause.getMessage must startWith( """ERROR: insert or update on table "user_logs" violates foreign key constraint "user_logs_users_fk"""" ) } } "updatePassword" should { "update the password for an existing user" in withRepositories() { implicit repositories => val request = createUser().futureValue val newPassword = "test" repositories.users.updatePassword(request.id, newPassword, EmailMessage.updatePassword(request.name)).futureValue val response = repositories.users.find(request.id).futureValue response.value.hashedPassword must be(newPassword) } "produce a notification for the user" in withRepositories() { implicit repositories => val request = createUser().futureValue val newPassword = "test" repositories.users.updatePassword(request.id, newPassword, EmailMessage.updatePassword(request.name)).futureValue val response = repositories.backgroundJobs.streamPendingJobs.futureValue .runWith(Sink.seq) .futureValue response.length must be(1) } "fail when the user doesn't exist" in withRepositories() { repositories => val name = Name.trusted("test") val ex = intercept[RuntimeException] { repositories.users.updatePassword(UUID.randomUUID(), "test", EmailMessage.updatePassword(name)).futureValue } ex.getCause.getMessage must startWith( """ERROR: insert or update on table "user_logs" violates foreign key constraint "user_logs_users_fk"""" ) } } "verify" should { "verify a user given a token" in withRepositories() { implicit repositories => val request = createUser().futureValue repositories.users.verify(request.id, UUID.randomUUID(), EmailMessage.confirm(request.name)).futureValue val response = repositories.users.find(request.id).futureValue response.value.verifiedOn.isDefined must be(true) } "produce a notification for the user" in withRepositories() { implicit repositories => val request = createUser().futureValue repositories.users.verify(request.id, UUID.randomUUID(), EmailMessage.confirm(request.name)).futureValue val response = repositories.backgroundJobs.streamPendingJobs.futureValue .runWith(Sink.seq) .futureValue response.length must be(1) } "fail when the user doesn't exist" in withRepositories() { repositories => val name = Name.trusted("test") val ex = intercept[RuntimeException] { repositories.users.verify(UUID.randomUUID(), UUID.randomUUID(), EmailMessage.confirm(name)).futureValue } ex.getCause.getMessage must startWith( """ERROR: insert or update on table "user_logs" violates foreign key constraint "user_logs_users_fk"""" ) } } "resetPassword" should { "update the password" in withRepositories() { implicit repositories => val request = createUser().futureValue val newPassword = "test" repositories.users.resetPassword(request.id, newPassword, EmailMessage.resetPassword(request.name)).futureValue val response = repositories.users.find(request.id).futureValue response.value.hashedPassword must be(newPassword) } "produce a notification for the user" in withRepositories() { implicit repositories => val request = createUser().futureValue val newPassword = "test" repositories.users.resetPassword(request.id, newPassword, EmailMessage.resetPassword(request.name)).futureValue val response = repositories.backgroundJobs.streamPendingJobs.futureValue .runWith(Sink.seq) .futureValue response.length must be(1) } "fail when the user doesn't exist" in withRepositories() { repositories => val name = Name.trusted("test") val ex = intercept[RuntimeException] { repositories.users.resetPassword(UUID.randomUUID(), "test", EmailMessage.resetPassword(name)).futureValue } ex.getCause.getMessage must startWith( """ERROR: insert or update on table "user_logs" violates foreign key constraint "user_logs_users_fk"""" ) } } } ================================================ FILE: server/src/test/scala/net/wiringbits/util/DelayGeneratorSpec.scala ================================================ package net.wiringbits.util import org.scalatest.matchers.must.Matchers.{be, must} import org.scalatest.wordspec.AnyWordSpec class DelayGeneratorSpec extends AnyWordSpec { "createDelay" should { "create an exponential sequence in a linear sequence of numbers" in { val expected = List(1, 2, 4, 8, 16) val response = expected.indices.map(x => DelayGenerator.createDelay(x)).toList response must be(expected) } } } ================================================ FILE: server/src/test/scala/net/wiringbits/util/TokensHelperSpec.scala ================================================ package net.wiringbits.util import org.scalatest.matchers.must.Matchers.{be, empty, must, mustNot} import org.scalatest.wordspec.AnyWordSpec import java.util.UUID class TokensHelperSpec extends AnyWordSpec { "doHMACSHA1" should { "create a valid hmac" in { val uuid = UUID.randomUUID() val secretKey = "test" val hmac = TokensHelper.doHMACSHA1(value = uuid.toString.getBytes, secretKey = secretKey) hmac mustNot be(empty) } } "isSignatureValid" should { "return true when the data doesn't changes" in { val uuid = UUID.randomUUID() val secretKey = "test" val hmac = TokensHelper.doHMACSHA1(value = uuid.toString.getBytes, secretKey = secretKey) TokensHelper.isSignatureValid(tokensSecret = secretKey, digest = hmac, data = uuid.toString.getBytes) must be( true ) } "return false when the data changes" in { val secretKey = "test" val hmac = TokensHelper.doHMACSHA1(value = UUID.randomUUID.toString.getBytes, secretKey = secretKey) TokensHelper.isSignatureValid( tokensSecret = secretKey, digest = hmac, data = UUID.randomUUID.toString.getBytes ) must be(false) } } } ================================================ FILE: server/src/test/scala/utils/Executors.scala ================================================ package utils import net.wiringbits.executors.DatabaseExecutionContext import scala.concurrent.ExecutionContext object Executors { implicit val globalEC: ExecutionContext = scala.concurrent.ExecutionContext.global implicit val databaseEC: DatabaseExecutionContext = new DatabaseExecutionContext { override def execute(runnable: Runnable): Unit = globalEC.execute(runnable) override def reportFailure(cause: Throwable): Unit = globalEC.reportFailure(cause) } } ================================================ FILE: server/src/test/scala/utils/LoginUtils.scala ================================================ package utils import net.wiringbits.api.ApiClient import net.wiringbits.api.models.auth.Login import net.wiringbits.api.models.users.{CreateUser, VerifyEmail} import net.wiringbits.common.models.* import net.wiringbits.util.TokenGenerator import org.mockito.Mockito.* import java.util.UUID import scala.annotation.unused import scala.concurrent.{ExecutionContext, Future} trait LoginUtils { def createUser( nameMaybe: Option[Name] = None, emailMaybe: Option[Email] = None, passwordMaybe: Option[Password] = None )(using @unused ec: ExecutionContext, client: ApiClient ): Future[CreateUser.Response] = { val request = CreateUser.Request( name = nameMaybe.getOrElse(Name.trusted("wiringbits")), email = emailMaybe.getOrElse(Email.trusted(s"test${UUID.randomUUID()}@email.com")), password = passwordMaybe.getOrElse(Password.trusted("test123...")), captcha = Captcha.trusted("test") ) client.createUser(request) } def createVerifyLoginUser( tokenGenerator: TokenGenerator, nameMaybe: Option[Name] = None, emailMaybe: Option[Email] = None, passwordMaybe: Option[Password] = None )(using @unused ec: ExecutionContext, client: ApiClient): Future[Login.Response] = { val verificationToken = UUID.randomUUID() when(tokenGenerator.next()).thenReturn(verificationToken) for { user <- createUser(nameMaybe, emailMaybe, passwordMaybe) _ <- client.verifyEmail(VerifyEmail.Request(UserToken(user.id, verificationToken))) loginRequest = Login.Request( email = user.email, password = passwordMaybe.getOrElse(Password.trusted("test123...")), captcha = Captcha.trusted("test") ) response <- client.login(loginRequest) } yield response } } ================================================ FILE: server/src/test/scala/utils/RepositoryUtils.scala ================================================ package utils import net.wiringbits.common.models.{Email, Name} import net.wiringbits.core.RepositoryComponents import net.wiringbits.models.jobs.{BackgroundJobPayload, BackgroundJobStatus, BackgroundJobType} import net.wiringbits.repositories.daos.BackgroundJobDAO import net.wiringbits.repositories.models.{BackgroundJobData, User, UserLog, UserToken, UserTokenType} import org.scalatest.concurrent.ScalaFutures.* import java.time.Instant import java.time.temporal.ChronoUnit import java.util.UUID import scala.annotation.unused import scala.concurrent.{ExecutionContext, Future} trait RepositoryUtils { val backgroundJobPayload: BackgroundJobPayload.SendEmail = BackgroundJobPayload.SendEmail(Email.trusted("sample@wiringbits.net"), subject = "Test message", body = "it works") def createBackgroundJobData( id: UUID = UUID.randomUUID(), backgroundJobType: BackgroundJobType = BackgroundJobType.SendEmail, status: BackgroundJobStatus = BackgroundJobStatus.Pending, payload: BackgroundJobPayload = backgroundJobPayload )(using repositories: RepositoryComponents): BackgroundJobData.Create = { val createRequest = BackgroundJobData.Create( id = id, `type` = backgroundJobType, payload = payload, status = status, executeAt = Instant.now(), createdAt = Instant.now(), updatedAt = Instant.now() ) repositories.database.withConnection { implicit conn => BackgroundJobDAO.create(createRequest) } createRequest } def createUser( email: Email = Email.trusted("hello@wiringbits.net") )(using repository: RepositoryComponents)(using @unused ec: ExecutionContext): Future[User.CreateUser] = { val createRequest = User.CreateUser( id = UUID.randomUUID(), email = email, name = Name.trusted("Sample"), hashedPassword = "password", verifyEmailToken = "token" ) for { _ <- repository.users.create(createRequest) } yield createRequest } def createUserLog( userId: UUID )(using repository: RepositoryComponents)(using @unused ec: ExecutionContext): Future[UserLog.CreateUserLog] = { val createRequest = UserLog.CreateUserLog(userLogId = UUID.randomUUID(), userId = userId, message = "test") for { _ <- repository.userLogs.create(createRequest) } yield createRequest } def createUserLog( userId: UUID, message: String )(using repository: RepositoryComponents): Future[Unit] = { repository.userLogs.create(userId, message) } def createToken( userId: UUID )(using @unused ec: ExecutionContext, repository: RepositoryComponents): Future[UserToken.Create] = { val tokenRequest = UserToken.Create( id = UUID.randomUUID(), token = "test", tokenType = UserTokenType.ResetPassword, createdAt = Instant.now(), expiresAt = Instant.now.plus(2, ChronoUnit.DAYS), userId = userId ) for { _ <- repository.userTokens.create(tokenRequest) } yield tokenRequest } } ================================================ FILE: web/src/main/js/index.css ================================================ html, body, #root { min-height: 100vh; display: flex; flex-direction: column; } ================================================ FILE: web/src/main/js/index.html ================================================ Wiringbits Web App Template
================================================ FILE: web/src/main/scala/net/wiringbits/API.scala ================================================ package net.wiringbits import net.wiringbits.api.ApiClient import net.wiringbits.services.StorageService import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.* import sttp.client3.SttpBackend import scala.concurrent.Future case class API(client: ApiClient, storage: StorageService) object API { // allows overriding the server url private val apiUrl = { net.wiringbits.BuildInfo.apiUrl.filter(_.nonEmpty).getOrElse { "http://localhost:8080/api" } } def apply(): API = { println(s"Server API expected at: $apiUrl") implicit val sttpBackend: SttpBackend[Future, _] = sttp.client3.FetchBackend() val client = new ApiClient(ApiClient.Config(apiUrl)) val storage = new StorageService API(client, storage) } } ================================================ FILE: web/src/main/scala/net/wiringbits/App.scala ================================================ package net.wiringbits import com.olvind.mui.muiMaterial.components.ThemeProvider import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.components.AppSplash import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.BrowserRouter import slinky.core.{FunctionalComponent, KeyAddingStage} import slinky.core.facade.ReactElement object App { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => ThemeProvider(AppTheme.value)( mui.ThemeProvider(AppTheme.value)( mui.CssBaseline(), BrowserRouter(basename = "")( AppSplash(props.ctx)(AppRouter(props.ctx): ReactElement) ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/AppContext.scala ================================================ package net.wiringbits import monix.reactive.subjects.Var import net.wiringbits.common.models.Email import net.wiringbits.core.I18nLang import net.wiringbits.models.{AuthState, User} import scala.concurrent.ExecutionContext import scala.language.postfixOps case class AppContext( api: API, $auth: Var[AuthState], $lang: Var[I18nLang], contactEmail: Email, contactPhone: String, executionContext: ExecutionContext ) { // TODO: This is hacky but it works while preventing to pollute all components from depending on the Texts // still, it would be ideal to keep a Var with the current Texts instance def texts(lang: I18nLang): I18nMessages = new I18nMessages(lang) def loggedIn(user: User): Unit = { $auth := AuthState.Authenticated(user) } def loggedOut(): Unit = { $auth := AuthState.Unauthenticated } def switchLang(newLang: I18nLang): Unit = { api.storage.saveLang(newLang) $lang := newLang } } ================================================ FILE: web/src/main/scala/net/wiringbits/AppRouter.scala ================================================ package net.wiringbits import net.wiringbits.components.pages.* import net.wiringbits.components.widgets.{AppBar, Footer} import net.wiringbits.core.ReactiveHooks import net.wiringbits.models.{AuthState, User} import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.Scaffold import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.{Redirect, Route, Switch} import slinky.core.facade.ReactElement import slinky.core.{FunctionalComponent, KeyAddingStage} import scala.concurrent.ExecutionContext import scala.util.{Failure, Success} object AppRouter { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private def route(path: String, ctx: AppContext)(child: => ReactElement): ReactElement = { Route(path = path, exact = true)( Scaffold( appbar = Some(AppBar(ctx)), body = child, footer = Some(Footer(ctx)) ) ) } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => implicit val ec: ExecutionContext = props.ctx.executionContext val auth = ReactiveHooks.useDistinctValue(props.ctx.$auth) val home = route("/", props.ctx)(HomePage(props.ctx)) val about = route("/about", props.ctx)(AboutPage(props.ctx)) val signIn = route("/signin", props.ctx)(SignInPage(props.ctx)) val signUp = route("/signup", props.ctx)(SignUpPage(props.ctx)) val email = route("/verify-email", props.ctx)(VerifyEmailPage(props.ctx)) val emailCode = route("/verify-email/:emailCode", props.ctx)(VerifyEmailWithTokenPage(props.ctx)) val forgotPassword = route("/forgot-password", props.ctx)(ForgotPasswordPage(props.ctx)) val resetPassword = route("/reset-password/:resetPasswordCode", props.ctx)(ResetPasswordPage(props.ctx)) val resendVerifyEmail = route("/resend-verify-email", props.ctx)(ResendVerifyEmailPage(props.ctx)) def dashboard(user: User) = route("/dashboard", props.ctx)(DashboardPage(props.ctx, user)) def me(user: User) = route("/me", props.ctx)(UserEditPage(props.ctx, user)) val signOut = route("/signout", props.ctx) { props.ctx.api.client.logout.onComplete { case Success(_) => props.ctx.loggedOut() println("Logged out successfully") case Failure(exception) => println(s"Failed to log out: ${exception.getMessage}") } Redirect("/") } val catchAllRoute = Route(path = "*")(render = Redirect("/")) auth match { case AuthState.Unauthenticated => Switch( home, about, signIn, signUp, email, emailCode, forgotPassword, resetPassword, resendVerifyEmail, catchAllRoute ) case AuthState.Authenticated(user) => Switch(home, me(user), dashboard(user), about, signOut, catchAllRoute) } } } ================================================ FILE: web/src/main/scala/net/wiringbits/AppTheme.scala ================================================ package net.wiringbits import com.olvind.mui.muiMaterial.stylesCreateThemeMod.ThemeOptions import com.olvind.mui.muiMaterial.stylesCreateThemeMod.Theme import com.olvind.mui.muiMaterial.stylesCreatePaletteMod.SimplePaletteColorOptions import com.olvind.mui.muiMaterial.stylesCreatePaletteMod.PaletteOptions import com.olvind.mui.muiMaterial.stylesCreateTypographyMod.TypographyOptions import com.olvind.mui.muiMaterial.stylesMod.{createMuiTheme, createTheme} import com.olvind.mui.muiMaterial.colorsMod as Colors import com.olvind.mui.muiMaterial.components as mui import com.olvind.mui.react.mod.CSSProperties import com.olvind.mui.muiMaterial.mod.PropTypes.Color import com.olvind.mui.muiIconsMaterial.components as muiIcons import com.olvind.mui.csstype.mod.Property.{BoxSizing, FlexDirection, Position} import com.olvind.mui.muiSystem.createThemeShapeMod.ShapeOptions object AppTheme { val primaryColor = Colors.teal.`500` val secondaryColor = Colors.amber val typography = TypographyOptions() val borderRadius = 8 val value: Theme = createTheme( ThemeOptions() .setPalette( PaletteOptions() .setPrimary(SimplePaletteColorOptions(primaryColor)) ) .setTypography(typography) .setShape(ShapeOptions().setBorderRadius(borderRadius)) ) } ================================================ FILE: web/src/main/scala/net/wiringbits/I18nMessages.scala ================================================ package net.wiringbits import net.wiringbits.common.models.Name import net.wiringbits.core.I18nLang import net.wiringbits.models.UserMenuOption // TODO: conditionaly render messages when we support more than 1 language class I18nMessages(_lang: I18nLang) { def appName = "Wiringbits Web App Template" def appNameCopyright = s"$appName ${java.time.ZonedDateTime.now.getYear}" def description = "While wiringbits is a company based in Culiacan, Mexico, there is no office, everyone works remotely. We strive for great quality on the software we built, and try to open source everything we can." def profile = "Profile" def home = "Home" def dashboard = "Dashboard" def user = "User" def about = "About" def signOut = "Sign out" def signUp: String = "Sign up" def signIn: String = "Sign in" def loading: String = "Loading" def welcome = "Welcome" def completeData = "Complete the necessary data" def contact = "Contact" def phone = "Phone" def name = "Name" def email = "Email" def password = "Password" def oldPassword = "Old password" def repeatPassword = "Repeat password" def createdAt = "Created at" def createAccount = "Create account" def login = "Login" def savePassword = "Save password" def resetPassword = "Reset password" def forgotYourPassword = "Forgot your password?" def recoverYourPassword = "Recover your password" def dontHaveAccountYet = "You don't have an account yet?" def alreadyHaveAccount = "Do you already have an account?" def enterNewPassword = "Enter your new password" def save = "Save" def recover = "Recover" def recoverIt = "Recover it" def reload = "Reload" def logs = "Logs" def resendEmail = "Re-send email" def aboutPage = "About page" def projectDetails = "Add details about the project" def dashboardPage = "Dashboard page" def homePage = "Home page" def landingPageContent = "The landing page content goes here" def verifyYourEmailAddress = "Verify your email address" def successfulEmailVerification = "Successful email verification" def failedEmailVerification = "Failed email verification" def invalidVerificationToken = "Invalid verification token" def goingToBeRedirected = "You're going to be redirected" def emailHasBeenSent = "An email has been sent to your email with a URL to verify your account." def emailNotReceived = "If you haven't received the email after a few minutes, please check your spam folder" def verifyingEmail = "We're verifying your email" def waitAMomentPlease = "Wait a moment, please" def completeTheCaptcha = "Complete the captcha" def checkoutTheRepo = "Checkout the repository!" def homePageDescription = "A reusable skeleton to build web applications in Scala/Scala.js, including user registration, login, and deployments." def userProfile = "User profile" def userProfileDescription = "All the necessary code to create accounts, change passwords, update profile is there, " def tryIt = "Try it." def swaggerIntegration = "Swagger integration" def swaggerIntegrationDescription = "The template already has the necessary boilerplate to expose the application's API through Swagger, " def consistentDataLoading = "Consistent data loading" def consistentDataLoadingDescription = "Asynchronous data loading is consistent when using our `AsyncComponent`, for example:" def dataIsBeingLoaded = "When the data is being loaded, a progress indicator is displayed:" def problemFetchingData = "When there is a problem fetching data, we get an opportunity to retry:" def simpleToFollowArchitecture = "A simple-to-follow architecture where tests are first class citizens" def simpleToFollowArchitectureDescription1 = "There is already an integration with GitHub Actions, and, there are already many integration tests to make sure that your APIs/Database work the way you expect them." def simpleToFollowArchitectureDescription2 = "There are many layers which are easy to follow, which means, boarding new developers takes little effort." def welcome(name: Name): String = { s"Welcome ${name.string}" } def userMenuOption(menuOption: UserMenuOption): String = { menuOption match { case UserMenuOption.EditSummary => "Summary" case UserMenuOption.EditPassword => "Change password" } } } ================================================ FILE: web/src/main/scala/net/wiringbits/Main.scala ================================================ package net.wiringbits import monix.reactive.subjects.Var import net.wiringbits.common.models.Email import net.wiringbits.core.I18nLang import net.wiringbits.models.AuthState import net.wiringbits.webapp.utils.slinkyUtils.components.core.{ErrorBoundaryComponent, ErrorBoundaryInfo} import org.scalajs.dom import slinky.web.ReactDOM import scala.scalajs.js import scala.scalajs.js.annotation.JSImport @JSImport("js/index.css", JSImport.Default) @js.native object IndexCSS extends js.Object object Main { val css = IndexCSS def main(argv: Array[String]): Unit = { val scheduler = monix.execution.Scheduler.global val $authState = Var[AuthState](AuthState.Unauthenticated)(scheduler) val $lang = Var[I18nLang](I18nLang.English)(scheduler) val ctx = AppContext( API(), $authState, $lang, Email.trusted("hello@wiringbits.net"), "+52 (999) 9999 999", org.scalajs.macrotaskexecutor.MacrotaskExecutor ) val app = ErrorBoundaryComponent( ErrorBoundaryComponent.Props( child = App(ctx), renderError = e => ErrorBoundaryInfo(e) ) ) ReactDOM.render(app, dom.document.getElementById("root")) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/AppSplash.scala ================================================ package net.wiringbits.components import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.models.User import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{Container, Subtitle, Title} import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.* import slinky.core.FunctionalComponent import slinky.core.facade.{Fragment, Hooks, ReactElement} import scala.util.{Failure, Success} object AppSplash { def apply(ctx: AppContext)(child: ReactElement): ReactElement = component(Props(ctx = ctx, child = child)) case class Props(ctx: AppContext, child: ReactElement) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val (initialized, setInitialized) = Hooks.useState(false) Hooks.useEffect( () => { // load language // TODO: It is ideal to detect the browser language when there is no language stored props.ctx.api.storage .findLang() .foreach(lang => props.ctx.$lang := lang) props.ctx.api.client.currentUser.onComplete { case Success(res) => props.ctx.loggedIn(User(name = res.name, email = res.email)) setInitialized(true) case Failure(ex) => println( s"Failed to get current user, we are either unauthenticated or the server had a problem: ${ex.getMessage}" ) setInitialized(true) } }, "" ) if (initialized) { Fragment(props.child) } else { Container( flex = Some(1), alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = Fragment( Title(texts.appName), Subtitle(texts.loading) ) ) } } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/AboutPage.scala ================================================ package net.wiringbits.components.pages import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.Container import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} import slinky.web.html.{alt, img, src, style} object AboutPage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private val styling = new CSSPropertiesUtils { maxWidth = 300 maxHeight = 164 } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val wiringbitsImage = img(src := "/img/wiringbits-logo.png", alt := "wiringbits logo", style := styling) val repositoryLink = mui .Link(texts.checkoutTheRepo) .variant("h5") .color("inherit") .href("https://github.com/wiringbits/scala-webapp-template") .target("_blank") Container( flex = Some(1), alignItems = Container.Alignment.center, margin = Container.EdgeInsets.top(48), child = Fragment( wiringbitsImage, Container( margin = Container.EdgeInsets.top(32), child = repositoryLink ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/DashboardPage.scala ================================================ package net.wiringbits.components.pages import net.wiringbits.AppContext import net.wiringbits.components.widgets.Logs import net.wiringbits.core.I18nHooks import net.wiringbits.models.User import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{Container, Subtitle, Title} import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} object DashboardPage { def apply(ctx: AppContext, user: User): KeyAddingStage = component(Props(ctx = ctx, user = user)) case class Props(ctx: AppContext, user: User) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) Fragment( Container( margin = Container.EdgeInsets.bottom(16), child = Fragment( Title(texts.dashboardPage), Subtitle(texts.welcome(props.user.name)) ) ), Logs(props.ctx, props.user) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/ForgotPasswordPage.scala ================================================ package net.wiringbits.components.pages import com.olvind.mui.csstype.mod.Property.FlexDirection import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.components.widgets.{AppCard, ForgotPasswordForm} import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.Container import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.useHistory import slinky.core.facade.Fragment import slinky.core.facade.ReactElement.jsUndefOrToElement import slinky.core.{FunctionalComponent, KeyAddingStage} import scala.scalajs.js object ForgotPasswordPage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private val styling = new CSSPropertiesUtils { maxWidth = 350 width = "100%" } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() Container( flex = Some(1), justifyContent = Container.Alignment.center, alignItems = Container.Alignment.center, child = mui.Box.sx(styling)( AppCard( Fragment( Container( alignItems = Container.Alignment.center, child = mui.Typography(texts.recoverYourPassword).variant("h5") ), ForgotPasswordForm(props.ctx), Container( margin = Container.EdgeInsets.top(8), flexDirection = FlexDirection.row, alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = Fragment( mui.Typography(texts.dontHaveAccountYet), mui.Button .normal()(texts.signUp) .variant("text") .color("primary") .onClick(_ => history.push("/signUp")) ) ) ) ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/HomePage.scala ================================================ package net.wiringbits.components.pages import com.olvind.mui.csstype.mod.Property.TextAlign import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.* import slinky.core.facade.{Fragment, ReactElement} import slinky.core.{FunctionalComponent, KeyAddingStage} import slinky.web.html.* object HomePage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private val homeContainerStyling = new CSSPropertiesUtils { maxWidth = 1300 width = "100%" } private val homeTitleStyling = new CSSPropertiesUtils { textAlign = TextAlign.center margin = "8px 0" } private val screenshotStyling = new CSSPropertiesUtils { maxWidth = 1200 width = "100%" display = "block" margin = "1em auto" } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) def title(msg: String) = mui .Typography(msg) .variant("h4") .color("inherit") def paragraph(args: ReactElement) = mui .Typography(args) .variant("body1") .color("inherit") def link(msg: String, url: String) = mui .Link(msg) .href(url) .target("_blank") def image(srcImg: String, altImg: String, classImg: String) = img(src := srcImg, alt := altImg, className := classImg, style := screenshotStyling) val homeFragment = Fragment( mui.Typography .sx(homeTitleStyling)(texts.homePage) .variant("h4") .color("inherit"), paragraph(texts.homePageDescription), br(), br() ) val userProfileFragment = Fragment( title(texts.userProfile), paragraph( Fragment( texts.userProfileDescription, link(texts.tryIt.toLowerCase, "https://template-demo.wiringbits.net/signin") ) ), br(), br() ) val swaggerFragment = Fragment( title(texts.swaggerIntegration), paragraph( Fragment( texts.swaggerIntegrationDescription, link(texts.tryIt.toLowerCase, "https://template-demo.wiringbits.net/api/docs/index.html") ) ), image("/img/home/swagger.png", texts.swaggerIntegration, "screenshot"), br(), br() ) val dataLoadingFragment = Fragment( title(texts.consistentDataLoading), paragraph(texts.consistentDataLoadingDescription), image("/img/home/async-component-snippet.png", texts.swaggerIntegration, "snippet"), paragraph(texts.dataIsBeingLoaded), image("/img/home/async-progress.png", texts.swaggerIntegration, "screenshot"), paragraph(texts.problemFetchingData), image("/img/home/async-retry.png", texts.swaggerIntegration, "screenshot"), br(), br() ) val simpleArchitectureFragment = Fragment( title(texts.simpleToFollowArchitecture), paragraph(texts.simpleToFollowArchitectureDescription1), paragraph(texts.simpleToFollowArchitectureDescription2), br(), br() ) Container( flex = Some(1), alignItems = Container.Alignment.center, child = mui.Box.sx(homeContainerStyling)( homeFragment, userProfileFragment, swaggerFragment, dataLoadingFragment, simpleArchitectureFragment ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/ResendVerifyEmailPage.scala ================================================ package net.wiringbits.components.pages import net.wiringbits.AppContext import net.wiringbits.components.widgets.ResendVerifyEmailForm import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.Container import slinky.core.{FunctionalComponent, KeyAddingStage} object ResendVerifyEmailPage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => Container( flex = Some(1), justifyContent = Container.Alignment.center, alignItems = Container.Alignment.center, child = ResendVerifyEmailForm(props.ctx) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/ResetPasswordPage.scala ================================================ package net.wiringbits.components.pages import com.olvind.mui.csstype.mod.Property.FlexDirection import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.common.models.UserToken import net.wiringbits.components.widgets.{AppCard, ResetPasswordForm} import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.Container import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.{useHistory, useParams} import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} import scala.scalajs.js object ResetPasswordPage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private val styling = new CSSPropertiesUtils { maxWidth = 350 width = "100%" } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() val params = useParams() val resetPasswordCode = params.get("resetPasswordCode").getOrElse("") val userToken = UserToken.validate(resetPasswordCode) Container( flex = Some(1), justifyContent = Container.Alignment.center, alignItems = Container.Alignment.center, child = mui.Box.sx(styling)( AppCard( Fragment( Container( alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = mui.Typography(texts.enterNewPassword).variant("h5") ), ResetPasswordForm(props.ctx, userToken), Container( margin = Container.EdgeInsets.top(8), flexDirection = FlexDirection.row, alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = Fragment( mui.Typography(texts.alreadyHaveAccount), mui.Button .normal(texts.signIn) .variant("text") .color("primary") .onClick(_ => history.push("/signin")) ) ) ) ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/SignInPage.scala ================================================ package net.wiringbits.components.pages import com.olvind.mui.csstype.mod.Property.FlexDirection import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.components.widgets.* import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{Container, Title} import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.useHistory import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} import scala.scalajs.js object SignInPage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private val styling = new CSSPropertiesUtils { maxWidth = 350 width = "100%" } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() Container( flex = Some(1), justifyContent = Container.Alignment.center, alignItems = Container.Alignment.center, child = mui.Box.sx(styling)( AppCard( Fragment( Container( justifyContent = Container.Alignment.center, alignItems = Container.Alignment.center, child = Title(texts.signIn) ), Container( flex = Some(1), alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, padding = Container.EdgeInsets.top(16), child = SignInForm(props.ctx) ), Container( margin = Container.EdgeInsets.top(8), flexDirection = FlexDirection.row, alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = Fragment( mui.Typography(texts.dontHaveAccountYet), mui.Button .normal()(texts.signUp) .variant("text") .color("primary") .onClick(_ => history.push("/signUp")) ) ), Container( flexDirection = FlexDirection.row, alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = Fragment( mui.Typography(texts.forgotYourPassword), mui.Button .normal(texts.recoverIt) .variant("text") .color("primary") .onClick(_ => history.push("/forgot-password")) ) ) ) ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/SignUpPage.scala ================================================ package net.wiringbits.components.pages import net.wiringbits.AppContext import net.wiringbits.components.widgets.SignUpForm import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.Container import slinky.core.{FunctionalComponent, KeyAddingStage} object SignUpPage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => Container( flex = Some(1), alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = SignUpForm(props.ctx) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/UserEditPage.scala ================================================ package net.wiringbits.components.pages import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.components.widgets.{EditPasswordForm, UserInfo} import net.wiringbits.core.I18nHooks import net.wiringbits.models.UserMenuOption.{EditPassword, EditSummary} import net.wiringbits.models.{User, UserMenuOption} import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{Container, Title} import slinky.core.facade.{Fragment, Hooks} import slinky.core.{FunctionalComponent, KeyAddingStage} object UserEditPage { def apply(ctx: AppContext, user: User): KeyAddingStage = component(Props(ctx = ctx, user = user)) case class Props(ctx: AppContext, user: User) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val (menuOption, setMenuOption) = Hooks.useState[UserMenuOption](UserMenuOption.EditSummary) val header = Container( margin = Container.EdgeInsets.bottom(16), child = Title(texts.user) ) val tabs = mui.CardContent()( mui .Tabs()( UserMenuOption.values.map(x => mui.Tab.normal().label(texts.userMenuOption(x)).withKey(x.toString).build) ) .value(UserMenuOption.values.indexOf(menuOption)) .onChange((_, index) => setMenuOption(UserMenuOption.values(index.toString.toInt))) ) val body = mui.CardContent()( menuOption match { case EditSummary => UserInfo(props.ctx, props.user) case EditPassword => EditPasswordForm(props.ctx, props.user) } ) Fragment( header, mui.Paper()( mui.Card()( tabs, body ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/VerifyEmailPage.scala ================================================ package net.wiringbits.components.pages import com.olvind.mui.csstype.mod.Property.{FlexDirection, TextAlign} import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.{useHistory, useLocation} import org.scalajs.dom import org.scalajs.dom.URLSearchParams import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} import slinky.web.html.br import scala.scalajs.js object VerifyEmailPage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private val emailPageStyling = new CSSPropertiesUtils { flex = 1 display = "flex" flexDirection = FlexDirection.column alignItems = "center" textAlign = TextAlign.center justifyContent = "center" } private val emailTitleStyling = new CSSPropertiesUtils { fontWeight = 600 } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() val params = new URLSearchParams(useLocation().asInstanceOf[js.Dynamic].search.asInstanceOf[String]) val emailParam = Option(params.get("email")).getOrElse("") Fragment( mui.Box.sx(emailPageStyling)( mui .Typography(texts.verifyYourEmailAddress) .variant("h5") .className("emailTitle") .sx(emailTitleStyling), br(), mui .Typography( texts.emailHasBeenSent ) .variant("h6"), mui .Typography( texts.emailNotReceived ) .variant("h6"), br(), mui.Button .normal()(texts.resendEmail) .variant("contained") .color("primary") .onClick(_ => history.push(s"/resend-verify-email?email=$emailParam")) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/pages/VerifyEmailWithTokenPage.scala ================================================ package net.wiringbits.components.pages import com.olvind.mui.csstype.mod.Property.{FlexDirection, TextAlign} import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.api.models.users.VerifyEmail import net.wiringbits.common.models.UserToken import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container} import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.{useHistory, useParams} import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global import slinky.core.facade.{Fragment, Hooks} import slinky.core.{FunctionalComponent, KeyAddingStage} import scala.scalajs.js import scala.scalajs.js.timers.setTimeout import scala.util.{Failure, Success} object VerifyEmailWithTokenPage { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private case class State( loading: Boolean, error: Option[String], title: String, message: String ) private val emailPageStyling = new CSSPropertiesUtils { flex = 1 display = "flex" flexDirection = FlexDirection.column alignItems = "center" textAlign = TextAlign.center justifyContent = "center" } private val emailTitleStyling = new CSSPropertiesUtils { fontWeight = 600 } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val initialState = State( loading = false, error = None, title = texts.verifyingEmail, message = texts.waitAMomentPlease ) val history = useHistory() val params = useParams() val (state, setState) = Hooks.useState(initialState) val emailCodeOpt = UserToken.validate(params.get("emailCode").getOrElse("")) def sendEmailCode(): Unit = { setState(_.copy(loading = true)) emailCodeOpt match { case Some(emailCode) => props.ctx.api.client.verifyEmail(VerifyEmail.Request(emailCode)).onComplete { case Success(_) => val title = texts.successfulEmailVerification val message = texts.goingToBeRedirected setState(_.copy(loading = false, title = title, message = message)) setTimeout(2000) { history.push("/signin") } case Failure(ex) => val title = texts.failedEmailVerification val message = ex.getMessage setState(_.copy(loading = false, title = title, message = message, error = Some(message))) } case None => val title = texts.failedEmailVerification val message = texts.invalidVerificationToken setState(_.copy(loading = false, title = title, message = message, error = Some(message))) } } Hooks.useEffect(() => sendEmailCode(), "") val loading = if (state.loading || state.error.isEmpty) Fragment( loader ) else { Fragment( ) } mui.Box.sx(emailPageStyling)( mui.Typography(state.title).variant("h5").className("emailTitle").sx(emailTitleStyling), mui.Typography(state.message).variant("h6"), loading ) } private def loader = Container( alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, padding = Container.EdgeInsets.vertical(16), child = CircularLoader(50) ) } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/AppBar.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiIconsMaterial.components as muiIcons import com.olvind.mui.muiMaterial.components as mui import com.olvind.mui.muiMaterial.mod.PropTypes.Color import net.wiringbits.AppContext import net.wiringbits.core.{I18nHooks, ReactiveHooks} import net.wiringbits.models.AuthState import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{Container, NavLinkButton, Subtitle, Title} import net.wiringbits.webapp.utils.slinkyUtils.core.MediaQueryHooks import slinky.core.facade.{Fragment, Hooks, ReactElement} import slinky.core.{FunctionalComponent, KeyAddingStage} object AppBar { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx)) case class Props(ctx: AppContext) private val appbarStyling = new CSSPropertiesUtils { color = "#FFF" } private val toolBarStyling = new CSSPropertiesUtils { display = "flex" alignItems = "center" justifyContent = "space-between" } private val toolbarMobileStyling = new CSSPropertiesUtils { display = "flex" alignItems = "center" } private val menuStyling = new CSSPropertiesUtils { display = "flex" } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val auth = ReactiveHooks.useDistinctValue(props.ctx.$auth) val isMobileOrTablet = MediaQueryHooks.useIsMobileOrTablet() val (visibleDrawer, setVisibleDrawer) = Hooks.useState(false) def onButtonClick(): Unit = { if (visibleDrawer) { setVisibleDrawer(false) } } val menu = auth match { case AuthState.Authenticated(_) => Fragment( NavLinkButton("/", texts.home, onButtonClick), NavLinkButton("/dashboard", texts.dashboard, onButtonClick), NavLinkButton("/about", texts.about, onButtonClick), NavLinkButton("/me", texts.profile, onButtonClick), NavLinkButton("/signout", texts.signOut, onButtonClick) ) case AuthState.Unauthenticated => Fragment( NavLinkButton("/", texts.home, onButtonClick), NavLinkButton("/about", texts.about, onButtonClick), NavLinkButton("/signup", texts.signUp, onButtonClick), NavLinkButton("/signin", texts.signIn, onButtonClick) ) } if (isMobileOrTablet) { val drawerContent = Container( minWidth = Some("256px"), flex = Some(1), margin = Container.EdgeInsets.bottom(32), alignItems = Container.Alignment.flexEnd, justifyContent = Container.Alignment.spaceBetween, child = Fragment( mui.AppBar .sx(appbarStyling) .position("relative")( mui.Toolbar .sx(toolbarMobileStyling)( Subtitle(texts.appName) ) ), Container( alignItems = Container.Alignment.flexEnd, justifyContent = Container.Alignment.spaceBetween, child = menu ) ) ) val drawer = mui .SwipeableDrawer( onOpen = _ => setVisibleDrawer(true), onClose = _ => setVisibleDrawer(false) )(drawerContent) .open(visibleDrawer) val toolbar = mui.Toolbar.sx(toolbarMobileStyling)( mui.IconButton .normal()(mui.Icon(muiIcons.Menu())) .color(Color.inherit) .onClick(_ => setVisibleDrawer(true)), Subtitle(texts.appName) ) mui.AppBar .sx(appbarStyling) .position("relative")(toolbar, drawer) } else { mui.AppBar .sx(appbarStyling) .position("relative")( mui.Toolbar.sx(toolBarStyling)( Title(texts.appName), mui.Box.sx(menuStyling)(menu) ) ) } } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/AppCard.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.csstype.mod.Property.FlexDirection import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import slinky.core.facade.{Fragment, ReactElement} import slinky.core.{FunctionalComponent, KeyAddingStage} import scala.scalajs.js object AppCard { def apply(child: ReactElement, title: Option[String] = None, centerTitle: Boolean = false): KeyAddingStage = component(Props(child = child, title = title, centerTitle = centerTitle)) case class Props(child: ReactElement, title: Option[String] = None, centerTitle: Boolean = false) private val appCardStyling = new CSSPropertiesUtils { width = "100%" display = "flex" flexDirection = FlexDirection.column border = "1px solid rgba(0, 0, 0, 0.12)" overflow = "hidden" } private val appCardHeadStyling = new CSSPropertiesUtils { padding = "16px 16px 0 16px" } private val appCardBodyStyling = new CSSPropertiesUtils { padding = "25px 16px" } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val head: ReactElement = props.title match { case Some(title) => val textStyle = new CSSPropertiesUtils { textAlign = if (props.centerTitle) "center" else "left" fontWeight = 700 } mui.Box.sx(appCardHeadStyling)( mui .Typography(title) .sx(textStyle) .variant("h5") .color("inherit") ) case None => Fragment() } val body = mui.Box.sx(appCardBodyStyling)(props.child) mui.Paper .sx(appCardStyling) .elevation(0)(head, body) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/EditPasswordForm.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.forms.UpdatePasswordFormData import net.wiringbits.models.User import net.wiringbits.ui.components.inputs.PasswordInput import net.wiringbits.webapp.utils.slinkyUtils.components.core.ErrorLabel import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container} import net.wiringbits.webapp.utils.slinkyUtils.forms.StatefulFormData import org.scalajs.dom import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global import slinky.core.facade.{Fragment, Hooks} import slinky.core.{FunctionalComponent, KeyAddingStage, SyntheticEvent} import slinky.web.html.{form, onSubmit} import scala.util.{Failure, Success} object EditPasswordForm { def apply(ctx: AppContext, user: User): KeyAddingStage = component(Props(ctx = ctx, user = user)) case class Props(ctx: AppContext, user: User) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val (formData, setFormData) = Hooks.useState( StatefulFormData( UpdatePasswordFormData.initial( oldPasswordLabel = texts.oldPassword, passwordLabel = texts.password, repeatPasswordLabel = texts.repeatPassword ) ) ) def onDataChanged(f: UpdatePasswordFormData => UpdatePasswordFormData): Unit = { setFormData { current => current.filling.copy(data = f(current.data)) } } def handleSubmit(e: SyntheticEvent[_, dom.Event]): Unit = { e.preventDefault() if (formData.isSubmitButtonEnabled) { setFormData(_.submit) for { request <- formData.data.submitRequest .orElse { setFormData(_.submissionFailed(texts.completeData)) None } } yield props.ctx.api.client .updatePassword(request) .onComplete { case Success(_) => // TODO: Show dialog? setFormData(_.submitted) case Failure(ex) => setFormData(_.submissionFailed(ex.getMessage)) } } else { println("Submit fired when it is not available") } } val oldPasswordInput = PasswordInput .component( PasswordInput.Props( formData.data.oldPassword, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(oldPassword = x.oldPassword.updated(value))) ) ) val passwordInput = PasswordInput .component( PasswordInput.Props( formData.data.password, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(password = x.password.updated(value))) ) ) val repeatPasswordInput = PasswordInput .component( PasswordInput.Props( formData.data.repeatPassword, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(repeatPassword = x.repeatPassword.updated(value))) ) ) val saveButton = { val text = if (formData.isSubmitting) { Fragment( CircularLoader(), Container(margin = Container.EdgeInsets.left(8), child = texts.loading) ) } else { Fragment(texts.savePassword) } mui.Button .normal()(text) .fullWidth(true) .disabled(formData.isSubmitButtonDisabled) .variant("contained") .color("primary") .size("large") .`type`("submit") } val error = formData.firstValidationError.map { text => Container( margin = Container.EdgeInsets.vertical(16), child = ErrorLabel(text) ) } form(onSubmit := (handleSubmit(_)))( oldPasswordInput, passwordInput, repeatPasswordInput, Container( alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = error ), saveButton ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/EditUserForm.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.api.models.auth.GetCurrentUser import net.wiringbits.api.utils.Formatter import net.wiringbits.core.I18nHooks import net.wiringbits.forms.UpdateInfoFormData import net.wiringbits.models.User import net.wiringbits.ui.components.inputs.{EmailInput, NameInput} import net.wiringbits.webapp.utils.slinkyUtils.components.core.ErrorLabel import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container} import net.wiringbits.webapp.utils.slinkyUtils.forms.StatefulFormData import org.scalajs.dom import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global import slinky.core.facade.{Fragment, Hooks} import slinky.core.{FunctionalComponent, KeyAddingStage, SyntheticEvent} import slinky.web.html.{form, onSubmit} import scala.util.{Failure, Success} object EditUserForm { def apply(ctx: AppContext, user: User, response: GetCurrentUser.Response, onSave: () => Unit): KeyAddingStage = component(Props(ctx = ctx, user = user, response = response, onSave = onSave)) case class Props(ctx: AppContext, user: User, response: GetCurrentUser.Response, onSave: () => Unit) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val (hasChanges, setHasChanges) = Hooks.useState(false) val (formData, setFormData) = Hooks.useState( StatefulFormData( UpdateInfoFormData.initial( nameLabel = texts.name, nameInitialValue = Some(props.response.name), emailLabel = texts.email, emailValue = Some(props.response.email) ) ) ) def onDataChanged(f: UpdateInfoFormData => UpdateInfoFormData): Unit = { setFormData { current => current.filling.copy(data = f(current.data)) } } def handleSubmit(e: SyntheticEvent[_, dom.Event]): Unit = { e.preventDefault() if (formData.isSubmitButtonEnabled) { setFormData(_.submit) for { request <- formData.data.submitRequest .orElse { setFormData(_.submissionFailed(texts.completeData)) None } } yield props.ctx.api.client .updateUser(request) .onComplete { case Success(_) => setFormData(_.submitted) props.onSave() case Failure(ex) => setFormData(_.submissionFailed(ex.getMessage)) } } else { println("Submit fired when it is not available") } } val nameInput = NameInput .component( NameInput.Props( formData.data.name, disabled = formData.isInputDisabled, onChange = value => { setHasChanges(value.input != props.response.name.string) onDataChanged(x => x.copy(name = x.name.updated(value))) } ) ) val emailInput = EmailInput .component( EmailInput.Props( formData.data.email, disabled = true, onChange = _ => () ) ) val saveButton = { val text = if (formData.isSubmitting) { Fragment( CircularLoader(), Container(margin = Container.EdgeInsets.left(8), child = texts.loading) ) } else { Fragment(texts.save) } mui.Button .normal()(text) .fullWidth(true) .disabled(formData.isSubmitButtonDisabled || !hasChanges) .variant("contained") .color("primary") .size("large") .`type`("submit") } val createdAt = Fragment( mui.Typography(texts.createdAt).variant("subtitle2"), mui.Typography(Formatter.instant(props.response.createdAt)) ) form(onSubmit := (handleSubmit(_)))( nameInput, emailInput, formData.firstValidationError.map { text => Container( margin = Container.EdgeInsets.top(16), child = ErrorLabel(text) ) }, createdAt, saveButton ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/Footer.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.csstype.mod.Property.FlexDirection import com.olvind.mui.muiMaterial.components as mui import com.olvind.mui.muiMaterial.mod.PropTypes.Color import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.Container import net.wiringbits.webapp.utils.slinkyUtils.core.MediaQueryHooks import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} object Footer { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) private val styling = new CSSPropertiesUtils { color = "#FFF" backgroundColor = "#222" borderRadius = 0 } private val margin = 16 val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val isMobileOrTablet = MediaQueryHooks.useIsMobileOrTablet() val appName = Container( margin = Container.EdgeInsets.bottom(margin), child = mui.Typography(texts.appName).variant("h4").color(Color.inherit) ) val appDescription = mui.Typography(texts.description).variant("body2").color(Color.inherit) def title(text: String) = Container( margin = Container.EdgeInsets.bottom(margin), child = mui.Typography(text).variant("h5").color(Color.inherit) ) def subtitle(text: String) = mui.Typography(text).variant("subtitle2").color(Color.inherit) def link(text: String, url: String) = mui .Link( mui.Typography(text).variant("body2").color(Color.inherit) ) .href(url) .color(Color.inherit) val copyright = Container( margin = Container.EdgeInsets.vertical(margin), alignItems = Container.Alignment.center, child = mui.Typography(texts.appNameCopyright).color(Color.inherit) ) val projects = Container( flex = Some(1), child = Fragment( title("Projects"), link("CollabUML", "https://collabuml.com"), link("The Stakenet Block Explorer", "https://xsnexplorer.io/"), link("The Stakenet Orderbook", "https://orderbook.stakenet.io/XSN_BTC"), link("Pull Request Attention", "https://prattention.com"), link("CazaDescuentos", "https://cazadescuentos.net"), link("safer.chat", "https://safer.chat"), link("Crypto Coin Alerts", "https://github.com/AlexITC/crypto-coin-alerts") ) ) val contact = Container( flex = Some(1), child = Fragment( title(texts.contact), Container( child = Fragment( subtitle(texts.contact), link(props.ctx.contactEmail.string, s"mailto:${props.ctx.contactEmail.string}") ) ), Container( margin = Container.EdgeInsets.top(margin / 2), child = Fragment( subtitle(texts.phone), mui.Typography(props.ctx.contactPhone).variant("body2").color(Color.inherit) ) ) ) ) val body = if (isMobileOrTablet) { Container( padding = Container.EdgeInsets.all(margin), child = Fragment( appName, appDescription, Container(margin = Container.EdgeInsets.top(margin), child = projects), Container(margin = Container.EdgeInsets.top(margin), child = contact) ) ) } else { Container( padding = Container.EdgeInsets.all(margin), flexDirection = FlexDirection.row, child = Fragment( Container( margin = Container.EdgeInsets.right(margin / 2), flex = Some(1), child = Fragment(appName, appDescription) ), Container( flex = Some(1), margin = Container.EdgeInsets.left(margin / 2), flexDirection = FlexDirection.row, child = Fragment( projects, contact ) ) ) ) } mui .Paper()( Fragment( body, copyright ) ) .sx(styling) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/ForgotPasswordForm.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.forms.ForgotPasswordFormData import net.wiringbits.ui.components.inputs.EmailInput import net.wiringbits.webapp.utils.slinkyUtils.components.core.ErrorLabel import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container} import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.useHistory import net.wiringbits.webapp.utils.slinkyUtils.forms.StatefulFormData import org.scalajs.dom import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global import slinky.core.facade.{Fragment, Hooks} import slinky.core.{FunctionalComponent, KeyAddingStage, SyntheticEvent} import slinky.web.html.{form, onSubmit} import scala.scalajs.js import scala.util.{Failure, Success} object ForgotPasswordForm { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() val (formData, setFormData) = Hooks.useState( StatefulFormData( ForgotPasswordFormData.initial( emailLabel = texts.email ) ) ) def onDataChanged(f: ForgotPasswordFormData => ForgotPasswordFormData): Unit = { setFormData { current => current.filling.copy(data = f(current.data)) } } def handleSubmit(e: SyntheticEvent[_, dom.Event]): Unit = { e.preventDefault() if (formData.isSubmitButtonEnabled) { setFormData(_.submit) for { request <- formData.data.submitRequest .orElse { setFormData(_.submissionFailed(texts.completeData)) None } } yield props.ctx.api.client .forgotPassword(request) .onComplete { case Success(_) => setFormData(_.submitted) history.push("/signin") // redirects to sign in page case Failure(ex) => setFormData(_.submissionFailed(ex.getMessage)) } } else { println("Submit fired when it is not available") } } val forgotPasswordButton = { val text = if (formData.isSubmitting) { Fragment( CircularLoader(), Container(margin = Container.EdgeInsets.left(8), child = texts.loading) ) } else { Fragment(texts.recover) } mui.Button .normal()(text) .fullWidth(true) .disabled(formData.isSubmitButtonDisabled) .variant("contained") .color("primary") .size("large") .`type`("submit") } val emailInput = EmailInput .component( EmailInput.Props( formData.data.email, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(email = x.email.updated(value))) ) ) val recaptcha = ReCaptcha(props.ctx, onChange = captchaOpt => onDataChanged(x => x.copy(captcha = captchaOpt))) form(onSubmit := (handleSubmit(_)))( Container( margin = Container.EdgeInsets.all(16), alignItems = Container.Alignment.center, child = Fragment( emailInput, Container( margin = Container.EdgeInsets.top(8), child = recaptcha ), formData.firstValidationError.map { text => Container( margin = Container.EdgeInsets.top(16), child = ErrorLabel(text) ) } ) ), Container( alignItems = Container.Alignment.center, child = forgotPasswordButton ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/Loader.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container} import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} object Loader { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) Container( flex = Some(1), alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = Fragment( CircularLoader(), mui.Typography(texts.loading).variant("h6") ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/LogList.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.csstype.mod.Property.FlexDirection import com.olvind.mui.muiMaterial.components as mui import com.olvind.mui.muiMaterial.mod.PropTypes.Color import net.wiringbits.AppContext import net.wiringbits.api.models.users.GetUserLogs import net.wiringbits.api.utils.Formatter import net.wiringbits.core.I18nHooks import net.wiringbits.webapp.utils.slinkyUtils.Utils.CSSPropertiesUtils import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{Container, Subtitle} import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} object LogList { def apply(ctx: AppContext, response: GetUserLogs.Response, forceRefresh: () => Unit): KeyAddingStage = component(Props(ctx = ctx, response = response, forceRefresh = forceRefresh)) case class Props(ctx: AppContext, response: GetUserLogs.Response, forceRefresh: () => Unit) private val styling = new CSSPropertiesUtils { width = "100%" } val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val items = props.response.data.map { item => mui.ListItem .normal()( mui .ListItemText() .primary(item.message) .secondary(Formatter.instant(item.createdAt)) ) .divider(true) .withKey(item.userLogId.toString) .build } Container( minWidth = Some("100%"), child = Fragment( Container( minWidth = Some("100%"), flexDirection = FlexDirection.row, alignItems = Container.Alignment.center, justifyContent = Container.Alignment.spaceBetween, child = Fragment( Subtitle(texts.logs), mui.Button .normal()(texts.reload) .color(Color.primary) .onClick(_ => props.forceRefresh()) ) ), mui .List(items) .sx(styling) .dense(true) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/Logs.scala ================================================ package net.wiringbits.components.widgets import net.wiringbits.AppContext import net.wiringbits.api.models.users.GetUserLogs import net.wiringbits.models.User import net.wiringbits.webapp.utils.slinkyUtils.components.core.AsyncComponent import net.wiringbits.webapp.utils.slinkyUtils.core.GenericHooks import slinky.core.{FunctionalComponent, KeyAddingStage} object Logs { def apply(ctx: AppContext, user: User): KeyAddingStage = component(Props(ctx = ctx, user = user)) case class Props(ctx: AppContext, user: User) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val (timesRefreshingData, forceRefresh) = GenericHooks.useForceRefresh AsyncComponent[GetUserLogs.Response]( fetch = () => props.ctx.api.client.getUserLogs, render = response => LogList(props.ctx, response, () => forceRefresh()), progressIndicator = () => Loader(props.ctx), watchedObjects = List(timesRefreshingData) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/ReCaptcha.scala ================================================ package net.wiringbits.components.widgets import net.wiringbits.AppContext import net.wiringbits.common.models.Captcha import net.wiringbits.webapp.utils.slinkyUtils.components.core.AsyncComponent import slinky.core.facade.Hooks import slinky.core.{FunctionalComponent, KeyAddingStage} import typings.reactGoogleRecaptcha.components.ReactGoogleRecaptcha import scala.concurrent.ExecutionContext object ReCaptcha { def apply(ctx: AppContext, onChange: Option[Captcha] => Unit): KeyAddingStage = component(Props(ctx = ctx, onChange = onChange)) case class Props(ctx: AppContext, onChange: Option[Captcha] => Unit) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => implicit val ec: ExecutionContext = props.ctx.executionContext // Without useMemo, the component gets rendered everytime the captcha is solved Hooks.useMemo( () => AsyncComponent[String]( fetch = () => props.ctx.api.client.getEnvironmentConfig.map(_.recaptchaSiteKey), render = recaptchaSiteKey => ReactGoogleRecaptcha(recaptchaSiteKey) .onChange(x => props.onChange(Captcha.validate(x.asInstanceOf[String]).toOption)) ), "" ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/ResendVerifyEmailForm.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.csstype.mod.Property.FlexDirection import com.olvind.mui.muiMaterial.components as mui import com.olvind.mui.muiMaterial.mod.PropTypes.Color import com.olvind.mui.react.components.Fragment import com.olvind.mui.react.mod.CSSProperties import net.wiringbits.AppContext import net.wiringbits.common.models.Email import net.wiringbits.core.I18nHooks import net.wiringbits.forms.ResendVerifyEmailFormData import net.wiringbits.ui.components.inputs.EmailInput import net.wiringbits.webapp.utils.slinkyUtils.components.core.ErrorLabel import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container, Title} import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.useHistory import net.wiringbits.webapp.utils.slinkyUtils.forms.StatefulFormData import org.scalajs.dom import org.scalajs.dom.URLSearchParams import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global import slinky.core.facade.Hooks import slinky.core.{FunctionalComponent, KeyAddingStage, SyntheticEvent} import slinky.web.html.{form, onSubmit} import scala.scalajs.js import scala.util.{Failure, Success} object ResendVerifyEmailForm { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() val params = URLSearchParams(dom.window.location.search) val emailParam = Option(params.get("email")).getOrElse("") val (formData, setFormData) = Hooks.useState( StatefulFormData( ResendVerifyEmailFormData.initial( ResendVerifyEmailFormData.Texts(texts.completeTheCaptcha), emailLabel = texts.email, emailValue = Some(Email.validate(emailParam)) ) ) ) def onDataChanged(f: ResendVerifyEmailFormData => ResendVerifyEmailFormData): Unit = { setFormData { current => current.filling.copy(data = f(current.data)) } } def handleSubmit(e: SyntheticEvent[_, dom.Event]): Unit = { e.preventDefault() if (formData.isSubmitButtonEnabled) { setFormData(_.submit) for { request <- formData.data.submitRequest .orElse { setFormData(_.submissionFailed(texts.completeData)) None } } yield props.ctx.api.client .sendEmailVerificationToken(request) .onComplete { case Success(_) => val email = formData.data.email.inputValue setFormData(_.submitted) history.push(s"/verify-email?email=${email}") case Failure(ex) => setFormData(_.submissionFailed(ex.getMessage)) } } else { println("Submit fired when it is not available") } } val emailInput = Container( minWidth = Some("100%"), margin = Container.EdgeInsets.bottom(8), child = EmailInput.component( EmailInput.Props( formData.data.email, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(email = x.email.updated(value))) ) ) ) val error = formData.firstValidationError.map { text => Container( margin = Container.EdgeInsets.top(16), child = ErrorLabel(text) ) } val recaptcha = ReCaptcha(props.ctx, onChange = captchaOpt => onDataChanged(x => x.copy(captcha = captchaOpt))) val resendVerifyEmailButton = { val text = if (formData.isSubmitting) { Fragment( CircularLoader(), Container(margin = Container.EdgeInsets.left(8), child = texts.loading) ) } else Fragment(texts.resendEmail) mui.Button .normal()(text) .fullWidth(true) .disabled(formData.isSubmitButtonDisabled) .variant("contained") .color("primary") .`type`("submit") } form(onSubmit := (handleSubmit(_)))( mui .Paper() .elevation(1)( Container( minWidth = Some("300px"), alignItems = Container.Alignment.center, padding = Container.EdgeInsets.all(16), child = Fragment( Title(texts.resendEmail), emailInput, recaptcha, error, Container( minWidth = Some("100%"), margin = Container.EdgeInsets.top(16), alignItems = Container.Alignment.center, child = resendVerifyEmailButton ) ) ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/ResetPasswordForm.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.common.models.UserToken import net.wiringbits.core.I18nHooks import net.wiringbits.forms.ResetPasswordFormData import net.wiringbits.models.User import net.wiringbits.ui.components.inputs.PasswordInput import net.wiringbits.webapp.utils.slinkyUtils.components.core.ErrorLabel import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container} import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.useHistory import net.wiringbits.webapp.utils.slinkyUtils.forms.StatefulFormData import org.scalajs.dom import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global import slinky.core.facade.{Fragment, Hooks} import slinky.core.{FunctionalComponent, KeyAddingStage, SyntheticEvent} import slinky.web.html.{form, onSubmit} import scala.scalajs.js import scala.util.{Failure, Success} object ResetPasswordForm { def apply(ctx: AppContext, token: Option[UserToken]): KeyAddingStage = component(Props(ctx = ctx, token = token)) case class Props(ctx: AppContext, token: Option[UserToken]) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() val (formData, setFormData) = Hooks.useState( StatefulFormData( ResetPasswordFormData.initial( passwordLabel = texts.password, repeatPasswordLabel = texts.repeatPassword, token = props.token ) ) ) def onDataChanged(f: ResetPasswordFormData => ResetPasswordFormData): Unit = { setFormData { current => current.filling.copy(data = f(current.data)) } } def handleSubmit(e: SyntheticEvent[_, dom.Event]): Unit = { e.preventDefault() if (formData.isSubmitButtonEnabled) { setFormData(_.submit) for { request <- formData.data.submitRequest .orElse { setFormData(_.submissionFailed(texts.completeData)) None } } yield props.ctx.api.client .resetPassword(request) .onComplete { case Success(res) => props.ctx.loggedIn(User(name = res.name, email = res.email)) setFormData(_.submitted) history.push("/dashboard") case Failure(ex) => setFormData(_.submissionFailed(ex.getMessage)) } } else { println("Submit fired when it is not available") } } val passwordInput = PasswordInput .component( PasswordInput.Props( formData.data.password, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(password = x.password.updated(value))) ) ) val repeatPasswordInput = PasswordInput .component( PasswordInput.Props( formData.data.repeatPassword, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(repeatPassword = x.repeatPassword.updated(value))) ) ) val resetPasswordButton = { val text = if (formData.isSubmitting) { Fragment( CircularLoader(), Container(margin = Container.EdgeInsets.left(8), child = texts.loading) ) } else { Fragment(texts.resetPassword) } mui.Button .normal()(text) .fullWidth(true) .disabled(formData.isSubmitButtonDisabled) .variant("contained") .color("primary") .size("large") .`type`("submit") } form(onSubmit := (handleSubmit(_)))( Container( margin = Container.EdgeInsets.all(16), alignItems = Container.Alignment.center, child = Fragment( passwordInput, repeatPasswordInput, formData.firstValidationError.map { text => Container( margin = Container.EdgeInsets.top(16), child = ErrorLabel(text) ) } ) ), Container( alignItems = Container.Alignment.center, child = Fragment( resetPasswordButton ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/SignInForm.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiMaterial.components as mui import com.olvind.mui.muiMaterial.mod.PropTypes.Color import net.wiringbits.AppContext import net.wiringbits.common.ErrorMessages import net.wiringbits.core.I18nHooks import net.wiringbits.forms.SignInFormData import net.wiringbits.models.User import net.wiringbits.ui.components.inputs.{EmailInput, PasswordInput} import net.wiringbits.webapp.utils.slinkyUtils.components.core.ErrorLabel import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container} import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.useHistory import net.wiringbits.webapp.utils.slinkyUtils.forms.StatefulFormData import org.scalajs.dom import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.* import slinky.core.facade.{Fragment, Hooks, ReactElement} import slinky.core.{FunctionalComponent, KeyAddingStage, SyntheticEvent} import slinky.web.html.{form, onSubmit} import scala.scalajs.js import scala.util.{Failure, Success} object SignInForm { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() val (formData, setFormData) = Hooks.useState( StatefulFormData( SignInFormData.initial( emailLabel = texts.email, passwordLabel = texts.password ) ) ) def onDataChanged(f: SignInFormData => SignInFormData): Unit = { setFormData { current => current.filling.copy(data = f(current.data)) } } def handleSubmit(e: SyntheticEvent[_, dom.Event]): Unit = { e.preventDefault() if (formData.isSubmitButtonEnabled) { setFormData(_.submit) for { request <- formData.data.submitRequest .orElse { setFormData(_.submissionFailed(texts.completeData)) None } } yield props.ctx.api.client .login(request) .onComplete { case Success(res) => setFormData(_.submitted) props.ctx.loggedIn(User(res.name, res.email)) history.push("/dashboard") // redirects to the dashboard case Failure(ex) => setFormData(_.submissionFailed(ex.getMessage)) } } else { println("Submit fired when it is not available") } } val emailInput = Container( minWidth = Some("100%"), margin = Container.EdgeInsets.bottom(8), child = EmailInput .component( EmailInput.Props( formData.data.email, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(email = x.email.updated(value))) ) ) ) val passwordInput = Container( minWidth = Some("100%"), margin = Container.EdgeInsets.bottom(16), child = PasswordInput .component( PasswordInput.Props( formData.data.password, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(password = x.password.updated(value))) ) ) ) def resendVerifyEmailButton(text: String): ReactElement = { // TODO: It would be ideal to match the error against a code than matching a text text match { case ErrorMessages.`emailNotVerified` => val email = formData.data.email.inputValue mui.Button .normal()(texts.resendEmail) .variant("text") .color("primary") .onClick(_ => history.push(s"/resend-verify-email?email=${email}")) case _ => Fragment() } } val error = formData.firstValidationError.map { errorMessage => Container( alignItems = Container.Alignment.center, margin = Container.EdgeInsets.top(16), child = Fragment( ErrorLabel(errorMessage), resendVerifyEmailButton(errorMessage) ) ) } val recaptcha = ReCaptcha(props.ctx, onChange = captchaOpt => onDataChanged(x => x.copy(captcha = captchaOpt))) val loginButton = { val text = if (formData.isSubmitting) Fragment( CircularLoader(), Container(margin = Container.EdgeInsets.left(8), child = texts.loading) ) else Fragment(texts.login) mui.Button .normal()(text) .fullWidth(true) .disabled(formData.isSubmitButtonDisabled) .variant("contained") .color("primary") .`type`("submit") } form(onSubmit := (handleSubmit(_)))( Container( alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = Fragment( emailInput, passwordInput, recaptcha, error, Container( minWidth = Some("100%"), margin = Container.EdgeInsets.top(16), alignItems = Container.Alignment.center, child = loginButton ) ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/SignUpForm.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.core.I18nHooks import net.wiringbits.forms.SignUpFormData import net.wiringbits.ui.components.inputs.{EmailInput, NameInput, PasswordInput} import net.wiringbits.webapp.utils.slinkyUtils.components.core.ErrorLabel import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container, Title} import net.wiringbits.webapp.utils.slinkyUtils.facades.reactrouterdom.useHistory import net.wiringbits.webapp.utils.slinkyUtils.forms.StatefulFormData import org.scalajs.dom import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global import slinky.core.facade.{Fragment, Hooks} import slinky.core.{FunctionalComponent, KeyAddingStage, SyntheticEvent} import slinky.web.html.{form, onSubmit} import scala.scalajs.js import scala.util.{Failure, Success} object SignUpForm { def apply(ctx: AppContext): KeyAddingStage = component(Props(ctx = ctx)) case class Props(ctx: AppContext) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) val history = useHistory() val (formData, setFormData) = Hooks.useState( StatefulFormData( SignUpFormData.initial( nameLabel = texts.name, emailLabel = texts.email, passwordLabel = texts.password, repeatPasswordLabel = texts.repeatPassword ) ) ) def onDataChanged(f: SignUpFormData => SignUpFormData): Unit = { setFormData { current => current.filling.copy(data = f(current.data)) } } def handleSubmit(e: SyntheticEvent[_, dom.Event]): Unit = { e.preventDefault() if (formData.isSubmitButtonEnabled) { setFormData(_.submit) for { request <- formData.data.submitRequest .orElse { setFormData(_.submissionFailed(texts.completeData)) None } } yield props.ctx.api.client .createUser(request) .onComplete { case Success(_) => val email = formData.data.email.inputValue setFormData(_.submitted) history.push(s"/verify-email?email=$email") // redirects to email page case Failure(ex) => setFormData(_.submissionFailed(ex.getMessage)) } } else { println("Submit fired when it is not available") } } val nameInput = Container( minWidth = Some("100%"), margin = Container.EdgeInsets.bottom(8), child = NameInput.component( NameInput.Props( formData.data.name, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(name = x.name.updated(value))) ) ) ) val emailInput = Container( minWidth = Some("100%"), margin = Container.EdgeInsets.bottom(8), child = EmailInput.component( EmailInput.Props( formData.data.email, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(email = x.email.updated(value))) ) ) ) val passwordInput = Container( minWidth = Some("100%"), margin = Container.EdgeInsets.bottom(16), child = PasswordInput .component( PasswordInput.Props( formData.data.password, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(password = x.password.updated(value))) ) ) ) val repeatPasswordInput = Container( minWidth = Some("100%"), margin = Container.EdgeInsets.bottom(16), child = PasswordInput .component( PasswordInput.Props( formData.data.repeatPassword, disabled = formData.isInputDisabled, onChange = value => onDataChanged(x => x.copy(repeatPassword = x.repeatPassword.updated(value))) ) ) ) val error = formData.firstValidationError.map { text => Container( margin = Container.EdgeInsets.top(16), child = ErrorLabel(text) ) } val recaptcha = ReCaptcha(props.ctx, onChange = captchaOpt => onDataChanged(x => x.copy(captcha = captchaOpt))) val signUpButton = { val text = if (formData.isSubmitting) { Fragment( CircularLoader(), Container(margin = Container.EdgeInsets.left(8), child = texts.loading) ) } else Fragment(texts.createAccount) mui.Button .normal()(text) .fullWidth(true) .disabled(formData.isSubmitButtonDisabled) .variant("contained") .color("primary") .`type`("submit") } // TODO: Use a form to get the enter key submitting the form form(onSubmit := (handleSubmit(_)))( mui .Paper() .elevation(1)( Container( minWidth = Some("300px"), alignItems = Container.Alignment.center, padding = Container.EdgeInsets.all(16), child = Fragment( Title(texts.signUp), nameInput, emailInput, passwordInput, repeatPasswordInput, recaptcha, error, Container( minWidth = Some("100%"), margin = Container.EdgeInsets.top(16), alignItems = Container.Alignment.center, child = signUpButton ) ) ) ) ) } } ================================================ FILE: web/src/main/scala/net/wiringbits/components/widgets/UserInfo.scala ================================================ package net.wiringbits.components.widgets import com.olvind.mui.muiMaterial.components as mui import net.wiringbits.AppContext import net.wiringbits.api.models.auth.GetCurrentUser import net.wiringbits.core.I18nHooks import net.wiringbits.models.User import net.wiringbits.webapp.utils.slinkyUtils.components.core.AsyncComponent import net.wiringbits.webapp.utils.slinkyUtils.components.core.widgets.{CircularLoader, Container} import slinky.core.facade.Fragment import slinky.core.{FunctionalComponent, KeyAddingStage} object UserInfo { def apply(ctx: AppContext, user: User): KeyAddingStage = component(Props(ctx = ctx, user = user)) case class Props(ctx: AppContext, user: User) val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props => val texts = I18nHooks.useMessages(props.ctx.$lang) def loader = Container( flex = Some(1), alignItems = Container.Alignment.center, justifyContent = Container.Alignment.center, child = Fragment( CircularLoader(48), mui.Typography(texts.loading).variant("h4").color("primary") ) ) def onSaveClick(): Unit = { renderBody() } def renderBody() = { AsyncComponent[GetCurrentUser.Response]( fetch = () => props.ctx.api.client.currentUser, render = response => EditUserForm(props.ctx, props.user, response, onSaveClick), progressIndicator = () => loader ) } renderBody() } } ================================================ FILE: web/src/main/scala/net/wiringbits/core/I18nHooks.scala ================================================ package net.wiringbits.core import monix.reactive.subjects.Var import net.wiringbits.I18nMessages import slinky.core.facade.Hooks object I18nHooks { def useMessages($lang: Var[I18nLang]): I18nMessages = { val lang = ReactiveHooks.useDistinctValue($lang) Hooks.useMemo(() => new I18nMessages(lang), List(lang)) } } ================================================ FILE: web/src/main/scala/net/wiringbits/core/I18nLang.scala ================================================ package net.wiringbits.core sealed trait I18nLang extends Product with Serializable object I18nLang { case object English extends I18nLang val values: List[I18nLang] = List(English) def from(string: String): Option[I18nLang] = { values.find(_.toString.toLowerCase == string.toLowerCase) } implicit val catsEq: cats.Eq[I18nLang] = cats.Eq.fromUniversalEquals } ================================================ FILE: web/src/main/scala/net/wiringbits/core/ReactiveHooks.scala ================================================ package net.wiringbits.core import monix.reactive.subjects.Var import slinky.core.facade.Hooks object ReactiveHooks { import monix.execution.Scheduler.Implicits.global /** Gets the value from a monix Var, and, updates the state when the Var gets new values */ def useValue[T](value: Var[T]): T = { val (state, setState) = Hooks.useState[T](value()) Hooks.useEffect( () => { val cancelable = value.foreach(setState.apply) () => cancelable.cancel() }, List(value) ) state } /** Gets the value from a monix Var, and, updates the state only when it gets a different value */ def useDistinctValue[T](value: Var[T])(implicit A: cats.Eq[T]): T = { val (state, setState) = Hooks.useState[T](value()) Hooks.useEffect( () => { val cancelable = value.distinctUntilChanged.foreach(setState.apply) () => cancelable.cancel() }, List(value) ) state } } ================================================ FILE: web/src/main/scala/net/wiringbits/forms/ForgotPasswordFormData.scala ================================================ package net.wiringbits.forms import net.wiringbits.api.models.users.ForgotPassword import net.wiringbits.common.models.{Captcha, Email} import net.wiringbits.webapp.utils.slinkyUtils.forms.{FormData, FormField} case class ForgotPasswordFormData( email: FormField[Email], captcha: Option[Captcha] = None ) extends FormData[ForgotPassword.Request] { override def fields: List[FormField[_]] = List(email) override def formValidationErrors: List[String] = { val captchaError = Option.when(captcha.isEmpty)("Complete the captcha") List( fieldsError, captchaError ).flatten } override def submitRequest: Option[ForgotPassword.Request] = { val formData = this for { email <- formData.email.valueOpt captcha <- formData.captcha } yield ForgotPassword.Request( email = email, captcha = captcha ) } } object ForgotPasswordFormData { // TODO: Implement "Complete captcha message" from i18nMessages like ResendVerifyEmailFormData def initial( emailLabel: String ): ForgotPasswordFormData = ForgotPasswordFormData( email = new FormField(label = emailLabel, name = "email", required = true, `type` = "email") ) } ================================================ FILE: web/src/main/scala/net/wiringbits/forms/ResendVerifyEmailFormData.scala ================================================ package net.wiringbits.forms import net.wiringbits.api.models.users.SendEmailVerificationToken import net.wiringbits.common.models.{Captcha, Email} import net.wiringbits.webapp.common.validators.ValidationResult import net.wiringbits.webapp.utils.slinkyUtils.forms.{FormData, FormField} case class ResendVerifyEmailFormData( texts: ResendVerifyEmailFormData.Texts, email: FormField[Email], captcha: Option[Captcha] = None ) extends FormData[SendEmailVerificationToken.Request] { override def fields: List[FormField[_]] = List(email) override def formValidationErrors: List[String] = { val emptyCaptcha = Option.when(captcha.isEmpty)(texts.emptyCaptchaError) List( fieldsError, emptyCaptcha ).flatten } override def submitRequest: Option[SendEmailVerificationToken.Request] = { val formData = this for { email <- formData.email.valueOpt captcha <- formData.captcha } yield SendEmailVerificationToken.Request( email, captcha ) } } object ResendVerifyEmailFormData { case class Texts(emptyCaptchaError: String) def initial( texts: ResendVerifyEmailFormData.Texts, emailLabel: String, emailValue: Option[ValidationResult[Email]] = None ): ResendVerifyEmailFormData = ResendVerifyEmailFormData( texts = texts, email = new FormField[Email]( label = emailLabel, name = "email", required = true, `type` = "email", value = emailValue ) ) } ================================================ FILE: web/src/main/scala/net/wiringbits/forms/ResetPasswordFormData.scala ================================================ package net.wiringbits.forms import net.wiringbits.api.models.users.ResetPassword import net.wiringbits.common.models.{Password, UserToken} import net.wiringbits.webapp.utils.slinkyUtils.forms.{FormData, FormField} case class ResetPasswordFormData( password: FormField[Password], repeatPassword: FormField[Password], token: Option[UserToken] ) extends FormData[ResetPassword.Request] { override def fields: List[FormField[_]] = List(password, repeatPassword) override def formValidationErrors: List[String] = { val isTokenDefined = Option.when(token.isEmpty)("The token doesn't exists") // the error is rendered only when both fields are provided val passwordMatchesError = (for { password1 <- password.valueOpt password2 <- repeatPassword.valueOpt } yield password1 != password2) .filter(identity) .map(_ => "The passwords does not match") List( fieldsError, passwordMatchesError, isTokenDefined ).flatten } override def submitRequest: Option[ResetPassword.Request] = { val formData = this for { password <- formData.password.valueOpt token <- formData.token } yield ResetPassword.Request( token = token, password = password ) } } object ResetPasswordFormData { def initial( passwordLabel: String, repeatPasswordLabel: String, token: Option[UserToken] ): ResetPasswordFormData = ResetPasswordFormData( password = new FormField(label = passwordLabel, name = "password", required = true, `type` = "password"), repeatPassword = new FormField(label = repeatPasswordLabel, name = "repeatPassword", required = true, `type` = "password"), token = token ) } ================================================ FILE: web/src/main/scala/net/wiringbits/forms/SignInFormData.scala ================================================ package net.wiringbits.forms import net.wiringbits.api.models.auth.Login import net.wiringbits.common.models.{Captcha, Email, Password} import net.wiringbits.webapp.utils.slinkyUtils.forms.{FormData, FormField} case class SignInFormData( email: FormField[Email], password: FormField[Password], captcha: Option[Captcha] = None ) extends FormData[Login.Request] { override def fields: List[FormField[_]] = List(email, password) override def formValidationErrors: List[String] = { val emptyCaptcha = Option.when(captcha.isEmpty)("Complete the captcha") List( fieldsError, emptyCaptcha ).flatten } override def submitRequest: Option[Login.Request] = { val formData = this for { email <- formData.email.valueOpt password <- formData.password.valueOpt captcha <- formData.captcha } yield Login.Request( email, password, captcha ) } } object SignInFormData { // TODO: Implement "Complete captcha message" from i18nMessages like ResendVerifyEmailFormData def initial( emailLabel: String, passwordLabel: String ): SignInFormData = SignInFormData( email = new FormField(label = emailLabel, name = "email", required = true, `type` = "email"), password = new FormField(label = passwordLabel, name = "password", required = true, `type` = "password") ) } ================================================ FILE: web/src/main/scala/net/wiringbits/forms/SignUpFormData.scala ================================================ package net.wiringbits.forms import net.wiringbits.api.models.users.CreateUser import net.wiringbits.common.models.{Captcha, Email, Name, Password} import net.wiringbits.webapp.utils.slinkyUtils.forms.{FormData, FormField} case class SignUpFormData( name: FormField[Name], email: FormField[Email], password: FormField[Password], repeatPassword: FormField[Password], captcha: Option[Captcha] = None ) extends FormData[CreateUser.Request] { override def fields: List[FormField[_]] = List(name, email, password, repeatPassword) override def formValidationErrors: List[String] = { // the error is rendered only when both fields are provided val passwordMatchesError = (for { password1 <- password.valueOpt password2 <- repeatPassword.valueOpt } yield password1 != password2) .filter(identity) .map(_ => "The passwords does not match") val emptyCaptcha = Option.when(captcha.isEmpty)("Complete the captcha") List( fieldsError, passwordMatchesError, emptyCaptcha ).flatten } override def submitRequest: Option[CreateUser.Request] = { val formData = this for { name <- formData.name.valueOpt email <- formData.email.valueOpt password <- formData.password.valueOpt captcha <- formData.captcha } yield CreateUser.Request( name, email, password, captcha ) } } object SignUpFormData { // TODO: Implement "Complete captcha message" from i18nMessages like ResendVerifyEmailFormData def initial( nameLabel: String, emailLabel: String, passwordLabel: String, repeatPasswordLabel: String ): SignUpFormData = SignUpFormData( name = new FormField(label = nameLabel, name = "name", required = true), email = new FormField(label = emailLabel, name = "email", required = true, `type` = "email"), password = new FormField(label = passwordLabel, name = "password", required = true, `type` = "password"), repeatPassword = new FormField(label = repeatPasswordLabel, name = "repeatPassword", required = true, `type` = "password") ) } ================================================ FILE: web/src/main/scala/net/wiringbits/forms/UpdateInfoFormData.scala ================================================ package net.wiringbits.forms import net.wiringbits.api.models.users.UpdateUser import net.wiringbits.common.models.* import net.wiringbits.webapp.common.validators.ValidationResult import net.wiringbits.webapp.utils.slinkyUtils.forms.{FormData, FormField} case class UpdateInfoFormData( name: FormField[Name], email: FormField[Email] ) extends FormData[UpdateUser.Request] { override def fields: List[FormField[_]] = List(name) override def formValidationErrors: List[String] = { List( fieldsError ).flatten } override def submitRequest: Option[UpdateUser.Request] = { val formData = this for { name <- formData.name.valueOpt } yield UpdateUser.Request( name ) } } object UpdateInfoFormData { def initial( nameLabel: String, nameInitialValue: Option[Name] = None, emailLabel: String, emailValue: Option[Email] = None ): UpdateInfoFormData = UpdateInfoFormData( name = new FormField( label = nameLabel, name = "name", value = nameInitialValue.map(x => ValidationResult.Valid(x.string, x)) ), email = new FormField( label = emailLabel, name = "email", `type` = "email", value = emailValue.map(x => ValidationResult.Valid(x.string, x)) ) ) } ================================================ FILE: web/src/main/scala/net/wiringbits/forms/UpdatePasswordFormData.scala ================================================ package net.wiringbits.forms import net.wiringbits.api.models.users.UpdatePassword import net.wiringbits.common.models.* import net.wiringbits.webapp.utils.slinkyUtils.forms.{FormData, FormField} case class UpdatePasswordFormData( oldPassword: FormField[Password], password: FormField[Password], repeatPassword: FormField[Password] ) extends FormData[UpdatePassword.Request] { override def fields: List[FormField[_]] = List(oldPassword, password, repeatPassword) override def formValidationErrors: List[String] = { // the error is rendered only when both fields are provided val passwordMatchesError = (for { password1 <- password.valueOpt password2 <- repeatPassword.valueOpt } yield password1 != password2) .filter(identity) .map(_ => "The passwords does not match") List( fieldsError, passwordMatchesError ).flatten } override def submitRequest: Option[UpdatePassword.Request] = { val formData = this for { oldPassword <- formData.oldPassword.valueOpt password <- formData.password.valueOpt } yield UpdatePassword.Request( oldPassword, password ) } } object UpdatePasswordFormData { def initial( oldPasswordLabel: String, passwordLabel: String, repeatPasswordLabel: String ): UpdatePasswordFormData = UpdatePasswordFormData( oldPassword = new FormField(label = oldPasswordLabel, name = "oldPassword", required = true, `type` = "password"), password = new FormField(label = passwordLabel, name = "password", required = true, `type` = "password"), repeatPassword = new FormField(label = repeatPasswordLabel, name = "repeatPassword", required = true, `type` = "password") ) } ================================================ FILE: web/src/main/scala/net/wiringbits/models/AuthState.scala ================================================ package net.wiringbits.models sealed trait AuthState extends Product with Serializable object AuthState { case object Unauthenticated extends AuthState case class Authenticated(user: User) extends AuthState implicit val authStateEq: cats.Eq[AuthState] = cats.Eq.fromUniversalEquals } ================================================ FILE: web/src/main/scala/net/wiringbits/models/User.scala ================================================ package net.wiringbits.models import net.wiringbits.common.models.{Email, Name} case class User(name: Name, email: Email) ================================================ FILE: web/src/main/scala/net/wiringbits/models/UserMenuOption.scala ================================================ package net.wiringbits.models import enumeratum.{Enum, EnumEntry} sealed abstract class UserMenuOption extends EnumEntry with Product with Serializable object UserMenuOption extends Enum[UserMenuOption] { case object EditSummary extends UserMenuOption case object EditPassword extends UserMenuOption val values = findValues } ================================================ FILE: web/src/main/scala/net/wiringbits/services/StorageService.scala ================================================ package net.wiringbits.services import net.wiringbits.core.I18nLang import org.scalajs.dom class StorageService { def saveLang(lang: I18nLang): Unit = save("lang", lang.toString) def findLang(): Option[I18nLang] = find("lang").flatMap(I18nLang.from) private def save(key: String, value: String): Unit = { dom.window.localStorage.setItem(key, value) } private def find(key: String): Option[String] = { Option(dom.window.localStorage.getItem(key)) .filter(_.nonEmpty) } } ================================================ FILE: web/src/test/scala/java/security/SecureRandom.scala ================================================ /* * scalajs-fake-insecure-java-securerandom (https://github.com/scala-js/scala-js-fake-insecure-java-securerandom) * * Copyright EPFL. * * Licensed under Apache License 2.0 * (https://www.apache.org/licenses/LICENSE-2.0). * * See the NOTICE file distributed with this work for * additional information regarding copyright ownership. */ package java.security import scala.scalajs.js import scala.scalajs.js.typedarray.* // DISCLAIMER: This is almost identical to the official library https://github.com/scala-js/scala-js-fake-insecure-java-securerandom // There was the need to apply a patch that won't be accepted by the upstream library, given that this is used // only for tests, it shouldn't be a problem to keep the patch. // // The seed in java.util.Random will be unused, so set to 0L instead of having to generate one class SecureRandom() extends java.util.Random(0L) { // Make sure to resolve the appropriate function no later than the first instantiation private val getRandomValuesFun = SecureRandom.getRandomValuesFun /* setSeed has no effect. For cryptographically secure PRNGs, giving a seed * can only ever increase the entropy. It is never allowed to decrease it. * Given that we don't have access to an API to strengthen the entropy of the * underlying PRNG, it's fine to ignore it instead. * * Note that the doc of `SecureRandom` says that it will seed itself upon * first call to `nextBytes` or `next`, if it has not been seeded yet. This * suggests that an *initial* call to `setSeed` would make a `SecureRandom` * instance deterministic. Experimentally, this does not seem to be the case, * however, so we don't spend extra effort to make that happen. */ override def setSeed(x: Long): Unit = () override def nextBytes(bytes: Array[Byte]): Unit = { val len = bytes.length val buffer = new Int8Array(len) getRandomValuesFun(buffer) var i = 0 while (i != len) { bytes(i) = buffer(i) i += 1 } } override protected final def next(numBits: Int): Int = { if (numBits <= 0) { 0 // special case because the formula on the last line is incorrect for numBits == 0 } else { val buffer = new Int32Array(1) getRandomValuesFun(buffer) val rand32 = buffer(0) rand32 & (-1 >>> (32 - numBits)) // Clear the (32 - numBits) higher order bits } } } object SecureRandom { private lazy val getRandomValuesFun: js.Function1[ArrayBufferView, Unit] = { if ( js.typeOf(js.Dynamic.global.crypto) != "undefined" && js.typeOf(js.Dynamic.global.crypto.getRandomValues) == "function" ) { { (buffer: ArrayBufferView) => js.Dynamic.global.crypto.getRandomValues(buffer) () } } else if (js.typeOf(js.Dynamic.global.require) == "function") { try { val crypto = js.Dynamic.global.require("crypto") if (js.typeOf(crypto.randomFillSync) == "function") { { (buffer: ArrayBufferView) => /** This part differs from the official implementation because it catches runtime exceptions * * This was necessary because webpack seems to be polluting our runtime libraries with one that breaks in * the tests. */ try { crypto.randomFillSync(buffer) } catch { case _: Throwable => insecureDefault(buffer) } () } } else { insecureDefault } } catch { case _: Throwable => insecureDefault } } else { insecureDefault } } private def insecureDefault: js.Function1[ArrayBufferView, Unit] = { val insecureRandom = new java.util.Random() { (buffer: ArrayBufferView) => val asInt8Array = new Int8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) val len = asInt8Array.length val arrayBuffer = new Array[Byte](len) insecureRandom.nextBytes(arrayBuffer) var i = 0 while (i != len) { asInt8Array(i) = arrayBuffer(i) i += 1 } } } } ================================================ FILE: web/src/test/scala/net/wiringbits/forms/ForgotPasswordFormDataSpec.scala ================================================ package net.wiringbits.forms import net.wiringbits.common.models.{Captcha, Email} import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec class ForgotPasswordFormDataSpec extends AnyWordSpec { private val initialForm = ForgotPasswordFormData.initial( emailLabel = "Email" ) private val validForm = initialForm .copy( email = initialForm.email.updated(Email.validate("hello@test.com")), captcha = Some(Captcha.trusted("test")) ) private val allDataInvalidForm = initialForm .copy( email = initialForm.email.updated(Email.validate("x@")) ) "fields" should { "return the expected fields" in { val expected = List("email") initialForm.fields.map(_.name).toSet must be(expected.toSet) } } "formValidationErrors" should { "return no errors when everything mandatory is correct" in { val result = validForm.formValidationErrors result must be(empty) } "return all errors" in { allDataInvalidForm.formValidationErrors.size must be(2) } } "submitRequest" should { "return a request when the emmail is valid" in { val result = validForm.submitRequest result.isDefined must be(true) } "return None when the data is not valid" in { val form = validForm val invalidEmail = form.copy(email = allDataInvalidForm.email) List(invalidEmail).foreach { form => form.submitRequest.isDefined must be(false) } } } } ================================================ FILE: web/src/test/scala/net/wiringbits/forms/ResendVerifyEmailFormDataSpec.scala ================================================ package net.wiringbits.forms import net.wiringbits.common.models.{Captcha, Email} import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec class ResendVerifyEmailFormDataSpec extends AnyWordSpec { private val initialForm = ResendVerifyEmailFormData.initial( texts = ResendVerifyEmailFormData.Texts("Captcha message"), emailLabel = "Email" ) private val validForm = initialForm .copy( email = initialForm.email.updated(Email.validate("hello@test.com")), captcha = Some(Captcha.trusted("test")) ) private val allDataInvalidForm = initialForm .copy( email = initialForm.email.updated(Email.validate("x@")), captcha = None ) "fields" should { "return the expected fields" in { val expected = List("email") initialForm.fields.map(_.name).toSet must be(expected.toSet) } } "formValidationErrors" should { "return no errors when everything mandatory is correct" in { val result = validForm.formValidationErrors result must be(empty) } "return all errors" in { allDataInvalidForm.formValidationErrors.size must be(2) } } "submitRequest" should { "return a request when the emmail is valid" in { val result = validForm.submitRequest result.isDefined must be(true) } "return None when the data is not valid" in { val form = validForm val invalidEmail = form.copy(email = allDataInvalidForm.email) List(invalidEmail).foreach { form => form.submitRequest.isDefined must be(false) } } } } ================================================ FILE: web/src/test/scala/net/wiringbits/forms/ResetPasswordFormDataSpec.scala ================================================ package net.wiringbits.forms import net.wiringbits.common.models.{Password, UserToken} import org.scalatest.matchers.must.Matchers.{be, empty, must} import org.scalatest.wordspec.AnyWordSpec import java.util.UUID class ResetPasswordFormDataSpec extends AnyWordSpec { private val initialForm = ResetPasswordFormData.initial( passwordLabel = "Password", repeatPasswordLabel = "Repeat password", token = Some(UserToken(UUID.randomUUID, UUID.randomUUID)) ) private val validForm = initialForm .copy( password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456789")), token = Some(UserToken(UUID.randomUUID, UUID.randomUUID)) ) private val allDataInvalidForm = initialForm .copy( password = initialForm.password.updated(Password.validate("x")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("x")), token = None ) "fields" should { "return the expected fields" in { val expected = List("password", "repeatPassword") initialForm.fields.map(_.name).toSet must be(expected.toSet) } } "formValidationErrors" should { "return no errors when everything mandatory is correct" in { val result = validForm.formValidationErrors result must be(empty) } "return error when the password do not match" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456788")) ) form.formValidationErrors must be(List("The passwords does not match")) } "return no password match error when password isn't valid" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("19")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456789")) ) form.formValidationErrors.contains("The passwords does not match") must be(false) } "return no password match error when repeatPassword isn't valid" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("12")) ) form.formValidationErrors.contains("The passwords does not match") must be(false) } "return all errors" in { allDataInvalidForm.formValidationErrors.size must be(2) } } "submitRequest" should { "return None when the data is not valid" in { val form = validForm val invalidPassword = form.copy(password = allDataInvalidForm.password) List(invalidPassword).foreach { form => form.submitRequest.isDefined must be(false) } } } } ================================================ FILE: web/src/test/scala/net/wiringbits/forms/SignInFormDataSpec.scala ================================================ package net.wiringbits.forms import net.wiringbits.common.models.{Captcha, Email, Password} import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec class SignInFormDataSpec extends AnyWordSpec { private val initialForm = SignInFormData.initial( emailLabel = "Email", passwordLabel = "Password" ) private val validForm = initialForm .copy( email = initialForm.email.updated(Email.validate("hello@test.com")), password = initialForm.password.updated(Password.validate("123456789")), captcha = Some(Captcha.trusted("test")) ) private val allDataInvalidForm = initialForm .copy( email = initialForm.email.updated(Email.validate("x@")), password = initialForm.password.updated(Password.validate("x")), captcha = None ) "fields" should { "return the expected fields" in { val expected = List("email", "password") initialForm.fields.map(_.name).toSet must be(expected.toSet) } } "formValidationErrors" should { "return no errors when everything mandatory is correct" in { val result = validForm.formValidationErrors result must be(empty) } "return all errors" in { allDataInvalidForm.formValidationErrors.size must be(2) } } "submitRequest" should { "return a request when the data is valid" in { val result = validForm.submitRequest result.isDefined must be(true) } "return None when the data is not valid" in { val form = validForm val invalidEmail = form.copy(email = allDataInvalidForm.email) val invalidPassword = form.copy(password = allDataInvalidForm.password) List(invalidEmail, invalidPassword).foreach { form => form.submitRequest.isDefined must be(false) } } } } ================================================ FILE: web/src/test/scala/net/wiringbits/forms/SignUpFormDataSpec.scala ================================================ package net.wiringbits.forms import net.wiringbits.common.models.{Captcha, Email, Name, Password} import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec class SignUpFormDataSpec extends AnyWordSpec { private val initialForm = SignUpFormData.initial( nameLabel = "name", emailLabel = "Email", passwordLabel = "Password", repeatPasswordLabel = "Repeat password" ) private val validForm = initialForm .copy( name = initialForm.name.updated(Name.validate("someone")), email = initialForm.email.updated(Email.validate("hello@test.com")), password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456789")), captcha = Some(Captcha.trusted("test")) ) private val allDataInvalidForm = initialForm .copy( name = initialForm.name.updated(Name.validate("x")), email = initialForm.email.updated(Email.validate("x@")), password = initialForm.password.updated(Password.validate("x")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("x")), captcha = None ) "fields" should { "return the expected fields" in { val expected = List("name", "email", "password", "repeatPassword") initialForm.fields.map(_.name).toSet must be(expected.toSet) } } "formValidationErrors" should { "return no errors when everything mandatory is correct" in { val result = validForm.formValidationErrors result must be(empty) } "return error when the password do not match" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456788")) ) form.formValidationErrors must be(List("The passwords does not match")) } "return no password match error when password isn't valid" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("19")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456789")) ) form.formValidationErrors.contains("The passwords does not match") must be(false) } "return no password match error when repeatPassword isn't valid" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("12")) ) form.formValidationErrors.contains("The passwords does not match") must be(false) } "return all errors" in { allDataInvalidForm.formValidationErrors.size must be(2) } } "submitRequest" should { "return a request when the data is valid" in { val result = validForm.submitRequest result.isDefined must be(true) } "return None when the data is not valid" in { val form = validForm val invalidName = form.copy(name = allDataInvalidForm.name) val invalidEmail = form.copy(email = allDataInvalidForm.email) val invalidPassword = form.copy(password = allDataInvalidForm.password) List(invalidName, invalidEmail, invalidPassword).foreach { form => form.submitRequest.isDefined must be(false) } } } } ================================================ FILE: web/src/test/scala/net/wiringbits/forms/UpdateInfoFormDataSpec.scala ================================================ package net.wiringbits.forms import net.wiringbits.common.models.{Email, Name} import org.scalatest.matchers.must.Matchers.* import org.scalatest.wordspec.AnyWordSpec class UpdateInfoFormDataSpec extends AnyWordSpec { private val initialForm = UpdateInfoFormData.initial( nameLabel = "name", emailLabel = "Email" ) private val validForm = initialForm .copy( name = initialForm.name.updated(Name.validate("someone")), email = initialForm.email.updated(Email.validate("hello@test.com")) ) private val allDataInvalidForm = initialForm .copy( name = initialForm.name.updated(Name.validate("x")), email = initialForm.email.updated(Email.validate("x@")) ) "fields" should { "return the expected fields" in { val expected = List("name") initialForm.fields.map(_.name).toSet must be(expected.toSet) } } "formValidationErrors" should { "return no errors when everything mandatory is correct" in { val result = validForm.formValidationErrors result must be(empty) } "return all errors" in { allDataInvalidForm.formValidationErrors.size must be(1) } } "submitRequest" should { "return a request when the data is valid" in { val result = validForm.submitRequest result.isDefined must be(true) } "return None when the data is not valid" in { val result = allDataInvalidForm.submitRequest result.isDefined must be(false) } } } ================================================ FILE: web/src/test/scala/net/wiringbits/forms/UpdatePasswordFormDataSpec.scala ================================================ package net.wiringbits.forms import net.wiringbits.common.models.Password import org.scalatest.matchers.must.Matchers.{be, empty, must} import org.scalatest.wordspec.AnyWordSpec class UpdatePasswordFormDataSpec extends AnyWordSpec { private val initialForm = UpdatePasswordFormData.initial( oldPasswordLabel = "Old password", passwordLabel = "Password", repeatPasswordLabel = "Repeat password" ) private val validForm = initialForm .copy( oldPassword = initialForm.oldPassword.updated(Password.validate("1234567890")), password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456789")) ) private val allDataInvalidForm = initialForm .copy( oldPassword = initialForm.oldPassword.updated(Password.validate("x")), password = initialForm.password.updated(Password.validate("x")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("x")) ) "fields" should { "return the expected fields" in { val expected = List("oldPassword", "password", "repeatPassword") initialForm.fields.map(_.name).toSet must be(expected.toSet) } } "formValidationErrors" should { "return no errors when everything mandatory is correct" in { val result = validForm.formValidationErrors result must be(empty) } "return error when the password do not match" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456788")) ) form.formValidationErrors must be(List("The passwords does not match")) } "return no password match error when password isn't valid" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("19")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("123456789")) ) form.formValidationErrors.contains("The passwords does not match") must be(false) } "return no password match error when repeatPassword isn't valid" in { val form = validForm.copy( password = initialForm.password.updated(Password.validate("123456789")), repeatPassword = initialForm.repeatPassword.updated(Password.validate("12")) ) form.formValidationErrors.contains("The passwords does not match") must be(false) } "return all errors" in { allDataInvalidForm.formValidationErrors.size must be(1) } } "submitRequest" should { "return a request when the data is valid" in { val result = validForm.submitRequest result.isDefined must be(true) } "return None when the data is not valid" in { val form = validForm val invalidOldPassword = form.copy(oldPassword = allDataInvalidForm.oldPassword) val invalidPassword = form.copy(password = allDataInvalidForm.password) List(invalidOldPassword, invalidPassword).foreach { form => form.submitRequest.isDefined must be(false) } } } }