├── .editorconfig
├── .git-blame-ignore-revs
├── .github
├── helm-ct.yml
├── labeler.yml
├── release-drafter.yaml
└── workflows
│ ├── bootzooka-ci.yml
│ ├── bootzooka-helm-ci.yaml
│ └── scala-steward.yml
├── .gitignore
├── .mergify.yml
├── .scala-steward.conf
├── .scalafix.conf
├── .scalafmt.conf
├── CHANGELOG.md
├── LICENSE
├── README.md
├── backend-start.bat
├── backend-start.sh
├── backend
└── src
│ ├── main
│ ├── resources
│ │ ├── application.conf
│ │ ├── db
│ │ │ └── migration
│ │ │ │ └── V1__create_schema.sql
│ │ ├── logback.xml
│ │ ├── psw4j.properties
│ │ └── templates
│ │ │ └── email
│ │ │ ├── emailSignature.txt
│ │ │ ├── passwordChangeNotification.txt
│ │ │ ├── profileDetailsChangeNotification.txt
│ │ │ ├── registrationConfirmation.txt
│ │ │ └── resetPassword.txt
│ └── scala
│ │ └── com
│ │ └── softwaremill
│ │ └── bootzooka
│ │ ├── Dependencies.scala
│ │ ├── Fail.scala
│ │ ├── Main.scala
│ │ ├── OpenAPIDescription.scala
│ │ ├── admin
│ │ └── VersionApi.scala
│ │ ├── config
│ │ ├── Config.scala
│ │ └── Sensitive.scala
│ │ ├── email
│ │ ├── EmailConfig.scala
│ │ ├── EmailModel.scala
│ │ ├── EmailService.scala
│ │ ├── EmailTemplateRenderer.scala
│ │ ├── EmailTemplates.scala
│ │ └── sender
│ │ │ ├── DummyEmailSender.scala
│ │ │ ├── EmailSender.scala
│ │ │ ├── MailgunEmailSender.scala
│ │ │ └── SmtpEmailSender.scala
│ │ ├── http
│ │ ├── Http.scala
│ │ ├── HttpApi.scala
│ │ ├── HttpConfig.scala
│ │ └── package.scala
│ │ ├── infrastructure
│ │ ├── DB.scala
│ │ ├── DBConfig.scala
│ │ ├── Magnum.scala
│ │ └── SetTraceIdInMDCInterceptor.scala
│ │ ├── logging
│ │ └── Logging.scala
│ │ ├── metrics
│ │ └── Metrics.scala
│ │ ├── passwordreset
│ │ ├── PasswordResetApi.scala
│ │ ├── PasswordResetCodeModel.scala
│ │ ├── PasswordResetConfig.scala
│ │ └── PasswordResetService.scala
│ │ ├── security
│ │ ├── ApiKeyModel.scala
│ │ ├── ApiKeyService.scala
│ │ └── Auth.scala
│ │ ├── user
│ │ ├── UserApi.scala
│ │ ├── UserConfig.scala
│ │ ├── UserModel.scala
│ │ └── UserService.scala
│ │ └── util
│ │ ├── Clock.scala
│ │ ├── IdGenerator.scala
│ │ ├── PasswordVerificationStatus.scala
│ │ ├── SecureRandomIdGenerator.scala
│ │ └── Strings.scala
│ └── test
│ └── scala
│ └── com
│ └── softwaremill
│ └── bootzooka
│ ├── email
│ ├── EmailTemplatesTest.scala
│ └── sender
│ │ └── DummyEmailSenderTest.scala
│ ├── passwordreset
│ └── PasswordResetApiTest.scala
│ ├── test
│ ├── BaseTest.scala
│ ├── RegisteredUser.scala
│ ├── Requests.scala
│ ├── TestClock.scala
│ ├── TestDependencies.scala
│ ├── TestEmbeddedPostgres.scala
│ ├── TestSupport.scala
│ └── package.scala
│ └── user
│ ├── UserApiTest.scala
│ └── UserValidatorSpec.scala
├── banner.png
├── build.sbt
├── docker-compose.yml
├── docs
├── _config.yml
├── _layouts
│ └── default.html
├── architecture.md
├── backend.md
├── configuration.md
├── devtips.md
├── frontend.md
├── getting-started.md
├── index.md
├── javascripts
│ └── scale.fix.js
├── production.md
├── stack.md
├── stylesheets
│ ├── github-light.css
│ └── styles.css
└── testing.md
├── frontend-start.sh
├── helm
└── bootzooka
│ ├── .helmignore
│ ├── Chart.yaml
│ ├── README.md
│ ├── README.md.gotmpl
│ ├── templates
│ ├── NOTES.txt
│ ├── _helpers.tpl
│ ├── configmap.yaml
│ ├── deployment.yaml
│ ├── ingress.yaml
│ ├── secrets.yaml
│ ├── service.yaml
│ └── tests
│ │ └── test-postgresql-connection.yaml
│ └── values.yaml
├── integration-tests
└── docker-compose.yml
├── project
├── RenameProject.scala
├── build.properties
└── plugins.sbt
├── ui
├── .env.example
├── .gitignore
├── .prettierignore
├── .prettierrc
├── README.md
├── eslint.config.js
├── index.html
├── openapi-codegen.config.ts
├── package.json
├── public
│ ├── favicon.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.test.tsx
│ ├── App.tsx
│ ├── api
│ │ ├── apiComponents.ts
│ │ ├── apiContext.ts
│ │ ├── apiFetcher.ts
│ │ ├── apiSchemas.ts
│ │ └── apiUtils.ts
│ ├── assets
│ │ ├── forkme_orange.png
│ │ ├── sml-logo-vertical-rgb-trans.png
│ │ └── sml-logo-vertical-white-all-trans.png
│ ├── components
│ │ ├── ErrorMessage
│ │ │ └── ErrorMessage.tsx
│ │ ├── FeedbackButton
│ │ │ ├── FeedbackButton.tsx
│ │ │ └── useFormikValuesChanged.tsx
│ │ ├── FormikInput
│ │ │ └── FormikInput.tsx
│ │ ├── TwoColumnHero
│ │ │ └── TwoColumnHero.tsx
│ │ └── index.ts
│ ├── contexts
│ │ ├── UserContext
│ │ │ ├── User.context.ts
│ │ │ ├── UserContext.constants.ts
│ │ │ ├── UserContext.test.tsx
│ │ │ └── UserContext.tsx
│ │ └── index.ts
│ ├── index.css
│ ├── main.tsx
│ ├── main
│ │ ├── Footer
│ │ │ ├── Footer.test.tsx
│ │ │ └── Footer.tsx
│ │ ├── ForkMe
│ │ │ ├── ForkMe.test.tsx
│ │ │ └── ForkMe.tsx
│ │ ├── Loader
│ │ │ ├── Loader.test.tsx
│ │ │ └── Loader.tsx
│ │ ├── Main
│ │ │ ├── Main.test.tsx
│ │ │ ├── Main.tsx
│ │ │ ├── useLocalStorageApiKey.test.tsx
│ │ │ ├── useLocalStorageApiKey.tsx
│ │ │ ├── useLoginOnApiKey.test.tsx
│ │ │ └── useLoginOnApiKey.tsx
│ │ ├── Routes
│ │ │ ├── ProtectedRoute.test.tsx
│ │ │ ├── ProtectedRoute.tsx
│ │ │ ├── Routes.test.tsx
│ │ │ └── Routes.tsx
│ │ ├── Top
│ │ │ ├── Top.test.tsx
│ │ │ └── Top.tsx
│ │ └── index.ts
│ ├── pages
│ │ ├── Login
│ │ │ ├── Login.test.tsx
│ │ │ ├── Login.tsx
│ │ │ └── Login.validations.ts
│ │ ├── NotFound
│ │ │ ├── NotFound.test.tsx
│ │ │ └── NotFound.tsx
│ │ ├── Profile
│ │ │ ├── Profile.test.tsx
│ │ │ ├── Profile.tsx
│ │ │ └── components
│ │ │ │ ├── PasswordDetails.test.tsx
│ │ │ │ ├── PasswordDetails.tsx
│ │ │ │ ├── PasswordDetails.validations.ts
│ │ │ │ ├── ProfileDetails.test.tsx
│ │ │ │ ├── ProfileDetails.tsx
│ │ │ │ └── ProfileDetails.validations.ts
│ │ ├── RecoverLostPassword
│ │ │ ├── RecoverLostPassword.test.tsx
│ │ │ ├── RecoverLostPassword.tsx
│ │ │ └── RecoverLostPassword.validations.ts
│ │ ├── Register
│ │ │ ├── Register.test.tsx
│ │ │ ├── Register.tsx
│ │ │ ├── Register.utils.ts
│ │ │ └── Register.validations.ts
│ │ ├── SecretMain
│ │ │ ├── SecretMain.test.tsx
│ │ │ └── SecretMain.tsx
│ │ ├── Welcome
│ │ │ ├── Welcome.test.tsx
│ │ │ └── Welcome.tsx
│ │ └── index.ts
│ ├── setupTest.ts
│ ├── tests
│ │ ├── index.ts
│ │ └── utils.tsx
│ └── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
├── vitest.config.ts
└── yarn.lock
├── version.sbt
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: http://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | # all files
7 | [*]
8 | end_of_line = lf
9 | insert_final_newline = true
10 | indent_style = space
11 | indent_size = 2
12 | charset = utf-8
13 | trim_trailing_whitespace = true
14 | max_line_length = 120
15 |
16 | [*.js]
17 | quote_type = single
18 | curly_bracket_next_line = false
19 | spaces_around_operators = true
20 | spaces_around_brackets = inside
21 | indent_brace_style = BSD KNF
22 |
23 | # HTML
24 | [*.html]
25 | quote_type = double
26 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Scala Steward: Reformat with scalafmt 3.6.0
2 | c28b856063143ba908043a4cdce797cd3a20f394
3 |
4 | # Scala Steward: Reformat with scalafmt 3.8.1
5 | 13cfe8a8bc538f317563ba6902a922d0819f2871
6 |
--------------------------------------------------------------------------------
/.github/helm-ct.yml:
--------------------------------------------------------------------------------
1 | remote: origin
2 | chart-dirs:
3 | - helm
4 | chart-repos:
5 | - bitnami=https://charts.bitnami.com/bitnami
6 | debug: false
7 | helm-extra-args: --timeout 600s
8 | check-version-increment: false
9 |
--------------------------------------------------------------------------------
/.github/labeler.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | labels:
3 | - label: "automerge"
4 | authors: ["softwaremill-ci"]
5 | files:
6 | - "build.sbt"
7 | - "project/plugins.sbt"
8 | - "project/build.properties"
9 | - label: "dependency"
10 | authors: ["softwaremill-ci"]
11 | files:
12 | - "build.sbt"
13 | - "project/plugins.sbt"
14 | - "project/build.properties"
15 |
--------------------------------------------------------------------------------
/.github/release-drafter.yaml:
--------------------------------------------------------------------------------
1 | template: |
2 | ## What’s Changed
3 |
4 | $CHANGES
5 |
--------------------------------------------------------------------------------
/.github/workflows/bootzooka-ci.yml:
--------------------------------------------------------------------------------
1 | name: Bootzooka CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | tags: [v*]
7 | branches:
8 | - master
9 | paths-ignore:
10 | - "helm/**"
11 | release:
12 | types:
13 | - released
14 |
15 | jobs:
16 | verify:
17 | runs-on: ubuntu-24.04
18 |
19 | steps:
20 | - name: Check-out repository
21 | id: repo-checkout
22 | uses: actions/checkout@v4
23 |
24 | - name: Set up JDK
25 | uses: actions/setup-java@v4
26 | with:
27 | distribution: 'zulu'
28 | java-version: '21'
29 | cache: 'sbt'
30 |
31 | - uses: sbt/setup-sbt@v1
32 |
33 | - name: Set up Node.js
34 | uses: actions/setup-node@v4
35 | with:
36 | node-version: 22
37 |
38 | - name: Generate OpenAPI Spec
39 | id: generate-openapi-spec
40 | run: sbt "backend/generateOpenAPIDescription"
41 |
42 | - name: Run tests
43 | id: run-tests
44 | run: sbt test
45 |
46 | - name: Test UI build
47 | id: test-ui-build
48 | run: yarn build
49 | working-directory: ./ui
50 |
51 | deploy:
52 | if: github.event_name != 'pull_request' && startsWith(github.ref, 'refs/tags/v')
53 | needs: [ verify ]
54 | runs-on: ubuntu-24.04
55 |
56 | steps:
57 | - name: Check-out repository
58 | id: repo-checkout
59 | uses: actions/checkout@v4
60 |
61 | - name: Set up JDK
62 | uses: actions/setup-java@v4
63 | with:
64 | distribution: 'zulu'
65 | java-version: '21'
66 | cache: 'sbt'
67 |
68 | - name: Login to DockerHub
69 | uses: docker/login-action@v1
70 | with:
71 | username: ${{ secrets.DOCKERHUB_USERNAME }}
72 | password: ${{ secrets.DOCKERHUB_TOKEN }}
73 |
74 | - name: Extract version
75 | run: |
76 | version=${GITHUB_REF/refs\/tags\/v/}
77 | echo "VERSION=$version" >> $GITHUB_ENV
78 |
79 | - name: Publish release notes
80 | uses: release-drafter/release-drafter@v5
81 | with:
82 | config-name: release-drafter.yaml
83 | publish: true
84 | name: "v${{ env.VERSION }}"
85 | tag: "v${{ env.VERSION }}"
86 | version: "v${{ env.VERSION }}"
87 | env:
88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
89 |
90 | - name: Publish docker image
91 | run: sbt backend/docker:publish
92 |
93 | label:
94 | # only for PRs by softwaremill-ci
95 | if: github.event.pull_request.user.login == 'softwaremill-ci'
96 | uses: softwaremill/github-actions-workflows/.github/workflows/label.yml@main
97 |
98 | auto-merge:
99 | # only for PRs by softwaremill-ci
100 | if: github.event.pull_request.user.login == 'softwaremill-ci'
101 | needs: [ verify, label ]
102 | uses: softwaremill/github-actions-workflows/.github/workflows/auto-merge.yml@main
103 |
--------------------------------------------------------------------------------
/.github/workflows/bootzooka-helm-ci.yaml:
--------------------------------------------------------------------------------
1 | name: Bootzooka Helm Chart CI
2 |
3 | on:
4 | push:
5 | paths:
6 | - "helm/**"
7 | - ".github/**"
8 | branches:
9 | - master
10 | pull_request:
11 | paths:
12 | - "helm/**"
13 | - ".github/**"
14 |
15 | jobs:
16 | lint-chart:
17 | name: Lint Helm Chart
18 | runs-on: ubuntu-24.04
19 | steps:
20 | - name: Check-out repository
21 | id: repo-checkout
22 | uses: actions/checkout@v2
23 | with:
24 | fetch-depth: 0
25 |
26 | - name: Run chart-testing (lint)
27 | id: ct-lint
28 | uses: helm/chart-testing-action@v1.1.0
29 | with:
30 | command: lint
31 | config: .github/helm-ct.yml
32 |
33 | install-test-chart:
34 | needs:
35 | - lint-chart
36 | name: Install & Test Helm Chart
37 | runs-on: ubuntu-24.04
38 | strategy:
39 | matrix:
40 | k8s:
41 | - v1.20.7
42 | - v1.21.2
43 | - v1.22.4
44 | steps:
45 | - name: Check-out repository
46 | id: repo-checkout
47 | uses: actions/checkout@v2
48 | with:
49 | fetch-depth: 0
50 |
51 | - name: Create kind ${{ matrix.k8s }} cluster
52 | id: kind-cluster-setup
53 | uses: helm/kind-action@v1.2.0
54 | with:
55 | node_image: kindest/node:${{ matrix.k8s }}
56 | wait: "120s"
57 |
58 | - name: Run chart-testing (install)
59 | id: ct-install
60 | uses: helm/chart-testing-action@v1.1.0
61 | with:
62 | command: install
63 | config: .github/helm-ct.yml
64 |
65 | validate-chart-docs:
66 | needs:
67 | - lint-chart
68 | - install-test-chart
69 | name: Validate Helm Chart Docs
70 | runs-on: ubuntu-24.04
71 | steps:
72 | - name: Check-out repository
73 | id: repo-checkout
74 | uses: actions/checkout@v2
75 | with:
76 | fetch-depth: 0
77 |
78 | - name: Run helm-docs
79 | id: helm-docs-run
80 | uses: softwaremill/helm-docs-action@main
81 |
82 | - name: Validate there's no diff
83 | id: git-no-diff
84 | run: git diff --exit-code
85 |
86 | publish-chart:
87 | # run only on push to master
88 | if: github.event_name == 'push'
89 | needs:
90 | - lint-chart
91 | - install-test-chart
92 | - validate-chart-docs
93 | name: Publish Helm Chart
94 | runs-on: ubuntu-24.04
95 | steps:
96 | - name: Check-out repository
97 | id: repo-checkout
98 | uses: actions/checkout@v2
99 | with:
100 | fetch-depth: 0
101 |
102 | - name: Publish Helm Chart
103 | id: helm-publish-chart
104 | uses: stefanprodan/helm-gh-pages@v1.2.0
105 | with:
106 | token: ${{ secrets.CR_TOKEN }}
107 | charts_dir: helm
108 | charts_url: https://softwaremill.github.io/sml-helm-public-repo
109 | owner: softwaremill
110 | repository: sml-helm-public-repo
111 | helm_version: 3.7.1
112 | linting: off
113 |
--------------------------------------------------------------------------------
/.github/workflows/scala-steward.yml:
--------------------------------------------------------------------------------
1 | name: Scala Steward
2 |
3 | # This workflow will launch at 00:00 every day
4 | on:
5 | schedule:
6 | - cron: '0 0 * * *'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | scala-steward:
11 | uses: softwaremill/github-actions-workflows/.github/workflows/scala-steward.yml@main
12 | secrets:
13 | repo-github-token: ${{secrets.REPO_GITHUB_TOKEN}}
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | build
13 | target
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 | ui/.env
22 |
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | *.log
28 | work.txt
29 |
30 | # generated code
31 | /ui/src/api-client/openapi.d.ts
32 |
33 | # IDE
34 | *.iml
35 | *.ipr
36 | *.iws
37 | .idea/
38 | *.bloop
39 | *.metals
40 | .vscode
41 | metals.sbt
42 | .bsp
43 |
44 | metals.sbt
45 | .vscode
46 |
--------------------------------------------------------------------------------
/.mergify.yml:
--------------------------------------------------------------------------------
1 | pull_request_rules:
2 | - name: delete head branch after merge
3 | conditions: []
4 | actions:
5 | delete_head_branch: {}
6 | - name: automatic merge for softwaremill-ci pull requests affecting build.sbt
7 | conditions:
8 | - author=softwaremill-ci
9 | - check-success=verify
10 | - "#files=1"
11 | - files=build.sbt
12 | actions:
13 | merge:
14 | method: merge
15 | - name: automatic merge for softwaremill-ci pull requests affecting project plugins.sbt
16 | conditions:
17 | - author=softwaremill-ci
18 | - check-success=verify
19 | - "#files=1"
20 | - files=project/plugins.sbt
21 | actions:
22 | merge:
23 | method: merge
24 | - name: semi-automatic merge for softwaremill-ci pull requests
25 | conditions:
26 | - author=softwaremill-ci
27 | - check-success=verify
28 | - "#approved-reviews-by>=1"
29 | actions:
30 | merge:
31 | method: merge
32 | - name: automatic merge for softwaremill-ci pull requests affecting project build.properties
33 | conditions:
34 | - author=softwaremill-ci
35 | - check-success=verify
36 | - "#files=1"
37 | - files=project/build.properties
38 | actions:
39 | merge:
40 | method: merge
41 | - name: automatic merge for softwaremill-ci pull requests affecting .scalafmt.conf
42 | conditions:
43 | - author=softwaremill-ci
44 | - check-success=verify
45 | - "#files=1"
46 | - files=.scalafmt.conf
47 | actions:
48 | merge:
49 | method: merge
50 |
--------------------------------------------------------------------------------
/.scala-steward.conf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/.scala-steward.conf
--------------------------------------------------------------------------------
/.scalafix.conf:
--------------------------------------------------------------------------------
1 | OrganizeImports.groupedImports = AggressiveMerge
2 | OrganizeImports.targetDialect = Scala3
--------------------------------------------------------------------------------
/.scalafmt.conf:
--------------------------------------------------------------------------------
1 | version=3.9.4
2 | maxColumn = 140
3 | runner.dialect = scala3
4 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 | All notable changes to this project will be documented in this file.
3 |
4 | ## 2019-07-11
5 | - rewrite of the backend using http4s, tapir, monix and doobie
6 |
7 | ## 2015-01-22
8 | - MongoDB replaced by Slick & H2 with Flyway for easy database schema management
9 |
10 | ## 2014-12-16
11 | - Adding mongo docker container start to backend-start.sh
12 | - Dependency version update
13 | - Adding changelog file
14 | - explicit mongo-java-driver version (not inherited from lift's mongo record)
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | [See the docs](http://softwaremill.github.io/bootzooka/) for more information.
4 |
5 | [](https://github.com/softwaremill/bootzooka/actions?query=workflow%3A%22Bootzooka+CI%22)
6 |
7 | ## Quick start
8 |
9 | ### Using Docker compose
10 |
11 | The fastest way to experiment with Bootzooka is using the provided Docker compose setup. It starts three images:
12 | Bootzooka itself (either locally built or downloaded), PostgreSQL server and Graphana LGTM for observability.
13 |
14 | ### Backend: PostgreSQL & API
15 |
16 | To run Bootzooka's backend locally, you'll still need a running instance of PostgreSQL with a `bootzooka` database.
17 | You can spin up one easily using docker:
18 |
19 | ```sh
20 | # use "bootzooka" as a password
21 | docker run --name bootzooka-postgres -p 5432:5432 -e POSTGRES_PASSWORD=bootzooka -e POSTGRES_DB=bootzooka -d postgres
22 | ```
23 |
24 | Then, you can start the backend:
25 |
26 | ```sh
27 | OTEL_SDK_DISABLED=true SQL_PASSWORD=bootzooka ./backend-start.sh
28 | ```
29 |
30 | Unless you've got an OpenTelemetry collector running, OpenTelemetry should be disabled to avoid telemetry export
31 | exceptions.
32 |
33 | ### Frontend: Yarn & webapp
34 |
35 | You will need the [yarn package manager](https://yarnpkg.com) to run the UI. Install it using your package manager or:
36 |
37 | ```sh
38 | curl -o- -L https://yarnpkg.com/install.sh | bash
39 | ```
40 |
41 | Then, you can start the frontend:
42 |
43 | ```sh
44 | ./frontend-start.sh
45 | ```
46 |
47 | And open `http://localhost:8081`.
48 |
49 | ## Commercial Support
50 |
51 | We offer commercial support for Bootzooka and related technologies, as well as development services. [Contact us](https://softwaremill.com) to learn more about our offer!
52 |
53 | ## Copyright
54 |
55 | Copyright (C) 2013-2025 SoftwareMill [https://softwaremill.com](https://softwaremill.com).
56 |
--------------------------------------------------------------------------------
/backend-start.bat:
--------------------------------------------------------------------------------
1 | @setlocal
2 | sbt "~backend/reStart"
3 |
--------------------------------------------------------------------------------
/backend-start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | sbt "~backend/reStart"
4 |
--------------------------------------------------------------------------------
/backend/src/main/resources/application.conf:
--------------------------------------------------------------------------------
1 | api {
2 | host = "0.0.0.0"
3 | host = ${?API_HOST}
4 |
5 | port = 8080
6 | port = ${?API_PORT}
7 | port = ${?PORT}
8 | }
9 |
10 | db {
11 | username = "postgres"
12 | username = ${?SQL_USERNAME}
13 |
14 | password = "bootzooka"
15 | password = ${?SQL_PASSWORD}
16 |
17 | name = "bootzooka"
18 | name = ${?SQL_DBNAME}
19 | host = "localhost"
20 | host = ${?SQL_HOST}
21 | port = 5432
22 | port = ${?SQL_PORT}
23 |
24 | url = "jdbc:postgresql://"${db.host}":"${db.port}"/"${db.name}
25 | url = ${?DATABASE_URL}
26 |
27 | migrate-on-start = true
28 | migrate-on-start = ${?MIGRATE_ON_START}
29 |
30 | driver = "org.postgresql.Driver"
31 | }
32 |
33 | email {
34 | mailgun {
35 | enabled = false
36 | enabled = ${?MAILGUN_ENABLED}
37 |
38 | domain = "XXXX.mailgun.org"
39 | domain = ${?MAILGUN_DOMAIN}
40 |
41 | url = "https://api.eu.mailgun.net/v3/"${email.mailgun.domain}"/messages"
42 | url = ${?MAILGUN_URL}
43 |
44 | api-key = "XXX-XXX-XXX"
45 | api-key = ${?MAILGUN_API_KEY}
46 |
47 | sender-name = "bootzooka"
48 | sender-name = ${?MAILGUN_SENDER_NAME}
49 |
50 | sender-display-name = "Bootzooka"
51 | sender-display-name = ${?MAILGUN_SENDER_DISPLAY_NAME}
52 | }
53 |
54 | smtp {
55 | enabled = false
56 | enabled = ${?SMTP_ENABLED}
57 |
58 | host = ""
59 | host = ${?SMTP_HOST}
60 |
61 | port = 25
62 | port = ${?SMTP_PORT}
63 |
64 | username = ""
65 | username = ${?SMTP_USERNAME}
66 |
67 | password = ""
68 | password = ${?SMTP_PASSWORD}
69 |
70 | ssl-connection = false
71 | ssl-connection = ${?SMTP_SSL_CONNECTION}
72 |
73 | verify-ssl-certificate = true
74 | verify-ssl-certificate = ${?SMTP_VERIFY_SSL_CERTIFICATE}
75 |
76 | encoding = "UTF-8"
77 | encoding = ${?SMTP_ENCODING}
78 |
79 | from = "info@bootzooka.com"
80 | from = ${?SMTP_FROM}
81 | }
82 |
83 | batch-size = 10
84 | batch-size = ${?EMAIL_BATCH_SIZE}
85 |
86 | email-send-interval = 1 second
87 | email-send-interval = ${?EMAIL_SEND_INTERVAL}
88 | }
89 |
90 | password-reset {
91 | reset-link-pattern = "http://localhost:8081/password-reset?code=%s"
92 | reset-link-pattern = ${?PASSWORD_RESET_LINK_PATTERN}
93 |
94 | code-valid = 1 day
95 | code-valid = ${?PASSWORD_RESET_CODE_VALID}
96 | }
97 |
98 | user {
99 | default-api-key-valid = 1 day
100 | default-api-key-valid = ${?USER_DEFAULT_API_KEY_VALID}
101 | }
102 |
--------------------------------------------------------------------------------
/backend/src/main/resources/db/migration/V1__create_schema.sql:
--------------------------------------------------------------------------------
1 | -- USERS
2 | CREATE TABLE "users"
3 | (
4 | "id" TEXT NOT NULL,
5 | "login" TEXT NOT NULL,
6 | "login_lowercase" TEXT NOT NULL,
7 | "email_lowercase" TEXT NOT NULL,
8 | "password" TEXT NOT NULL,
9 | "created_on" TIMESTAMPTZ NOT NULL
10 | );
11 | ALTER TABLE "users"
12 | ADD CONSTRAINT "users_id" PRIMARY KEY ("id");
13 | CREATE UNIQUE INDEX "users_login_lowercase" ON "users" ("login_lowercase");
14 | CREATE UNIQUE INDEX "users_email_lowercase" ON "users" ("email_lowercase");
15 |
16 | -- API KEYS
17 | CREATE TABLE "api_keys"
18 | (
19 | "id" TEXT NOT NULL,
20 | "user_id" TEXT NOT NULL,
21 | "created_on" TIMESTAMPTZ NOT NULL,
22 | "valid_until" TIMESTAMPTZ NOT NULL
23 | );
24 | ALTER TABLE "api_keys"
25 | ADD CONSTRAINT "api_keys_id" PRIMARY KEY ("id");
26 | ALTER TABLE "api_keys"
27 | ADD CONSTRAINT "api_keys_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
28 |
29 | -- PASSWORD RESET CODES
30 | CREATE TABLE "password_reset_codes"
31 | (
32 | "id" TEXT NOT NULL,
33 | "user_id" TEXT NOT NULL,
34 | "valid_until" TIMESTAMPTZ NOT NULL
35 | );
36 | ALTER TABLE "password_reset_codes"
37 | ADD CONSTRAINT "password_reset_codes_id" PRIMARY KEY ("id");
38 | ALTER TABLE "password_reset_codes"
39 | ADD CONSTRAINT "password_reset_codes_user_fk"
40 | FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
41 |
42 | -- EMAILS
43 | CREATE TABLE "scheduled_emails"
44 | (
45 | "id" TEXT NOT NULL,
46 | "recipient" TEXT NOT NULL,
47 | "subject" TEXT NOT NULL,
48 | "content" TEXT NOT NULL
49 | );
50 | ALTER TABLE "scheduled_emails"
51 | ADD CONSTRAINT "scheduled_emails_id" PRIMARY KEY ("id");
52 |
--------------------------------------------------------------------------------
/backend/src/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | true
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/backend/src/main/resources/psw4j.properties:
--------------------------------------------------------------------------------
1 | global.salt.length=64
2 |
--------------------------------------------------------------------------------
/backend/src/main/resources/templates/email/emailSignature.txt:
--------------------------------------------------------------------------------
1 |
2 | --
3 | Regards,
4 | SoftwareMill Bootzooka Dev Team
5 | http://softwaremill.com
6 |
--------------------------------------------------------------------------------
/backend/src/main/resources/templates/email/passwordChangeNotification.txt:
--------------------------------------------------------------------------------
1 | SoftwareMill Bootzooka password change notification
2 | Dear {{userName}},
3 |
4 | Your password has been changed.
5 |
--------------------------------------------------------------------------------
/backend/src/main/resources/templates/email/profileDetailsChangeNotification.txt:
--------------------------------------------------------------------------------
1 | SoftwareMill Bootzooka profile details change notification
2 | Dear {{userName}},
3 |
4 | Your user name and/or email has been changed.
5 |
--------------------------------------------------------------------------------
/backend/src/main/resources/templates/email/registrationConfirmation.txt:
--------------------------------------------------------------------------------
1 | SoftwareMill Bootzooka - registration confirmation for user {{userName}}
2 | Dear {{userName}},
3 |
4 | Thank you for registering in our application.
--------------------------------------------------------------------------------
/backend/src/main/resources/templates/email/resetPassword.txt:
--------------------------------------------------------------------------------
1 | SoftwareMill Bootzooka password reset
2 | Dear {{userName}},
3 |
4 | To be able to set a new password, please visit the link below:
5 |
6 | {{resetLink}}
7 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/Dependencies.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka
2 |
3 | import com.softwaremill.bootzooka.admin.VersionApi
4 | import com.softwaremill.bootzooka.config.Config
5 | import com.softwaremill.bootzooka.email.EmailService
6 | import com.softwaremill.bootzooka.email.sender.EmailSender
7 | import com.softwaremill.bootzooka.http.{HttpApi, HttpConfig}
8 | import com.softwaremill.bootzooka.infrastructure.DB
9 | import com.softwaremill.bootzooka.metrics.Metrics
10 | import com.softwaremill.bootzooka.passwordreset.{PasswordResetApi, PasswordResetAuthToken}
11 | import com.softwaremill.bootzooka.security.{ApiKeyAuthToken, ApiKeyService, Auth}
12 | import com.softwaremill.bootzooka.user.UserApi
13 | import com.softwaremill.bootzooka.util.{Clock, DefaultClock, DefaultIdGenerator, IdGenerator}
14 | import com.softwaremill.macwire.{autowire, autowireMembersOf}
15 | import io.opentelemetry.api.OpenTelemetry
16 | import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender
17 | import io.opentelemetry.instrumentation.runtimemetrics.java8.{Classes, Cpu, GarbageCollector, MemoryPools, Threads}
18 | import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk
19 | import ox.{Ox, discard, tap, useCloseableInScope, useInScope}
20 | import sttp.client4.SyncBackend
21 | import sttp.client4.httpclient.HttpClientSyncBackend
22 | import sttp.client4.logging.slf4j.Slf4jLoggingBackend
23 | import sttp.client4.opentelemetry.{OpenTelemetryMetricsBackend, OpenTelemetryTracingBackend}
24 | import sttp.tapir.AnyEndpoint
25 |
26 | case class Dependencies(httpApi: HttpApi, emailService: EmailService)
27 |
28 | object Dependencies:
29 | val endpointsForDocs: List[AnyEndpoint] = List(UserApi, PasswordResetApi, VersionApi).flatMap(_.endpointsForDocs)
30 |
31 | private case class Apis(userApi: UserApi, passwordResetApi: PasswordResetApi, versionApi: VersionApi):
32 | def endpoints = List(userApi, passwordResetApi, versionApi).flatMap(_.endpoints)
33 |
34 | def create(using Ox): Dependencies =
35 | val config = Config.read.tap(Config.log)
36 | val otel = initializeOtel()
37 | val sttpBackend = useInScope(
38 | Slf4jLoggingBackend(OpenTelemetryMetricsBackend(OpenTelemetryTracingBackend(HttpClientSyncBackend(), otel), otel))
39 | )(_.close())
40 | val db: DB = useCloseableInScope(DB.createTestMigrate(config.db))
41 |
42 | create(config, otel, sttpBackend, db, DefaultClock)
43 |
44 | /** Create the service graph using the given infrastructure services & configuration. */
45 | def create(config: Config, otel: OpenTelemetry, sttpBackend: SyncBackend, db: DB, clock: Clock): Dependencies =
46 | autowire[Dependencies](
47 | autowireMembersOf(config),
48 | otel,
49 | sttpBackend,
50 | db,
51 | DefaultIdGenerator,
52 | clock,
53 | EmailSender.create,
54 | (apis: Apis, otel: OpenTelemetry, httpConfig: HttpConfig) =>
55 | new HttpApi(apis.endpoints, Dependencies.endpointsForDocs, otel, httpConfig),
56 | classOf[EmailService],
57 | new Auth(_: ApiKeyAuthToken, _: DB, _: Clock),
58 | new Auth(_: PasswordResetAuthToken, _: DB, _: Clock)
59 | )
60 |
61 | private def initializeOtel(): OpenTelemetry =
62 | AutoConfiguredOpenTelemetrySdk
63 | .initialize()
64 | .getOpenTelemetrySdk()
65 | .tap { otel =>
66 | // see https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/runtime-telemetry/runtime-telemetry-java8/library
67 | Classes.registerObservers(otel)
68 | Cpu.registerObservers(otel)
69 | MemoryPools.registerObservers(otel)
70 | Threads.registerObservers(otel)
71 | GarbageCollector.registerObservers(otel).discard
72 | }
73 | .tap(OpenTelemetryAppender.install)
74 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka
2 |
3 | /** Base class for all failures in the application. The failures are translated to HTTP API results in the
4 | * [[com.softwaremill.bootzooka.http.Http]] class.
5 | *
6 | * The class hierarchy is not sealed and can be extended as required by specific functionalities.
7 | */
8 | abstract class Fail
9 |
10 | object Fail:
11 | case class NotFound(what: String) extends Fail
12 | case class Conflict(msg: String) extends Fail
13 | case class IncorrectInput(msg: String) extends Fail
14 | case class Unauthorized(msg: String) extends Fail
15 | case object Forbidden extends Fail
16 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka
2 |
3 | import com.softwaremill.bootzooka.logging.Logging
4 | import ox.logback.InheritableMDC
5 | import ox.{Ox, OxApp, never}
6 | import org.slf4j.bridge.SLF4JBridgeHandler
7 | import ox.OxApp.Settings
8 | import ox.otel.context.PropagatingVirtualThreadFactory
9 |
10 | object Main extends OxApp.Simple with Logging:
11 | // route JUL to SLF4J (JUL is used by Magnum & OTEL for logging)
12 | SLF4JBridgeHandler.removeHandlersForRootLogger()
13 | SLF4JBridgeHandler.install()
14 |
15 | // https://ox.softwaremill.com/latest/integrations/mdc-logback.html
16 | InheritableMDC.init
17 |
18 | Thread.setDefaultUncaughtExceptionHandler((t, e) => logger.error("Uncaught exception in thread: " + t, e))
19 |
20 | // https://ox.softwaremill.com/latest/integrations/otel-context.html
21 | override protected def settings: Settings = Settings.Default.copy(threadFactory = Some(PropagatingVirtualThreadFactory()))
22 |
23 | override def run(using Ox): Unit =
24 | val deps = Dependencies.create
25 |
26 | deps.emailService.startProcesses()
27 | deps.httpApi.start()
28 | logger.info(s"Bootzooka started")
29 |
30 | // blocking until the application is shut down
31 | never
32 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/OpenAPIDescription.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka
2 |
3 | import sttp.apispec.openapi.circe.yaml.*
4 | import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter
5 |
6 | import java.nio.file.*
7 |
8 | object OpenAPIDescription:
9 | val Title = "Bootzooka"
10 | val Version = "1.0"
11 |
12 | @main def writeOpenAPIDescription(path: String): Unit =
13 | val yaml = OpenAPIDocsInterpreter().toOpenAPI(Dependencies.endpointsForDocs, OpenAPIDescription.Title, OpenAPIDescription.Version).toYaml
14 | Files.writeString(Paths.get(path), yaml)
15 | println(s"OpenAPI description document written to: $path")
16 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/admin/VersionApi.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.admin
2 |
3 | import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec
4 | import com.softwaremill.bootzooka.http.Http.*
5 | import com.softwaremill.bootzooka.version.BuildInfo
6 | import sttp.shared.Identity
7 | import sttp.tapir.Schema
8 | import sttp.tapir.server.ServerEndpoint
9 | import com.softwaremill.bootzooka.http.EndpointsForDocs
10 | import com.softwaremill.bootzooka.http.ServerEndpoints
11 |
12 | /** Defines an endpoint which exposes the current application version information. */
13 | class VersionApi extends ServerEndpoints:
14 | import VersionApi._
15 |
16 | private val versionServerEndpoint: ServerEndpoint[Any, Identity] = versionEndpoint.handleSuccess { _ =>
17 | Version_OUT(BuildInfo.lastCommitHash)
18 | }
19 |
20 | override val endpoints = List(versionServerEndpoint)
21 |
22 | object VersionApi extends EndpointsForDocs:
23 | private val AdminPath = "admin"
24 |
25 | private val versionEndpoint = baseEndpoint.get
26 | .in(AdminPath / "version")
27 | .out(jsonBody[Version_OUT])
28 |
29 | override val endpointsForDocs = List(versionEndpoint).map(_.tag("admin"))
30 |
31 | case class Version_OUT(buildSha: String) derives ConfiguredJsonValueCodec, Schema
32 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/config/Config.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.config
2 |
3 | import com.softwaremill.bootzooka.email.EmailConfig
4 | import com.softwaremill.bootzooka.http.HttpConfig
5 | import com.softwaremill.bootzooka.infrastructure.DBConfig
6 | import com.softwaremill.bootzooka.logging.Logging
7 | import com.softwaremill.bootzooka.passwordreset.PasswordResetConfig
8 | import com.softwaremill.bootzooka.user.UserConfig
9 | import com.softwaremill.bootzooka.version.BuildInfo
10 | import pureconfig.{ConfigReader, ConfigSource}
11 | import pureconfig.generic.derivation.default.*
12 |
13 | import scala.collection.immutable.TreeMap
14 |
15 | /** Maps to the `application.conf` file. Configuration for all modules of the application. */
16 | case class Config(db: DBConfig, api: HttpConfig, email: EmailConfig, passwordReset: PasswordResetConfig, user: UserConfig)
17 | derives ConfigReader
18 |
19 | object Config extends Logging:
20 | def log(config: Config): Unit =
21 | val baseInfo = s"""
22 | |Bootzooka configuration:
23 | |-----------------------
24 | |DB: ${config.db}
25 | |API: ${config.api}
26 | |Email: ${config.email}
27 | |Password reset: ${config.passwordReset}
28 | |User: ${config.user}
29 | |
30 | |Build & env info:
31 | |-----------------
32 | |""".stripMargin
33 |
34 | val info = TreeMap(BuildInfo.toMap.toSeq*).foldLeft(baseInfo) { case (str, (k, v)) =>
35 | str + s"$k: $v\n"
36 | }
37 |
38 | logger.info(info)
39 | end log
40 |
41 | def read: Config = ConfigSource.default.loadOrThrow[Config]
42 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/config/Sensitive.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.config
2 |
3 | import pureconfig.ConfigReader
4 |
5 | /** Wrapper class for any configuration strings which shouldn't be logged verbatim. */
6 | case class Sensitive(value: String):
7 | override def toString: String = "***"
8 |
9 | object Sensitive:
10 | given ConfigReader[Sensitive] = pureconfig.ConfigReader[String].map(Sensitive(_))
11 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailConfig.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email
2 |
3 | import com.softwaremill.bootzooka.config.Sensitive
4 | import pureconfig.ConfigReader
5 | import pureconfig.generic.derivation.default.*
6 |
7 | import scala.concurrent.duration.FiniteDuration
8 |
9 | case class EmailConfig(
10 | mailgun: MailgunConfig,
11 | smtp: SmtpConfig,
12 | batchSize: Int,
13 | emailSendInterval: FiniteDuration
14 | ) derives ConfigReader
15 |
16 | case class SmtpConfig(
17 | enabled: Boolean,
18 | host: String,
19 | port: Int,
20 | username: String,
21 | password: Sensitive,
22 | sslConnection: Boolean,
23 | verifySslCertificate: Boolean,
24 | from: String,
25 | encoding: String
26 | ) derives ConfigReader
27 |
28 | case class MailgunConfig(enabled: Boolean, apiKey: Sensitive, url: String, domain: String, senderName: String, senderDisplayName: String)
29 | derives ConfigReader
30 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email
2 |
3 | import com.augustnagro.magnum.{PostgresDbType, Repo, Spec, SqlNameMapper, Table}
4 | import com.softwaremill.bootzooka.infrastructure.Magnum.{*, given}
5 | import com.softwaremill.bootzooka.util.Strings.Id
6 | import ox.discard
7 |
8 | /** Model for storing and retrieving scheduled emails. */
9 | class EmailModel:
10 | private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]]
11 |
12 | def insert(email: Email)(using DbTx): Unit = emailRepo.insert(ScheduledEmails(email))
13 | def find(limit: Int)(using DbTx): Vector[Email] = emailRepo.findAll(Spec[ScheduledEmails].limit(limit)).map(_.toEmail)
14 | def count()(using DbTx): Long = emailRepo.count
15 | def delete(ids: Vector[Id[Email]])(using DbTx): Unit = emailRepo.deleteAllById(ids).discard
16 |
17 | @Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
18 | private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String):
19 | def toEmail: Email = Email(id, EmailData(recipient, EmailSubjectContent(subject, content)))
20 |
21 | private object ScheduledEmails:
22 | def apply(email: Email): ScheduledEmails = ScheduledEmails(email.id, email.data.recipient, email.data.subject, email.data.content)
23 |
24 | case class Email(id: Id[Email], data: EmailData)
25 |
26 | case class EmailData(recipient: String, subject: String, content: String)
27 | object EmailData:
28 | def apply(recipient: String, subjectContent: EmailSubjectContent): EmailData =
29 | EmailData(recipient, subjectContent.subject, subjectContent.content)
30 |
31 | case class EmailSubjectContent(subject: String, content: String)
32 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email
2 |
3 | import com.softwaremill.bootzooka.email.sender.EmailSender
4 | import com.softwaremill.bootzooka.infrastructure.DB
5 | import com.softwaremill.bootzooka.infrastructure.Magnum.*
6 | import com.softwaremill.bootzooka.logging.Logging
7 | import com.softwaremill.bootzooka.metrics.Metrics
8 | import com.softwaremill.bootzooka.util.IdGenerator
9 | import ox.{Fork, Ox, discard, forever, fork, sleep}
10 |
11 | import scala.util.control.NonFatal
12 |
13 | /** Schedules emails to be sent asynchronously, in the background, as well as manages sending of emails in batches. */
14 | class EmailService(
15 | emailModel: EmailModel,
16 | idGenerator: IdGenerator,
17 | emailSender: EmailSender,
18 | config: EmailConfig,
19 | db: DB,
20 | metrics: Metrics
21 | ) extends EmailScheduler
22 | with Logging:
23 |
24 | def schedule(data: EmailData)(using DbTx): Unit =
25 | logger.debug(s"Scheduling email to be sent to: ${data.recipient}")
26 | val id = idGenerator.nextId[Email]()
27 | emailModel.insert(Email(id, data))
28 |
29 | def sendBatch(): Unit =
30 | val emails = db.transact(emailModel.find(config.batchSize))
31 | if emails.nonEmpty then logger.info(s"Sending ${emails.size} emails")
32 | emails.map(_.data).foreach(emailSender.apply)
33 | db.transact(emailModel.delete(emails.map(_.id)))
34 |
35 | /** Starts an asynchronous process which attempts to send batches of emails in defined intervals, as well as updates a metric which holds
36 | * the size of the email queue.
37 | */
38 | def startProcesses()(using Ox): Unit =
39 | foreverPeriodically("Exception when sending emails") {
40 | sendBatch()
41 | }
42 |
43 | foreverPeriodically("Exception when counting emails") {
44 | val count = db.transact(emailModel.count())
45 | metrics.emailQueueGauge.set(count.toDouble)
46 | }.discard
47 |
48 | private def foreverPeriodically(errorMsg: String)(t: => Unit)(using Ox): Fork[Nothing] =
49 | fork {
50 | forever {
51 | sleep(config.emailSendInterval)
52 | try t
53 | catch case NonFatal(e) => logger.error(errorMsg, e)
54 | }
55 | }
56 | end EmailService
57 |
58 | trait EmailScheduler:
59 | def schedule(data: EmailData)(using DbTx): Unit
60 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailTemplateRenderer.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email
2 |
3 | import scala.io.Source
4 |
5 | object EmailTemplateRenderer:
6 | def apply(templateNameWithoutExtension: String, params: Map[String, String]): EmailSubjectContent =
7 | val template = prepareTemplate(templateNameWithoutExtension, params)
8 | addSignature(splitToContentAndSubject(template))
9 |
10 | private def prepareTemplate(templateNameWithoutExtension: String, params: Map[String, String]): String =
11 | val source = Source
12 | .fromURL(getClass.getResource(s"/templates/email/$templateNameWithoutExtension.txt"), "UTF-8")
13 |
14 | try {
15 | val rawTemplate = source.getLines().mkString("\n")
16 | params.foldLeft(rawTemplate) { case (template, (param, paramValue)) =>
17 | template.replaceAll(s"\\{\\{$param\\}\\}", paramValue.toString)
18 | }
19 | } finally source.close()
20 | end prepareTemplate
21 |
22 | private def splitToContentAndSubject(template: String): EmailSubjectContent =
23 | // First line of template is used as an email subject, rest of the template goes to content
24 | val emailLines = template.split('\n')
25 | require(
26 | emailLines.length > 1,
27 | "Invalid email template. It should consist of at least two lines: one for subject and one for content"
28 | )
29 |
30 | EmailSubjectContent(emailLines.head, emailLines.tail.mkString("\n"))
31 | end splitToContentAndSubject
32 |
33 | private lazy val signature = prepareTemplate("emailSignature", Map())
34 |
35 | private def addSignature(email: EmailSubjectContent): EmailSubjectContent =
36 | email.copy(content = s"${email.content}\n$signature")
37 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailTemplates.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email
2 |
3 | class EmailTemplates:
4 | def registrationConfirmation(userName: String): EmailSubjectContent =
5 | EmailTemplateRenderer("registrationConfirmation", Map("userName" -> userName))
6 |
7 | def passwordReset(userName: String, resetLink: String): EmailSubjectContent =
8 | EmailTemplateRenderer("resetPassword", Map("userName" -> userName, "resetLink" -> resetLink))
9 |
10 | def passwordChangeNotification(userName: String): EmailSubjectContent =
11 | EmailTemplateRenderer("passwordChangeNotification", Map("userName" -> userName))
12 |
13 | def profileDetailsChangeNotification(userName: String): EmailSubjectContent =
14 | EmailTemplateRenderer("profileDetailsChangeNotification", Map("userName" -> userName))
15 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email.sender
2 |
3 | import com.softwaremill.bootzooka.email.EmailData
4 | import com.softwaremill.bootzooka.logging.Logging
5 |
6 | import java.util.concurrent.{BlockingQueue, LinkedBlockingQueue}
7 | import scala.jdk.CollectionConverters.*
8 |
9 | object DummyEmailSender extends EmailSender with Logging:
10 | private val sentEmails: BlockingQueue[EmailData] = new LinkedBlockingQueue[EmailData]()
11 |
12 | def reset(): Unit = sentEmails.clear()
13 |
14 | override def apply(email: EmailData): Unit =
15 | sentEmails.put(email)
16 | logger.info(s"Would send email, if this wasn't a dummy email service implementation: $email")
17 |
18 | def findSentEmail(recipient: String, subjectContains: String): Option[EmailData] =
19 | sentEmails.iterator().asScala.find(email => email.recipient == recipient && email.subject.contains(subjectContains))
20 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email.sender
2 |
3 | import com.softwaremill.bootzooka.email.{EmailConfig, EmailData}
4 | import sttp.client4.SyncBackend
5 |
6 | trait EmailSender:
7 | def apply(email: EmailData): Unit
8 |
9 | object EmailSender:
10 | def create(sttpBackend: SyncBackend, config: EmailConfig): EmailSender = if config.mailgun.enabled then
11 | new MailgunEmailSender(config.mailgun, sttpBackend)
12 | else if config.smtp.enabled then new SmtpEmailSender(config.smtp)
13 | else DummyEmailSender
14 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/MailgunEmailSender.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email.sender
2 |
3 | import com.softwaremill.bootzooka.email.{EmailData, MailgunConfig}
4 | import com.softwaremill.bootzooka.logging.Logging
5 | import sttp.client4.*
6 |
7 | /** Sends emails using the [[https://www.mailgun.com Mailgun]] service. The external http call is done using
8 | * [[sttp https://github.com/softwaremill/sttp]].
9 | */
10 | class MailgunEmailSender(config: MailgunConfig, sttpBackend: SyncBackend) extends EmailSender with Logging:
11 | override def apply(email: EmailData): Unit =
12 | basicRequest.auth
13 | .basic("api", config.apiKey.value)
14 | .post(uri"${config.url}")
15 | .body(
16 | Map(
17 | "from" -> s"${config.senderDisplayName} <${config.senderName}@${config.domain}>",
18 | "to" -> email.recipient,
19 | "subject" -> email.subject,
20 | "html" -> email.content
21 | )
22 | )
23 | .send(sttpBackend)
24 | logger.debug(s"Email to: ${email.recipient} sent")
25 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.http
2 |
3 | import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec
4 | import com.softwaremill.bootzooka.*
5 | import com.softwaremill.bootzooka.logging.Logging
6 | import com.softwaremill.bootzooka.util.Strings.{Id, asId}
7 | import sttp.model.StatusCode
8 | import sttp.tapir.json.jsoniter.TapirJsonJsoniter
9 | import sttp.tapir.{Codec, Endpoint, EndpointOutput, PublicEndpoint, Schema, SchemaType, Tapir}
10 |
11 | /** Helper object for defining HTTP endpoints. Import as `Http.*` to gain access to Tapir's API and customizations. */
12 | object Http extends Tapir with TapirJsonJsoniter with Logging:
13 | private val internalServerError = (StatusCode.InternalServerError, "Internal server error")
14 | private val failToResponseData: Fail => (StatusCode, String) = {
15 | case Fail.NotFound(what) => (StatusCode.NotFound, what)
16 | case Fail.Conflict(msg) => (StatusCode.Conflict, msg)
17 | case Fail.IncorrectInput(msg) => (StatusCode.BadRequest, msg)
18 | case Fail.Forbidden => (StatusCode.Forbidden, "Forbidden")
19 | case Fail.Unauthorized(msg) => (StatusCode.Unauthorized, msg)
20 | case _ => internalServerError
21 | }
22 |
23 | //
24 |
25 | val jsonErrorOutOutput: EndpointOutput[Error_OUT] = jsonBody[Error_OUT]
26 |
27 | /** Description of the output, that is used to represent an error that occurred during endpoint invocation. */
28 | private val failOutput: EndpointOutput[Fail] =
29 | statusCode
30 | .and(jsonErrorOutOutput.map(_.error)(Error_OUT.apply))
31 | // we're not interpreting the endpoints as clients, so we don't need the mapping in one direction
32 | .map((_, _) => throw new UnsupportedOperationException())(failToResponseData)
33 |
34 | /** Base endpoint description for non-secured endpoints. Specifies that errors are always returned as JSON values corresponding to the
35 | * [[Error_OUT]] class, translated from a [[Fail]] instance.
36 | */
37 | val baseEndpoint: PublicEndpoint[Unit, Fail, Unit, Any] =
38 | endpoint
39 | .errorOut(failOutput)
40 | // Prevent clickjacking attacks: https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html
41 | .out(header("X-Frame-Options", "DENY"))
42 | .out(header("Content-Security-Policy", "frame-ancestors 'none'"))
43 |
44 | /** Base endpoint description for secured endpoints. Specifies that errors are always returned as JSON values corresponding to the
45 | * [[Error_OUT]] class, translated from a [[Fail]] instance, and that authentication is read from the `Authorization: Bearer` header.
46 | */
47 | def secureEndpoint[T]: Endpoint[Id[T], Unit, Fail, Unit, Any] =
48 | baseEndpoint.securityIn(auth.bearer[String]().map(_.asId[T])(_.toString))
49 | end Http
50 |
51 | case class Error_OUT(error: String) derives ConfiguredJsonValueCodec, Schema
52 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/http/HttpConfig.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.http
2 |
3 | import pureconfig.ConfigReader
4 | import pureconfig.generic.derivation.default.*
5 |
6 | case class HttpConfig(host: String, port: Int) derives ConfigReader
7 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/http/package.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.http
2 |
3 | import sttp.shared.Identity
4 | import sttp.tapir.AnyEndpoint
5 | import sttp.tapir.server.ServerEndpoint
6 |
7 | trait EndpointsForDocs:
8 | /** The list of endpoints which should appear in the generated OpenAPI description (used for docs and to generate frontend code).
9 | *
10 | * Usually, each endpoint should have the same [[sttp.tapir.Endpoint.tag]], and corresponds to exactly one endpoint defined in
11 | * [[ServerEndpoints.endpoints]].
12 | */
13 | def endpointsForDocs: List[AnyEndpoint]
14 |
15 | trait ServerEndpoints:
16 | /** The list of server endpoints which should be exposed by the HTTP server. */
17 | def endpoints: List[ServerEndpoint[Any, Identity]]
18 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.infrastructure
2 |
3 | import java.net.URI
4 | import org.flywaydb.core.Flyway
5 |
6 | import scala.concurrent.duration.*
7 | import Magnum.*
8 | import com.augustnagro.magnum.{SqlLogger, Transactor, connect}
9 | import com.softwaremill.bootzooka.config.Sensitive
10 | import com.softwaremill.bootzooka.infrastructure.DB.LeftException
11 | import com.softwaremill.bootzooka.logging.Logging
12 | import com.zaxxer.hikari.{HikariConfig, HikariDataSource}
13 | import ox.{discard, sleep}
14 |
15 | import java.io.Closeable
16 | import javax.sql.DataSource
17 | import scala.annotation.tailrec
18 | import scala.util.NotGiven
19 | import scala.util.control.{NoStackTrace, NonFatal}
20 |
21 | class DB(dataSource: DataSource & Closeable) extends Logging with AutoCloseable:
22 | private val transactor = Transactor(
23 | dataSource = dataSource,
24 | sqlLogger = SqlLogger.logSlowQueries(200.millis)
25 | )
26 |
27 | /** Runs `f` in a transaction. The transaction is commited if the result is a [[Right]], and rolled back otherwise. */
28 | def transactEither[E, T](f: DbTx ?=> Either[E, T]): Either[E, T] =
29 | try com.augustnagro.magnum.transact(transactor)(Right(f.fold(e => throw LeftException(e), identity)))
30 | catch case e: LeftException[E] @unchecked => Left(e.left)
31 |
32 | /** Runs `f` in a transaction. The result cannot be an `Either`, as then [[transactEither]] should be used. The transaction is commited if
33 | * no exception is thrown.
34 | */
35 | def transact[T](f: DbTx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T =
36 | com.augustnagro.magnum.transact(transactor)(f)
37 |
38 | override def close(): Unit = dataSource.close()
39 |
40 | object DB extends Logging:
41 | private class LeftException[E](val left: E) extends RuntimeException with NoStackTrace
42 |
43 | /** Configures the database, setting up the connection pool and performing migrations. */
44 | def createTestMigrate(_config: DBConfig): DB =
45 | val config: DBConfig =
46 | if (_config.url.startsWith("postgres://")) {
47 | val dbUri = URI.create(_config.url)
48 | val usernamePassword = dbUri.getUserInfo.split(":")
49 | _config.copy(
50 | username = usernamePassword(0),
51 | password = Sensitive(if (usernamePassword.length > 1) usernamePassword(1) else ""),
52 | url = "jdbc:postgresql://" + dbUri.getHost + ':' + dbUri.getPort + dbUri.getPath
53 | )
54 | } else _config
55 | end config
56 |
57 | val hikariConfig = new HikariConfig()
58 | hikariConfig.setJdbcUrl(_config.url)
59 | hikariConfig.setUsername(config.username)
60 | hikariConfig.setPassword(config.password.value)
61 | hikariConfig.addDataSourceProperty("cachePrepStmts", "true")
62 | hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250")
63 | hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048")
64 | hikariConfig.setThreadFactory(Thread.ofVirtual().factory())
65 |
66 | val flyway = Flyway
67 | .configure()
68 | .dataSource(config.url, config.username, config.password.value)
69 | .load()
70 |
71 | def migrate(): Unit = if config.migrateOnStart then flyway.migrate().discard
72 | def testConnection(ds: DataSource): Unit = connect(ds)(sql"SELECT 1".query[Int].run()).discard
73 |
74 | @tailrec
75 | def connectAndMigrate(ds: DataSource): Unit =
76 | try
77 | migrate()
78 | testConnection(ds)
79 | logger.info("Database migration & connection test complete")
80 | catch
81 | case NonFatal(e) =>
82 | logger.warn("Database not available, waiting 5 seconds to retry...", e)
83 | sleep(5.seconds)
84 | connectAndMigrate(ds)
85 |
86 | val ds = new HikariDataSource(hikariConfig)
87 | connectAndMigrate(ds)
88 | DB(ds)
89 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.infrastructure
2 |
3 | import com.softwaremill.bootzooka.config.Sensitive
4 | import pureconfig.ConfigReader
5 | import pureconfig.generic.derivation.default.*
6 |
7 | case class DBConfig(username: String, password: Sensitive, url: String, migrateOnStart: Boolean, driver: String) derives ConfigReader
8 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.infrastructure
2 |
3 | import com.augustnagro.magnum.{DbCodec, Frag}
4 | import com.softwaremill.bootzooka.logging.Logging
5 | import com.softwaremill.bootzooka.util.Strings.*
6 |
7 | import java.time.{Instant, OffsetDateTime, ZoneOffset}
8 |
9 | /** Import the members of this object when defining SQL queries using Magnum. */
10 | object Magnum extends Logging:
11 | given DbCodec[Instant] = summon[DbCodec[OffsetDateTime]].biMap(_.toInstant, _.atOffset(ZoneOffset.UTC))
12 |
13 | given idCodec[T]: DbCodec[Id[T]] = DbCodec.StringCodec.biMap(_.asId[T], _.toString)
14 | given DbCodec[Hashed] = DbCodec.StringCodec.biMap(_.asHashed, _.toString)
15 | given DbCodec[LowerCased] = DbCodec.StringCodec.biMap(_.toLowerCased, _.toString)
16 |
17 | // proxies to the magnum functions/types, so that we can have only one import
18 | export com.augustnagro.magnum.{sql, DbTx, DbCon, DbCodec}
19 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/SetTraceIdInMDCInterceptor.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.infrastructure
2 |
3 | import com.softwaremill.bootzooka.logging.Logging
4 | import io.opentelemetry.api.trace.Span
5 | import ox.logback.InheritableMDC
6 | import sttp.shared.Identity
7 | import sttp.tapir.server.interceptor.{EndpointInterceptor, RequestHandler, RequestInterceptor, Responder}
8 |
9 | /** A Tapir interceptor, which sets the current trace id in the MDC, so that the logs that are printed to the console can be easily
10 | * correlated as well. This interceptor should come after the OpenTelemetry tracing interceptor.
11 | */
12 | object SetTraceIdInMDCInterceptor extends RequestInterceptor[Identity] with Logging:
13 | val MDCKey = "traceId"
14 |
15 | override def apply[R, B](
16 | responder: Responder[Identity, B],
17 | requestHandler: EndpointInterceptor[Identity] => RequestHandler[Identity, R, B]
18 | ): RequestHandler[Identity, R, B] =
19 | RequestHandler.from { case (request, endpoints, monad) =>
20 | val traceId = Span.current().getSpanContext().getTraceId()
21 | InheritableMDC.unsupervisedWhere(MDCKey -> traceId) {
22 | requestHandler(EndpointInterceptor.noop)(request, endpoints)(monad)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/logging/Logging.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.logging
2 |
3 | import org.slf4j.{Logger, LoggerFactory}
4 |
5 | trait Logging:
6 | protected val logger: Logger = LoggerFactory.getLogger(getClass.getName)
7 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/metrics/Metrics.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.metrics
2 |
3 | import io.opentelemetry.api.OpenTelemetry
4 | import io.opentelemetry.api.metrics.{DoubleGauge, LongCounter}
5 |
6 | class Metrics(otel: OpenTelemetry):
7 | private val meter = otel.meterBuilder("bootzooka").setInstrumentationVersion("1.0").build()
8 |
9 | lazy val registeredUsersCounter: LongCounter =
10 | meter
11 | .counterBuilder("bootzooka_registered_users_counter")
12 | .setDescription("How many users registered on this instance since it was started")
13 | .build()
14 |
15 | lazy val emailQueueGauge: DoubleGauge =
16 | meter
17 | .gaugeBuilder("bootzooka_email_queue_gauge")
18 | .setDescription("How many emails are waiting to be sent")
19 | .build()
20 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.passwordreset
2 |
3 | import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec
4 | import com.softwaremill.bootzooka.http.Http.*
5 | import com.softwaremill.bootzooka.infrastructure.DB
6 | import com.softwaremill.bootzooka.http.{EndpointsForDocs, ServerEndpoints}
7 | import sttp.tapir.Schema
8 |
9 | class PasswordResetApi(passwordResetService: PasswordResetService, db: DB) extends ServerEndpoints:
10 | import PasswordResetApi._
11 |
12 | private val passwordResetServerEndpoint = passwordResetEndpoint.handle { data =>
13 | passwordResetService.resetPassword(data.code, data.password).map(_ => PasswordReset_OUT())
14 | }
15 |
16 | private val forgotPasswordServerEndpoint = forgotPasswordEndpoint.handleSuccess { data =>
17 | db.transact(passwordResetService.forgotPassword(data.loginOrEmail))
18 | ForgotPassword_OUT()
19 | }
20 |
21 | override val endpoints = List(
22 | passwordResetServerEndpoint,
23 | forgotPasswordServerEndpoint
24 | )
25 |
26 | object PasswordResetApi extends EndpointsForDocs:
27 | private val PasswordResetPath = "passwordreset"
28 |
29 | private val passwordResetEndpoint = baseEndpoint.post
30 | .in(PasswordResetPath / "reset")
31 | .in(jsonBody[PasswordReset_IN])
32 | .out(jsonBody[PasswordReset_OUT])
33 |
34 | private val forgotPasswordEndpoint = baseEndpoint.post
35 | .in(PasswordResetPath / "forgot")
36 | .in(jsonBody[ForgotPassword_IN])
37 | .out(jsonBody[ForgotPassword_OUT])
38 |
39 | override val endpointsForDocs = List(
40 | passwordResetEndpoint,
41 | forgotPasswordEndpoint
42 | ).map(_.tag("passwordreset"))
43 |
44 | //
45 |
46 | case class PasswordReset_IN(code: String, password: String) derives ConfiguredJsonValueCodec, Schema
47 | case class PasswordReset_OUT() derives ConfiguredJsonValueCodec, Schema
48 |
49 | case class ForgotPassword_IN(loginOrEmail: String) derives ConfiguredJsonValueCodec, Schema
50 | case class ForgotPassword_OUT() derives ConfiguredJsonValueCodec, Schema
51 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.passwordreset
2 |
3 | import com.augustnagro.magnum.{PostgresDbType, Repo, SqlName, SqlNameMapper, Table}
4 | import com.softwaremill.bootzooka.infrastructure.Magnum.{*, given}
5 | import com.softwaremill.bootzooka.security.AuthTokenOps
6 | import com.softwaremill.bootzooka.user.User
7 | import com.softwaremill.bootzooka.util.Strings.Id
8 |
9 | import java.time.Instant
10 |
11 | class PasswordResetCodeModel:
12 | private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]]
13 |
14 | def insert(pr: PasswordResetCode)(using DbTx): Unit = passwordResetCodeRepo.insert(pr)
15 | def delete(id: Id[PasswordResetCode])(using DbTx): Unit = passwordResetCodeRepo.deleteById(id)
16 | def findById(id: Id[PasswordResetCode])(using DbTx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id)
17 |
18 | @Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
19 | @SqlName("password_reset_codes")
20 | case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant)
21 |
22 | class PasswordResetAuthToken(passwordResetCodeModel: PasswordResetCodeModel) extends AuthTokenOps[PasswordResetCode]:
23 | override def tokenName: String = "PasswordResetCode"
24 | override def findById: DbTx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById
25 | override def delete: DbTx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id)
26 | override def userId: PasswordResetCode => Id[User] = _.userId
27 | override def validUntil: PasswordResetCode => Instant = _.validUntil
28 | // password reset code is a one-time token
29 | override def deleteWhenValid: Boolean = true
30 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetConfig.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.passwordreset
2 |
3 | import pureconfig.ConfigReader
4 | import pureconfig.generic.derivation.default.*
5 |
6 | import scala.concurrent.duration.Duration
7 |
8 | case class PasswordResetConfig(resetLinkPattern: String, codeValid: Duration) derives ConfigReader:
9 | validate()
10 |
11 | def validate(): Unit =
12 | val testCode = "TEST_123"
13 | assert(
14 | String.format(resetLinkPattern, testCode).contains(testCode),
15 | s"Invalid reset link pattern: $resetLinkPattern. Formatting with a test code didn't contain the code in the result."
16 | )
17 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.passwordreset
2 |
3 | import com.softwaremill.bootzooka.Fail
4 | import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailSubjectContent, EmailTemplates}
5 | import com.softwaremill.bootzooka.infrastructure.DB
6 | import com.softwaremill.bootzooka.infrastructure.Magnum.*
7 | import com.softwaremill.bootzooka.logging.Logging
8 | import com.softwaremill.bootzooka.security.Auth
9 | import com.softwaremill.bootzooka.user.{User, UserModel}
10 | import com.softwaremill.bootzooka.util.*
11 | import com.softwaremill.bootzooka.util.Strings.{Id, asId, toLowerCased}
12 | import ox.either
13 | import ox.either.*
14 |
15 | class PasswordResetService(
16 | userModel: UserModel,
17 | passwordResetCodeModel: PasswordResetCodeModel,
18 | emailScheduler: EmailScheduler,
19 | emailTemplates: EmailTemplates,
20 | auth: Auth[PasswordResetCode],
21 | idGenerator: IdGenerator,
22 | config: PasswordResetConfig,
23 | clock: Clock,
24 | db: DB
25 | ) extends Logging:
26 | def forgotPassword(loginOrEmail: String)(using DbTx): Unit =
27 | userModel.findByLoginOrEmail(loginOrEmail.toLowerCased) match {
28 | case None => logger.debug(s"Could not find user with $loginOrEmail login/email")
29 | case Some(user) =>
30 | val pcr = createCode(user)
31 | sendCode(user, pcr)
32 | }
33 |
34 | private def createCode(user: User)(using DbTx): PasswordResetCode =
35 | logger.debug(s"Creating password reset code for user: ${user.id}")
36 | val id = idGenerator.nextId[PasswordResetCode]()
37 | val validUntil = clock.now().plusMillis(config.codeValid.toMillis)
38 | val passwordResetCode = PasswordResetCode(id, user.id, validUntil)
39 | passwordResetCodeModel.insert(passwordResetCode)
40 | passwordResetCode
41 |
42 | private def sendCode(user: User, code: PasswordResetCode)(using DbTx): Unit =
43 | logger.debug(s"Scheduling e-mail with reset code for user: ${user.id}")
44 | emailScheduler.schedule(EmailData(user.emailLowerCase, prepareResetEmail(user, code)))
45 |
46 | private def prepareResetEmail(user: User, code: PasswordResetCode): EmailSubjectContent =
47 | val resetLink = String.format(config.resetLinkPattern, code.id)
48 | emailTemplates.passwordReset(user.login, resetLink)
49 |
50 | def resetPassword(code: String, newPassword: String): Either[Fail, Unit] = either {
51 | val userId = auth(code.asId[PasswordResetCode]).ok()
52 | logger.debug(s"Resetting password for user: $userId")
53 | db.transact(userModel.updatePassword(userId, User.hashPassword(newPassword)))
54 | }
55 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.security
2 |
3 | import com.augustnagro.magnum.{PostgresDbType, Repo, SqlName, SqlNameMapper, Table, TableInfo}
4 | import com.softwaremill.bootzooka.infrastructure.Magnum.{*, given}
5 | import com.softwaremill.bootzooka.user.User
6 | import com.softwaremill.bootzooka.util.Strings.Id
7 | import ox.discard
8 |
9 | import java.time.Instant
10 |
11 | class ApiKeyModel:
12 | private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]]
13 | private val a = TableInfo[ApiKey, ApiKey, Id[ApiKey]]
14 |
15 | def insert(apiKey: ApiKey)(using DbTx): Unit = apiKeyRepo.insert(apiKey)
16 | def findById(id: Id[ApiKey])(using DbTx): Option[ApiKey] = apiKeyRepo.findById(id)
17 | def deleteAllForUser(id: Id[User])(using DbTx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard
18 | def delete(id: Id[ApiKey])(using DbTx): Unit = apiKeyRepo.deleteById(id)
19 |
20 | @Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
21 | @SqlName("api_keys")
22 | case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant)
23 |
24 | class ApiKeyAuthToken(apiKeyModel: ApiKeyModel) extends AuthTokenOps[ApiKey]:
25 | override def tokenName: String = "ApiKey"
26 | override def findById: DbTx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById
27 | override def delete: DbTx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id)
28 | override def userId: ApiKey => Id[User] = _.userId
29 | override def validUntil: ApiKey => Instant = _.validUntil
30 | override def deleteWhenValid: Boolean = false
31 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.security
2 |
3 | import com.softwaremill.bootzooka.infrastructure.Magnum.DbTx
4 | import com.softwaremill.bootzooka.logging.Logging
5 | import com.softwaremill.bootzooka.user.User
6 | import com.softwaremill.bootzooka.util.Strings.Id
7 | import com.softwaremill.bootzooka.util.{Clock, IdGenerator}
8 |
9 | import java.time.temporal.ChronoUnit
10 | import scala.concurrent.duration.Duration
11 |
12 | class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: Clock) extends Logging:
13 | def create(userId: Id[User], valid: Duration)(using DbTx): ApiKey =
14 | val id = idGenerator.nextId[ApiKey]()
15 | val now = clock.now()
16 | val validUntil = now.plus(valid.toMillis, ChronoUnit.MILLIS)
17 | val apiKey = ApiKey(id, userId, now, validUntil)
18 | logger.debug(s"Creating a new api key for user $userId, valid until: $validUntil")
19 | apiKeyModel.insert(apiKey)
20 | apiKey
21 |
22 | def invalidate(id: Id[ApiKey])(using DbTx): Unit =
23 | logger.debug(s"Invalidating api key $id")
24 | apiKeyModel.delete(id)
25 |
26 | def invalidateAllForUser(userId: Id[User])(using DbTx): Unit =
27 | logger.debug(s"Invalidating all api keys for user $userId")
28 | apiKeyModel.deleteAllForUser(userId)
29 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.security
2 |
3 | import com.softwaremill.bootzooka.*
4 | import com.softwaremill.bootzooka.infrastructure.DB
5 | import com.softwaremill.bootzooka.infrastructure.Magnum.*
6 | import com.softwaremill.bootzooka.logging.Logging
7 | import com.softwaremill.bootzooka.user.User
8 | import com.softwaremill.bootzooka.util.*
9 | import com.softwaremill.bootzooka.util.Strings.Id
10 | import ox.sleep
11 | import ox.either.{fail, ok}
12 |
13 | import java.security.SecureRandom
14 | import java.time.Instant
15 | import scala.concurrent.duration.*
16 |
17 | class Auth[T](authTokenOps: AuthTokenOps[T], db: DB, clock: Clock) extends Logging:
18 |
19 | // see https://hackernoon.com/hack-how-to-use-securerandom-with-kubernetes-and-docker-a375945a7b21
20 | private val random = SecureRandom.getInstance("NativePRNGNonBlocking")
21 |
22 | /** Authenticates using the given authentication token. If the token is invalid, a [[Fail.Unauthorized]] error is returned. Otherwise,
23 | * returns the id of the authenticated user .
24 | */
25 | def apply(id: Id[T]): Either[Fail.Unauthorized, Id[User]] =
26 | db.transact(authTokenOps.findById(id)) match {
27 | case None =>
28 | logger.debug(s"Auth failed for: ${authTokenOps.tokenName} $id")
29 | // random sleep to prevent timing attacks
30 | sleep(random.nextInt(1000).millis)
31 | Left(Fail.Unauthorized("Unauthorized"))
32 | case Some(token) if expired(token) =>
33 | logger.info(s"${authTokenOps.tokenName} expired: $token")
34 | db.transact(authTokenOps.delete(token))
35 | Left(Fail.Unauthorized("Unauthorized"))
36 | case Some(token) =>
37 | if (authTokenOps.deleteWhenValid) db.transact(authTokenOps.delete(token))
38 | Right(authTokenOps.userId(token))
39 | }
40 |
41 | private def expired(token: T): Boolean = clock.now().isAfter(authTokenOps.validUntil(token))
42 |
43 | /** A set of operations on an authentication token, which are performed during authentication. Supports both one-time tokens (when
44 | * `deleteWhenValid=true`) and multi-use tokens.
45 | */
46 | trait AuthTokenOps[T]:
47 | def tokenName: String
48 | def findById: DbTx ?=> Id[T] => Option[T]
49 | def delete: DbTx ?=> T => Unit
50 | def userId: T => Id[User]
51 | def validUntil: T => Instant
52 | def deleteWhenValid: Boolean
53 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/user/UserConfig.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.user
2 |
3 | import pureconfig.ConfigReader
4 | import pureconfig.generic.derivation.default.*
5 |
6 | import scala.concurrent.duration.Duration
7 |
8 | case class UserConfig(defaultApiKeyValid: Duration) derives ConfigReader
9 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.user
2 |
3 | import com.augustnagro.magnum.{Frag, PostgresDbType, Repo, Spec, SqlName, SqlNameMapper, Table, TableInfo}
4 | import com.password4j.{Argon2Function, Password}
5 | import com.softwaremill.bootzooka.infrastructure.Magnum.{*, given}
6 | import com.softwaremill.bootzooka.user.User.PasswordHashing
7 | import com.softwaremill.bootzooka.user.User.PasswordHashing.Argon2Config.*
8 | import com.softwaremill.bootzooka.util.PasswordVerificationStatus
9 | import com.softwaremill.bootzooka.util.Strings.{Hashed, Id, LowerCased, asHashed}
10 | import ox.discard
11 |
12 | import java.time.Instant
13 |
14 | class UserModel:
15 | private val userRepo = Repo[User, User, Id[User]]
16 | private val u = TableInfo[User, User, Id[User]]
17 |
18 | export userRepo.{insert, findById}
19 |
20 | def findByEmail(email: LowerCased)(using DbTx): Option[User] = findBy(
21 | Spec[User].where(sql"${u.emailLowerCase} = $email")
22 | )
23 | def findByLogin(login: LowerCased)(using DbTx): Option[User] = findBy(
24 | Spec[User].where(sql"${u.loginLowerCase} = $login")
25 | )
26 | def findByLoginOrEmail(loginOrEmail: LowerCased)(using DbTx): Option[User] =
27 | findBy(Spec[User].where(sql"${u.loginLowerCase} = ${loginOrEmail: String} OR ${u.emailLowerCase} = $loginOrEmail"))
28 |
29 | private def findBy(by: Spec[User])(using DbTx): Option[User] =
30 | userRepo.findAll(by).headOption
31 |
32 | def updatePassword(userId: Id[User], newPassword: Hashed)(using DbTx): Unit =
33 | sql"""UPDATE $u SET ${u.passwordHash} = $newPassword WHERE ${u.id} = $userId""".update.run().discard
34 |
35 | def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using DbTx): Unit =
36 | sql"""UPDATE $u SET ${u.login} = $newLogin, login_lowercase = ${newLoginLowerCase: String} WHERE ${u.id} = $userId""".update
37 | .run()
38 | .discard
39 |
40 | def updateEmail(userId: Id[User], newEmail: LowerCased)(using DbTx): Unit =
41 | sql"""UPDATE $u SET ${u.emailLowerCase} = $newEmail WHERE ${u.id} = $userId""".update.run().discard
42 |
43 | end UserModel
44 |
45 | @Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
46 | @SqlName("users")
47 | case class User(
48 | id: Id[User],
49 | login: String,
50 | @SqlName("login_lowercase") loginLowerCase: LowerCased,
51 | @SqlName("email_lowercase") emailLowerCase: LowerCased,
52 | @SqlName("password") passwordHash: Hashed,
53 | createdOn: Instant
54 | ):
55 | def verifyPassword(password: String): PasswordVerificationStatus =
56 | if (Password.check(password, passwordHash) `with` PasswordHashing.Argon2) PasswordVerificationStatus.Verified
57 | else PasswordVerificationStatus.VerificationFailed
58 | end User
59 |
60 | object User:
61 | object PasswordHashing:
62 | val Argon2: Argon2Function =
63 | Argon2Function.getInstance(MemoryInKib, NumberOfIterations, LevelOfParallelism, LengthOfTheFinalHash, Type, Version)
64 |
65 | object Argon2Config:
66 | val MemoryInKib = 12
67 | val NumberOfIterations = 20
68 | val LevelOfParallelism = 2
69 | val LengthOfTheFinalHash = 32
70 | val Type = com.password4j.types.Argon2.ID
71 | val Version = 19
72 | end PasswordHashing
73 |
74 | def hashPassword(password: String): Hashed = Password.hash(password).`with`(PasswordHashing.Argon2).getResult.asHashed
75 | end User
76 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/util/Clock.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.util
2 |
3 | import java.time.Instant
4 |
5 | trait Clock:
6 | def now(): Instant
7 |
8 | object DefaultClock extends Clock:
9 | override def now(): Instant = java.time.Clock.systemUTC().instant()
10 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/util/IdGenerator.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.util
2 |
3 | import com.softwaremill.bootzooka.util.Strings.{asId, Id}
4 |
5 | trait IdGenerator:
6 | def nextId[U](): Id[U]
7 |
8 | object DefaultIdGenerator extends IdGenerator:
9 | override def nextId[U](): Id[U] = SecureRandomIdGenerator.Strong.generate.asId[U]
10 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/util/PasswordVerificationStatus.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.util
2 |
3 | enum PasswordVerificationStatus:
4 | case Verified, VerificationFailed
5 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/util/SecureRandomIdGenerator.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.util
2 |
3 | import java.security.SecureRandom
4 |
5 | /** based on TSec https://github.com/jmcardon/tsec */
6 |
7 | case class SecureRandomIdGenerator(sizeInBytes: Int):
8 | /** Cache our random, and seed it properly as per [[https://tersesystems.com/2015/12/17/the-right-way-to-use-securerandom/]] */
9 | private val cachedRand: SecureRandom =
10 | val r = SecureRandom.getInstance(if (scala.util.Properties.isWin) "Windows-PRNG" else "NativePRNGNonBlocking")
11 | r.nextBytes(new Array[Byte](20)) // Force reseed
12 | r
13 |
14 | private def nextBytes(bytes: Array[Byte]): Unit = cachedRand.nextBytes(bytes)
15 |
16 | private def toHexString(byteArray: Array[Byte]) = byteArray.map(b => String.format("%02x", b)).mkString
17 |
18 | def generate: String =
19 | val byteArray = new Array[Byte](sizeInBytes)
20 | nextBytes(byteArray)
21 | toHexString(byteArray)
22 |
23 | object SecureRandomIdGenerator:
24 | lazy val Strong: SecureRandomIdGenerator = SecureRandomIdGenerator(32)
25 | lazy val Interactive: SecureRandomIdGenerator = SecureRandomIdGenerator(16)
26 |
--------------------------------------------------------------------------------
/backend/src/main/scala/com/softwaremill/bootzooka/util/Strings.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.util
2 |
3 | import java.util.Locale
4 |
5 | object Strings:
6 | opaque type Id[T] = String
7 | opaque type LowerCased <: String = String
8 | opaque type Hashed <: String = String
9 |
10 | extension (s: String)
11 | def asId[T]: Id[T] = s
12 | def asHashed: Hashed = s
13 | def toLowerCased[T]: LowerCased = s.toLowerCase(Locale.ENGLISH)
14 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/email/EmailTemplatesTest.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email
2 |
3 | import org.scalatest.flatspec.AnyFlatSpec
4 | import org.scalatest.matchers.should.Matchers
5 |
6 | class EmailTemplatesTest extends AnyFlatSpec with Matchers:
7 | val templates = new EmailTemplates
8 |
9 | it should "generate the registration confirmation email" in {
10 | // when
11 | val email = templates.registrationConfirmation("john")
12 |
13 | // then
14 | email.subject should be("SoftwareMill Bootzooka - registration confirmation for user john")
15 | email.content should include("Dear john,")
16 | email.content should include("Regards,")
17 | }
18 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.email.sender
2 |
3 | import com.softwaremill.bootzooka.email.EmailData
4 | import com.softwaremill.bootzooka.test.BaseTest
5 |
6 | class DummyEmailSenderTest extends BaseTest:
7 | it should "send scheduled email" in {
8 | DummyEmailSender(EmailData("test@sml.com", "subject", "content"))
9 | DummyEmailSender.findSentEmail("test@sml.com", "subject").isDefined shouldBe true
10 | }
11 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/test/BaseTest.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.test
2 |
3 | import org.scalatest.flatspec.AnyFlatSpec
4 | import org.scalatest.matchers.should.Matchers
5 | import ox.logback.InheritableMDC
6 |
7 | trait BaseTest extends AnyFlatSpec with Matchers:
8 | InheritableMDC.init
9 | val testClock = new TestClock()
10 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/test/RegisteredUser.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.test
2 |
3 | case class RegisteredUser(login: String, email: String, password: String, apiKey: String)
4 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/test/Requests.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.test
2 |
3 | import com.github.plokhotnyuk.jsoniter_scala.core.writeToString
4 | import com.softwaremill.bootzooka.passwordreset.PasswordResetApi.{ForgotPassword_IN, PasswordReset_IN}
5 | import com.softwaremill.bootzooka.user.UserApi.*
6 | import sttp.client3.{Response, SttpBackend, UriContext, basicRequest}
7 | import sttp.shared.Identity
8 |
9 | import scala.util.Random
10 |
11 | class Requests(backend: SttpBackend[Identity, Any]) extends TestSupport:
12 | private val random = new Random()
13 |
14 | def randomLoginEmailPassword(): (String, String, String) =
15 | (random.nextString(12), s"user${random.nextInt(9000)}@bootzooka.com", random.nextString(12))
16 |
17 | private val basePath = "http://localhost:8080/api/v1"
18 |
19 | def registerUser(login: String, email: String, password: String): Response[Either[String, String]] =
20 | basicRequest
21 | .post(uri"$basePath/user/register")
22 | .body(writeToString(Register_IN(login, email, password)))
23 | .send(backend)
24 |
25 | def newRegisteredUsed(): RegisteredUser =
26 | val (login, email, password) = randomLoginEmailPassword()
27 | val apiKey = registerUser(login, email, password).body.shouldDeserializeTo[Register_OUT].apiKey
28 | RegisteredUser(login, email, password, apiKey)
29 |
30 | def loginUser(loginOrEmail: String, password: String, apiKeyValidHours: Option[Int] = None): Response[Either[String, String]] =
31 | basicRequest
32 | .post(uri"$basePath/user/login")
33 | .body(writeToString(Login_IN(loginOrEmail, password, apiKeyValidHours)))
34 | .send(backend)
35 |
36 | def logoutUser(apiKey: String): Response[Either[String, String]] =
37 | basicRequest
38 | .post(uri"$basePath/user/logout")
39 | .body(writeToString(Logout_IN(apiKey)))
40 | .header("Authorization", s"Bearer $apiKey")
41 | .send(backend)
42 |
43 | def getUser(apiKey: String): Response[Either[String, String]] =
44 | basicRequest
45 | .get(uri"$basePath/user")
46 | .header("Authorization", s"Bearer $apiKey")
47 | .send(backend)
48 |
49 | def changePassword(apiKey: String, password: String, newPassword: String): Response[Either[String, String]] =
50 | basicRequest
51 | .post(uri"$basePath/user/changepassword")
52 | .body(writeToString(ChangePassword_IN(password, newPassword)))
53 | .header("Authorization", s"Bearer $apiKey")
54 | .send(backend)
55 |
56 | def updateUser(apiKey: String, login: String, email: String): Response[Either[String, String]] =
57 | basicRequest
58 | .post(uri"$basePath/user")
59 | .body(writeToString(UpdateUser_IN(login, email)))
60 | .header("Authorization", s"Bearer $apiKey")
61 | .send(backend)
62 |
63 | def forgotPassword(loginOrEmail: String): Response[Either[String, String]] =
64 | basicRequest
65 | .post(uri"$basePath/passwordreset/forgot")
66 | .body(writeToString(ForgotPassword_IN(loginOrEmail)))
67 | .send(backend)
68 |
69 | def resetPassword(code: String, password: String): Response[Either[String, String]] =
70 | basicRequest
71 | .post(uri"$basePath/passwordreset/reset")
72 | .body(writeToString(PasswordReset_IN(code, password)))
73 | .send(backend)
74 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/test/TestClock.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.test
2 |
3 | import com.softwaremill.bootzooka.logging.Logging
4 |
5 | import java.time.Instant
6 | import java.time.temporal.ChronoUnit
7 | import java.util.concurrent.atomic.AtomicReference
8 | import com.softwaremill.bootzooka.util.Clock
9 |
10 | import scala.concurrent.duration.Duration
11 |
12 | class TestClock(nowRef: AtomicReference[Instant]) extends Clock with Logging:
13 | logger.info(s"New test clock, the time is: ${nowRef.get()}")
14 |
15 | def this(now: Instant) = this(new AtomicReference(now))
16 |
17 | def this() = this(Instant.now())
18 |
19 | def forward(d: Duration): Unit =
20 | val newNow = nowRef.get().plus(d.toMillis, ChronoUnit.MILLIS)
21 | logger.info(s"The time is now $newNow")
22 | nowRef.set(newNow)
23 |
24 | override def now(): Instant = nowRef.get()
25 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/test/TestDependencies.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.test
2 |
3 | import com.softwaremill.bootzooka.Dependencies
4 | import io.opentelemetry.api.OpenTelemetry
5 | import org.scalatest.{BeforeAndAfterAll, Suite}
6 | import sttp.client3.SttpBackend
7 | import sttp.shared.Identity
8 | import sttp.tapir.server.stub.TapirStubInterpreter
9 |
10 | import scala.compiletime.uninitialized
11 |
12 | trait TestDependencies extends BeforeAndAfterAll with TestEmbeddedPostgres:
13 | self: Suite & BaseTest =>
14 |
15 | var dependencies: Dependencies = uninitialized
16 |
17 | override protected def beforeAll(): Unit = {
18 | super.beforeAll()
19 | dependencies =
20 | Dependencies.create(TestConfig, OpenTelemetry.noop(), sttp.client4.httpclient.HttpClientSyncBackend.stub, currentDb, testClock)
21 | }
22 |
23 | private lazy val serverStub: SttpBackend[Identity, Any] =
24 | TapirStubInterpreter[Identity, Any](sttp.client3.HttpClientSyncBackend.stub)
25 | .whenServerEndpointsRunLogic(dependencies.httpApi.allEndpoints)
26 | .backend()
27 |
28 | lazy val requests = new Requests(serverStub)
29 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.test
2 |
3 | import com.opentable.db.postgres.embedded.EmbeddedPostgres
4 | import com.softwaremill.bootzooka.config.Sensitive
5 | import com.softwaremill.bootzooka.infrastructure.{DB, DBConfig}
6 | import com.softwaremill.bootzooka.logging.Logging
7 | import org.flywaydb.core.Flyway
8 | import org.postgresql.jdbc.PgConnection
9 | import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Suite}
10 | import ox.discard
11 |
12 | import scala.compiletime.uninitialized
13 |
14 | /** Base trait for tests which use the database. The database is cleaned after each test. */
15 | trait TestEmbeddedPostgres extends BeforeAndAfterEach with BeforeAndAfterAll with Logging { self: Suite =>
16 | private var postgres: EmbeddedPostgres = uninitialized
17 | private var currentDbConfig: DBConfig = uninitialized
18 | var currentDb: DB = uninitialized
19 |
20 | //
21 |
22 | override protected def beforeAll(): Unit = {
23 | super.beforeAll()
24 | postgres = EmbeddedPostgres.builder().start()
25 | val url = postgres.getJdbcUrl("postgres")
26 | postgres.getPostgresDatabase.getConnection.asInstanceOf[PgConnection].setPrepareThreshold(100)
27 | currentDbConfig = TestConfig.db.copy(
28 | username = "postgres",
29 | password = Sensitive(""),
30 | url = url,
31 | migrateOnStart = true
32 | )
33 | currentDb = DB.createTestMigrate(currentDbConfig)
34 | }
35 |
36 | override protected def afterAll(): Unit =
37 | currentDb.close()
38 | postgres.close()
39 | super.afterAll()
40 |
41 | //
42 |
43 | override protected def beforeEach(): Unit =
44 | super.beforeEach()
45 | flyway().migrate().discard
46 |
47 | override protected def afterEach(): Unit =
48 | clean()
49 | super.afterEach()
50 |
51 | private def clean(): Unit = flyway().clean().discard
52 |
53 | private def flyway(): Flyway = Flyway
54 | .configure()
55 | .dataSource(currentDbConfig.url, currentDbConfig.username, currentDbConfig.password.value)
56 | .cleanDisabled(false)
57 | .load()
58 | }
59 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/test/TestSupport.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.test
2 |
3 | import com.github.plokhotnyuk.jsoniter_scala.core.{JsonValueCodec, readFromString}
4 | import com.softwaremill.bootzooka.http.Error_OUT
5 |
6 | trait TestSupport:
7 | extension (v: Either[String, String])
8 | def shouldDeserializeTo[T: JsonValueCodec]: T = v.map(readFromString[T](_)).fold(s => throw new IllegalArgumentException(s), identity)
9 | def shouldDeserializeToError: String = readFromString[Error_OUT](v.fold(identity, s => throw new IllegalArgumentException(s))).error
10 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/test/package.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka
2 |
3 | import com.softwaremill.bootzooka.config.Config
4 | import com.softwaremill.quicklens._
5 |
6 | import scala.concurrent.duration._
7 |
8 | package object test:
9 | val DefaultConfig: Config = Config.read
10 | val TestConfig: Config = DefaultConfig.modify(_.email.emailSendInterval).setTo(100.milliseconds)
11 |
--------------------------------------------------------------------------------
/backend/src/test/scala/com/softwaremill/bootzooka/user/UserValidatorSpec.scala:
--------------------------------------------------------------------------------
1 | package com.softwaremill.bootzooka.user
2 |
3 | import org.scalatest.flatspec.AnyFlatSpec
4 | import org.scalatest.matchers.should.Matchers
5 |
6 | class UserValidatorSpec extends AnyFlatSpec with Matchers:
7 | private def validate(userName: String, email: String, password: String) =
8 | UserValidator(Some(userName), Some(email), Some(password)).result
9 |
10 | "validate" should "accept valid data" in {
11 | val dataIsValid = validate("login", "admin@bootzooka.com", "password")
12 |
13 | dataIsValid shouldBe Right(())
14 | }
15 |
16 | it should "not accept login containing only empty spaces" in {
17 | val dataIsValid = validate(" ", "admin@bootzooka.com", "password")
18 |
19 | dataIsValid.isLeft shouldBe true
20 | }
21 |
22 | it should "not accept too short login" in {
23 | val tooShortLogin = "a" * (UserValidator.MinLoginLength - 1)
24 | val dataIsValid = validate(tooShortLogin, "admin@bootzooka.com", "password")
25 |
26 | dataIsValid.isLeft shouldBe true
27 | }
28 |
29 | it should "not accept too short login after trimming" in {
30 | val loginTooShortAfterTrim = "a" * (UserValidator.MinLoginLength - 1) + " "
31 | val dataIsValid = validate(loginTooShortAfterTrim, "admin@bootzooka.com", "password")
32 |
33 | dataIsValid.isLeft shouldBe true
34 | }
35 |
36 | it should "not accept missing email with spaces only" in {
37 | val dataIsValid = validate("login", " ", "password")
38 |
39 | dataIsValid.isLeft shouldBe true
40 | }
41 |
42 | it should "not accept invalid email" in {
43 | val dataIsValid = validate("login", "invalidEmail", "password")
44 |
45 | dataIsValid.isLeft shouldBe true
46 | }
47 |
48 | it should "not accept password with empty spaces only" in {
49 | val dataIsValid = validate("login", "admin@bootzooka.com", " ")
50 |
51 | dataIsValid.isLeft shouldBe true
52 | }
53 |
--------------------------------------------------------------------------------
/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/banner.png
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | bootzooka:
3 | image: 'softwaremill/bootzooka:latest'
4 | ports:
5 | - '8080:8080'
6 | depends_on:
7 | - bootzooka-db
8 | environment:
9 | SQL_USERNAME: 'postgres'
10 | SQL_PASSWORD: 'b00t200k4'
11 | SQL_DBNAME: 'bootzooka'
12 | SQL_HOST: 'bootzooka-db'
13 | SQL_PORT: '5432'
14 | API_HOST: '0.0.0.0'
15 | OTEL_EXPORTER_OTLP_ENDPOINT: 'http://observability:4317'
16 | OTEL_SERVICE_NAME: 'bootzooka'
17 | OTEL_METRIC_EXPORT_INTERVAL: '500' # The default is 60s, in development it's useful to see the metrics faster
18 | OTEL_RESOURCE_ATTRIBUTES: 'service.instance.id=local,service.version=latest'
19 |
20 | bootzooka-db:
21 | image: 'postgres'
22 | ports:
23 | - '25432:5432'
24 | environment:
25 | POSTGRES_USER: 'postgres'
26 | POSTGRES_PASSWORD: 'b00t200k4'
27 | POSTGRES_DB: 'bootzooka'
28 |
29 | # OpenTelemetry Collector, Prometheus, Loki, Tempo, Grafana
30 | observability:
31 | image: 'grafana/otel-lgtm'
32 | ports:
33 | - '3000:3000' # Grafana's UI
34 | - '4317:4317' # Exporter
35 |
--------------------------------------------------------------------------------
/docs/_config.yml:
--------------------------------------------------------------------------------
1 | name: Bootzooka Documentation
2 | highlighter: rouge
3 | baseurl: "/bootzooka/"
4 |
--------------------------------------------------------------------------------
/docs/_layouts/default.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ site.name }} - {{ page.title }}
7 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
17 |
42 |
43 | {{ page.title }}
44 | {{ content }}
45 |
49 |
50 |
51 |
55 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/docs/architecture.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: "High-level architecture"
4 | ---
5 |
6 | Bootzooka uses different technologies for the frontend and backend parts, taking the best of both worlds and combining in a single build. Bootzooka is structured how many modern web applications are done these days.
7 |
8 | The backend server, written using Scala, exposes a JSON API which can be consumed by any client you want. In case of Bootzooka this client is a single-page browser application built with React. Such an approach allows better scaling and independent development of the server and client parts. This separation is mirrored in how Bootzooka projects are structured.
9 |
10 | There's one sub-project for backend code and one for client-side application. They are completely unrelated in terms of code and dependencies. `ui` directory contains the browser part (JavaScript, CSS, HTML) and `backend` contains the backend application.
11 |
--------------------------------------------------------------------------------
/docs/backend.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: "Backend code structure"
4 | ---
5 |
6 | ## Backend code structure
7 |
8 | The backend code is divided into a number of packages, each implementing a different functionality/use-case.
9 |
10 | The classes in each package follow a similar pattern:
11 |
12 | * `XModel` class. Contains queries to access the model corresponding to the given functionality. The file also contains
13 | the model classes which are used in this functionality.
14 | * `XService` class. Implements the business logic of the given functionality. Uses other services and models as
15 | dependencies. May return results that either need to be run within a transaction (when there's a `using DbTx` parameter
16 | list), or returns result directly. Results might be wrapped in `Either[Fail, ...]`, when "expected" errors might occur
17 | (e.g. validation errors). A service implementation might include communicating with external services or performing
18 | whole database transactions.
19 | * `XApi` class. Defines descriptions of endpoints for the given functionality, as well as defines the server logic
20 | for each endpoint. The server logic usually calls the corresponding service, or reads data from the model directly. All
21 | classes used for input/output are defined in the companion object of the api class.
22 |
23 | The `email`, `passwordreset`, `security` and `user` packages directly implement user-facing functionalities.
24 |
25 | The `infrastructure` and `logging` packages contain utility classes for working with the database, logging and tracing.
26 |
27 | The `util` package contains common classes, type aliases and extension methods which are used in other classes.
28 |
--------------------------------------------------------------------------------
/docs/configuration.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: "Configuration"
4 | ---
5 |
6 | Configuration uses the [Typesafe Config](https://github.com/lightbend/config) file format (HOCON), but is read using [pureconfig](https://pureconfig.github.io) library.
7 |
8 | The configuration is stored in the `application.conf` file. You can either modify that file directly or override it using system properties (see Typesafe Config's readme on how to do that).
9 |
10 | ## Email configuration
11 |
12 | By default, a dummy (no-op) email sender is used. If you'd like to send real emails, you'll need to enable either the
13 | smtp sender, or the mailgun sender by specifying the appropriate configuration options (one of `email.mailgun.enabled`
14 | or `email.smtp.enabled` needs to be `true`).
15 |
16 | You can also add support for another email service by implementing the `EmailSender` trait.
17 |
18 | ## Project name customization
19 |
20 | If you want to use Bootzooka as a scaffolding for your own project, use the `renameProject` command with sbt, for example:
21 |
22 | ````
23 | sbt "renameProject com.mycompany foobar"
24 | ````
25 |
26 | This should rename your project to **Foobar**, move all sources to top-level package `com.mycompany.foobar`.
27 |
--------------------------------------------------------------------------------
/docs/devtips.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: "Development tips"
4 | ---
5 |
6 | Generally during development you'll need two processes:
7 |
8 | * sbt running the backend server
9 | * yarn server which automatically picks up any changes
10 |
11 | ## Cloning
12 |
13 | If you are planning to use Bootzooka as scaffolding for your own project, consider cloning the repo with `git clone --depth 1` in order to start the history with last commit. You can now switch to your origin repository of choice with: `git remote set-url origin https://repo.com/OTHERREPOSITORY.git`
14 |
15 | ## Useful sbt commands
16 |
17 | * `renameProject` - replace Bootzooka with your custom name and adjust scala package names
18 | * `compile` - compile the whole project
19 | * `test` - run all the tests
20 | * `project ` - switch context to the given sub-project, then all the commands will be executed only for
21 | that sub-project, this can be also achieved with e.g.: `/test`
22 | * `~backend/re-start` - runs the backend server and waits for source code changes to automatically compile changed file and to reload it
23 |
24 | ## Database schema evolution
25 |
26 | With Flyway, all you need to do is to put DDL script within bootzooka-backend/src/main/resources/db/migration/ directory. You have to obey the following [naming convention](http://flywaydb.org/documentation/migration/sql.html): `V#__your_arbitrary_description.sql` where `#` stands for *unique* version of your schema.
27 |
28 | ## Developing frontend without backend
29 |
30 | If you'd like to work only on the frontend, without starting the backend, you can proxy requests to a working, remote backend instance. In `ui/package.json` you need to edit the proxy settings.
31 |
32 | ## Imports
33 |
34 | There are two imports that are useful when developing a new functionality:
35 |
36 | ### Database
37 |
38 | If you are defining database queries or running transactions, add the following import:
39 |
40 | ```scala
41 | import com.softwaremill.bootzooka.infrastructure.Magnum.*
42 | ```
43 |
44 | This will bring into scope custom [Magnum](https://github.com/AugustNagro/magnum) codecs.
45 |
46 | ### HTTP API
47 |
48 | If you are describing new endpoints, import all members of `Http`:
49 |
50 | ```scala
51 | import com.softwaremill.bootzooka.http.Http.*
52 | ```
53 |
54 | This will bring into scope Tapir builder methods and schemas for documentation, along with Bootzooka-specific customizations.
55 |
56 | ### Logging
57 |
58 | Logging is performed using Slf4j. Extend `Logging` to bring into scope a `logger` value. The logs that are output to the console include the current trace id.
59 |
--------------------------------------------------------------------------------
/docs/frontend.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: "Frontend application"
4 | ---
5 |
6 | Bootzooka's frontend is a true Single Page Application built with React. It can be treated as a completely separate application or as a client for Bootzooka server.
7 |
8 | As a separate application it deserves its own build process handling all the details (linting, testing, minifying etc). Hence the frontend part is almost completely decoupled from server side code. The only coupling is on the level of packaging final application (which is described later in this doc).
9 |
10 | Please note, that the UI is based on [fantastic tool called Vite](https://github.com/vitejs/vite) which takes care of fine details like build configuration, minification & hot reloading under the hood, without you having to worry about it. For more details, see the project's [page](https://github.com/vitejs/vite).
11 |
12 | ## Installing Node.js & Yarn
13 |
14 | To work with the `ui` module you need to have `node.js` installed in version 22 or newer. Make sure you have `node` command available on `PATH`.
15 |
16 | As a package manager, Bootzooka's UI uses [Yarn](https://yarnpkg.com). Make sure to have it installed before the first run.
17 |
18 | ## First run
19 |
20 | If this is your first attempt to run `ui`, please go to `ui` project and run
21 |
22 | yarn install
23 |
24 | This will install all required dependencies for this project. If all is well you can start your development version of frontend by issuing `yarn start` from command line (or running the provided `frontend-start` script in the main directory). It should start your browser and point you to [Bootzooka home page](http://0.0.0.0:8081/#/).
25 |
26 | ## Development
27 |
28 | Build system exposes several tasks that can be run, you can find them in `package.json` file.
29 |
30 | The most important tasks exposed are:
31 |
32 | - `yarn start`
33 | - `yarn build`
34 | - `yarn test`
35 | - `yarn test:ci`
36 |
37 | ## `yarn start` task
38 |
39 | This task serves Bootzooka application on port `8081` on `0.0.0.0` (it is available to all hosts from the same network). Your default browser should open at this location. All requests to the backend will be proxied to port `8080` where it expects the server to be run.
40 |
41 | Hot reload is in place already (provided by the Vite stack), so every change is automatically compiled (if necessary) and browser is automatically refreshed to apply changes. No need to refresh it by hand.
42 |
43 | In this task all scripts are served in non-concatenated and non-minified version from their original locations (if possible).
44 |
45 | ## `yarn build` task
46 |
47 | It builds everything as a distribution-ready version to `dist` directory. It doesn't fire up the proxy server.
48 |
49 | ## `yarn test` task
50 |
51 | This task runs tests and watches for changes in files. When change is detected it runs tests automatically. This is especially helpful in hard-development mode. Tests are run with Vitest.
52 |
53 | ## `yarn test:ci` task
54 |
55 | This task runs tests just once (useful in CI environments, where an exit code is required).
56 |
57 | ## Distribution and deployment
58 |
59 | Although in development `ui` is separate project there is no need to deploy it separately. All files from `ui/dist` (which are generated during `yarn build`) are used by `backend` to build the final fat-jar application. All necessary integration with SBT (backend build) is provided. That means when you issue `package` in SBT, you get a complete web application which contains both server side and frontend components.
60 |
--------------------------------------------------------------------------------
/docs/getting-started.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: "Getting started"
4 | ---
5 |
6 | ## Prerequisites
7 |
8 | In order to build and develop on Bootzooka foundations you need the following:
9 |
10 | - Java JDK >= 21
11 | - [sbt](http://www.scala-sbt.org/) >= 1.10
12 | - Node.js >= 22 (We recommend [nvm](https://github.com/nvm-sh/nvm) - node version manager)
13 | - PostgreSQL
14 |
15 | ## How to run
16 |
17 | Because (as said before) Bootzooka consists of two separate applications, in development you need to run both separately. This way the server-side code can be reloaded independently of the frontend code: if, for example, you make a small change to an HTML file, thanks to live-reload you'll see the changes immediately, rebuilding and reloading only the frontend part, while the server is running undisturbed.
18 |
19 | **NOTE: This is not the case in production by default. When the final fat-jar application package or docker image is built it contains both client and server parts.**
20 |
21 | ### Database
22 |
23 | Bootzooka uses [PostgreSQL](https://www.postgresql.org) to store data, so you will need the database running to use the application. By default, Bootzooka uses the `bootzooka` database using the `postgres` user, connecting to a server running on `localhost:5432`. This can be customised in the `application.conf` file.
24 |
25 | You can either use a stand-alone database, a docker image (see the `docker-compose.yml` file), or any other PostgreSQL instance.
26 |
27 | ### Server
28 |
29 | To run the backend server part, enter the main directory and type `./backend-start.sh` or `backend-start.bat` depending on your OS.
30 |
31 | ### Browser client
32 |
33 | To run the frontend server part, enter the main directory and type `./frontend-start.sh`. This should open `http://localhost:8081/` in your browser (frontend listens on port 8081, backend on port 8080; so all backend HTTP requests will be proxied to port 8080).
34 |
35 | For details of frontend build and architecture please refer to the [frontend docs](frontend.html).
36 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: "Overview"
4 | ---
5 |
6 | Bootzooka is a scaffolding project to allow quick start of development of a microservice or a web application. If you'd like to have a jump start developing a Scala-based project, skipping the boring parts and focusing on the real business value, this template might be for you!
7 |
8 | Bootzooka contains only the very basic features, that almost any application needs (listed below). These features are fully implemented both on server- and client- side. We hope that the implementations can also serve as blueprints for new functionalities.
9 |
10 | Current (user visible) features:
11 |
12 | * User registration
13 | * Lost password recovery (via e-mail)
14 | * Logging in/out
15 | * Profile management
16 |
17 | "Developer" features:
18 |
19 | * integrated SBT + Yarn build
20 | * unit & integration backend tests
21 | * frontend JS tests
22 | * fat-jar and Docker deployment
23 |
24 | This may not sound "cool", but the goal of Bootzooka is to be helpful when bootstrapping a new project. It contains the whole required setup and automation of build processes both for frontend and backend. You get it out of the box which means significantly less time spent on setting up infrastructure and tools and more time spent on actual coding features in project.
25 |
26 | Live demo is available on [http://bootzooka.softwaremill.com](http://bootzooka.softwaremill.com).
27 |
28 | ## License
29 |
30 | Project is licensed under [Apache 2.0 License](http://www.apache.org/licenses/LICENSE-2.0.html) which means you can freely use any part of the project.
31 |
--------------------------------------------------------------------------------
/docs/javascripts/scale.fix.js:
--------------------------------------------------------------------------------
1 | var metas = document.getElementsByTagName('meta');
2 | var i;
3 | if (navigator.userAgent.match(/iPhone/i)) {
4 | for (i=0; i=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
3 | apiVersion: networking.k8s.io/v1
4 | {{- else if semverCompare ">=1.14-0 <1.19-0" .Capabilities.KubeVersion.GitVersion -}}
5 | apiVersion: networking.k8s.io/v1beta1
6 | {{- else -}}
7 | apiVersion: extensions/v1beta1
8 | {{- end }}
9 | kind: Ingress
10 | metadata:
11 | name: {{ include "bootzooka.fullname" . }}-ingress
12 | labels:
13 | app.kubernetes.io/name: {{ include "bootzooka.name" . }}
14 | helm.sh/chart: {{ include "bootzooka.chart" . }}
15 | app.kubernetes.io/instance: {{ .Release.Name }}
16 | app.kubernetes.io/managed-by: {{ .Release.Service }}
17 | {{- with .Values.bootzooka.ingress.annotations }}
18 | annotations:
19 | {{ toYaml . | indent 4 }}
20 | {{- end }}
21 | spec:
22 | {{- if .Values.bootzooka.ingress.tls_enabled }}
23 | tls:
24 | {{- range .Values.bootzooka.ingress.tls }}
25 | - hosts:
26 | {{- range .hosts }}
27 | - {{ . | quote }}
28 | {{- end }}
29 | secretName: {{ .secretName }}
30 | {{- end }}
31 | {{- end }}
32 | rules:
33 | {{- $serviceName := include "bootzooka.fullname" . -}}
34 | {{- range .Values.bootzooka.ingress.hosts }}
35 | - host: {{ .host.domain | quote }}
36 | http:
37 | paths:
38 | - path: {{ .host.path }}
39 | {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
40 | pathType: {{ .host.pathType }}
41 | {{- end }}
42 | backend:
43 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
44 | service:
45 | name: {{ $serviceName }}
46 | port:
47 | name: {{ .host.port }}
48 | {{- else }}
49 | serviceName: {{ $serviceName }}
50 | servicePort: {{ .host.port }}
51 | {{- end }}
52 | {{- end }}
53 | {{- end }}
54 |
--------------------------------------------------------------------------------
/helm/bootzooka/templates/secrets.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Secret
3 | type: Opaque
4 | metadata:
5 | name: {{ include "bootzooka.fullname" . }}-secret
6 | data:
7 | SQL_PASSWORD: {{ tpl .Values.bootzooka.sql.password . | b64enc | quote }}
8 | SMTP_PASSWORD: {{ tpl .Values.bootzooka.smtp.password . | b64enc | quote }}
9 |
--------------------------------------------------------------------------------
/helm/bootzooka/templates/service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: {{ include "bootzooka.fullname" . }}
5 | labels:
6 | app.kubernetes.io/name: {{ include "bootzooka.name" . }}
7 | helm.sh/chart: {{ include "bootzooka.chart" . }}
8 | app.kubernetes.io/instance: {{ .Release.Name }}
9 | app.kubernetes.io/managed-by: {{ .Release.Service }}
10 | spec:
11 | type: {{ .Values.bootzooka.service.type }}
12 | ports:
13 | - port: {{ .Values.bootzooka.service.port }}
14 | targetPort: http
15 | protocol: TCP
16 | name: http
17 | selector:
18 | app.kubernetes.io/name: {{ include "bootzooka.name" . }}
19 | app.kubernetes.io/instance: {{ .Release.Name }}
20 |
--------------------------------------------------------------------------------
/helm/bootzooka/templates/tests/test-postgresql-connection.yaml:
--------------------------------------------------------------------------------
1 | {{- if .Values.postgresql.enabled }}
2 | apiVersion: v1
3 | kind: Pod
4 | metadata:
5 | name: "{{ .Release.Name }}-postgresql-test"
6 | annotations:
7 | "helm.sh/hook": test
8 | spec:
9 | containers:
10 | - name: {{ .Release.Name }}-postgresql-test
11 | image: "{{ .Values.postgresql.connectionTest.image.repository }}:{{ .Values.postgresql.connectionTest.image.tag }}"
12 | imagePullPolicy: {{ .Values.postgresql.connectionTest.image.pullPolicy | quote }}
13 | env:
14 | - name: DB_HOST
15 | valueFrom:
16 | configMapKeyRef:
17 | name: {{ include "bootzooka.fullname" . }}-config
18 | key: SQL_HOST
19 | - name: DB_PORT
20 | valueFrom:
21 | configMapKeyRef:
22 | name: {{ include "bootzooka.fullname" . }}-config
23 | key: SQL_PORT
24 | - name: DB_DATABASE
25 | valueFrom:
26 | configMapKeyRef:
27 | name: {{ include "bootzooka.fullname" . }}-config
28 | key: SQL_DBNAME
29 | - name: DB_USERNAME
30 | valueFrom:
31 | configMapKeyRef:
32 | name: {{ include "bootzooka.fullname" . }}-config
33 | key: SQL_USERNAME
34 | - name: DB_PASSWORD
35 | valueFrom:
36 | secretKeyRef:
37 | name: {{ include "bootzooka.fullname" . }}-secret
38 | key: SQL_PASSWORD
39 | command:
40 | - /bin/bash
41 | - -ec
42 | - |
43 | PGPASSWORD=$DB_PASSWORD psql --host $DB_HOST --port $DB_PORT -U $DB_USERNAME
44 | restartPolicy: Never
45 | {{- end }}
46 |
--------------------------------------------------------------------------------
/helm/bootzooka/values.yaml:
--------------------------------------------------------------------------------
1 | # Default values for Bootzooka.
2 |
3 | postgresql:
4 | # -- Disable if you already have PostgreSQL running in cluster where Bootzooka chart is being deployed
5 | enabled: true
6 | fullnameOverride: "bootzooka-pgsql-postgresql"
7 | postgresqlUsername: "postgres"
8 | postgresqlPassword: "bootzooka"
9 | postgresqlDatabase: "bootzooka"
10 | service:
11 | port: 5432
12 | connectionTest:
13 | image:
14 | repository: bitnami/postgresql
15 | tag: 11
16 | pullPolicy: IfNotPresent
17 |
18 | bootzooka:
19 | replicaCount: 1
20 | reset_password_url: "https://bootzooka.example.com/password-reset?code=%s"
21 | sql:
22 | # -- Value will be taken from 'postgresql.fullnameOverride' setting
23 | host: '{{ .Values.postgresql.fullnameOverride }}'
24 | # -- Value will be taken from 'postgresql.service.port' setting
25 | port: '{{ .Values.postgresql.service.port }}'
26 | # -- Value will be taken from 'postgresql.postgresqlUsername' setting
27 | username: '{{ .Values.postgresql.postgresqlUsername }}'
28 | # -- Value will be taken from 'postgresql.postgresqlDatabase' setting
29 | name: '{{ .Values.postgresql.postgresqlDatabase }}'
30 | # -- Value will be taken from 'postgresql.postgresqlPassword' setting
31 | password: '{{ .Values.postgresql.postgresqlPassword }}'
32 | smtp:
33 | enabled: true
34 | host: "server.example.com"
35 | port: 465
36 | ssl: "true"
37 | ssl_ver: "false"
38 | username: "server.example.com"
39 | from: "hello@bootzooka.example.com"
40 | password: "bootzooka"
41 |
42 | image:
43 | repository: softwaremill/bootzooka
44 | tag: latest
45 | pullPolicy: Always
46 |
47 | nameOverride: ""
48 | fullnameOverride: ""
49 |
50 | service:
51 | type: ClusterIP
52 | port: 8080
53 |
54 | ingress:
55 | enabled: true
56 | tls_enabled: false
57 | annotations:
58 | kubernetes.io/ingress.class: nginx
59 | kubernetes.io/tls-acme: "true"
60 | hosts:
61 | - host:
62 | domain: bootzooka.example.com
63 | path: /
64 | pathType: ImplementationSpecific
65 | port: http
66 | tls:
67 | - secretName: bootzooka-tls
68 | hosts:
69 | - bootzooka.example.com
70 |
71 | resources: {}
72 | # We usually recommend not to specify default resources and to leave this as a conscious
73 | # choice for the user. This also increases chances charts run on environments with little
74 | # resources, such as Minikube. If you do want to specify resources, uncomment the following
75 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
76 | # limits:
77 | # cpu: 100m
78 | # memory: 128Mi
79 | # requests:
80 | # cpu: 100m
81 | # memory: 128Mi
82 |
83 | nodeSelector: {}
84 |
85 | tolerations: []
86 |
87 | affinity: {}
88 |
--------------------------------------------------------------------------------
/integration-tests/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | bootzooka:
4 | image: 'softwaremill/bootzooka:latest'
5 | ports:
6 | - '8080:8080'
7 | depends_on:
8 | - bootzooka-db
9 | - bootzooka-mailhog
10 | environment:
11 | SQL_USERNAME: 'postgres'
12 | SQL_PASSWORD: 'b00t200k4'
13 | SQL_DBNAME: 'bootzooka'
14 | SQL_HOST: 'bootzooka-db'
15 | SQL_PORT: '5432'
16 | API_HOST: '0.0.0.0'
17 | SMTP_ENABLED: 'true'
18 | SMTP_HOST: 'bootzooka-mailhog'
19 | SMTP_PORT: '1025'
20 | bootzooka-db:
21 | image: 'postgres'
22 | ports:
23 | - '25432:5432'
24 | environment:
25 | POSTGRES_USER: 'postgres'
26 | POSTGRES_PASSWORD: 'b00t200k4'
27 | POSTGRES_DB: 'bootzooka'
28 | bootzooka-mailhog:
29 | image: 'mailhog/mailhog'
30 | ports:
31 | - '11025:1025'
32 | - '18025:8025'
33 |
34 |
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version=1.11.2
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.13.1")
2 |
3 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0")
4 |
5 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1")
6 |
7 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.11.1")
8 |
9 | addSbtPlugin("com.github.sbt" % "sbt-git" % "2.1.0")
10 |
11 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4")
12 |
--------------------------------------------------------------------------------
/ui/.env.example:
--------------------------------------------------------------------------------
1 | VITE_APP_BASE_URL = "http://localhost:8080/api/v1"
2 |
--------------------------------------------------------------------------------
/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | build
15 | coverage
16 |
17 | # Editor directories and files
18 | .vscode/*
19 | !.vscode/extensions.json
20 | .idea
21 | .DS_Store
22 | *.suo
23 | *.ntvs*
24 | *.njsproj
25 | *.sln
26 | *.sw?
27 |
--------------------------------------------------------------------------------
/ui/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | public
3 |
--------------------------------------------------------------------------------
/ui/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "trailingComma": "es5",
4 | "tabWidth": 2,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/ui/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with [Vite](https://vite.dev/).
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `yarn start`
8 |
9 | Runs the app in the development mode.
10 | Open [http://localhost:8081](http://localhost:8081) to view it in the browser.
11 |
12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console.
14 |
15 | #### API client & associated types
16 |
17 | Before running `yarn start`, make sure to run `sbt "backend/generateOpenAPIDescription"` in the project's root directory. This command will generate the `/backend/target/openapi.yaml` file.
18 |
19 | Type-safe React Query hooks are generated upon UI application start (`yarn start`), based on the `openapi.yaml` contents.
20 |
21 | These files are:
22 |
23 | - `src/api/{namespace}Fetcher.ts` - defines a function that will make requests to your API.
24 | - `src/api/{namespace}Context.ts` - the context that provides `{namespace}Fetcher` to other components.
25 | - `src/api/{namespace}Components.ts` - generated React Query components (if you selected React Query as part of initialization).
26 | - `src/api/{namespace}Schemas.ts` - the generated Typescript types from the provided Open API schemas.
27 |
28 | A file watch is engaged, re-generating types on each change to the `/backend/target/openapi.yaml` file.
29 |
30 | ### `yarn test`
31 |
32 | Launches the test runner in the interactive watch mode.
33 | See the section about [running tests](https://vitest.dev/guide/) for more information.
34 |
35 | ### `yarn build`
36 |
37 | Builds the app for production to the `dist` folder.
38 | It correctly bundles React in production mode and optimizes the build for the best performance.
39 |
40 | The build is minified and the filenames include the hashes.
41 | Your app is ready to be deployed!
42 |
43 | See the section about [deployment](https://vite.dev/guide/static-deploy) for more information.
44 |
45 | ## Learn More
46 |
47 | You can learn more in the [Vite documentation](https://vite.dev/).
48 |
49 | To learn React, check out the [React documentation](https://react.dev/).
50 |
--------------------------------------------------------------------------------
/ui/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import globals from 'globals';
3 | import reactHooks from 'eslint-plugin-react-hooks';
4 | import reactRefresh from 'eslint-plugin-react-refresh';
5 | import tseslint from 'typescript-eslint';
6 | import eslintPluginPrettier from 'eslint-plugin-prettier/recommended';
7 |
8 | export default tseslint.config(
9 | { ignores: ['dist', 'src/api'] },
10 | {
11 | extends: [
12 | js.configs.recommended,
13 | ...tseslint.configs.recommended,
14 | eslintPluginPrettier,
15 | ],
16 | files: ['**/*.{ts,tsx}'],
17 | languageOptions: {
18 | ecmaVersion: 2020,
19 | globals: globals.browser,
20 | },
21 | plugins: {
22 | 'react-hooks': reactHooks,
23 | 'react-refresh': reactRefresh,
24 | },
25 | rules: {
26 | ...reactHooks.configs.recommended.rules,
27 | 'react-refresh/only-export-components': [
28 | 'warn',
29 | { allowConstantExport: true },
30 | ],
31 | '@typescript-eslint/no-explicit-any': 'off',
32 | 'prettier/prettier': ['error', { printWidth: 80 }],
33 | },
34 | }
35 | );
36 |
--------------------------------------------------------------------------------
/ui/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Bootzooka
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/ui/openapi-codegen.config.ts:
--------------------------------------------------------------------------------
1 | import {
2 | generateSchemaTypes,
3 | generateReactQueryComponents,
4 | } from '@openapi-codegen/typescript';
5 | import { defineConfig } from '@openapi-codegen/cli';
6 | export default defineConfig({
7 | apiFile: {
8 | from: {
9 | relativePath: '../backend/target/openapi.yaml',
10 | source: 'file',
11 | },
12 | outputDir: './src/api',
13 | to: async (context) => {
14 | const filenamePrefix = 'api';
15 | const { schemasFiles } = await generateSchemaTypes(context, {
16 | filenamePrefix,
17 | });
18 | await generateReactQueryComponents(context, {
19 | filenamePrefix,
20 | schemasFiles,
21 | });
22 | },
23 | },
24 | apiWeb: {
25 | from: {
26 | source: 'url',
27 | url: 'http://localhost:8080/api/v1/docs/docs.yaml',
28 | },
29 | outputDir: './src/api',
30 | to: async (context) => {
31 | const filenamePrefix = 'api';
32 | const { schemasFiles } = await generateSchemaTypes(context, {
33 | filenamePrefix,
34 | });
35 | await generateReactQueryComponents(context, {
36 | filenamePrefix,
37 | schemasFiles,
38 | });
39 | },
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bootzooka-ui",
3 | "version": "0.1.0",
4 | "type": "module",
5 | "private": true,
6 | "proxy": "http://localhost:8080",
7 | "engines": {
8 | "node": ">=22"
9 | },
10 | "scripts": {
11 | "start": "yarn generate:openapi-types && concurrently vite \"yarn watch:openapi\"",
12 | "build": "yarn generate:openapi-types && tsc -b && vite build",
13 | "preview": "vite preview",
14 | "test": "vitest",
15 | "test:coverage": "vitest run --coverage",
16 | "test:ci": "CI=true vitest",
17 | "lint": "eslint .",
18 | "generate:openapi-types": "npx openapi-codegen gen apiWeb --source file --relativePath ../backend/target/openapi.yaml",
19 | "watch:openapi": "chokidar '../backend/target/openapi.yaml' -c 'yarn generate:openapi-types'",
20 | "start:frontend": "yarn start"
21 | },
22 | "dependencies": {
23 | "@tanstack/react-query": "^5.62.7",
24 | "bootstrap": "^5.3.2",
25 | "formik": "^2.4.5",
26 | "immer": "^10.0.3",
27 | "react": "^18.2.0",
28 | "react-bootstrap": "^2.9.2",
29 | "react-dom": "^18.2.0",
30 | "react-icons": "^4.12.0",
31 | "react-router": "^7.1.3",
32 | "react-router-bootstrap": "^0.26.2",
33 | "yup": "^1.3.3"
34 | },
35 | "devDependencies": {
36 | "@eslint/js": "^9.18.0",
37 | "@openapi-codegen/cli": "^2.0.2",
38 | "@openapi-codegen/typescript": "^8.0.2",
39 | "@testing-library/dom": "^10.4.0",
40 | "@testing-library/jest-dom": "^6.6.3",
41 | "@testing-library/react": "^16.2.0",
42 | "@testing-library/user-event": "^14.6.0",
43 | "@types/node": "^22.10.7",
44 | "@types/react": "^19.0.7",
45 | "@types/react-dom": "^19.0.3",
46 | "@vitejs/plugin-react-swc": "^3.7.2",
47 | "@vitest/coverage-v8": "^3.0.2",
48 | "chokidar-cli": "^3.0.0",
49 | "concurrently": "^8.2.2",
50 | "eslint": "^9.18.0",
51 | "eslint-config-prettier": "^10.0.1",
52 | "eslint-plugin-prettier": "^5.2.2",
53 | "eslint-plugin-react": "^7.37.4",
54 | "eslint-plugin-react-hooks": "^5.1.0",
55 | "eslint-plugin-react-refresh": "^0.4.18",
56 | "globals": "^15.14.0",
57 | "jsdom": "^26.0.0",
58 | "prettier": "^3.4.2",
59 | "typescript": "^5.7.3",
60 | "typescript-eslint": "^8.20.0",
61 | "vite": "^6.0.15",
62 | "vitest": "^3.0.5"
63 | },
64 | "jest": {
65 | "collectCoverageFrom": [
66 | "src/**/*.{ts,tsx}",
67 | "!src/index.tsx",
68 | "!src/serviceWorker.ts"
69 | ]
70 | },
71 | "browserslist": {
72 | "production": [
73 | ">0.2%",
74 | "not dead",
75 | "not op_mini all"
76 | ],
77 | "development": [
78 | "last 1 chrome version",
79 | "last 1 firefox version",
80 | "last 1 safari version"
81 | ]
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/ui/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/ui/public/favicon.png
--------------------------------------------------------------------------------
/ui/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Bootzooka",
3 | "name": "Bootzooka",
4 | "icons": [
5 | {
6 | "src": "favicon.png",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/ui/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/ui/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { App } from './App';
3 |
4 | test('should render', () => {
5 | render( );
6 | const header = screen.getByText('Welcome to Bootzooka!');
7 | expect(header).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/ui/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter } from 'react-router';
2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3 | import { Main } from 'main/Main/Main';
4 | import { UserContextProvider } from 'contexts';
5 |
6 | const queryClient = new QueryClient();
7 |
8 | export const App = () => (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/ui/src/api/apiContext.ts:
--------------------------------------------------------------------------------
1 | import type { QueryKey, UseQueryOptions } from '@tanstack/react-query';
2 | import { QueryOperation } from './apiComponents';
3 |
4 | export type ApiContext = {
5 | fetcherOptions: {
6 | /**
7 | * Headers to inject in the fetcher
8 | */
9 | headers?: {};
10 | /**
11 | * Query params to inject in the fetcher
12 | */
13 | queryParams?: {};
14 | };
15 | queryOptions: {
16 | /**
17 | * Set this to `false` to disable automatic refetching when the query mounts or changes query keys.
18 | * Defaults to `true`.
19 | */
20 | enabled?: boolean;
21 | };
22 | /**
23 | * Query key manager.
24 | */
25 | queryKeyFn: (operation: QueryOperation) => QueryKey;
26 | };
27 |
28 | /**
29 | * Context injected into every react-query hook wrappers
30 | *
31 | * @param queryOptions options from the useQuery wrapper
32 | */
33 | export function useApiContext<
34 | TQueryFnData = unknown,
35 | TError = unknown,
36 | TData = TQueryFnData,
37 | TQueryKey extends QueryKey = QueryKey,
38 | >(
39 | _queryOptions?: Omit<
40 | UseQueryOptions,
41 | 'queryKey' | 'queryFn'
42 | >
43 | ): ApiContext {
44 | return {
45 | fetcherOptions: {},
46 | queryOptions: {},
47 | queryKeyFn,
48 | };
49 | }
50 |
51 | export const queryKeyFn = (operation: QueryOperation) => {
52 | const queryKey: unknown[] = hasPathParams(operation)
53 | ? operation.path
54 | .split('/')
55 | .filter(Boolean)
56 | .map((i) => resolvePathParam(i, operation.variables.pathParams))
57 | : operation.path.split('/').filter(Boolean);
58 |
59 | if (hasQueryParams(operation)) {
60 | queryKey.push(operation.variables.queryParams);
61 | }
62 |
63 | if (hasBody(operation)) {
64 | queryKey.push(operation.variables.body);
65 | }
66 |
67 | return queryKey;
68 | };
69 | // Helpers
70 | const resolvePathParam = (key: string, pathParams: Record) => {
71 | if (key.startsWith('{') && key.endsWith('}')) {
72 | return pathParams[key.slice(1, -1)];
73 | }
74 | return key;
75 | };
76 |
77 | const hasPathParams = (
78 | operation: QueryOperation
79 | ): operation is QueryOperation & {
80 | variables: { pathParams: Record };
81 | } => {
82 | return Boolean((operation.variables as any).pathParams);
83 | };
84 |
85 | const hasBody = (
86 | operation: QueryOperation
87 | ): operation is QueryOperation & {
88 | variables: { body: Record };
89 | } => {
90 | return Boolean((operation.variables as any).body);
91 | };
92 |
93 | const hasQueryParams = (
94 | operation: QueryOperation
95 | ): operation is QueryOperation & {
96 | variables: { queryParams: Record };
97 | } => {
98 | return Boolean((operation.variables as any).queryParams);
99 | };
100 |
--------------------------------------------------------------------------------
/ui/src/api/apiFetcher.ts:
--------------------------------------------------------------------------------
1 | import { ApiContext } from './apiContext';
2 |
3 | const baseUrl = process.env.REACT_APP_BASE_URL;
4 |
5 | export type ErrorWrapper =
6 | | TError
7 | | { status: 'unknown'; payload: string };
8 |
9 | export type ApiFetcherOptions = {
10 | url: string;
11 | method: string;
12 | body?: TBody;
13 | headers?: THeaders;
14 | queryParams?: TQueryParams;
15 | pathParams?: TPathParams;
16 | signal?: AbortSignal;
17 | } & ApiContext['fetcherOptions'];
18 |
19 | export async function apiFetch<
20 | TData,
21 | TError,
22 | TBody extends {} | FormData | undefined | null,
23 | THeaders extends {},
24 | TQueryParams extends {},
25 | TPathParams extends {},
26 | >({
27 | url,
28 | method,
29 | body,
30 | headers,
31 | pathParams,
32 | queryParams,
33 | signal,
34 | }: ApiFetcherOptions<
35 | TBody,
36 | THeaders,
37 | TQueryParams,
38 | TPathParams
39 | >): Promise {
40 | try {
41 | const requestHeaders: HeadersInit = {
42 | 'Content-Type': 'application/json',
43 | ...headers,
44 | };
45 |
46 | if (!requestHeaders.Authorization && localStorage.getItem('apiKey')) {
47 | requestHeaders.Authorization = `Bearer ${localStorage.getItem('apiKey')}`;
48 | }
49 |
50 | /**
51 | * As the fetch API is being used, when multipart/form-data is specified
52 | * the Content-Type header must be deleted so that the browser can set
53 | * the correct boundary.
54 | * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object
55 | */
56 | if (
57 | requestHeaders['Content-Type']
58 | .toLowerCase()
59 | .includes('multipart/form-data')
60 | ) {
61 | delete requestHeaders['Content-Type'];
62 | }
63 |
64 | const response = await window.fetch(
65 | `${baseUrl}${resolveUrl(url, queryParams, pathParams)}`,
66 | {
67 | signal,
68 | method: method.toUpperCase(),
69 | body: body
70 | ? body instanceof FormData
71 | ? body
72 | : JSON.stringify(body)
73 | : undefined,
74 | headers: requestHeaders,
75 | }
76 | );
77 | if (!response.ok) {
78 | let error: ErrorWrapper;
79 | try {
80 | error = await response.json();
81 | } catch (e) {
82 | error = {
83 | status: 'unknown' as const,
84 | payload:
85 | e instanceof Error
86 | ? `Unexpected error (${e.message})`
87 | : 'Unexpected error',
88 | };
89 | }
90 |
91 | throw error;
92 | }
93 |
94 | if (response.headers.get('content-type')?.includes('json')) {
95 | return await response.json();
96 | } else {
97 | // if it is not a json response, assume it is a blob and cast it to TData
98 | return (await response.blob()) as unknown as TData;
99 | }
100 | } catch (e) {
101 | const errorObject: Error = {
102 | name: 'unknown' as const,
103 | message:
104 | e instanceof Error ? `Network error (${e.message})` : 'Network error',
105 | stack: e as string,
106 | };
107 | throw errorObject;
108 | }
109 | }
110 |
111 | const resolveUrl = (
112 | url: string,
113 | queryParams: Record = {},
114 | pathParams: Record = {}
115 | ) => {
116 | let query = new URLSearchParams(queryParams).toString();
117 | if (query) query = `?${query}`;
118 | return url.replace(/\{\w*\}/g, (key) => pathParams[key.slice(1, -1)]) + query;
119 | };
120 |
--------------------------------------------------------------------------------
/ui/src/api/apiSchemas.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generated by @openapi-codegen
3 | *
4 | * @version 1.0
5 | */
6 | export type ChangePasswordIN = {
7 | currentPassword: string;
8 | newPassword: string;
9 | };
10 |
11 | export type ChangePasswordOUT = {
12 | apiKey: string;
13 | };
14 |
15 | export type ErrorOUT = {
16 | error: string;
17 | };
18 |
19 | export type ForgotPasswordIN = {
20 | loginOrEmail: string;
21 | };
22 |
23 | export type ForgotPasswordOUT = Record;
24 |
25 | export type GetUserOUT = {
26 | login: string;
27 | email: string;
28 | /**
29 | * @format date-time
30 | */
31 | createdOn: string;
32 | };
33 |
34 | export type LoginIN = {
35 | loginOrEmail: string;
36 | password: string;
37 | /**
38 | * @format int32
39 | */
40 | apiKeyValidHours?: number;
41 | };
42 |
43 | export type LoginOUT = {
44 | apiKey: string;
45 | };
46 |
47 | export type LogoutIN = {
48 | apiKey: string;
49 | };
50 |
51 | export type LogoutOUT = Record;
52 |
53 | export type PasswordResetIN = {
54 | code: string;
55 | password: string;
56 | };
57 |
58 | export type PasswordResetOUT = Record;
59 |
60 | export type RegisterIN = {
61 | login: string;
62 | email: string;
63 | password: string;
64 | };
65 |
66 | export type RegisterOUT = {
67 | apiKey: string;
68 | };
69 |
70 | export type UpdateUserIN = {
71 | login: string;
72 | email: string;
73 | };
74 |
75 | export type UpdateUserOUT = Record;
76 |
77 | export type VersionOUT = {
78 | buildSha: string;
79 | };
80 |
--------------------------------------------------------------------------------
/ui/src/api/apiUtils.ts:
--------------------------------------------------------------------------------
1 | type ComputeRange<
2 | N extends number,
3 | Result extends Array = [],
4 | > = Result["length"] extends N
5 | ? Result
6 | : ComputeRange;
7 |
8 | export type ClientErrorStatus = Exclude<
9 | ComputeRange<500>[number],
10 | ComputeRange<400>[number]
11 | >;
12 | export type ServerErrorStatus = Exclude<
13 | ComputeRange<600>[number],
14 | ComputeRange<500>[number]
15 | >;
16 |
--------------------------------------------------------------------------------
/ui/src/assets/forkme_orange.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/ui/src/assets/forkme_orange.png
--------------------------------------------------------------------------------
/ui/src/assets/sml-logo-vertical-rgb-trans.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/ui/src/assets/sml-logo-vertical-rgb-trans.png
--------------------------------------------------------------------------------
/ui/src/assets/sml-logo-vertical-white-all-trans.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/ui/src/assets/sml-logo-vertical-white-all-trans.png
--------------------------------------------------------------------------------
/ui/src/components/ErrorMessage/ErrorMessage.tsx:
--------------------------------------------------------------------------------
1 | interface ErrorMessageProps {
2 | error: any;
3 | }
4 |
5 | export const ErrorMessage: React.FC = ({ error }) => (
6 |
7 | {(
8 | error?.response?.data?.error ||
9 | error?.message ||
10 | 'Unknown error'
11 | ).toString()}
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/ui/src/components/FeedbackButton/FeedbackButton.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from 'react';
2 | import Button, { ButtonProps } from 'react-bootstrap/Button';
3 | import Spinner from 'react-bootstrap/Spinner';
4 | import Form from 'react-bootstrap/Form';
5 | import { IconType } from 'react-icons';
6 | import { BsExclamationCircle, BsCheck } from 'react-icons/bs';
7 | import useFormikValuesChanged from './useFormikValuesChanged';
8 | import { ErrorMessage } from '../';
9 | import { UseMutationResult } from '@tanstack/react-query';
10 |
11 | interface FeedbackButtonProps extends ButtonProps {
12 | label: string;
13 | Icon: IconType;
14 | mutation: UseMutationResult;
15 | successLabel?: string;
16 | }
17 |
18 | export const FeedbackButton = ({
19 | mutation,
20 | label,
21 | Icon,
22 | successLabel = 'Success',
23 | ...buttonProps
24 | }: FeedbackButtonProps): ReactElement => {
25 | useFormikValuesChanged(() => {
26 | return !mutation.isIdle && mutation.reset();
27 | });
28 |
29 | if (mutation?.isPending) {
30 | return (
31 |
32 |
33 | {label}
34 |
35 | );
36 | }
37 |
38 | if (mutation?.isError) {
39 | return (
40 |
41 |
42 |
43 | {label}
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | if (mutation?.isSuccess) {
53 | return (
54 |
55 |
56 |
57 | {label}
58 |
59 |
60 | {successLabel}
61 |
62 |
63 | );
64 | }
65 |
66 | return (
67 |
68 |
69 | {label}
70 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/ui/src/components/FeedbackButton/useFormikValuesChanged.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { useFormikContext } from 'formik';
3 |
4 | const useFormikValuesChanged = (onChange: () => void) => {
5 | const { values } = useFormikContext();
6 | const onChangeRef = useRef(onChange);
7 |
8 | useEffect(() => {
9 | onChangeRef.current = onChange;
10 | }, [onChange]);
11 |
12 | useEffect(() => {
13 | onChangeRef.current();
14 | }, [values]);
15 | };
16 |
17 | export default useFormikValuesChanged;
18 |
--------------------------------------------------------------------------------
/ui/src/components/FormikInput/FormikInput.tsx:
--------------------------------------------------------------------------------
1 | import { Field, FieldProps } from 'formik';
2 | import Form from 'react-bootstrap/Form';
3 | import Row from 'react-bootstrap/Row';
4 | import Col from 'react-bootstrap/Col';
5 |
6 | interface FormikInputProps {
7 | type?: string;
8 | name: string;
9 | label: string;
10 | }
11 |
12 | export const FormikInput: React.FC = ({
13 | type = 'text',
14 | name,
15 | label,
16 | }) => (
17 |
18 | {({ field, meta }: FieldProps) => (
19 |
20 |
21 | {label}
22 |
23 |
24 |
31 |
32 | {meta.error}
33 |
34 |
35 |
36 | )}
37 |
38 | );
39 |
--------------------------------------------------------------------------------
/ui/src/components/TwoColumnHero/TwoColumnHero.tsx:
--------------------------------------------------------------------------------
1 | import Container from 'react-bootstrap/Container';
2 | import Col from 'react-bootstrap/Col';
3 | import Row from 'react-bootstrap/Row';
4 | import Fade from 'react-bootstrap/Fade';
5 | import Image from 'react-bootstrap/Image';
6 | import logo from 'assets/sml-logo-vertical-white-all-trans.png';
7 |
8 | interface TwoColumnHeroProps {
9 | children: React.ReactNode;
10 | }
11 |
12 | export const TwoColumnHero: React.FC = ({ children }) => {
13 | return (
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 |
26 | {children}
27 |
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/ui/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ErrorMessage/ErrorMessage';
2 | export * from './FeedbackButton/FeedbackButton';
3 | export * from './FormikInput/FormikInput';
4 | export * from './TwoColumnHero/TwoColumnHero';
5 |
--------------------------------------------------------------------------------
/ui/src/contexts/UserContext/User.context.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 | import { UserAction, UserState } from './UserContext';
3 | import { initialUserState } from './UserContext.constants';
4 |
5 | export const UserContext = createContext<{
6 | state: UserState;
7 | dispatch: React.Dispatch;
8 | }>({
9 | state: initialUserState,
10 | dispatch: () => {},
11 | });
12 |
13 | export const useUserContext = () => useContext(UserContext);
14 |
--------------------------------------------------------------------------------
/ui/src/contexts/UserContext/UserContext.constants.ts:
--------------------------------------------------------------------------------
1 | import { UserState } from './UserContext';
2 |
3 | export const initialUserState: UserState = {
4 | apiKey: null,
5 | user: null,
6 | loggedIn: null,
7 | };
8 |
--------------------------------------------------------------------------------
/ui/src/contexts/UserContext/UserContext.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useReducer } from 'react';
2 | import { produce } from 'immer';
3 | import { initialUserState } from './UserContext.constants';
4 | import { UserContext } from './User.context';
5 |
6 | export interface UserDetails {
7 | createdOn: string;
8 | email: string;
9 | login: string;
10 | }
11 |
12 | export interface UserState {
13 | apiKey: string | null;
14 | user: UserDetails | null;
15 | loggedIn: boolean | null;
16 | }
17 |
18 | export type UserAction =
19 | | { type: 'SET_API_KEY'; apiKey: string | null }
20 | | { type: 'UPDATE_USER_DATA'; user: Partial }
21 | | { type: 'LOG_IN'; user: UserDetails }
22 | | { type: 'LOG_OUT' };
23 |
24 | const userReducer = (state: UserState, action: UserAction): UserState => {
25 | switch (action.type) {
26 | case 'SET_API_KEY':
27 | return produce(state, (draftState) => {
28 | draftState.apiKey = action.apiKey;
29 | });
30 |
31 | case 'UPDATE_USER_DATA':
32 | return produce(state, (draftState) => {
33 | if (!draftState.user) return;
34 | draftState.user = { ...draftState.user, ...action.user };
35 | });
36 |
37 | case 'LOG_IN':
38 | return produce(state, (draftState) => {
39 | draftState.user = action.user;
40 | draftState.loggedIn = true;
41 | });
42 |
43 | case 'LOG_OUT':
44 | return produce(state, (draftState) => {
45 | draftState.apiKey = null;
46 | draftState.user = null;
47 | draftState.loggedIn = false;
48 | });
49 |
50 | default:
51 | return state;
52 | }
53 | };
54 |
55 | interface UserContextProviderProps {
56 | children: ReactNode;
57 | }
58 |
59 | export const UserContextProvider: React.FC = ({
60 | children,
61 | }) => {
62 | const [state, dispatch] = useReducer(userReducer, initialUserState);
63 |
64 | return (
65 |
66 | {children}
67 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/ui/src/contexts/index.ts:
--------------------------------------------------------------------------------
1 | export * from './UserContext/UserContext';
2 |
--------------------------------------------------------------------------------
/ui/src/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body,
3 | #root {
4 | height: 100%;
5 | }
6 |
--------------------------------------------------------------------------------
/ui/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { App } from './App';
4 | import 'bootstrap/dist/css/bootstrap.min.css';
5 | import './index.css';
6 |
7 | createRoot(document.getElementById('root')!).render(
8 |
9 |
10 |
11 | );
12 |
--------------------------------------------------------------------------------
/ui/src/main/Footer/Footer.test.tsx:
--------------------------------------------------------------------------------
1 | import { Mock } from 'vitest';
2 | import { screen } from '@testing-library/react';
3 | import { renderWithClient } from 'tests';
4 | import { useGetAdminVersion } from 'api/apiComponents';
5 | import { Footer } from './Footer';
6 |
7 | vi.mock('api/apiComponents', () => ({
8 | useGetAdminVersion: vi.fn(),
9 | }));
10 |
11 | beforeEach(() => {
12 | vi.clearAllMocks();
13 | });
14 |
15 | test('renders version data', async () => {
16 | const mockedUseGetAdminVersion = useGetAdminVersion as Mock;
17 |
18 | mockedUseGetAdminVersion.mockReturnValue({
19 | isPending: false,
20 | isLoading: false,
21 | isError: false,
22 | isSuccess: true,
23 | data: { buildDate: 'testDate', buildSha: 'testSha' },
24 | });
25 |
26 | renderWithClient();
27 |
28 | const info = screen.getByText(/Bootzooka - application scaffolding by /);
29 | const buildSha = await screen.findByText(/testSha/i);
30 |
31 | expect(mockedUseGetAdminVersion).toHaveBeenCalled();
32 | expect(info).toBeInTheDocument();
33 | expect(buildSha).toBeInTheDocument();
34 | });
35 |
--------------------------------------------------------------------------------
/ui/src/main/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Container from 'react-bootstrap/Container';
2 | import Row from 'react-bootstrap/Row';
3 | import Col from 'react-bootstrap/Col';
4 | import Spinner from 'react-bootstrap/Spinner';
5 | import { useGetAdminVersion } from 'api/apiComponents';
6 |
7 | export const Footer = () => {
8 | const mutation = useGetAdminVersion({});
9 |
10 | return (
11 |
15 |
16 |
17 |
18 |
19 | Bootzooka - application scaffolding by{' '}
20 | SoftwareMill , sources
21 | available on{' '}
22 | GitHub
23 |
24 |
25 |
26 | {mutation.isPending && <>>}
27 | {mutation.isLoading && (
28 |
29 | )}
30 | {mutation.isError && <>>}
31 | {mutation.isSuccess && (
32 |
33 | Version: {mutation.data.buildSha}
34 |
35 | )}
36 |
37 |
38 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/ui/src/main/ForkMe/ForkMe.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { ForkMe } from './ForkMe';
3 |
4 | test('renders image', () => {
5 | render( );
6 | const header = screen.getByAltText(/fork me on github/i);
7 | expect(header).toBeInTheDocument();
8 | });
9 |
10 | test('renders children', () => {
11 | render(test children content );
12 | const header = screen.getByText(/test children content/i);
13 | expect(header).toBeInTheDocument();
14 | });
15 |
--------------------------------------------------------------------------------
/ui/src/main/ForkMe/ForkMe.tsx:
--------------------------------------------------------------------------------
1 | import Container from 'react-bootstrap/Container';
2 | import Row from 'react-bootstrap/Row';
3 | import Image from 'react-bootstrap/Image';
4 | import forkMeOrange from 'assets/forkme_orange.png';
5 |
6 | interface ForkMeProps {
7 | children?: React.ReactNode;
8 | }
9 |
10 | export const ForkMe: React.FC = ({ children }) => (
11 |
12 |
13 | {children}
14 |
26 |
27 |
28 |
29 |
30 | );
31 |
--------------------------------------------------------------------------------
/ui/src/main/Loader/Loader.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { Loader } from './Loader';
3 |
4 | test('renders loader', () => {
5 | render( );
6 | const header = screen.getByRole('loader');
7 | expect(header).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/ui/src/main/Loader/Loader.tsx:
--------------------------------------------------------------------------------
1 | import Container from 'react-bootstrap/Container';
2 | import Spinner from 'react-bootstrap/Spinner';
3 |
4 | export const Loader: React.FC = () => (
5 |
6 |
7 |
8 | );
9 |
--------------------------------------------------------------------------------
/ui/src/main/Main/Main.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import { MemoryRouter } from 'react-router';
3 | import { UserContext } from 'contexts/UserContext/User.context';
4 | import { initialUserState } from 'contexts/UserContext/UserContext.constants';
5 | import { renderWithClient } from 'tests';
6 | import { Main } from './Main';
7 |
8 | const dispatch = vi.fn();
9 |
10 | beforeEach(() => {
11 | vi.clearAllMocks();
12 | });
13 |
14 | test('shows loader on unspecified logged in status', () => {
15 | renderWithClient(
16 |
17 |
18 |
19 |
20 |
21 | );
22 |
23 | expect(screen.getByRole('loader')).toBeInTheDocument();
24 | });
25 |
26 | test('shows app on logged in status', () => {
27 | renderWithClient(
28 |
29 |
32 |
33 |
34 |
35 | );
36 |
37 | expect(screen.getByText('Welcome to Bootzooka!')).toBeInTheDocument();
38 | });
39 |
40 | test('shows app on logged out status', () => {
41 | renderWithClient(
42 |
43 |
46 |
47 |
48 |
49 | );
50 |
51 | expect(screen.getByText('Welcome to Bootzooka!')).toBeInTheDocument();
52 | });
53 |
--------------------------------------------------------------------------------
/ui/src/main/Main/Main.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { UserContext } from 'contexts/UserContext/User.context';
3 | import { Top } from 'main/Top/Top';
4 | import { Footer } from 'main/Footer/Footer';
5 | import { Loader } from 'main/Loader/Loader';
6 | import { ForkMe } from 'main/ForkMe/ForkMe';
7 | import { Routes } from 'main/Routes/Routes';
8 | import useLoginOnApiKey from './useLoginOnApiKey';
9 | import useLocalStorageApiKey from './useLocalStorageApiKey';
10 |
11 | export const Main = () => {
12 | const {
13 | state: { loggedIn },
14 | } = useContext(UserContext);
15 |
16 | useLocalStorageApiKey();
17 | useLoginOnApiKey();
18 |
19 | if (loggedIn === null) {
20 | return ;
21 | }
22 |
23 | return (
24 | <>
25 |
26 |
27 |
28 |
29 |
30 | >
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/ui/src/main/Main/useLocalStorageApiKey.test.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { MemoryRouter } from 'react-router';
3 | import { render, screen } from '@testing-library/react';
4 | import { userEvent } from '@testing-library/user-event';
5 | import { UserContextProvider, UserAction } from 'contexts';
6 | import { UserContext } from 'contexts/UserContext/User.context';
7 | import useLocalStorageApiKey from './useLocalStorageApiKey';
8 |
9 | const TestComponent: React.FC<{ actions?: UserAction[]; label?: string }> = ({
10 | actions,
11 | label,
12 | }) => {
13 | const { state, dispatch } = useContext(UserContext);
14 | useLocalStorageApiKey();
15 | return (
16 | <>
17 |
18 | {Object.entries(state).map(([key, value]) => (
19 |
20 | {key}:{JSON.stringify(value)}
21 |
22 | ))}
23 |
24 | {actions && (
25 | actions.forEach(dispatch)}>{label}
26 | )}
27 | >
28 | );
29 | };
30 |
31 | beforeEach(() => {
32 | vi.clearAllMocks();
33 | });
34 |
35 | test('handles not stored api key', () => {
36 | localStorage.removeItem('apiKey');
37 |
38 | render(
39 |
40 |
41 |
42 |
43 |
44 | );
45 |
46 | expect(screen.getByText('loggedIn:false')).toBeInTheDocument();
47 | expect(screen.getByText('apiKey:null')).toBeInTheDocument();
48 | });
49 |
50 | test('handles user logging in', async () => {
51 | localStorage.removeItem('apiKey');
52 |
53 | render(
54 |
55 |
56 |
70 |
71 |
72 | );
73 |
74 | await userEvent.click(screen.getByText('log in'));
75 |
76 | expect(localStorage.getItem('apiKey')).toEqual('test-api-key');
77 | expect(screen.getByText('loggedIn:true')).toBeInTheDocument();
78 | expect(screen.getByText('apiKey:"test-api-key"')).toBeInTheDocument();
79 | });
80 |
81 | test('handles user logging out', async () => {
82 | localStorage.setItem('apiKey', 'test-api-key');
83 |
84 | render(
85 |
86 |
87 |
103 |
104 |
105 | );
106 |
107 | await userEvent.click(screen.getByText('log in and out'));
108 |
109 | expect(screen.getByText('loggedIn:false')).toBeInTheDocument();
110 | expect(screen.getByText('apiKey:null')).toBeInTheDocument();
111 | expect(localStorage.getItem('apiKey')).toBeNull();
112 | });
113 |
--------------------------------------------------------------------------------
/ui/src/main/Main/useLocalStorageApiKey.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useRef } from 'react';
2 | import { UserContext } from 'contexts/UserContext/User.context';
3 |
4 | const useLocalStorageApiKey = () => {
5 | const {
6 | dispatch,
7 | state: { apiKey, loggedIn },
8 | } = useContext(UserContext);
9 |
10 | const apiKeyRef = useRef(apiKey);
11 |
12 | useEffect(() => {
13 | apiKeyRef.current = apiKey;
14 | }, [apiKey, dispatch]);
15 |
16 | useEffect(() => {
17 | const storedApiKey = localStorage.getItem('apiKey');
18 |
19 | if (!storedApiKey) return dispatch({ type: 'LOG_OUT' });
20 |
21 | dispatch({ type: 'SET_API_KEY', apiKey: storedApiKey });
22 | }, [dispatch]);
23 |
24 | useEffect(() => {
25 | switch (loggedIn) {
26 | case true:
27 | return localStorage.setItem('apiKey', apiKeyRef.current || '');
28 | case false:
29 | return localStorage.removeItem('apiKey');
30 | case null:
31 | default:
32 | return;
33 | }
34 | }, [loggedIn]);
35 | };
36 |
37 | export default useLocalStorageApiKey;
38 |
--------------------------------------------------------------------------------
/ui/src/main/Main/useLoginOnApiKey.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from 'react';
2 | import { UserContext } from 'contexts/UserContext/User.context';
3 | import { useGetUser } from 'api/apiComponents';
4 |
5 | const useLoginOnApiKey = () => {
6 | const {
7 | dispatch,
8 | state: { apiKey },
9 | } = useContext(UserContext);
10 |
11 | const result = useGetUser(
12 | { headers: { Authorization: `Bearer ${apiKey}` } },
13 | { retry: 1 }
14 | );
15 |
16 | useEffect(() => {
17 | if (!apiKey) return;
18 |
19 | result
20 | ?.refetch()
21 | .then((response) => {
22 | if (response.data) {
23 | dispatch({ type: 'LOG_IN', user: response.data });
24 | } else {
25 | dispatch({ type: 'LOG_OUT' });
26 | }
27 | })
28 | .catch(() => {
29 | dispatch({ type: 'LOG_OUT' });
30 | });
31 | }, [apiKey, dispatch, result]);
32 | };
33 |
34 | export default useLoginOnApiKey;
35 |
--------------------------------------------------------------------------------
/ui/src/main/Routes/ProtectedRoute.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import { MemoryRouter, Routes, Route } from 'react-router';
3 | import { UserState } from 'contexts';
4 | import { UserContext } from 'contexts/UserContext/User.context';
5 | import { initialUserState } from 'contexts/UserContext/UserContext.constants';
6 | import { renderWithClient } from 'tests';
7 | import { Login } from 'pages';
8 | import { ProtectedRoute } from './ProtectedRoute';
9 |
10 | const dispatch = vi.fn();
11 |
12 | beforeEach(() => {
13 | vi.clearAllMocks();
14 | });
15 |
16 | test('renders protected route for unlogged user', () => {
17 | renderWithClient(
18 |
19 |
20 |
21 | } />
22 | }>
23 | Protected Text>} />
24 |
25 |
26 |
27 |
28 | );
29 |
30 | expect(screen.getByText('Please sign in')).toBeInTheDocument();
31 | });
32 |
33 | test('renders protected route for logged user', () => {
34 | const loggedUserState: UserState = {
35 | ...initialUserState,
36 | loggedIn: true,
37 | };
38 |
39 | renderWithClient(
40 |
41 |
42 |
43 | } />
44 | }>
45 | Protected Text>} />
46 |
47 |
48 |
49 |
50 | );
51 |
52 | expect(screen.getByText('Protected Text')).toBeInTheDocument();
53 | });
54 |
--------------------------------------------------------------------------------
/ui/src/main/Routes/ProtectedRoute.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { useLocation, Outlet, Navigate } from 'react-router';
3 | import { UserContext } from 'contexts/UserContext/User.context';
4 |
5 | export const ProtectedRoute: React.FC = () => {
6 | const {
7 | state: { loggedIn },
8 | } = useContext(UserContext);
9 | const location = useLocation();
10 |
11 | return loggedIn ? (
12 |
13 | ) : (
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/ui/src/main/Routes/Routes.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import { MemoryRouter } from 'react-router';
3 | import { UserState } from 'contexts';
4 | import { UserContext } from 'contexts/UserContext/User.context';
5 | import { initialUserState } from 'contexts/UserContext/UserContext.constants';
6 | import { renderWithClient } from 'tests';
7 | import { Routes } from './Routes';
8 |
9 | const loggedUserState: UserState = {
10 | apiKey: 'test-api-key',
11 | user: {
12 | login: 'user-login',
13 | email: 'email@address.pl',
14 | createdOn: '2020-10-09T09:57:17.995288Z',
15 | },
16 | loggedIn: true,
17 | };
18 |
19 | const dispatch = vi.fn();
20 |
21 | beforeEach(() => {
22 | vi.clearAllMocks();
23 | });
24 |
25 | test('renders main route', () => {
26 | renderWithClient(
27 |
28 |
29 |
30 |
31 |
32 | );
33 |
34 | expect(screen.getByText('Welcome to Bootzooka!')).toBeInTheDocument();
35 | });
36 |
37 | test('renders protected route for unlogged user', () => {
38 | renderWithClient(
39 |
40 |
41 |
42 |
43 |
44 | );
45 |
46 | expect(screen.getByText('Please sign in')).toBeInTheDocument();
47 | });
48 |
49 | test('renders protected route for logged user', () => {
50 | renderWithClient(
51 |
52 |
53 |
54 |
55 |
56 | );
57 |
58 | expect(
59 | screen.getByText('Shhhh, this is a secret place.')
60 | ).toBeInTheDocument();
61 | });
62 |
63 | test('renders not found page', () => {
64 | renderWithClient(
65 |
66 |
67 |
68 |
69 |
70 | );
71 |
72 | expect(
73 | screen.getByText("You shouldn't be here for sure :)")
74 | ).toBeInTheDocument();
75 | });
76 |
--------------------------------------------------------------------------------
/ui/src/main/Routes/Routes.tsx:
--------------------------------------------------------------------------------
1 | import { Routes as RouterRoutes, Route } from 'react-router';
2 | import {
3 | Welcome,
4 | Login,
5 | Register,
6 | RecoverLostPassword,
7 | SecretMain,
8 | Profile,
9 | NotFound,
10 | } from 'pages';
11 | import { ProtectedRoute } from './ProtectedRoute';
12 |
13 | export const Routes: React.FC = () => (
14 |
15 | } />
16 |
17 | } />
18 |
19 | } />
20 |
21 | } />
22 |
23 | }>
24 | } />
25 | } />
26 |
27 |
28 | } />
29 |
30 | );
31 |
--------------------------------------------------------------------------------
/ui/src/main/Top/Top.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import { userEvent } from '@testing-library/user-event';
3 | import { MemoryRouter } from 'react-router';
4 | import { UserState } from 'contexts';
5 | import { UserContext } from 'contexts/UserContext/User.context';
6 | import { initialUserState } from 'contexts/UserContext/UserContext.constants';
7 | import { Top } from './Top';
8 | import { renderWithClient } from '../../tests';
9 |
10 | const loggedUserState: UserState = {
11 | apiKey: 'test-api-key',
12 | user: {
13 | login: 'user-login',
14 | email: 'email@address.pl',
15 | createdOn: '2020-10-09T09:57:17.995288Z',
16 | },
17 | loggedIn: true,
18 | };
19 |
20 | const dispatch = vi.fn();
21 | const mockMutate = vi.fn();
22 |
23 | vi.mock('api/apiComponents', () => ({
24 | usePostUserLogout: () => ({
25 | mutateAsync: mockMutate,
26 | isSuccess: false,
27 | }),
28 | }));
29 |
30 | beforeEach(() => {
31 | vi.clearAllMocks();
32 | });
33 |
34 | test('renders brand name', () => {
35 | renderWithClient(
36 |
37 |
38 |
39 |
40 |
41 | );
42 |
43 | expect(screen.getByText('Bootzooka')).toBeInTheDocument();
44 | });
45 |
46 | test('renders nav bar unlogged user', () => {
47 | renderWithClient(
48 |
49 |
50 |
51 |
52 |
53 | );
54 |
55 | expect(screen.getByText('Welcome')).toBeInTheDocument();
56 | expect(screen.getByText('Home')).toBeInTheDocument();
57 | expect(screen.getByText('Login')).toBeInTheDocument();
58 | expect(screen.getByText('Register')).toBeInTheDocument();
59 | });
60 |
61 | test('renders nav bar for logged user', () => {
62 | renderWithClient(
63 |
64 |
65 |
66 |
67 |
68 | );
69 |
70 | expect(screen.getByText('Welcome')).toBeInTheDocument();
71 | expect(screen.getByText('Home')).toBeInTheDocument();
72 | expect(screen.getByText('user-login')).toBeInTheDocument();
73 | expect(screen.getByText('Logout')).toBeInTheDocument();
74 | });
75 |
76 | test('handles logout logged user', async () => {
77 | renderWithClient(
78 |
79 |
80 |
81 |
82 |
83 | );
84 | await userEvent.click(screen.getByText(/logout/i));
85 | expect(mockMutate).toHaveBeenCalledTimes(1);
86 | expect(mockMutate).toHaveBeenCalledWith({ body: { apiKey: 'test-api-key' } });
87 | });
88 |
--------------------------------------------------------------------------------
/ui/src/main/Top/Top.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useContext } from 'react';
2 | import Navbar from 'react-bootstrap/Navbar';
3 | import Nav from 'react-bootstrap/Nav';
4 | import Container from 'react-bootstrap/Container';
5 | import { Link } from 'react-router';
6 | import { BiPowerOff, BiHappy } from 'react-icons/bi';
7 | import { UserContext } from 'contexts/UserContext/User.context';
8 | import { usePostUserLogout } from 'api/apiComponents';
9 |
10 | export const Top = () => {
11 | const {
12 | state: { user, loggedIn, apiKey },
13 | dispatch,
14 | } = useContext(UserContext);
15 |
16 | const { mutateAsync: logout, isSuccess } = usePostUserLogout();
17 |
18 | useEffect(() => {
19 | if (isSuccess) {
20 | dispatch({ type: 'LOG_OUT' });
21 | }
22 | }, [isSuccess, dispatch]);
23 |
24 | return (
25 |
26 |
27 |
28 | Bootzooka
29 |
30 |
31 |
32 |
33 |
34 | Welcome
35 |
36 |
37 | Home
38 |
39 |
40 | {loggedIn && apiKey !== null ? (
41 | <>
42 |
43 |
44 | {user?.login}
45 | {' '}
46 | logout({ body: { apiKey } })}
49 | >
50 |
51 | Logout
52 |
53 | >
54 | ) : (
55 | <>
56 |
57 | Register
58 |
59 |
60 | Login
61 |
62 | >
63 | )}
64 |
65 |
66 |
67 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/ui/src/main/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Footer/Footer';
2 | export * from './ForkMe/ForkMe';
3 | export * from './Loader/Loader';
4 | export * from './Main/Main';
5 | export * from './Routes/Routes';
6 | export * from './Top/Top';
7 |
--------------------------------------------------------------------------------
/ui/src/pages/Login/Login.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from 'react';
2 | import { Link, useNavigate } from 'react-router';
3 | import Form from 'react-bootstrap/Form';
4 | import { BiLogInCircle } from 'react-icons/bi';
5 | import { Formik, Form as FormikForm } from 'formik';
6 | import * as Yup from 'yup';
7 | import { UserContext } from 'contexts/UserContext/User.context';
8 | import { TwoColumnHero, FormikInput, FeedbackButton } from 'components';
9 | import { usePostUserLogin } from 'api/apiComponents';
10 | import { validationSchema } from './Login.validations';
11 |
12 | export type LoginParams = Yup.InferType;
13 |
14 | export const Login = () => {
15 | const {
16 | dispatch,
17 | state: { loggedIn },
18 | } = useContext(UserContext);
19 |
20 | const navigate = useNavigate();
21 |
22 | const mutation = usePostUserLogin({
23 | onSuccess: ({ apiKey }) => {
24 | dispatch({ type: 'SET_API_KEY', apiKey });
25 | },
26 | });
27 |
28 | const login = mutation?.mutateAsync;
29 |
30 | useEffect(() => {
31 | if (loggedIn) navigate('/main');
32 | }, [loggedIn, navigate]);
33 |
34 | return (
35 |
36 | Please sign in
37 |
38 | initialValues={{ loginOrEmail: '', password: '' }}
39 | onSubmit={(values) => login({ body: values })}
40 | validationSchema={validationSchema}
41 | >
42 |
59 |
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/ui/src/pages/Login/Login.validations.ts:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 |
3 | export const validationSchema = Yup.object({
4 | loginOrEmail: Yup.string().required('Required'),
5 | password: Yup.string().required('Required'),
6 | });
7 |
--------------------------------------------------------------------------------
/ui/src/pages/NotFound/NotFound.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { MemoryRouter } from 'react-router';
3 | import { NotFound } from './NotFound';
4 |
5 | test('renders text content', () => {
6 | render( , { wrapper: MemoryRouter });
7 | const header = screen.getByText(/You shouldn't be here for sure :\)/i);
8 | expect(header).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/ui/src/pages/NotFound/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router';
2 | import Container from 'react-bootstrap/Container';
3 |
4 | export const NotFound: React.FC = () => (
5 |
6 | Ooops!
7 | You shouldn't be here for sure :)
8 | Please choose one of the locations below:
9 |
24 |
25 | );
26 |
--------------------------------------------------------------------------------
/ui/src/pages/Profile/Profile.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import { UserState } from 'contexts';
3 | import { UserContext } from 'contexts/UserContext/User.context';
4 | import { renderWithClient } from 'tests';
5 | import { Profile } from './Profile';
6 |
7 | const mockState: UserState = {
8 | apiKey: 'test-api-key',
9 | user: {
10 | login: 'user-login',
11 | email: 'email@address.pl',
12 | createdOn: '2020-10-09T09:57:17.995288Z',
13 | },
14 | loggedIn: true,
15 | };
16 |
17 | const dispatch = vi.fn();
18 |
19 | beforeEach(() => {
20 | vi.clearAllMocks();
21 | });
22 |
23 | test('renders headers', () => {
24 | renderWithClient(
25 |
26 |
27 |
28 | );
29 |
30 | expect(screen.getByText('Profile details')).toBeInTheDocument();
31 | expect(screen.getByText('Password details')).toBeInTheDocument();
32 | });
33 |
--------------------------------------------------------------------------------
/ui/src/pages/Profile/Profile.tsx:
--------------------------------------------------------------------------------
1 | import Container from 'react-bootstrap/Container';
2 | import Row from 'react-bootstrap/Row';
3 | import { ProfileDetails } from './components/ProfileDetails';
4 | import { PasswordDetails } from './components/PasswordDetails';
5 |
6 | export const Profile: React.FC = () => (
7 |
8 |
9 |
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/ui/src/pages/Profile/components/PasswordDetails.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from 'react';
2 | import Form from 'react-bootstrap/Form';
3 | import Container from 'react-bootstrap/Container';
4 | import Col from 'react-bootstrap/Col';
5 | import Row from 'react-bootstrap/Row';
6 | import { BiArrowFromBottom } from 'react-icons/bi';
7 | import { Formik, Form as FormikForm } from 'formik';
8 | import * as Yup from 'yup';
9 | import { UserContext } from 'contexts/UserContext/User.context';
10 | import { FormikInput, FeedbackButton } from 'components';
11 | import { usePostUserChangepassword } from 'api/apiComponents';
12 | import { validationSchema } from './PasswordDetails.validations';
13 |
14 | type PasswordDetailsParams = Yup.InferType;
15 |
16 | export const PasswordDetails = () => {
17 | const {
18 | state: { apiKey },
19 | dispatch,
20 | } = useContext(UserContext);
21 |
22 | const mutation = usePostUserChangepassword();
23 | const { isSuccess, data } = mutation;
24 |
25 | useEffect(() => {
26 | if (isSuccess) {
27 | const { apiKey } = data;
28 | dispatch({ type: 'SET_API_KEY', apiKey });
29 | }
30 | }, [isSuccess, data, dispatch]);
31 |
32 | useEffect(() => {
33 | localStorage.setItem('apiKey', apiKey || '');
34 | }, [apiKey]);
35 |
36 | return (
37 |
38 |
39 |
40 | {apiKey ? (
41 | <>
42 | Password details
43 |
44 | initialValues={{
45 | currentPassword: '',
46 | newPassword: '',
47 | repeatedPassword: '',
48 | }}
49 | onSubmit={(values) => mutation.mutate({ body: values })}
50 | validationSchema={validationSchema}
51 | >
52 |
79 |
80 | >
81 | ) : (
82 | Password details not available.
83 | )}
84 |
85 |
86 |
87 | );
88 | };
89 |
--------------------------------------------------------------------------------
/ui/src/pages/Profile/components/PasswordDetails.validations.ts:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 |
3 | export const validationSchema = Yup.object({
4 | currentPassword: Yup.string()
5 | .min(3, 'At least 3 characters required')
6 | .required('Required'),
7 | newPassword: Yup.string()
8 | .min(3, 'At least 3 characters required')
9 | .required('Required'),
10 | repeatedPassword: Yup.string()
11 | .oneOf([Yup.ref('newPassword')], 'Passwords must match')
12 | .required('Required'),
13 | });
14 |
--------------------------------------------------------------------------------
/ui/src/pages/Profile/components/ProfileDetails.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from 'react';
2 | import Form from 'react-bootstrap/Form';
3 | import Container from 'react-bootstrap/Container';
4 | import Col from 'react-bootstrap/Col';
5 | import Row from 'react-bootstrap/Row';
6 | import { BiArrowFromBottom } from 'react-icons/bi';
7 | import { Formik, Form as FormikForm } from 'formik';
8 | import * as Yup from 'yup';
9 | import { FormikInput, FeedbackButton } from 'components';
10 | import { usePostUser } from 'api/apiComponents';
11 | import { UserContext } from 'contexts/UserContext/User.context';
12 | import { validationSchema } from './ProfileDetails.validations';
13 |
14 | export type ProfileDetailsParams = Yup.InferType;
15 |
16 | export const ProfileDetails = () => {
17 | const {
18 | dispatch,
19 | state: { apiKey, user },
20 | } = useContext(UserContext);
21 |
22 | const mutation = usePostUser();
23 | const { data, isSuccess } = mutation;
24 |
25 | useEffect(() => {
26 | if (isSuccess) {
27 | dispatch({ type: 'UPDATE_USER_DATA', user: data });
28 | }
29 | }, [isSuccess, dispatch, data]);
30 |
31 | return (
32 |
33 |
34 |
35 | {apiKey ? (
36 | <>
37 | Profile details
38 |
39 | initialValues={{
40 | login: user?.login || '',
41 | email: user?.email || '',
42 | }}
43 | onSubmit={(values) => mutation.mutate({ body: values })}
44 | validationSchema={validationSchema}
45 | >
46 |
60 |
61 | >
62 | ) : (
63 | Profile details not available.
64 | )}
65 |
66 |
67 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/ui/src/pages/Profile/components/ProfileDetails.validations.ts:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 |
3 | export const validationSchema = Yup.object({
4 | login: Yup.string()
5 | .min(3, 'At least 3 characters required')
6 | .required('Required'),
7 | email: Yup.string()
8 | .email('Valid email address required')
9 | .required('Required'),
10 | });
11 |
--------------------------------------------------------------------------------
/ui/src/pages/RecoverLostPassword/RecoverLostPassword.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import { userEvent } from '@testing-library/user-event';
3 | import { renderWithClient } from 'tests';
4 | import { RecoverLostPassword } from './RecoverLostPassword';
5 |
6 | const mockMutate = vi.fn();
7 | const mockResponse = vi.fn();
8 |
9 | vi.mock('api/apiComponents', () => ({
10 | usePostPasswordresetForgot: () => mockResponse(),
11 | }));
12 |
13 | beforeEach(() => {
14 | vi.clearAllMocks();
15 | });
16 |
17 | test('renders header', () => {
18 | mockResponse.mockReturnValueOnce({
19 | mutate: mockMutate,
20 | reset: vi.fn(),
21 | data: { apiKey: 'test-api-key' },
22 | isSuccess: true,
23 | isError: false,
24 | error: '',
25 | });
26 |
27 | renderWithClient( );
28 |
29 | expect(screen.getByText('Recover lost password')).toBeInTheDocument();
30 | });
31 |
32 | test('handles password recover success', async () => {
33 | mockResponse.mockReturnValueOnce({
34 | mutate: mockMutate,
35 | reset: vi.fn(),
36 | data: { apiKey: 'test-api-key' },
37 | isSuccess: true,
38 | isError: false,
39 | error: '',
40 | });
41 |
42 | renderWithClient( );
43 |
44 | await userEvent.type(screen.getByLabelText('Login or email'), 'test-login');
45 | await userEvent.click(screen.getByText('Reset password'));
46 |
47 | expect(mockMutate).toHaveBeenCalledWith({
48 | body: { loginOrEmail: 'test-login' },
49 | });
50 |
51 | await screen.findByRole('success');
52 | await screen.findByText('Password reset claim success');
53 | });
54 |
55 | test('handles password recover error', async () => {
56 | mockResponse.mockReturnValueOnce({
57 | mutate: mockMutate,
58 | reset: vi.fn(),
59 | data: { apiKey: 'test-api-key' },
60 | isSuccess: false,
61 | isError: true,
62 | error: 'Test error',
63 | });
64 |
65 | renderWithClient( );
66 |
67 | await userEvent.type(screen.getByLabelText('Login or email'), 'test-login');
68 | await userEvent.click(screen.getByText('Reset password'));
69 |
70 | expect(mockMutate).toHaveBeenCalledWith({
71 | body: { loginOrEmail: 'test-login' },
72 | });
73 |
74 | await screen.findByRole('error');
75 | });
76 |
--------------------------------------------------------------------------------
/ui/src/pages/RecoverLostPassword/RecoverLostPassword.tsx:
--------------------------------------------------------------------------------
1 | import Form from 'react-bootstrap/Form';
2 | import { BiReset } from 'react-icons/bi';
3 | import { Formik, Form as FormikForm } from 'formik';
4 | import * as Yup from 'yup';
5 | import { TwoColumnHero, FormikInput, FeedbackButton } from 'components';
6 | import { usePostPasswordresetForgot } from 'api/apiComponents';
7 | import { validationSchema } from './RecoverLostPassword.validations';
8 |
9 | export type RecoverLostPasswordParams = Yup.InferType;
10 |
11 | export const RecoverLostPassword = () => {
12 | const mutation = usePostPasswordresetForgot();
13 |
14 | return (
15 |
16 | Recover lost password
17 |
18 | initialValues={{
19 | loginOrEmail: '',
20 | }}
21 | onSubmit={(values) => mutation.mutate({ body: values })}
22 | validationSchema={validationSchema}
23 | >
24 |
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/ui/src/pages/RecoverLostPassword/RecoverLostPassword.validations.ts:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 |
3 | export const validationSchema = Yup.object({
4 | loginOrEmail: Yup.string().required('Required'),
5 | });
6 |
--------------------------------------------------------------------------------
/ui/src/pages/Register/Register.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from 'react';
2 | import { useNavigate } from 'react-router';
3 | import Form from 'react-bootstrap/Form';
4 | import { BiUserPlus } from 'react-icons/bi';
5 | import { Formik, Form as FormikForm } from 'formik';
6 | import { TwoColumnHero, FormikInput, FeedbackButton } from 'components';
7 | import { UserContext } from 'contexts/UserContext/User.context';
8 | import { usePostUserRegister } from 'api/apiComponents';
9 | import { validationSchema } from './Register.validations';
10 | import { initialValues, RegisterParams } from './Register.utils';
11 |
12 | export const Register = () => {
13 | const {
14 | dispatch,
15 | state: { loggedIn },
16 | } = useContext(UserContext);
17 |
18 | const navigate = useNavigate();
19 |
20 | const mutation = usePostUserRegister({
21 | onSuccess: ({ apiKey }) => {
22 | dispatch({ type: 'SET_API_KEY', apiKey });
23 | },
24 | });
25 |
26 | useEffect(() => {
27 | if (loggedIn) navigate('/main');
28 | }, [loggedIn, navigate]);
29 |
30 | return (
31 |
32 | Please sign up
33 |
34 | initialValues={initialValues}
35 | onSubmit={(values) => mutation.mutate({ body: values })}
36 | validationSchema={validationSchema}
37 | >
38 |
57 |
58 |
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/ui/src/pages/Register/Register.utils.ts:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 | import { validationSchema } from './Register.validations';
3 |
4 | export const initialValues = {
5 | login: '',
6 | email: '',
7 | password: '',
8 | repeatedPassword: '',
9 | };
10 |
11 | export type RegisterParams = Yup.InferType;
12 |
--------------------------------------------------------------------------------
/ui/src/pages/Register/Register.validations.ts:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 |
3 | export const validationSchema = Yup.object({
4 | login: Yup.string()
5 | .min(3, 'At least 3 characters required')
6 | .required('Required'),
7 | email: Yup.string()
8 | .email('Valid email address required')
9 | .required('Required'),
10 | password: Yup.string()
11 | .min(5, 'At least 5 characters required')
12 | .required('Required'),
13 | repeatedPassword: Yup.string()
14 | .oneOf([Yup.ref('password')], 'Passwords must match')
15 | .required('Required'),
16 | });
17 |
--------------------------------------------------------------------------------
/ui/src/pages/SecretMain/SecretMain.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { SecretMain } from './SecretMain';
3 |
4 | test('renders text content', () => {
5 | render( );
6 | const header = screen.getByText(/Shhhh, this is a secret place./i);
7 | expect(header).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/ui/src/pages/SecretMain/SecretMain.tsx:
--------------------------------------------------------------------------------
1 | import Container from 'react-bootstrap/Container';
2 |
3 | export const SecretMain: React.FC = () => (
4 | <>
5 |
6 | Shhhh, this is a secret place.
7 | You've just logged in. Congrats!
8 |
9 | >
10 | );
11 |
--------------------------------------------------------------------------------
/ui/src/pages/Welcome/Welcome.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { MemoryRouter } from 'react-router';
3 | import { Welcome } from './Welcome';
4 |
5 | test('renders text content', () => {
6 | render(
7 |
8 |
9 |
10 | );
11 | const header = screen.getByText('Welcome to Bootzooka!');
12 | expect(header).toBeInTheDocument();
13 | });
14 |
--------------------------------------------------------------------------------
/ui/src/pages/Welcome/Welcome.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router';
2 | import Image from 'react-bootstrap/Image';
3 | import Container from 'react-bootstrap/Container';
4 | import Row from 'react-bootstrap/Row';
5 | import Fade from 'react-bootstrap/Fade';
6 | import logo from 'assets/sml-logo-vertical-white-all-trans.png';
7 |
8 | export const Welcome: React.FC = () => (
9 | <>
10 |
11 |
12 |
13 |
14 | Hi there!
15 | Welcome to Bootzooka!
16 |
17 | In this template application you can{' '}
18 |
19 | Register
20 | {' '}
21 | as a new user,{' '}
22 |
23 | Login
24 | {' '}
25 | and later manage your user details.
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | Brought to you by
36 |
41 |
42 |
43 |
44 | If you are interested in how Bootzooka works,
45 |
46 | you can browse the{' '}
47 |
48 | Documentation
49 | {' '}
50 | or{' '}
51 |
55 | Source code
56 |
57 | .
58 |
59 |
60 |
61 |
62 |
63 | >
64 | );
65 |
--------------------------------------------------------------------------------
/ui/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Login/Login';
2 | export * from './NotFound/NotFound';
3 | export * from './Profile/Profile';
4 | export * from './RecoverLostPassword/RecoverLostPassword';
5 | export * from './Register/Register';
6 | export * from './SecretMain/SecretMain';
7 | export * from './Welcome/Welcome';
8 |
--------------------------------------------------------------------------------
/ui/src/setupTest.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/vitest';
2 | import { cleanup } from '@testing-library/react';
3 |
4 | afterEach(() => {
5 | cleanup();
6 | });
7 |
--------------------------------------------------------------------------------
/ui/src/tests/index.ts:
--------------------------------------------------------------------------------
1 | export * from './utils';
2 |
--------------------------------------------------------------------------------
/ui/src/tests/utils.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2 | import { ReactElement } from 'react';
3 | import { render } from '@testing-library/react';
4 |
5 | const defaultQueryClient = new QueryClient();
6 |
7 | export const renderWithClient = (
8 | ui: ReactElement,
9 | client: QueryClient = defaultQueryClient
10 | ) => render({ui} );
11 |
--------------------------------------------------------------------------------
/ui/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/ui/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "types": ["vitest/globals"],
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 |
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true,
23 |
24 | "paths": {
25 | "api/*": ["./src/api/*"],
26 | "assets/*": ["./src/assets/*"],
27 | "components": ["./src/components"],
28 | "components/*": ["./src/components/*"],
29 | "contexts": ["./src/contexts"],
30 | "contexts/*": ["./src/contexts/*"],
31 | "main": ["./src/main"],
32 | "main/*": ["./src/main/*"],
33 | "pages": ["./src/pages"],
34 | "pages/*": ["./src/pages/*"],
35 | "tests": ["./src/tests"],
36 | "tests/*": ["./src/tests/*"]
37 | }
38 | },
39 | "include": ["src", "src/setupTest.ts"],
40 | "exclude": ["build", "node_modules", "src/api"]
41 | }
42 |
--------------------------------------------------------------------------------
/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
4 | }
5 |
--------------------------------------------------------------------------------
/ui/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "isolatedModules": true,
12 | "moduleDetection": "force",
13 | "noEmit": true,
14 |
15 | "strict": true,
16 | "noUnusedLocals": true,
17 | "noUnusedParameters": true,
18 | "noFallthroughCasesInSwitch": true,
19 | "noUncheckedSideEffectImports": true
20 | },
21 | "include": ["vite.config.ts"]
22 | }
23 |
--------------------------------------------------------------------------------
/ui/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { defineConfig, loadEnv } from 'vite';
3 | import react from '@vitejs/plugin-react-swc';
4 |
5 | export default defineConfig(({ mode }) => {
6 | const env = loadEnv(mode, process.cwd(), '');
7 |
8 | return {
9 | define: {
10 | 'process.env.REACT_APP_BASE_URL': JSON.stringify(env.VITE_APP_BASE_URL),
11 | },
12 | plugins: [react()],
13 | server: {
14 | open: true,
15 | port: 8081,
16 | },
17 | resolve: {
18 | alias: {
19 | api: path.resolve(__dirname, './src/api'),
20 | assets: path.resolve(__dirname, './src/assets'),
21 | components: path.resolve(__dirname, './src/components'),
22 | contexts: path.resolve(__dirname, './src/contexts'),
23 | main: path.resolve(__dirname, './src/main'),
24 | pages: path.resolve(__dirname, './src/pages'),
25 | tests: path.resolve(__dirname, './src/tests'),
26 | },
27 | },
28 | };
29 | });
30 |
--------------------------------------------------------------------------------
/ui/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, mergeConfig } from 'vitest/config';
2 | import viteConfig from './vite.config';
3 |
4 | export default defineConfig((env) =>
5 | mergeConfig(
6 | viteConfig(env),
7 | defineConfig({
8 | test: {
9 | globals: true,
10 | environment: 'jsdom',
11 | setupFiles: ['./src/setupTest.ts'],
12 | reporters: ['verbose'],
13 | coverage: {
14 | reporter: ['text', 'json', 'html'],
15 | include: ['src/**/*'],
16 | exclude: [
17 | 'src/api/*',
18 | 'src/main.tsx',
19 | 'src/vite-env.d.ts',
20 | 'src/main/index.ts',
21 | ],
22 | },
23 | },
24 | })
25 | )
26 | );
27 |
--------------------------------------------------------------------------------
/version.sbt:
--------------------------------------------------------------------------------
1 | ThisBuild / version := "0.0.1-SNAPSHOT"
2 |
--------------------------------------------------------------------------------
/yarn.lock:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 |
--------------------------------------------------------------------------------