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

[](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:
[](https://youtu.be/hURUK4NCGBk "Users app 1m demo")
Deployment 2m demo:
[](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

## 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

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

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

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 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 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

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 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 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

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}.log303GB%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)
}
}
}
}