├── .dockerignore ├── .githooks ├── README.md └── pre-commit ├── .github ├── CODEOWNERS ├── copilot-instructions.md ├── dependabot.yml └── workflows │ ├── deploy-production.yml │ ├── deploy-staging.yml │ ├── qa-backend.yml │ └── qa-frontend.yml ├── .gitignore ├── .run ├── Django tests.run.xml ├── Django.run.xml ├── React.run.xml ├── Services.run.xml └── Vitest.run.xml ├── .vscode └── settings.json ├── CHANGELOG.example.md ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.example.md ├── README.md ├── TODO.md ├── backend ├── .coveragerc ├── .env.example ├── .env.test.example ├── .gitignore ├── authentication │ ├── __init__.py │ ├── apps.py │ ├── serializers.py │ ├── templates │ │ └── authentication │ │ │ └── password_reset_email.html │ ├── tests │ │ ├── __init__.py │ │ └── test_views.py │ ├── utils.py │ └── views.py ├── core │ ├── __init__.py │ ├── apps.py │ ├── serializers.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_views.py │ │ └── utils.py │ └── views.py ├── django_react_starter │ ├── __init__.py │ ├── asgi.py │ ├── celery.py │ ├── sentry.py │ ├── settings │ │ ├── base.py │ │ ├── development.py │ │ ├── production.py │ │ └── test.py │ ├── templates │ │ └── admin │ │ │ └── base_site.html │ ├── urls.py │ └── wsgi.py ├── frontend │ └── .gitkeep ├── health │ ├── __init__.py │ ├── apps.py │ ├── tests │ │ ├── __init__.py │ │ └── test_views.py │ └── views.py ├── logs │ └── .gitkeep ├── manage.py ├── pyproject.toml ├── run-app.sh ├── run-celery-worker.sh ├── supervisord.conf ├── user │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── indexers.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── createsu.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_user_managers.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── tasks.py │ ├── tests │ │ ├── __init__.py │ │ ├── factories.py │ │ ├── test_commands.py │ │ ├── test_indexers.py │ │ ├── test_models.py │ │ └── test_views.py │ └── views.py └── uv.lock ├── biome.json ├── docker-compose.yml ├── docker ├── Dockerfile.dev └── Dockerfile.prod ├── docs ├── french_dark.png ├── homepage.png ├── login.png ├── password_reset.png ├── password_reset_confirm.png ├── register.png ├── responsive.png └── settings.png ├── fly ├── production.example.toml └── staging.example.toml ├── frontend ├── .gitignore ├── i18n │ ├── check-empty-translations.cjs │ ├── en.json │ └── fr.json ├── i18next-parser.config.ts ├── index.html ├── package.json ├── public │ └── .gitkeep ├── src │ ├── App.tsx │ ├── api │ │ ├── .gitkeep │ │ ├── config.ts │ │ ├── queries │ │ │ ├── __mocks__ │ │ │ │ ├── useAppConfig.ts │ │ │ │ └── useSelf.ts │ │ │ ├── index.ts │ │ │ ├── useAppConfig.ts │ │ │ ├── useCheckAuth.ts │ │ │ ├── useLogout.ts │ │ │ └── useSelf.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── assets │ │ ├── .gitkeep │ │ ├── externalAssets.ts │ │ └── index.ts │ ├── components │ │ ├── .gitkeep │ │ ├── form │ │ │ ├── .gitkeep │ │ │ ├── FieldsetInput.test.tsx │ │ │ ├── FieldsetInput.tsx │ │ │ └── index.ts │ │ ├── layout │ │ │ ├── .gitkeep │ │ │ ├── Main.test.tsx │ │ │ ├── Main.tsx │ │ │ ├── NavBar.test.tsx │ │ │ ├── NavBar.tsx │ │ │ └── index.ts │ │ └── ui │ │ │ ├── .gitkeep │ │ │ ├── FadeIn.test.tsx │ │ │ ├── FadeIn.tsx │ │ │ ├── LoadingRing.test.tsx │ │ │ ├── LoadingRing.tsx │ │ │ ├── Logo.test.tsx │ │ │ ├── Logo.tsx │ │ │ ├── Modal.test.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Toaster.test.tsx │ │ │ ├── Toaster.tsx │ │ │ └── index.ts │ ├── config │ │ ├── .gitkeep │ │ ├── daisyui.ts │ │ ├── dayjs.ts │ │ ├── i18n.ts │ │ └── sentry.ts │ ├── contexts │ │ ├── .gitkeep │ │ ├── ThemeProvider.test.tsx │ │ ├── ThemeProvider.tsx │ │ └── index.ts │ ├── features │ │ ├── .gitkeep │ │ ├── home │ │ │ ├── pages │ │ │ │ ├── Homepage.test.tsx │ │ │ │ └── Homepage.tsx │ │ │ └── routes.ts │ │ ├── login │ │ │ ├── api │ │ │ │ ├── index.ts │ │ │ │ ├── useLogin.ts │ │ │ │ ├── usePasswordReset.ts │ │ │ │ ├── usePasswordResetConfirm.ts │ │ │ │ └── useRegister.ts │ │ │ ├── components │ │ │ │ ├── BaseForm.test.tsx │ │ │ │ ├── BaseForm.tsx │ │ │ │ ├── LoginForm.test.tsx │ │ │ │ ├── LoginForm.tsx │ │ │ │ ├── PasswordResetConfirmForm.test.tsx │ │ │ │ ├── PasswordResetConfirmForm.tsx │ │ │ │ ├── PasswordResetForm.test.tsx │ │ │ │ ├── PasswordResetForm.tsx │ │ │ │ ├── RegisterForm.test.tsx │ │ │ │ ├── RegisterForm.tsx │ │ │ │ └── index.tsx │ │ │ ├── pages │ │ │ │ ├── LoginPage.test.tsx │ │ │ │ ├── LoginPage.tsx │ │ │ │ ├── PasswordResetConfirmPage.test.tsx │ │ │ │ ├── PasswordResetConfirmPage.tsx │ │ │ │ ├── PasswordResetPage.test.tsx │ │ │ │ └── PasswordResetPage.tsx │ │ │ └── routes.ts │ │ └── settings │ │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── useDeleteAccount.ts │ │ │ ├── useUpdatePassword.ts │ │ │ └── useUpdateSelf.ts │ │ │ ├── components │ │ │ ├── DangerZone.test.tsx │ │ │ ├── DangerZone.tsx │ │ │ ├── GoBackButton.test.tsx │ │ │ ├── GoBackButton.tsx │ │ │ ├── PasswordForm.test.tsx │ │ │ ├── PasswordForm.tsx │ │ │ ├── UserForm.test.tsx │ │ │ ├── UserForm.tsx │ │ │ ├── UserSettings.test.tsx │ │ │ ├── UserSettings.tsx │ │ │ └── index.ts │ │ │ ├── pages │ │ │ ├── SettingsPage.test.tsx │ │ │ └── SettingsPage.tsx │ │ │ └── routes.ts │ ├── hooks │ │ ├── .gitkeep │ │ ├── index.ts │ │ ├── useLocalStorage.test.ts │ │ ├── useLocalStorage.ts │ │ ├── useLocale.test.ts │ │ └── useLocale.ts │ ├── main.tsx │ ├── router │ │ ├── .gitkeep │ │ ├── Routes.test.tsx │ │ ├── Routes.tsx │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useUpdateMetadata.test.ts │ │ │ └── useUpdateMetadata.ts │ │ ├── index.ts │ │ └── routeConfig.ts │ ├── styles │ │ ├── .gitkeep │ │ └── base.css │ ├── tests │ │ ├── .gitkeep │ │ ├── mocks │ │ │ ├── globals.ts │ │ │ ├── handlers │ │ │ │ ├── index.ts │ │ │ │ ├── login.ts │ │ │ │ ├── settings.ts │ │ │ │ └── shared.ts │ │ │ └── index.ts │ │ ├── server.ts │ │ ├── setup.ts │ │ └── utils.tsx │ ├── types │ │ ├── .gitkeep │ │ └── images.d.ts │ └── utils │ │ └── .gitkeep ├── tsconfig.json ├── vite.config.ts └── yarn.lock └── ty.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | # Backend 2 | backend/.venv/ 3 | backend/data/ 4 | backend/htmlcov/ 5 | backend/.env.example 6 | backend/.env.test.example 7 | 8 | # Frontend 9 | frontend/coverage/ 10 | frontend/node_modules/ 11 | 12 | # Docs 13 | docs/ 14 | -------------------------------------------------------------------------------- /.githooks/README.md: -------------------------------------------------------------------------------- 1 | # Git hooks 2 | 3 | This folder contains githooks for this repository. 4 | 5 | They are set in this folder as the `.git/hooks` is not tracked in `.git` 6 | and cannot be shared with the rest of the team 7 | 8 | Run the following command to tell `git` to look for hooks in this `.githooks` folder: 9 | 10 | ```shell 11 | git config core.hooksPath .githooks 12 | ``` 13 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | echo "---------- Git hook: pre-commit ----------" 5 | 6 | # Biome (lint and format) 7 | echo ">>> [1/5] Running Biome on frontend" 8 | (cd frontend && yarn biome:check) 9 | 10 | # Typescript (type checking) 11 | echo ">>> [2/5] Compiling Typescript" 12 | (cd frontend && yarn tsc) 13 | 14 | # Make sure the translations are up to date (type checking) 15 | echo ">>> [3/5] Check translations" 16 | (cd frontend && yarn i18n:check) 17 | 18 | # Ruff (imports, lint, and format) 19 | echo "" 20 | echo ">>> [4/5] Running Ruff on backend" 21 | (cd backend && ruff check --select I .) 22 | (cd backend && ruff check .) 23 | (cd backend && ruff format --check .) 24 | 25 | # Ty (type checking) 26 | echo "" 27 | echo ">>> [5/5] Running Ty on backend" 28 | (cd backend && ty check . --error-on-warning) 29 | 30 | echo "------------------------------------------" 31 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Jordan-Kowal 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/frontend" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 1 8 | groups: 9 | all: 10 | patterns: ["*"] 11 | commit-message: 12 | prefix: "[Frontend] " 13 | - package-ecosystem: "uv" 14 | directory: "/backend" 15 | schedule: 16 | interval: "weekly" 17 | open-pull-requests-limit: 1 18 | groups: 19 | all: 20 | patterns: ["*"] 21 | commit-message: 22 | prefix: "[Backend] " 23 | -------------------------------------------------------------------------------- /.github/workflows/deploy-production.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to production 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [ created ] 7 | 8 | jobs: 9 | qa_backend: 10 | uses: ./.github/workflows/qa-backend.yml 11 | 12 | qa_frontend: 13 | uses: ./.github/workflows/qa-frontend.yml 14 | 15 | deploy: 16 | needs: [ qa_backend, qa_frontend ] 17 | name: Deploy app to production using fly.io 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: superfly/flyctl-actions/setup-flyctl@master 22 | - run: | 23 | flyctl deploy \ 24 | --remote-only \ 25 | --build-arg VITE_ENVIRONMENT=production \ 26 | --build-arg VITE_APP_VERSION=${{ github.ref_name }} \ 27 | --build-arg VITE_SENTRY_DSN=${{ secrets.REACT_SENTRY_DSN }} \ 28 | --env ENVIRONMENT=production \ 29 | --env APP_VERSION=${{ github.ref_name }} \ 30 | --config ./fly/production.toml 31 | env: 32 | FLY_API_TOKEN: ${{ secrets.FLY_ACCESS_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/deploy-staging.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to staging 2 | 3 | on: 4 | workflow_dispatch: 5 | # push: 6 | # branches: [ main ] 7 | 8 | jobs: 9 | qa_backend: 10 | uses: ./.github/workflows/qa-backend.yml 11 | 12 | qa_frontend: 13 | uses: ./.github/workflows/qa-frontend.yml 14 | 15 | deploy: 16 | needs: [ qa_backend, qa_frontend ] 17 | name: Deploy app to staging using fly.io 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: superfly/flyctl-actions/setup-flyctl@master 22 | - run: | 23 | flyctl deploy \ 24 | --remote-only \ 25 | --build-arg VITE_ENVIRONMENT=staging \ 26 | --build-arg VITE_APP_VERSION=staging \ 27 | --build-arg VITE_SENTRY_DSN=${{ secrets.REACT_SENTRY_DSN }} \ 28 | --env ENVIRONMENT=staging \ 29 | --env APP_VERSION=staging \ 30 | --config ./fly/staging.toml 31 | env: 32 | FLY_API_TOKEN: ${{ secrets.FLY_ACCESS_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/qa-frontend.yml: -------------------------------------------------------------------------------- 1 | name: QA frontend 2 | 3 | on: 4 | workflow_call: 5 | workflow_dispatch: 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | setup: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: "22" # Cached by github (https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md#nodejs) 19 | cache: "yarn" 20 | cache-dependency-path: ./frontend/yarn.lock 21 | 22 | - name: Cache dependencies 23 | id: cache-dependencies 24 | uses: actions/cache@v3 25 | with: 26 | path: | 27 | ~/.yarn 28 | ./frontend/node_modules 29 | key: yarn-${{ hashFiles('./frontend/yarn.lock') }} 30 | 31 | - name: Install dependencies 32 | if: ${{ steps.cache-dependencies.outputs.cache-hit != 'true' }} 33 | run: | 34 | cd frontend 35 | yarn install 36 | 37 | biome: 38 | needs: setup 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Cache dependencies 45 | uses: actions/cache@v3 46 | with: 47 | path: | 48 | ~/.yarn 49 | ./frontend/node_modules 50 | key: yarn-${{ hashFiles('./frontend/yarn.lock') }} 51 | 52 | - name: Run biome 53 | run: | 54 | cd frontend 55 | yarn biome:check 56 | 57 | tsc: 58 | needs: setup 59 | runs-on: ubuntu-latest 60 | 61 | steps: 62 | - uses: actions/checkout@v4 63 | 64 | - name: Cache dependencies 65 | uses: actions/cache@v3 66 | with: 67 | path: | 68 | ~/.yarn 69 | ./frontend/node_modules 70 | key: yarn-${{ hashFiles('./frontend/yarn.lock') }} 71 | 72 | - name: Run tsc 73 | run: | 74 | cd frontend 75 | yarn tsc 76 | 77 | i18n: 78 | needs: setup 79 | runs-on: ubuntu-latest 80 | 81 | steps: 82 | - uses: actions/checkout@v4 83 | 84 | - name: Cache dependencies 85 | uses: actions/cache@v3 86 | with: 87 | path: | 88 | ~/.yarn 89 | ./frontend/node_modules 90 | key: yarn-${{ hashFiles('./frontend/yarn.lock') }} 91 | 92 | - name: Run i18n 93 | run: | 94 | cd frontend 95 | yarn i18n:check 96 | 97 | coverage: 98 | needs: setup 99 | runs-on: ubuntu-latest 100 | 101 | steps: 102 | - uses: actions/checkout@v4 103 | 104 | - name: Cache dependencies 105 | uses: actions/cache@v3 106 | with: 107 | path: | 108 | ~/.yarn 109 | ./frontend/node_modules 110 | key: yarn-${{ hashFiles('./frontend/yarn.lock') }} 111 | 112 | - name: Run coverage 113 | run: | 114 | cd frontend 115 | yarn test:coverage 116 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## IDE 2 | .vscode/** 3 | !.vscode/settings.json 4 | !.vscode/launch.json 5 | !.vscode/tasks.json 6 | .idea/ 7 | 8 | ## Env 9 | .env 10 | .env.test 11 | .python-version 12 | 13 | ## Postgres 14 | data/ 15 | -------------------------------------------------------------------------------- /.run/Django tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 35 | -------------------------------------------------------------------------------- /.run/Django.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 38 | -------------------------------------------------------------------------------- /.run/React.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.run/Services.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.run/Vitest.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ruff.configuration": "./backend/pyproject.toml" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.example.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Template 4 | 5 | ### 🚀 Features 6 | 7 | ### ✨ Improvements 8 | 9 | ### 🐞 Bugfixes 10 | 11 | ### 🔧 Others 12 | 13 | - 💫 **DX**: 14 | - 🍬 **UX**: 15 | - 💻 **Backend**: 16 | - 🎨 **Frontend**: 17 | - 🚂 **Deploy**: 18 | 19 | ## [v1.0.0] - YYYY-MM-DD 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jordan Kowal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Improvements 4 | 5 | - Password reset email translation and design 6 | 7 | ## Features 8 | 9 | - Auth with gmail 10 | - Add 2-step authentication 11 | -------------------------------------------------------------------------------- /backend/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # [Django] Server 4 | manage.py 5 | django_react_starter/settings/development.py 6 | django_react_starter/settings/production.py 7 | django_react_starter/asgi.py 8 | django_react_starter/wsgi.py 9 | django_react_starter/sentry.py 10 | # [Django] Common files 11 | */__init__.py 12 | */migrations/* 13 | */tests/* 14 | */admin.py 15 | # [Django] Celery tasks 16 | */tasks.py 17 | 18 | [report] 19 | exclude_lines = 20 | pragma: no cover 21 | if TYPE_CHECKING: 22 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # General 4 | export ENVIRONMENT='development' 5 | export DJANGO_SETTINGS_MODULE='django_react_starter.settings.development' 6 | export SECRET_KEY= 7 | export DJANGO_SUPERUSER_EMAIL= 8 | export DJANGO_SUPERUSER_PASSWORD= 9 | export DEFAULT_FROM_EMAIL= 10 | 11 | # Postgres 12 | export POSTGRES_HOST=postgres 13 | export POSTGRES_PORT=5432 14 | export POSTGRES_USER= 15 | export POSTGRES_PASSWORD= 16 | export POSTGRES_DB= 17 | 18 | # RabbitMQ 19 | export RABBITMQ_HOSTNAME=rabbitmq 20 | export RABBITMQ_PORT=5672 21 | export RABBITMQ_ADMIN_URL="http://rabbitmq:15672" 22 | export RABBITMQ_USERNAME=django_react_starter 23 | export RABBITMQ_PASSWORD=django_react_starter 24 | 25 | # Meilisearch 26 | export MEILISEARCH_HOST=http://meilisearch:7700 27 | export MEILISEARCH_API_KEY='@7t^a5xfv%9cg-oemhm0pi&fe6b=i7_v%dlikah^%0=z(hgqre' 28 | 29 | # Others 30 | export RUN_AS_DEV_SERVER=1 31 | -------------------------------------------------------------------------------- /backend/.env.test.example: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # General 4 | export ENVIRONMENT='test' 5 | export DJANGO_SETTINGS_MODULE='django_react_starter.settings.test' 6 | 7 | # Postgres 8 | export POSTGRES_HOST=postgres 9 | export POSTGRES_PORT=5432 10 | export POSTGRES_USER=django_react_starter 11 | export POSTGRES_PASSWORD=django_react_starter 12 | export POSTGRES_DB=django_react_starter 13 | 14 | # Meilisearch 15 | export MEILISEARCH_HOST=http://meilisearch:7700 16 | export MEILISEARCH_API_KEY='@7t^a5xfv%9cg-oemhm0pi&fe6b=i7_v%dlikah^%0=z(hgqre' 17 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Database 2 | data/ 3 | db.sqlite3 4 | test.sqlite3 5 | celerybeat-schedule 6 | 7 | # Django 8 | *.log 9 | *.pot 10 | *.pyc 11 | __pycache__ 12 | media 13 | static-files/ 14 | media-files/ 15 | media-files-test/ 16 | 17 | # Python 18 | *.py[cod] 19 | *$py.class 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | .pytest_cache/ 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | 53 | # Environments 54 | .env 55 | .venv 56 | env/ 57 | venv/ 58 | ENV/ 59 | .python-version 60 | 61 | # Mypy 62 | .mypy_cache/ 63 | -------------------------------------------------------------------------------- /backend/authentication/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "auth.apps.AuthenticationConfig" 2 | -------------------------------------------------------------------------------- /backend/authentication/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AuthenticationConfig(AppConfig): 5 | name = "authentication" 6 | -------------------------------------------------------------------------------- /backend/authentication/templates/authentication/password_reset_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Hi

5 | 6 |

We received a request to reset the password for your account on Django React Starter.

7 | 8 |

To reset your password, click the link below:

9 | 10 |

11 | Reset My Password 12 |

13 | 14 |

If you didn’t request this, you can safely ignore this email.

15 | 16 |

This link will expire in {{ expiration_time }} minutes for your security.

17 | 18 |

Thanks,
The Django React Starter Team

19 | 20 | 21 | -------------------------------------------------------------------------------- /backend/authentication/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/authentication/tests/__init__.py -------------------------------------------------------------------------------- /backend/authentication/utils.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.tokens import PasswordResetTokenGenerator 5 | from django_utils_kit.emails import Email 6 | 7 | if TYPE_CHECKING: 8 | from user.models import User as UserType 9 | 10 | PASSWORD_RESET_EMAIL = Email( 11 | default_subject="Django React Starter - Password Reset", 12 | template_path="authentication/password_reset_email.html", 13 | ) 14 | 15 | 16 | def send_password_reset_email(user: "UserType") -> None: 17 | token = PasswordResetTokenGenerator().make_token(user) 18 | url = f"{settings.SITE_DOMAIN}/password-reset-confirm/{user.pk}/{token}" 19 | duration_minute = int(settings.PASSWORD_RESET_TIMEOUT / 60) 20 | PASSWORD_RESET_EMAIL.send_async( 21 | to=[user.email], 22 | context={"password_reset_url": url, "expiration_time": duration_minute}, 23 | ) 24 | -------------------------------------------------------------------------------- /backend/core/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "core.apps.CoreConfig" 2 | -------------------------------------------------------------------------------- /backend/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = "core" 6 | -------------------------------------------------------------------------------- /backend/core/serializers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework import serializers 3 | 4 | 5 | class AppConfigSerializer(serializers.Serializer): 6 | debug = serializers.BooleanField(initial=lambda: settings.DEBUG) 7 | media_url = serializers.CharField(initial=lambda: settings.MEDIA_URL) 8 | static_url = serializers.CharField(initial=lambda: settings.STATIC_URL) 9 | app_version = serializers.CharField(initial=lambda: settings.APP_VERSION) 10 | -------------------------------------------------------------------------------- /backend/core/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .utils import BaseActionTestCase, BaseTestCase # noqa 2 | -------------------------------------------------------------------------------- /backend/core/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | from django.conf import settings 4 | from django.http import HttpResponse 5 | from django.test import override_settings 6 | from rest_framework.reverse import reverse 7 | 8 | from .utils import BaseActionTestCase 9 | 10 | APP_CONFIG_URL = reverse("app-config") 11 | INDEX_URL = "/" 12 | ROBOTS_TXT_URL = "/robots.txt/" 13 | 14 | 15 | class CoreViewsTestCase(BaseActionTestCase): 16 | @patch("core.views.render") 17 | def test_index(self, render_mock: Mock) -> None: 18 | render_mock.return_value = HttpResponse(content="

Hello

") 19 | response = self.client.get(INDEX_URL) 20 | args, kwargs = render_mock.call_args 21 | self.assertEqual(args[1], "dist/index.html") 22 | self.assertEqual(response.status_code, 200) 23 | self.assertEqual(response.content, b"

Hello

") 24 | 25 | def test_robots_txt(self) -> None: 26 | response = self.client.get(ROBOTS_TXT_URL) 27 | self.assertEqual(response.status_code, 200) 28 | self.assertEqual(response["Content-Type"], "text/plain") 29 | self.assertEqual(response.content, b"User-agent: *\nDisallow: /") 30 | 31 | 32 | class AppViewSetTestCase(BaseActionTestCase): 33 | @override_settings(APP_VERSION="v0.0.0") 34 | def test_config_success(self) -> None: 35 | self.api_client.force_authenticate(self.user) 36 | response = self.api_client.get(APP_CONFIG_URL) 37 | self.assertEqual(response.status_code, 200) 38 | self.assertEqual(response.data["debug"], False) 39 | self.assertEqual(response.data["media_url"], settings.MEDIA_URL) 40 | self.assertEqual(response.data["static_url"], settings.STATIC_URL) 41 | self.assertEqual(response.data["app_version"], "v0.0.0") 42 | 43 | def test_config_error_if_not_authenticated(self) -> None: 44 | response = self.api_client.get(APP_CONFIG_URL) 45 | self.assertEqual(response.status_code, 401) 46 | -------------------------------------------------------------------------------- /backend/core/tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from typing import cast 4 | from unittest.mock import patch 5 | 6 | from django.conf import settings 7 | from django_utils_kit.test_utils import APITestCase, ImprovedTestCase 8 | from meilisearch import Client 9 | 10 | from user.models import User as UserType 11 | from user.tests.factories import UserFactory 12 | 13 | 14 | class BaseTestCase(ImprovedTestCase): 15 | user: "UserType" 16 | meilisearch_client: Client 17 | 18 | @classmethod 19 | def setUpClass(cls) -> None: 20 | super().setUpClass() 21 | cls.meilisearch_client = Client( 22 | settings.MEILISEARCH_HOST, settings.MEILISEARCH_API_KEY 23 | ) 24 | 25 | def setUp(self) -> None: 26 | super().setUp() 27 | self._mock_celery_tasks() 28 | 29 | def _mock_celery_tasks(self) -> None: 30 | """ 31 | Patches the celery tasks in both forms: `delay` and `apply_async`. 32 | """ 33 | names = [ 34 | # Delay 35 | "user.tasks.index_all_users_atomically.delay", 36 | "user.tasks.index_users.delay", 37 | "user.tasks.unindex_users.delay", 38 | # Apply Async 39 | "user.tasks.index_all_users_atomically.apply_async", 40 | "user.tasks.index_users.apply_async", 41 | "user.tasks.unindex_users.apply_async", 42 | ] 43 | self.celery_task_mocks = {name: patch(name).start() for name in names} 44 | 45 | @classmethod 46 | def tearDownClass(cls) -> None: 47 | super().tearDownClass() 48 | media_root = settings.MEDIA_ROOT or "" 49 | if os.path.exists(media_root) and media_root.endswith("test"): 50 | shutil.rmtree(media_root) 51 | 52 | 53 | class BaseActionTestCase(BaseTestCase, APITestCase): 54 | @classmethod 55 | def setUpTestData(cls) -> None: 56 | super().setUpTestData() 57 | cls.user = cast(UserType, UserFactory()) 58 | 59 | def tearDown(self) -> None: 60 | super().tearDown() 61 | self.api_client.logout() 62 | -------------------------------------------------------------------------------- /backend/core/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, HttpResponse 2 | from django.shortcuts import render 3 | from django.views.decorators.http import require_GET 4 | from django_utils_kit.viewsets import ImprovedViewSet 5 | from rest_framework import permissions 6 | from rest_framework.decorators import action 7 | from rest_framework.request import Request 8 | from rest_framework.response import Response 9 | 10 | from core.serializers import AppConfigSerializer 11 | 12 | 13 | def index(request: HttpRequest) -> HttpResponse: 14 | return render(request, "dist/index.html") 15 | 16 | 17 | @require_GET # ty: ignore 18 | def robots_txt(request: HttpRequest) -> HttpResponse: 19 | lines = [ 20 | "User-agent: *", 21 | "Disallow: /", 22 | ] 23 | return HttpResponse("\n".join(lines), content_type="text/plain") 24 | 25 | 26 | class AppViewSet(ImprovedViewSet): 27 | default_permission_classes = [permissions.IsAuthenticated] 28 | serializer_class_per_action = { 29 | "config": AppConfigSerializer, 30 | } 31 | 32 | @action(detail=False, methods=["GET"]) 33 | def config(self, _request: Request) -> Response: 34 | serializer = self.get_serializer() 35 | return Response(serializer.data) 36 | -------------------------------------------------------------------------------- /backend/django_react_starter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/django_react_starter/__init__.py -------------------------------------------------------------------------------- /backend/django_react_starter/asgi.py: -------------------------------------------------------------------------------- 1 | """ASGI config for django_react_starter project. 2 | 3 | It exposes the ASGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ 7 | """ 8 | 9 | import os 10 | 11 | from django.core.asgi import get_asgi_application 12 | 13 | os.environ.setdefault( 14 | "DJANGO_SETTINGS_MODULE", "django_react_starter.settings.development" 15 | ) 16 | 17 | application = get_asgi_application() 18 | -------------------------------------------------------------------------------- /backend/django_react_starter/celery.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | from django.conf import settings 3 | from kombu import Exchange, Queue 4 | 5 | from django_react_starter.settings.base import CELERY_CONFIG_PREFIX 6 | from user.tasks import scheduled_cron_tasks as user_schedule 7 | 8 | app = Celery("django_react_starter") 9 | app.config_from_object("django.conf:settings", namespace=CELERY_CONFIG_PREFIX) 10 | app.autodiscover_tasks() 11 | 12 | app.conf.task_queues = [ 13 | Queue( 14 | settings.RABBITMQ_USER_QUEUE, 15 | Exchange(settings.RABBITMQ_USER_QUEUE), 16 | routing_key=settings.RABBITMQ_USER_QUEUE, 17 | ), 18 | ] 19 | 20 | app.conf.beat_schedule = { 21 | **user_schedule, 22 | } 23 | 24 | app.conf.timezone = "UTC" 25 | -------------------------------------------------------------------------------- /backend/django_react_starter/sentry.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | 4 | def traces_sampler(sampling_context: Dict[str, Any]) -> float: 5 | path_info = sampling_context.get("wsgi_environ", {}).get("PATH_INFO") or "" 6 | # Ignore anything not related to the API 7 | if not path_info.startswith("/api/v1/"): 8 | return 0.0 9 | # Ignore healthchecks 10 | if path_info.startswith("/api/v1/health/"): 11 | return 0.0 12 | # Ignore auth checks 13 | if path_info.startswith("/api/v1/auth/check/"): 14 | return 0.0 15 | # Sample 20% of the requests 16 | return 0.2 17 | -------------------------------------------------------------------------------- /backend/django_react_starter/settings/development.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | 3 | DEBUG = True 4 | ENVIRONMENT = "development" 5 | APP_VERSION = "v0.0.0" 6 | ALLOWED_HOSTS = [ 7 | "localhost", 8 | "127.0.0.1", 9 | "api", # Name of the django service in docker-compose.yml, used by frontend 10 | ] 11 | CSRF_TRUSTED_ORIGINS = [ 12 | "http://localhost", 13 | "http://127.0.0.1", 14 | "http://localhost:3000", # React dev server 15 | "http://localhost:8000/", 16 | ] 17 | SITE_DOMAIN = "http://localhost:3000" # React dev server 18 | SECRET_KEY = "yq-^$c^8r-^zebn#n+ilw3zegt9^9!b9@)-sv1abpca3i%hrko" 19 | DJANGO_SUPERUSER_EMAIL = "kowaljordan@gmail.com" 20 | DJANGO_SUPERUSER_PASSWORD = "password" 21 | -------------------------------------------------------------------------------- /backend/django_react_starter/settings/production.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import dj_database_url 4 | import sentry_sdk 5 | from sentry_sdk.integrations.django import DjangoIntegration 6 | 7 | from ..sentry import traces_sampler 8 | from .base import * # noqa 9 | from .base import APP_VERSION, ENVIRONMENT, LOGGING 10 | 11 | FLY_VOLUME_DIR = os.getenv("FLY_VOLUME_DIR", None) 12 | 13 | 14 | # -------------------------------------------------------------------------------- 15 | # > HTTP 16 | # -------------------------------------------------------------------------------- 17 | SITE_DOMAIN = os.getenv("SITE_DOMAIN") 18 | HOST_DNS_NAMES = os.getenv("HOST_DNS_NAMES", "").split(",") 19 | INTERNAL_IPS = os.getenv("INTERNAL_IPS", "").split(",") 20 | ALLOWED_HOSTS = ["localhost", "127.0.0.1", *HOST_DNS_NAMES, *INTERNAL_IPS] 21 | CSRF_TRUSTED_ORIGINS = [ 22 | "http://localhost", 23 | "http://127.0.0.1", 24 | *[f"https://{host_dns_name}" for host_dns_name in HOST_DNS_NAMES], 25 | ] 26 | CSRF_COOKIE_SECURE = True 27 | SESSION_COOKIE_SECURE = True 28 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 29 | SECURE_SSL_REDIRECT = False # Handled by fly.io and necessary for healthchecks 30 | 31 | 32 | # -------------------------------------------------------------------------------- 33 | # > Media 34 | # -------------------------------------------------------------------------------- 35 | if FLY_VOLUME_DIR is not None: 36 | MEDIA_ROOT = os.path.join(FLY_VOLUME_DIR, "media-files") 37 | 38 | 39 | # -------------------------------------------------------------------------------- 40 | # > Database 41 | # -------------------------------------------------------------------------------- 42 | db_config = dj_database_url.config( 43 | default=os.getenv("DATABASE_URL"), 44 | conn_max_age=600, 45 | conn_health_checks=True, 46 | ) 47 | DATABASES = { 48 | "default": { 49 | **db_config, 50 | "ENGINE": "django_prometheus.db.backends.postgis", 51 | } 52 | } 53 | 54 | # -------------------------------------------------------------------------------- 55 | # > Email 56 | # -------------------------------------------------------------------------------- 57 | # To use the SendInBlue API, the IP must be whitelisted in the SendInBlue GUI 58 | # https://app.sendinblue.com/account/security/authorised_ips/ 59 | # Make sure to add the server's IP address 60 | DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") 61 | EMAIL_BACKEND = "anymail.backends.sendinblue.EmailBackend" 62 | SENDINBLUE_API_URL = "https://api.sendinblue.com/v3/" 63 | ANYMAIL = { 64 | "SENDINBLUE_API_KEY": os.getenv("SENDINBLUE_API_KEY"), 65 | } 66 | 67 | # -------------------------------------------------------------------------------- 68 | # > Logging 69 | # -------------------------------------------------------------------------------- 70 | if FLY_VOLUME_DIR is not None: 71 | LOGGING["handlers"]["console.log"]["filename"] = os.path.join( 72 | FLY_VOLUME_DIR, "console.log" 73 | ) 74 | 75 | 76 | # -------------------------------------------------------------------------------- 77 | # > Sentry 78 | # -------------------------------------------------------------------------------- 79 | SENTRY_DSN = os.getenv("SENTRY_DSN") 80 | if SENTRY_DSN: 81 | sentry_sdk.init( 82 | dsn=SENTRY_DSN, 83 | environment=ENVIRONMENT, 84 | integrations=[DjangoIntegration()], 85 | send_default_pii=False, # GDPR 86 | traces_sampler=traces_sampler, 87 | profiles_sample_rate=0.2, 88 | sample_rate=0.2, 89 | release=f"django_react_starter@{APP_VERSION}", 90 | ) 91 | SENTRY_INITIALIZED = True 92 | else: 93 | print("Cannot start Sentry") 94 | -------------------------------------------------------------------------------- /backend/django_react_starter/settings/test.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | from .base import MEDIA_ROOT 3 | 4 | DEBUG = False 5 | ENVIRONMENT = "test" 6 | ALLOWED_HOSTS = ["localhost", "127.0.0.1"] 7 | SECRET_KEY = "+qbi539m9lip0yf5t97a8n4o(_3h@3&3u30kaw@ou5ydav+s_t" 8 | DJANGO_SUPERUSER_EMAIL = "random-email@for-test.com" 9 | DJANGO_SUPERUSER_PASSWORD = "random-password-for-test" 10 | EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" 11 | SITE_DOMAIN = "http://localhost:8000/" 12 | 13 | # Media 14 | MEDIA_ROOT = MEDIA_ROOT + "-test" 15 | 16 | # Logging: Logs are still captured but none are displayed in the console 17 | LOGGING = { 18 | "version": 1, 19 | "disable_existing_loggers": False, 20 | "handlers": { 21 | "console": { 22 | "class": "logging.StreamHandler", 23 | "level": "CRITICAL", # To hide info/warning/error logs from test console 24 | }, 25 | }, 26 | "root": { 27 | "handlers": ["console"], 28 | "level": "INFO", 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /backend/django_react_starter/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base_site.html' %} 2 | 3 | {% block extrahead %} 4 | {{ block.super }} 5 | 6 | {% endblock %} 7 | 8 | {% block branding %} 9 | 10 | logo 11 | 12 | {{ block.super }} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /backend/django_react_starter/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import include, path, re_path 5 | from django.utils.safestring import mark_safe 6 | from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 7 | from rest_framework import routers 8 | 9 | from authentication.views import AuthViewSet 10 | from core.views import AppViewSet, index, robots_txt 11 | from health.views import HealthViewSet 12 | from user.views import CurrentUserViewSet 13 | 14 | router = routers.SimpleRouter() 15 | router.register("app", AppViewSet, basename="app") 16 | router.register("auth", AuthViewSet, basename="auth") 17 | router.register("self", CurrentUserViewSet, basename="self") 18 | router.register("health", HealthViewSet, basename="health") 19 | 20 | API_ROOT = "api" 21 | 22 | urlpatterns = [ 23 | path("robots.txt/", robots_txt), 24 | path("admin/", admin.site.urls), 25 | *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), 26 | path(f"{API_ROOT}/v1/", include(router.urls)), 27 | path("", include("django_prometheus.urls")), 28 | ] 29 | 30 | # Only add swagger info in specific environments 31 | if settings.ENVIRONMENT in ["development", "staging", "test"]: 32 | urlpatterns += [ 33 | path(f"{API_ROOT}/schema/", SpectacularAPIView.as_view(), name="schema"), 34 | path( 35 | f"{API_ROOT}/swagger/", 36 | SpectacularSwaggerView.as_view(url_name="schema"), 37 | name="swagger-ui", 38 | ), 39 | ] 40 | 41 | # Match all and forward to react router on the front-end app. 42 | urlpatterns += [re_path(r"^.*$", index)] 43 | 44 | # Admin config 45 | admin.site.site_header = mark_safe("Admin Interface") 46 | admin.site.index_title = "Welcome to the Django React Starter admin interface" 47 | -------------------------------------------------------------------------------- /backend/django_react_starter/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI config for django_react_starter project. 2 | 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ 7 | """ 8 | 9 | import os 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | os.environ.setdefault( 14 | "DJANGO_SETTINGS_MODULE", "django_react_starter.settings.development" 15 | ) 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /backend/frontend/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/frontend/.gitkeep -------------------------------------------------------------------------------- /backend/health/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "health.apps.HealthConfig" 2 | -------------------------------------------------------------------------------- /backend/health/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HealthConfig(AppConfig): 5 | name = "health" 6 | -------------------------------------------------------------------------------- /backend/health/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/health/tests/__init__.py -------------------------------------------------------------------------------- /backend/health/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import get_user_model 3 | from django_utils_kit.viewsets import ImprovedViewSet 4 | from meilisearch import Client 5 | import requests 6 | from rest_framework.decorators import action 7 | from rest_framework.permissions import AllowAny 8 | from rest_framework.request import Request 9 | from rest_framework.response import Response 10 | from rest_framework.status import HTTP_200_OK, HTTP_500_INTERNAL_SERVER_ERROR 11 | 12 | from django_react_starter.celery import app as celery_app 13 | 14 | User = get_user_model() 15 | 16 | 17 | class HealthViewSet(ImprovedViewSet): 18 | default_permission_classes = [AllowAny] 19 | default_serializer_class = None 20 | 21 | @action(detail=False, methods=["get"]) 22 | def api(self, _request: Request) -> Response: 23 | return Response(status=HTTP_200_OK, data="API up") 24 | 25 | @action(detail=False, methods=["get"]) 26 | def database(self, _request: Request) -> Response: 27 | try: 28 | User.objects.exists() 29 | return Response(status=HTTP_200_OK, data="Database up") 30 | except Exception: 31 | return Response(status=HTTP_500_INTERNAL_SERVER_ERROR, data="Database down") 32 | 33 | @action(detail=False, methods=["get"]) 34 | def rabbitmq(self, _request: Request) -> Response: 35 | try: 36 | verify = settings.RABBITMQ_HEALTHCHECK_URL.startswith("https") 37 | response = requests.get( 38 | settings.RABBITMQ_HEALTHCHECK_URL, 39 | auth=(settings.RABBITMQ_USERNAME, settings.RABBITMQ_PASSWORD), 40 | verify=verify, 41 | ) 42 | response.raise_for_status() 43 | return Response(status=HTTP_200_OK, data="RabbitMQ up") 44 | except Exception: 45 | return Response(status=HTTP_500_INTERNAL_SERVER_ERROR, data="RabbitMQ down") 46 | 47 | @action(detail=False, methods=["get"]) 48 | def celery(self, _request: Request) -> Response: 49 | try: 50 | pong = celery_app.control.ping() 51 | if not pong: 52 | raise Exception("No Celery workers") 53 | return Response(status=HTTP_200_OK, data="Celery workers up") 54 | except Exception: 55 | return Response( 56 | status=HTTP_500_INTERNAL_SERVER_ERROR, data="Celery workers down" 57 | ) 58 | 59 | @action(detail=False, methods=["get"]) 60 | def meilisearch(self, _request: Request) -> Response: 61 | try: 62 | client = Client(settings.MEILISEARCH_HOST, settings.MEILISEARCH_API_KEY) 63 | response = client.health() 64 | status = response.get("status") 65 | if status != "available": 66 | raise Exception("Meilisearch down") 67 | return Response(status=HTTP_200_OK, data="Meilisearch up") 68 | except Exception: 69 | return Response( 70 | status=HTTP_500_INTERNAL_SERVER_ERROR, 71 | data="Cannot reach Meilisearch server", 72 | ) 73 | -------------------------------------------------------------------------------- /backend/logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/logs/.gitkeep -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main() -> None: 9 | """Run administrative tasks.""" 10 | os.environ.setdefault( 11 | "DJANGO_SETTINGS_MODULE", "django_react_starter.settings.development" 12 | ) 13 | try: 14 | # Django 15 | from django.core.management import execute_from_command_line 16 | except ImportError as exc: 17 | raise ImportError( 18 | "Couldn't import Django. Are you sure it's installed and " 19 | "available on your PYTHONPATH environment variable? Did you " 20 | "forget to activate a virtual environment?" 21 | ) from exc 22 | execute_from_command_line(sys.argv) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "backend" 3 | version = "4.2.0" 4 | requires-python = ">=3.13" 5 | dependencies = [ 6 | "celery>=5.4.0", 7 | "dj-database-url>=2.2.0", 8 | "django>=5.1.1", 9 | "django-celery-results>=2.5.1", 10 | "django-prometheus>=2.3.1", 11 | "djangorestframework>=3.15.2", 12 | "drf-spectacular>=0.27.2", 13 | "gunicorn>=23.0.0", 14 | "meilisearch>=0.31.5", 15 | "pillow>=10.4.0", 16 | "psycopg2-binary>=2.9.9", 17 | "sentry-sdk>=2.13.0", 18 | "whitenoise>=6.7.0", 19 | "django-utils-kit>=1.0.0", 20 | "django-meilisearch-indexer>=1.0.1", 21 | "django-anymail[sendinblue]>=13.0", 22 | ] 23 | 24 | # ------------------------------ 25 | # UV 26 | # ------------------------------ 27 | [tool.uv] 28 | dev-dependencies = [ 29 | "coverage>=7.6.1", 30 | "django-stubs>=5.0.4", 31 | "djangorestframework-stubs>=3.15.0", 32 | "factory-boy>=3.3.1", 33 | "ruff>=0.6.4", 34 | "tblib>=3.1.0", 35 | "ty>=0.0.1a3", 36 | "types-pytz>=2024.1.0.20240417", 37 | "types-requests>=2.32.0.20240907", 38 | ] 39 | 40 | # ------------------------------ 41 | # TY 42 | # ------------------------------ 43 | [tool.ty.rules] 44 | unresolved-attribute = "ignore" 45 | 46 | # ------------------------------ 47 | # RUFF 48 | # ------------------------------ 49 | [tool.ruff.format] 50 | quote-style = "double" 51 | docstring-code-format = true 52 | 53 | [tool.ruff.lint.isort] 54 | known-first-party = [ 55 | "core", 56 | "health", 57 | "django_react_starter", 58 | "user", 59 | "authentication", 60 | ] 61 | force-sort-within-sections = true 62 | section-order = [ 63 | "future", 64 | "standard-library", 65 | "third-party", 66 | "first-party", 67 | "local-folder", 68 | ] 69 | -------------------------------------------------------------------------------- /backend/run-app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Choose settings 6 | if [[ "$RUN_AS_DEV_SERVER" == 1 ]]; then 7 | SETTINGS=django_react_starter.settings.development 8 | else 9 | SETTINGS=django_react_starter.settings.production 10 | fi 11 | 12 | # Setup the app 13 | python manage.py collectstatic --noinput --settings=$SETTINGS 14 | python manage.py migrate --settings=$SETTINGS 15 | python manage.py createsu --settings=$SETTINGS 16 | 17 | # Run the app 18 | if [[ "$RUN_AS_DEV_SERVER" == 1 ]]; then 19 | echo "[run-app] Running app in development mode" 20 | python manage.py runserver 0.0.0.0:8000 --settings=$SETTINGS 21 | else 22 | echo "[run-app] Running app in production mode" 23 | gunicorn django_react_starter.wsgi:application \ 24 | --bind=:"${PORT:-8000}" \ 25 | --workers="${GUNICORN_WORKERS:-4}" 26 | fi 27 | -------------------------------------------------------------------------------- /backend/run-celery-worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Start the celery workers 6 | echo "[run-celery-worker] Starting celery worker in 15 seconds..." 7 | sleep 15 8 | echo "[run-celery-worker] Starting celery worker" 9 | celery --app=django_react_starter worker --beat --loglevel=INFO --concurrency="${CELERY_WORKERS:-2}" --scheduler=celery.beat.Scheduler 10 | -------------------------------------------------------------------------------- /backend/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | logfile=/home/app/logs/supervisord.log 3 | logfile_maxbytes=10MB 4 | nodaemon=true 5 | 6 | [program:django-app] 7 | ;user=app 8 | directory=/home/app/backend 9 | command=/home/app/backend/run-app.sh 10 | priority=1 11 | autostart=true 12 | autorestart=true 13 | stdout_logfile=/dev/stdout 14 | stderr_logfile=/dev/stderr 15 | stdout_logfile_maxbytes=0 16 | stderr_logfile_maxbytes=0 17 | 18 | [program:celery-worker] 19 | ;user=app 20 | directory=/home/app/backend 21 | command=/home/app/backend/run-celery-worker.sh 22 | priority=2 23 | autostart=true 24 | autorestart=true 25 | stdout_logfile=/dev/stdout 26 | stderr_logfile=/dev/stderr 27 | stdout_logfile_maxbytes=0 28 | stderr_logfile_maxbytes=0 29 | -------------------------------------------------------------------------------- /backend/user/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "user.apps.UserConfig" 2 | -------------------------------------------------------------------------------- /backend/user/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from django import forms 4 | from django.contrib import admin 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.admin import UserAdmin 7 | from django.contrib.auth.forms import UserCreationForm 8 | 9 | from user.models import Profile 10 | 11 | 12 | class ImprovedUserCreationForm(UserCreationForm): 13 | """A UserCreationForm that overrides username with email.""" 14 | 15 | class Meta: 16 | model = get_user_model() 17 | fields = ("email", "username") 18 | 19 | def __init__(self, *args: Any, **kwargs: Any) -> None: 20 | super(UserCreationForm, self).__init__(*args, **kwargs) 21 | self.fields["username"].widget = forms.HiddenInput() 22 | self.fields["username"].required = False 23 | 24 | def clean(self) -> Dict[str, Any]: 25 | super().clean() 26 | self.cleaned_data["username"] = self.cleaned_data["email"] 27 | return self.cleaned_data 28 | 29 | 30 | class ProfileInlineAdmin(admin.StackedInline): 31 | model = Profile 32 | fk_name = "user" 33 | readonly_fields: List[str] = [] 34 | fields: List[str] = [] 35 | can_delete = False 36 | extra = 0 37 | verbose_name = "Profile" 38 | 39 | 40 | @admin.register(get_user_model()) 41 | class UserModelAdmin(UserAdmin): 42 | # List view 43 | list_display = ( 44 | "email", 45 | "first_name", 46 | "last_name", 47 | "is_active", 48 | "is_staff", 49 | "is_superuser", 50 | ) 51 | search_fields = ("email", "first_name", "last_name") 52 | list_filter = ( 53 | "is_active", 54 | "is_staff", 55 | "is_superuser", 56 | ) 57 | 58 | # Detail view 59 | inlines = [ProfileInlineAdmin] 60 | fieldsets = ( 61 | ( 62 | None, 63 | { 64 | "fields": ( 65 | "id", 66 | "date_joined", 67 | "last_login", 68 | ) 69 | }, 70 | ), 71 | ( 72 | "Personal information", 73 | { 74 | "fields": ( 75 | "email", 76 | "first_name", 77 | "last_name", 78 | "password", 79 | ) 80 | }, 81 | ), 82 | ( 83 | "Permissions", 84 | { 85 | "fields": ( 86 | "is_active", 87 | "is_staff", 88 | "is_superuser", 89 | ), 90 | }, 91 | ), 92 | ) 93 | readonly_fields = ("id", "date_joined", "last_login") 94 | 95 | # Add Form 96 | add_form = ImprovedUserCreationForm 97 | add_fieldsets = ( 98 | ( 99 | None, 100 | { 101 | "classes": ("wide",), 102 | "fields": ( 103 | "email", 104 | "username", 105 | "password1", 106 | "password2", 107 | ), 108 | }, 109 | ), 110 | ) 111 | -------------------------------------------------------------------------------- /backend/user/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserConfig(AppConfig): 5 | name = "user" 6 | 7 | def ready(self) -> None: 8 | from django.conf import settings 9 | 10 | from user.indexers import UserIndexer 11 | 12 | if settings.ENVIRONMENT == "test": 13 | return 14 | 15 | UserIndexer.maybe_create_index() # pragma: no cover 16 | -------------------------------------------------------------------------------- /backend/user/indexers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from django_meilisearch_indexer.indexers import MeilisearchModelIndexer 4 | 5 | from user.models import User 6 | 7 | 8 | class UserIndexer(MeilisearchModelIndexer[User]): 9 | """This is an example of how to add a custom indexer for the User model.""" 10 | 11 | MODEL_CLASS = User 12 | PRIMARY_KEY = "id" 13 | SETTINGS = { 14 | "filterableAttributes": ["fake_type"], 15 | "searchableAttributes": ["indexed_name"], 16 | "sortableAttributes": ["indexed_name"], 17 | } 18 | 19 | @classmethod 20 | def build_object(cls, user: User) -> Dict[str, Any]: 21 | return { 22 | "id": user.id, 23 | "indexed_name": user.indexed_name, 24 | "fake_type": 1, 25 | } 26 | 27 | @classmethod 28 | def index_name(cls) -> str: 29 | return "users" 30 | -------------------------------------------------------------------------------- /backend/user/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/user/management/__init__.py -------------------------------------------------------------------------------- /backend/user/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/user/management/commands/__init__.py -------------------------------------------------------------------------------- /backend/user/management/commands/createsu.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import get_user_model 5 | from django.core.management import BaseCommand 6 | 7 | User = get_user_model() 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Create the default superuser using env variables" 12 | 13 | def handle(self, *args: Any, **options: Any) -> None: 14 | email = settings.DJANGO_SUPERUSER_EMAIL 15 | password = settings.DJANGO_SUPERUSER_PASSWORD 16 | if not email or not password: 17 | print("Please specify DJANGO_SUPERUSER_EMAIL and DJANGO_SUPERUSER_PASSWORD") 18 | return 19 | if User.objects.filter(email=email).exists(): 20 | print(f"Superuser '{email}' already exists") 21 | else: 22 | User.objects.create_superuser(email, email, password) 23 | print(f"Superuser '{email}' successfully created") 24 | -------------------------------------------------------------------------------- /backend/user/migrations/0002_alter_user_managers.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2024-01-23 00:11 2 | 3 | import django.contrib.auth.models 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("user", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelManagers( 14 | name="user", 15 | managers=[ 16 | ("objects", django.contrib.auth.models.UserManager()), 17 | ], 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/user/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/user/migrations/__init__.py -------------------------------------------------------------------------------- /backend/user/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from django.conf import settings 5 | from django.contrib.auth.models import AbstractUser 6 | from django.db import models 7 | from django_prometheus.models import ExportModelOperationsMixin 8 | 9 | LOGGER = logging.getLogger("default") 10 | 11 | 12 | class User(ExportModelOperationsMixin("user"), AbstractUser): # ty: ignore 13 | profile: "Profile" 14 | 15 | email = models.EmailField(unique=True, null=False, blank=False) 16 | 17 | def save(self, *args: Any, **kwargs: Any) -> None: 18 | created = self.pk is None 19 | if self.username != self.email: 20 | self.username = self.email 21 | super().save(*args, **kwargs) 22 | if created: 23 | Profile.objects.create(user=self) 24 | 25 | class Meta: 26 | ordering = ["id"] 27 | 28 | def __str__(self) -> str: 29 | return self.email 30 | 31 | @property 32 | def indexed_name(self) -> str: 33 | return f"user_{self.id}" 34 | 35 | 36 | class Profile(models.Model): 37 | user = models.OneToOneField( 38 | settings.AUTH_USER_MODEL, 39 | on_delete=models.CASCADE, 40 | related_name="profile", 41 | primary_key=True, 42 | ) 43 | 44 | class Meta: 45 | ordering = ["user"] 46 | 47 | def __str__(self) -> str: 48 | return f"Profile of {self.user.email}" 49 | -------------------------------------------------------------------------------- /backend/user/serializers.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Dict 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.password_validation import validate_password 5 | from rest_framework import serializers 6 | 7 | if TYPE_CHECKING: 8 | from user.models import User as UserType 9 | 10 | User = get_user_model() 11 | 12 | 13 | class UserSimpleSerializer(serializers.ModelSerializer): 14 | class Meta: 15 | model = User 16 | fields = ["id", "email", "first_name", "last_name"] 17 | extra_kwargs = { 18 | "first_name": {"required": True}, 19 | "last_name": {"required": True}, 20 | "email": {"required": True}, 21 | } 22 | 23 | @staticmethod 24 | def create(validated_data: Dict) -> "UserType": 25 | raise NotImplementedError 26 | 27 | def update(self, user: "UserType", validated_data: Dict) -> "UserType": 28 | # User 29 | user.first_name = validated_data["first_name"] 30 | user.last_name = validated_data["last_name"] 31 | user.email = validated_data["email"] 32 | user.save() 33 | return user 34 | 35 | 36 | class UpdatePasswordSerializer(serializers.Serializer): 37 | current_password = serializers.CharField( 38 | write_only=True, allow_blank=False, allow_null=False 39 | ) 40 | new_password = serializers.CharField( 41 | write_only=True, allow_blank=False, allow_null=False 42 | ) 43 | 44 | class Meta: 45 | fields = ["current_password", "new_password"] 46 | 47 | def validate_current_password(self, current_password: str) -> str: 48 | if not self.instance.check_password(current_password): 49 | raise serializers.ValidationError("Current password is incorrect") 50 | return current_password 51 | 52 | @staticmethod 53 | def validate_new_password(value: str) -> str: 54 | validate_password(value) 55 | return value 56 | 57 | @staticmethod 58 | def update(user: "UserType", validated_data: Dict) -> "UserType": 59 | user.set_password(validated_data["new_password"]) 60 | user.save() 61 | return user 62 | -------------------------------------------------------------------------------- /backend/user/tasks.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from celery import shared_task 4 | from celery.schedules import crontab 5 | from django.conf import settings 6 | from django.db.models import Q 7 | 8 | 9 | @shared_task(queue=settings.RABBITMQ_USER_QUEUE) 10 | def index_all_users_atomically() -> Dict[str, str]: 11 | from user.indexers import UserIndexer 12 | 13 | UserIndexer.index_all_atomically() 14 | return {"result": "ok"} 15 | 16 | 17 | @shared_task(queue=settings.RABBITMQ_USER_QUEUE) 18 | def index_users(ids: List[int]) -> Dict[str, str]: 19 | from user.indexers import UserIndexer 20 | 21 | UserIndexer.index_from_query(Q(pk__in=ids)) 22 | return {"result": "ok"} 23 | 24 | 25 | @shared_task(queue=settings.RABBITMQ_USER_QUEUE) 26 | def unindex_users(ids: List[int]) -> Dict[str, str]: 27 | from user.indexers import UserIndexer 28 | 29 | UserIndexer.unindex_multiple(ids) 30 | return {"result": "ok"} 31 | 32 | 33 | scheduled_cron_tasks = { 34 | "index_all_users_atomically": { 35 | "task": "users.tasks.index_all_users_atomically", 36 | "schedule": crontab(hour="1", minute="0"), 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/user/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/backend/user/tests/__init__.py -------------------------------------------------------------------------------- /backend/user/tests/factories.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.contrib.auth import get_user_model 4 | from factory import Sequence, post_generation 5 | from factory.django import DjangoModelFactory 6 | 7 | User = get_user_model() 8 | 9 | 10 | class UserFactory(DjangoModelFactory): 11 | class Meta: 12 | model = User 13 | exclude = ("is_staff", "is_superuser") 14 | 15 | username = Sequence(lambda x: f"username{x}") 16 | email = Sequence(lambda x: f"fake-email-{x}@fake-domain.com") 17 | password = Sequence(lambda x: f"Str0ngP4ssw0rd!{x}") 18 | first_name = Sequence(lambda x: f"Firstname{x}") 19 | last_name = Sequence(lambda x: f"Lastname{x}") 20 | is_staff = False 21 | is_superuser = False 22 | 23 | @post_generation 24 | def set_user_password(self, create: bool, extracted: str, **kwargs: Any) -> None: 25 | self.set_password(self.password) 26 | self.save() 27 | 28 | @post_generation 29 | def resource_id(self, create: bool, extracted: str, **kwargs: Any) -> None: 30 | if create and extracted: 31 | self.profile.resource_id = extracted 32 | self.profile.save() 33 | 34 | 35 | class AdminFactory(UserFactory): 36 | class Meta: 37 | model = User 38 | exclude = ("is_superuser",) 39 | 40 | is_staff = True 41 | 42 | 43 | class SuperUserFactory(UserFactory): 44 | class Meta: 45 | model = User 46 | 47 | is_staff = True 48 | is_superuser = True 49 | -------------------------------------------------------------------------------- /backend/user/tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.core.management import call_command 5 | from django.test import override_settings 6 | 7 | from core.tests import BaseTestCase 8 | 9 | if TYPE_CHECKING: 10 | from user.models import User as UserType 11 | 12 | User: "UserType" = get_user_model() 13 | 14 | 15 | class CreateSUCommandTestCase(BaseTestCase): 16 | @override_settings( 17 | DJANGO_SUPERUSER_EMAIL="random-email@for-test.com", 18 | DJANGO_SUPERUSER_PASSWORD="random-password-for-test", 19 | ) 20 | def test_createsu(self) -> None: 21 | self.assertEqual(User.objects.count(), 0) 22 | # Create superuser using env variables 23 | call_command("createsu") 24 | self.assertEqual(User.objects.count(), 1) 25 | user = User.objects.get(username="random-email@for-test.com") 26 | self.assertTrue(user.is_superuser) 27 | self.assertTrue(user.is_staff) 28 | # Check we cannot create/update it again using the same command 29 | user.is_staff = False 30 | user.save() 31 | call_command("createsu") 32 | user.refresh_from_db() 33 | self.assertEqual(User.objects.count(), 1) 34 | self.assertTrue(user.is_superuser) 35 | self.assertFalse(user.is_staff) 36 | 37 | @override_settings(DJANGO_SUPERUSER_EMAIL="", DJANGO_SUPERUSER_PASSWORD="") 38 | def test_createsu_no_env_variables(self) -> None: 39 | self.assertEqual(User.objects.count(), 0) 40 | # Fails to create SU 41 | call_command("createsu") 42 | self.assertEqual(User.objects.count(), 0) 43 | -------------------------------------------------------------------------------- /backend/user/tests/test_indexers.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from core.tests import BaseTestCase 4 | from user.indexers import UserIndexer 5 | from user.models import User as UserType 6 | from user.tests.factories import UserFactory 7 | 8 | 9 | class UserIndexerTestCase(BaseTestCase): 10 | indexer_class = UserIndexer 11 | 12 | @classmethod 13 | def setUpTestData(cls) -> None: 14 | cls.item_1 = UserFactory() 15 | cls.item_2 = UserFactory() 16 | 17 | def test_build_object(self) -> None: 18 | user = cast(UserType, UserFactory()) 19 | self.assertEqual( 20 | UserIndexer.build_object(user), 21 | { 22 | "id": user.id, 23 | "indexed_name": f"user_{user.id}", 24 | "fake_type": 1, 25 | }, 26 | ) 27 | 28 | def test_index_name(self) -> None: 29 | self.assertEqual(UserIndexer.index_name(), "users") 30 | -------------------------------------------------------------------------------- /backend/user/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import cast 3 | 4 | from django.contrib.auth import get_user_model 5 | 6 | from core.tests import BaseTestCase 7 | from user.models import Profile 8 | from user.models import User as UserType 9 | from user.tests.factories import UserFactory 10 | 11 | User = cast(UserType, get_user_model()) 12 | 13 | TODAY = date.today() 14 | 15 | 16 | class UserTestCase(BaseTestCase): 17 | def test_str(self) -> None: 18 | user = UserFactory(email="fake-email@fake-domain.org") 19 | self.assertEqual(str(user), "fake-email@fake-domain.org") 20 | 21 | def test_save_create_profile(self) -> None: 22 | user = UserFactory() 23 | self.assertEqual(Profile.objects.count(), 1) 24 | profile = Profile.objects.first() 25 | self.assertEqual(user.profile, profile) 26 | self.assertEqual(profile.user, user) 27 | 28 | def test_save_email_override(self) -> None: 29 | user = cast(UserType, UserFactory()) 30 | email = "random-email@random-domain.com" 31 | user.email = email 32 | user.save() 33 | # Check email is maintained and username is changed 34 | self.assertEqual(user.email, email) 35 | self.assertEqual(user.email, user.username) 36 | # Check that you cannot change the username 37 | user.username = "New username" 38 | user.save() 39 | self.assertEqual(user.email, email) 40 | self.assertEqual(user.email, user.username) 41 | 42 | def test_index_name(self) -> None: 43 | user = UserFactory() 44 | self.assertEqual(user.indexed_name, f"user_{user.id}") 45 | 46 | 47 | class ProfileTestCase(BaseTestCase): 48 | def test_str(self) -> None: 49 | user = UserFactory(email="fake-email@fake-domain.org") 50 | self.assertEqual(str(user.profile), "Profile of fake-email@fake-domain.org") 51 | -------------------------------------------------------------------------------- /backend/user/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model, logout, update_session_auth_hash 2 | from django_utils_kit.viewsets import ImprovedViewSet 3 | from drf_spectacular.utils import extend_schema 4 | from rest_framework import status 5 | from rest_framework.decorators import action 6 | from rest_framework.permissions import IsAuthenticated 7 | from rest_framework.request import Request 8 | from rest_framework.response import Response 9 | 10 | from user.serializers import ( 11 | UpdatePasswordSerializer, 12 | UserSimpleSerializer, 13 | ) 14 | 15 | User = get_user_model() 16 | 17 | 18 | class CurrentUserViewSet(ImprovedViewSet): 19 | """For the current user to view/update some of its information.""" 20 | 21 | default_permission_classes = (IsAuthenticated,) 22 | serializer_class_per_action = { 23 | "account": UserSimpleSerializer, 24 | "password": UpdatePasswordSerializer, 25 | } 26 | 27 | @extend_schema( 28 | methods=["DELETE"], 29 | description="Delete the current user's account.", 30 | responses={204: None}, 31 | ) 32 | @extend_schema( 33 | methods=["PUT"], 34 | description="Update the current user's account.", 35 | responses={200: UserSimpleSerializer}, 36 | ) 37 | @extend_schema( 38 | methods=["GET"], 39 | description="Get the current user's account.", 40 | responses={200: UserSimpleSerializer}, 41 | ) 42 | @action(detail=False, methods=["get", "put", "delete"]) 43 | def account(self, request: Request) -> Response: 44 | """Handle account operations: GET, PUT, DELETE.""" 45 | if request.method == "GET": 46 | # Fetch the user data 47 | serializer = self.get_serializer(request.user) 48 | return Response(serializer.data, status.HTTP_200_OK) 49 | elif request.method == "PUT": 50 | # Update the user 51 | serializer = self.get_valid_serializer(request.user, data=request.data) 52 | serializer.save() 53 | return Response(serializer.data, status.HTTP_200_OK) 54 | elif request.method == "DELETE": 55 | # Delete the current user 56 | user = request.user 57 | logout(request) 58 | user.delete() 59 | return Response(None, status.HTTP_204_NO_CONTENT) 60 | return Response(None, status.HTTP_405_METHOD_NOT_ALLOWED) 61 | 62 | @extend_schema(responses={204: None}) 63 | @action(detail=False, methods=["put"]) 64 | def password(self, request: Request) -> Response: 65 | serializer = self.get_valid_serializer(request.user, data=request.data) 66 | user = serializer.save() 67 | update_session_auth_hash(request, user) 68 | return Response(None, status.HTTP_204_NO_CONTENT) 69 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "formatWithErrors": true, 14 | "enabled": true, 15 | "indentStyle": "space" 16 | }, 17 | "organizeImports": { 18 | "enabled": true 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true, 24 | "complexity": { 25 | "noForEach": "off" 26 | }, 27 | "suspicious": { 28 | "noExplicitAny": "off" 29 | }, 30 | "a11y": { 31 | "useKeyWithClickEvents": "off" 32 | } 33 | } 34 | }, 35 | "javascript": { 36 | "formatter": { 37 | "quoteStyle": "double" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | image: django_react_starter_api 4 | container_name: django_react_starter_api 5 | build: 6 | context: . 7 | dockerfile: ./docker/Dockerfile.dev 8 | env_file: 9 | - ./backend/.env 10 | depends_on: 11 | - postgres 12 | - rabbitmq 13 | - meilisearch 14 | networks: 15 | - django_react_starter_network 16 | ports: 17 | - "8000:8000" 18 | volumes: 19 | - ./backend:/home/app/backend 20 | profiles: 21 | - all 22 | 23 | front: 24 | image: node:22.14.0-slim 25 | container_name: django_react_starter_front 26 | working_dir: /app 27 | command: bash -c "yarn install && yarn start --host" 28 | networks: 29 | - django_react_starter_network 30 | ports: 31 | - "3000:3000" 32 | volumes: 33 | - ./frontend:/app 34 | profiles: 35 | - all 36 | 37 | postgres: 38 | container_name: django_react_starter_postgres 39 | image: postgis/postgis:16-3.4-alpine 40 | environment: 41 | - POSTGRES_USER=postgres 42 | - POSTGRES_PASSWORD=postgres 43 | - POSTGRES_DB=django_react_starter 44 | networks: 45 | - django_react_starter_network 46 | ports: 47 | - "5432:5432" 48 | volumes: 49 | - ./data/postgres:/var/lib/postgresql/data 50 | profiles: 51 | - all 52 | - lite 53 | 54 | rabbitmq: 55 | container_name: django_react_starter_rabbitmq 56 | image: rabbitmq:4.0.8-management 57 | environment: 58 | - RABBITMQ_DEFAULT_USER=django_react_starter 59 | - RABBITMQ_DEFAULT_PASS=django_react_starter 60 | networks: 61 | - django_react_starter_network 62 | ports: 63 | - "15672:15672" 64 | - "5672:5672" 65 | volumes: 66 | - ./data/rabbitmq:/var/lib/rabbitmq/mnesia 67 | profiles: 68 | - all 69 | - lite 70 | 71 | meilisearch: 72 | container_name: django_react_starter_meilisearch 73 | image: getmeili/meilisearch:v1.13.3 74 | environment: 75 | - MEILI_MASTER_KEY=@7t^a5xfv%9cg-oemhm0pi&fe6b=i7_v%dlikah^%0=z(hgqre 76 | networks: 77 | - django_react_starter_network 78 | ports: 79 | - "7700:7700" 80 | volumes: 81 | - ./data/meilisearch:/meili_data 82 | profiles: 83 | - all 84 | - lite 85 | 86 | meilisearch-ui: 87 | container_name: django_react_starter_meilisearch_ui 88 | image: riccoxie/meilisearch-ui:v0.11.5 89 | depends_on: 90 | - meilisearch 91 | networks: 92 | - django_react_starter_network 93 | ports: 94 | - "24900:24900" 95 | profiles: 96 | - all 97 | - lite 98 | 99 | networks: 100 | django_react_starter_network: 101 | driver: bridge 102 | -------------------------------------------------------------------------------- /docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM python:3.13.2-slim 2 | 3 | # Python environment variables 4 | ENV PYTHONUNBUFFERED=1 5 | ENV PYTHONDONTWRITEBYTECODE=1 6 | ENV UV_PROJECT_ENVIRONMENT="/usr/local/" 7 | 8 | # Update OS 9 | RUN apt-get update \ 10 | && apt-get install -y --no-install-recommends \ 11 | build-essential \ 12 | libpq-dev \ 13 | supervisor \ 14 | nano \ 15 | gdal-bin \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | # Create dir and user 19 | RUN mkdir -p /home/app/backend && mkdir -p /home/app/logs 20 | RUN addgroup --system app && adduser --system --group app 21 | WORKDIR /home/app 22 | 23 | # Install dependencies with UV 24 | COPY ./backend/pyproject.toml ./backend/uv.lock ./ 25 | RUN pip install --upgrade pip \ 26 | && pip install uv \ 27 | && uv sync --frozen \ 28 | && rm -rf uv.lock pyproject.toml 29 | 30 | # Copy backend and change ownership 31 | COPY ./backend ./backend 32 | RUN chown -R app:app /home/app 33 | USER app 34 | 35 | # Run the app 36 | EXPOSE 8000 37 | CMD ["supervisord", "-c", "./backend/supervisord.conf"] 38 | -------------------------------------------------------------------------------- /docker/Dockerfile.prod: -------------------------------------------------------------------------------- 1 | FROM node:22.14-slim as app-react-image 2 | 3 | # Build args/env from GitHub actions 4 | ARG VITE_ENVIRONMENT 5 | ARG VITE_APP_VERSION 6 | ARG VITE_SENTRY_DSN 7 | ENV VITE_ENVIRONMENT=$VITE_ENVIRONMENT 8 | ENV VITE_APP_VERSION=$VITE_APP_VERSION 9 | ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN 10 | 11 | WORKDIR /front 12 | ADD ./frontend /front 13 | RUN yarn install && yarn build 14 | 15 | FROM python:3.13.2-slim 16 | 17 | # Python environment variables 18 | ENV PYTHONUNBUFFERED=1 19 | ENV PYTHONDONTWRITEBYTECODE=1 20 | ENV UV_PROJECT_ENVIRONMENT="/usr/local/" 21 | 22 | # Update OS 23 | RUN apt-get update \ 24 | && apt-get install -y --no-install-recommends \ 25 | build-essential \ 26 | libpq-dev \ 27 | supervisor \ 28 | nano \ 29 | gdal-bin \ 30 | && rm -rf /var/lib/apt/lists/* 31 | 32 | # Create dir and user 33 | RUN mkdir -p /home/app/backend && mkdir -p /home/app/logs 34 | RUN addgroup --system app && adduser --system --group app 35 | WORKDIR /home/app 36 | 37 | # Install dependencies with UV 38 | COPY ./backend/pyproject.toml ./backend/uv.lock ./ 39 | RUN pip install --upgrade pip \ 40 | && pip install uv \ 41 | && uv sync --frozen --no-dev \ 42 | && rm -rf uv.lock pyproject.toml 43 | 44 | # Copy backend and frontend, set frontend within backend, and change ownership 45 | COPY ./backend ./backend 46 | COPY --chown=app:app --from=app-react-image /front/dist /home/app/backend/frontend/dist 47 | RUN chown -R app:app /home/app 48 | USER app 49 | 50 | # Run the app 51 | EXPOSE 8000 52 | CMD supervisord -c ./backend/supervisord.conf 53 | -------------------------------------------------------------------------------- /docs/french_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/docs/french_dark.png -------------------------------------------------------------------------------- /docs/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/docs/homepage.png -------------------------------------------------------------------------------- /docs/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/docs/login.png -------------------------------------------------------------------------------- /docs/password_reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/docs/password_reset.png -------------------------------------------------------------------------------- /docs/password_reset_confirm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/docs/password_reset_confirm.png -------------------------------------------------------------------------------- /docs/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/docs/register.png -------------------------------------------------------------------------------- /docs/responsive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/docs/responsive.png -------------------------------------------------------------------------------- /docs/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/docs/settings.png -------------------------------------------------------------------------------- /fly/production.example.toml: -------------------------------------------------------------------------------- 1 | app = 'django_react_starter' 2 | primary_region = 'cdg' 3 | console_command = '/home/app/backend/manage.py shell' 4 | 5 | [build] 6 | dockerfile = '../docker/Dockerfile.prod' 7 | ignorefile = '.dockerignore' 8 | 9 | [deploy] 10 | strategy = 'rolling' 11 | 12 | [env] 13 | DJANGO_SETTINGS_MODULE = 'django_react_starter.settings.production' 14 | FLY_VOLUME_DIR = '/home/app/data' # like the mount destination 15 | SITE_DOMAIN = 'django_react_starter.jkdev.app' 16 | HOST_DNS_NAMES = 'django_react_starter.jkdev.app,django_react_starter.fly.dev' 17 | INTERNAL_IPS = '172.19.4.106' # Add prometheus internal IP (check sentry error on first boot) 18 | GUNICORN_WORKERS = '1' 19 | PORT = '8000' 20 | MEILISEARCH_HOST = 'https://meilisearch.jkdev.app' # Must use public hostname 21 | CELERY_WORKERS = '1' 22 | RABBITMQ_HOSTNAME = '[fdaa:1:a00d:a7b:10f:e769:75cf:2]' # Must use private ipv6 hostname within brackets 23 | RABBITMQ_PORT = '5672' 24 | RABBITMQ_ADMIN_URL = 'https://rabbitmq.jkdev.app' 25 | # >>> Secrets (fly secrets list) <<< 26 | # DATABASE_URL 27 | # DEFAULT_FROM_EMAIL 28 | # DJANGO_SUPERUSER_EMAIL 29 | # DJANGO_SUPERUSER_PASSWORD 30 | # MEILISEARCH_API_KEY 31 | # RABBITMQ_USERNAME 32 | # RABBITMQ_PASSWORD 33 | # SECRET_KEY 34 | # SENTRY_DSN 35 | # SENDINBLUE_API_KEY 36 | # >>> Generated during GitHub actions with --env <<< 37 | # APP_VERSION 38 | # ENVIRONMENT 39 | 40 | [http_service] 41 | internal_port = 8000 42 | force_https = true 43 | auto_stop_machines = true 44 | auto_start_machines = true 45 | min_machines_running = 1 46 | processes = ['app'] 47 | 48 | [[http_service.checks]] 49 | grace_period = "15s" 50 | interval = "60s" 51 | method = "GET" 52 | timeout = "5s" 53 | path = "/api/v1/health/api/" 54 | 55 | [[http_service.checks]] 56 | grace_period = "15s" 57 | interval = "60s" 58 | method = "GET" 59 | timeout = "5s" 60 | path = "/api/v1/health/database/" 61 | 62 | [[http_service.checks]] 63 | grace_period = "15s" 64 | interval = "60s" 65 | method = "GET" 66 | timeout = "5s" 67 | path = "/api/v1/health/rabbitmq/" 68 | 69 | [[http_service.checks]] 70 | grace_period = "15s" 71 | interval = "60s" 72 | method = "GET" 73 | timeout = "5s" 74 | path = "/api/v1/health/celery/" 75 | 76 | [[http_service.checks]] 77 | grace_period = "15s" 78 | interval = "60s" 79 | method = "GET" 80 | timeout = "5s" 81 | path = "/api/v1/health/meilisearch/" 82 | 83 | [[vm]] 84 | cpu_kind = 'shared' 85 | cpus = 1 86 | memory_mb = 512 87 | 88 | [mounts] 89 | source = 'django_react_starter_data' 90 | destination = '/home/app/data' 91 | 92 | [metrics] 93 | port = 8000 94 | path = "/metrics" 95 | -------------------------------------------------------------------------------- /fly/staging.example.toml: -------------------------------------------------------------------------------- 1 | app = 'django_react_starter' 2 | primary_region = 'cdg' 3 | console_command = '/home/app/backend/manage.py shell' 4 | 5 | [build] 6 | dockerfile = '../docker/Dockerfile.prod' 7 | ignorefile = '.dockerignore' 8 | 9 | [deploy] 10 | strategy = 'rolling' 11 | 12 | [env] 13 | DJANGO_SETTINGS_MODULE = 'django_react_starter.settings.production' 14 | FLY_VOLUME_DIR = '/home/app/data' # like the mount destination 15 | SITE_DOMAIN = 'django_react_starter.jkdev.app' 16 | HOST_DNS_NAMES = 'django_react_starter.jkdev.app,django_react_starter.fly.dev' 17 | INTERNAL_IPS = '172.19.4.106' # Add prometheus internal IP (check sentry error on first boot) 18 | GUNICORN_WORKERS = '1' 19 | PORT = '8000' 20 | MEILISEARCH_HOST = 'https://meilisearch.jkdev.app' # Must use public hostname 21 | CELERY_WORKERS = '1' 22 | RABBITMQ_HOSTNAME = '[fdaa:1:a00d:a7b:10f:e769:75cf:2]' # Must use private ipv6 hostname within brackets 23 | RABBITMQ_PORT = '5672' 24 | RABBITMQ_ADMIN_URL = 'https://rabbitmq.jkdev.app' 25 | # >>> Secrets (fly secrets list) <<< 26 | # DATABASE_URL 27 | # DEFAULT_FROM_EMAIL 28 | # DJANGO_SUPERUSER_EMAIL 29 | # DJANGO_SUPERUSER_PASSWORD 30 | # MEILISEARCH_API_KEY 31 | # RABBITMQ_USERNAME 32 | # RABBITMQ_PASSWORD 33 | # SECRET_KEY 34 | # SENTRY_DSN 35 | # SENDINBLUE_API_KEY 36 | # >>> Generated during GitHub actions with --env <<< 37 | # APP_VERSION 38 | # ENVIRONMENT 39 | 40 | [http_service] 41 | internal_port = 8000 42 | force_https = true 43 | auto_stop_machines = true 44 | auto_start_machines = true 45 | min_machines_running = 1 46 | processes = ['app'] 47 | 48 | [[http_service.checks]] 49 | grace_period = "15s" 50 | interval = "60s" 51 | method = "GET" 52 | timeout = "5s" 53 | path = "/api/v1/health/api/" 54 | 55 | [[http_service.checks]] 56 | grace_period = "15s" 57 | interval = "60s" 58 | method = "GET" 59 | timeout = "5s" 60 | path = "/api/v1/health/database/" 61 | 62 | [[http_service.checks]] 63 | grace_period = "15s" 64 | interval = "60s" 65 | method = "GET" 66 | timeout = "5s" 67 | path = "/api/v1/health/rabbitmq/" 68 | 69 | [[http_service.checks]] 70 | grace_period = "15s" 71 | interval = "60s" 72 | method = "GET" 73 | timeout = "5s" 74 | path = "/api/v1/health/celery/" 75 | 76 | [[http_service.checks]] 77 | grace_period = "15s" 78 | interval = "60s" 79 | method = "GET" 80 | timeout = "5s" 81 | path = "/api/v1/health/meilisearch/" 82 | 83 | [[vm]] 84 | cpu_kind = 'shared' 85 | cpus = 1 86 | memory_mb = 512 87 | 88 | [mounts] 89 | source = 'django_react_starter_data' 90 | destination = '/home/app/data' 91 | 92 | [metrics] 93 | port = 8000 94 | path = "/metrics" 95 | -------------------------------------------------------------------------------- /frontend/.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 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Vite 17 | coverage 18 | bundle-stats.html 19 | -------------------------------------------------------------------------------- /frontend/i18n/check-empty-translations.cjs: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/style/useNodejsImportProtocol: 2 | const fs = require("fs"); 3 | // biome-ignore lint/style/useNodejsImportProtocol: 4 | const path = require("path"); 5 | 6 | const CURRENT_DIR = path.dirname(__filename); 7 | const LOCALES = ["en", "fr"]; 8 | 9 | let hasErrors = false; 10 | 11 | LOCALES.map((locale) => { 12 | const filename = path.join(CURRENT_DIR, `${locale}.json`); 13 | const file = fs.readFileSync(filename, "utf-8"); 14 | const translations = JSON.parse(file); 15 | const emptyKeys = Object.keys(translations).filter( 16 | (key) => translations[key].trim() === "", 17 | ); 18 | 19 | if (emptyKeys.length > 0) { 20 | hasErrors = true; 21 | console.error(`Empty translations for ${locale}:`, emptyKeys); 22 | } 23 | }); 24 | 25 | if (hasErrors) { 26 | process.exit(1); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Account created successfully": "Account created successfully", 3 | "An easy way to start a Django + React project": "An easy way to start a Django + React project", 4 | "An email has been sent to reset your password": "An email has been sent to reset your password", 5 | "Are you sure you want to delete your account? This action cannot be undone": "Are you sure you want to delete your account? This action cannot be undone", 6 | "Back to homepage": "Back to homepage", 7 | "Cancel": "Cancel", 8 | "Color theme": "Color theme", 9 | "Confirm": "Confirm", 10 | "Confirm password": "Confirm password", 11 | "Confirm your password": "Confirm your password", 12 | "Current password": "Current password", 13 | "Danger Zone": "Danger Zone", 14 | "Dark": "Dark", 15 | "Delete": "Delete", 16 | "Delete Account": "Delete Account", 17 | "Delete your account": "Delete your account", 18 | "Django React Starter": "Django React Starter", 19 | "Email": "Email", 20 | "Email already taken": "Email already taken", 21 | "Enter your current password": "Enter your current password", 22 | "Enter your email address": "Enter your email address", 23 | "Enter your first name": "Enter your first name", 24 | "Enter your last name": "Enter your last name", 25 | "Enter your new password": "Enter your new password", 26 | "Enter your password": "Enter your password", 27 | "Failed to update information": "Failed to update information", 28 | "First name": "First name", 29 | "Forgot your password?": "Forgot your password?", 30 | "Go back": "Go back", 31 | "Go to settings": "Go to settings", 32 | "Information": "Information", 33 | "Information updated": "Information updated", 34 | "Invalid credentials": "Invalid credentials", 35 | "Invalid current password": "Invalid current password", 36 | "Invalid token": "Invalid token", 37 | "Language": "Language", 38 | "Last name": "Last name", 39 | "Light": "Light", 40 | "Login": "Login", 41 | "Logout": "Logout", 42 | "New password": "New password", 43 | "Password": "Password", 44 | "Password is too weak": "Password is too weak", 45 | "Password reset": "Password reset", 46 | "Password reset confirm": "Password reset confirm", 47 | "Password updated": "Password updated", 48 | "Passwords do not match": "Passwords do not match", 49 | "Preferences": "Preferences", 50 | "Register": "Register", 51 | "Registration failed": "Registration failed", 52 | "Reset": "Reset", 53 | "Save": "Save", 54 | "Security": "Security", 55 | "Set your new password": "Set your new password", 56 | "Settings": "Settings", 57 | "Something went wrong": "Something went wrong", 58 | "Your account has been deleted": "Your account has been deleted", 59 | "Your session has expired": "Your session has expired" 60 | } 61 | -------------------------------------------------------------------------------- /frontend/i18n/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "Account created successfully": "Compte créé avec succès", 3 | "An easy way to start a Django + React project": "Un moyen facile de démarrer un projet Django + React", 4 | "An email has been sent to reset your password": "Un e-mail vous a été envoyé pour réinitialiser votre mot de passe", 5 | "Are you sure you want to delete your account? This action cannot be undone": "Voulez-vous vraiment supprimer votre compte ? Cette action ne peut pas être annulée", 6 | "Back to homepage": "Retour à la page d'accueil", 7 | "Cancel": "Annuler", 8 | "Color theme": "Thème", 9 | "Confirm": "Confirmer", 10 | "Confirm password": "Confirmation", 11 | "Confirm your password": "Confirmer votre mot de passe", 12 | "Current password": "Mot de passe actuel", 13 | "Danger Zone": "Zone de danger", 14 | "Dark": "Sombre", 15 | "Delete": "Supprimer", 16 | "Delete Account": "Supprimer le compte", 17 | "Delete your account": "Supprimer votre compte", 18 | "Django React Starter": "Django React Starter", 19 | "Email": "Adresse e-mail", 20 | "Email already taken": "Cette adresse e-mail est déjà utilisée", 21 | "Enter your current password": "Saisissez votre mot de passe actuel", 22 | "Enter your email address": "Saisissez votre adresse e-mail", 23 | "Enter your first name": "Saisissez votre prénom", 24 | "Enter your last name": "Saisissez votre nom de famille", 25 | "Enter your new password": "Saisissez votre nouveau mot de passe", 26 | "Enter your password": "Saisissez votre mot de passe", 27 | "Failed to update information": "Échec lors de la mise à jour des informations", 28 | "First name": "Prénom", 29 | "Forgot your password?": "Mot de passe oublié ?", 30 | "Go back": "Retour", 31 | "Go to settings": "Voir les paramètres", 32 | "Information": "Informations", 33 | "Information updated": "Informations mises à jour", 34 | "Invalid credentials": "Identifiants invalides", 35 | "Invalid current password": "Mot de passe actuel incorrect", 36 | "Invalid token": "Jeton invalide", 37 | "Language": "Langue", 38 | "Last name": "Nom de famille", 39 | "Light": "Clair", 40 | "Login": "Connexion", 41 | "Logout": "Déconnexion", 42 | "New password": "Nouveau mot de passe", 43 | "Password": "Mot de passe", 44 | "Password is too weak": "Le mot de passe est trop faible", 45 | "Password reset": "Réinitialisation", 46 | "Password reset confirm": "Confirmation de réinitialisation du mot de passe", 47 | "Password updated": "Mot de passe mis à jour", 48 | "Passwords do not match": "Les mots de passe ne correspondent pas", 49 | "Preferences": "Préférences", 50 | "Register": "Créer un compte", 51 | "Registration failed": "Échec lors de la création du compte", 52 | "Reset": "Réinitialiser", 53 | "Save": "Enregistrer", 54 | "Security": "Sécurité", 55 | "Set your new password": "Saisissez votre nouveau mot de passe", 56 | "Settings": "Paramètres", 57 | "Something went wrong": "Une erreur s'est produite", 58 | "Your account has been deleted": "Votre compte a été supprimé", 59 | "Your session has expired": "Votre session a expiré" 60 | } 61 | -------------------------------------------------------------------------------- /frontend/i18next-parser.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | locales: ["en", "fr"], 3 | createOldCatalogs: false, 4 | output: "i18n/$LOCALE.json", 5 | sort: true, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | Django React Starter 15 | 16 | 17 | {% csrf_token %} 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "4.2.0", 4 | "private": true, 5 | "homepage": "/", 6 | "type": "module", 7 | "scripts": { 8 | "biome:check": "biome check ./src", 9 | "i18n": "npx i18next 'src/**/*.{ts,tsx}' --silent", 10 | "i18n:check": "npx i18next 'src/**/*.{ts,tsx}' --silent --fail-on-update && node ./i18n/check-empty-translations.cjs", 11 | "quality": "yarn biome:check && yarn i18n:check && yarn tsc", 12 | "start": "vite", 13 | "build": "vite build", 14 | "preview": "vite preview", 15 | "test": "(export NODE_NO_WARNINGS=1 && vitest --silent)", 16 | "test:watch": "(export NODE_NO_WARNINGS=1 && vitest --watch --silent)", 17 | "test:ui": "(export NODE_NO_WARNINGS=1 && vitest --ui --silent)", 18 | "test:coverage": "(export NODE_NO_WARNINGS=1 && vitest --coverage --silent)", 19 | "tsc": "tsc" 20 | }, 21 | "dependencies": { 22 | "@hookform/resolvers": "5.0.1", 23 | "@sentry/react": "^9.24.0", 24 | "@tanstack/react-query": "5.79.0", 25 | "dayjs": "1.11.13", 26 | "i18next": "25.2.1", 27 | "js-cookie": "3.0.5", 28 | "lucide-react": "0.511.0", 29 | "million": "3.1.11", 30 | "react": "19.1.0", 31 | "react-dom": "19.1.0", 32 | "react-hook-form": "7.57.0", 33 | "react-i18next": "15.5.2", 34 | "react-toastify": "11.0.5", 35 | "wouter": "3.7.1", 36 | "zod": "3.25.48", 37 | "zod-i18n-map": "2.27.0" 38 | }, 39 | "devDependencies": { 40 | "@biomejs/biome": "1.9.4", 41 | "@tailwindcss/typography": "0.5.16", 42 | "@tailwindcss/vite": "4.1.8", 43 | "@testing-library/dom": "10.4.0", 44 | "@testing-library/jest-dom": "6.6.3", 45 | "@testing-library/react": "16.3.0", 46 | "@testing-library/react-hooks": "8.0.1", 47 | "@testing-library/user-event": "14.6.1", 48 | "@types/js-cookie": "3.0.6", 49 | "@types/node": "22.15.29", 50 | "@types/react": "19.1.6", 51 | "@types/react-dom": "19.1.5", 52 | "@vitejs/plugin-react": "4.5.0", 53 | "@vitest/coverage-v8": "3.1.4", 54 | "@vitest/ui": "3.1.4", 55 | "daisyui": "5.0.43", 56 | "i18next-parser": "9.3.0", 57 | "jsdom": "26.1.0", 58 | "msw": "2.8.7", 59 | "rollup-plugin-visualizer": "6.0.1", 60 | "tailwindcss": "4.1.8", 61 | "ts-node": "10.9.2", 62 | "typescript": "5.8.3", 63 | "vite": "6.3.5", 64 | "vitest": "3.1.4" 65 | }, 66 | "browserslist": { 67 | "production": [ 68 | ">0.2%", 69 | "not dead", 70 | "not op_mini all" 71 | ], 72 | "development": [ 73 | "last 1 chrome version", 74 | "last 1 firefox version", 75 | "last 1 safari version" 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /frontend/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/public/.gitkeep -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { queryClient } from "@/api/config"; 2 | import { Toaster } from "@/components/ui"; 3 | import "@/config/dayjs"; 4 | import "@/config/i18n"; 5 | import { ThemeProvider } from "@/contexts"; 6 | import { Routes } from "@/router"; 7 | import "@/styles/base.css"; 8 | import { QueryClientProvider } from "@tanstack/react-query"; 9 | import { memo, useEffect } from "react"; 10 | import { Router } from "wouter"; 11 | import { useLocale } from "./hooks"; 12 | 13 | export const App: React.FC = memo(() => { 14 | const { initLocale } = useLocale(); 15 | 16 | useEffect(initLocale, []); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /frontend/src/api/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/api/.gitkeep -------------------------------------------------------------------------------- /frontend/src/api/config.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | 3 | export const API_ROOT_URL = "/api/v1"; 4 | export const CSRF_TOKEN_HEADER_NAME = "X-CSRFToken"; 5 | export const CSRF_TOKEN_COOKIE_NAME = "django_react_starter-csrftoken"; 6 | 7 | export const queryClient = new QueryClient({ 8 | defaultOptions: { 9 | queries: { 10 | refetchOnMount: false, 11 | refetchOnReconnect: false, 12 | refetchOnWindowFocus: false, 13 | retryOnMount: false, 14 | retry: false, 15 | staleTime: Number.POSITIVE_INFINITY, 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /frontend/src/api/queries/__mocks__/useAppConfig.ts: -------------------------------------------------------------------------------- 1 | import type { ApiAppConfig } from "../useAppConfig"; 2 | 3 | export const APP_CONFIG_MOCK: ApiAppConfig = { 4 | debug: false, 5 | media_url: "http://localhost:8000/media/", 6 | static_url: "http://localhost:8000/static/", 7 | app_version: "0.1.0", 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/src/api/queries/__mocks__/useSelf.ts: -------------------------------------------------------------------------------- 1 | import type { ApiSelf } from "../useSelf"; 2 | 3 | export const SELF_MOCK: ApiSelf = { 4 | id: 1, 5 | first_name: "John", 6 | last_name: "Doe", 7 | email: "john.doe@email.com", 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/src/api/queries/index.ts: -------------------------------------------------------------------------------- 1 | export { useAppConfig, type AppConfig } from "./useAppConfig"; 2 | export { useCheckAuth } from "./useCheckAuth"; 3 | export { useLogout } from "./useLogout"; 4 | export { useSelf, type Self } from "./useSelf"; 5 | -------------------------------------------------------------------------------- /frontend/src/api/queries/useAppConfig.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { useMemo } from "react"; 4 | import type { ApiError } from "../types"; 5 | import { performRequest } from "../utils"; 6 | 7 | export type AppConfig = { 8 | debug: boolean; 9 | mediaUrl: string; 10 | staticUrl: string; 11 | appVersion: string; 12 | }; 13 | 14 | export type ApiAppConfig = { 15 | debug: boolean; 16 | media_url: string; 17 | static_url: string; 18 | app_version: string; 19 | }; 20 | 21 | type UseAppConfigReturn = { 22 | isPending: boolean; 23 | isError: boolean; 24 | error: ApiError | null; 25 | data?: AppConfig; 26 | }; 27 | 28 | export const useAppConfig = (): UseAppConfigReturn => { 29 | const url = `${API_ROOT_URL}/app/config/`; 30 | const { isPending, isError, error, data } = useQuery< 31 | ApiAppConfig, 32 | ApiError, 33 | AppConfig 34 | >({ 35 | queryKey: ["appConfig"], 36 | queryFn: () => performRequest(url, { method: "GET" }), 37 | select: (data) => ({ 38 | debug: data.debug, 39 | mediaUrl: data.media_url, 40 | staticUrl: data.static_url, 41 | appVersion: data.app_version, 42 | }), 43 | }); 44 | 45 | return useMemo( 46 | () => ({ 47 | isPending, 48 | isError, 49 | error, 50 | data, 51 | }), 52 | [isPending, isError, error, data], 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /frontend/src/api/queries/useCheckAuth.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import { routeConfigMap } from "@/router"; 3 | import { useQuery, useQueryClient } from "@tanstack/react-query"; 4 | import { useMemo } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | import { toast } from "react-toastify"; 7 | import { useLocation } from "wouter"; 8 | import type { ApiError } from "../types"; 9 | import { performRequest } from "../utils"; 10 | import { useSelf } from "./useSelf"; 11 | 12 | type UseCheckAuthReturn = { 13 | isPending: boolean; 14 | isError: boolean; 15 | error: ApiError | null; 16 | }; 17 | 18 | export const useCheckAuth = (): UseCheckAuthReturn => { 19 | const url = `${API_ROOT_URL}/auth/check/`; 20 | const queryClient = useQueryClient(); 21 | const { data: user } = useSelf(); 22 | const { t } = useTranslation(); 23 | const [, navigate] = useLocation(); 24 | 25 | const { isPending, isError, error } = useQuery({ 26 | queryKey: ["auth", "check"], 27 | queryFn: () => performRequest(url, { method: "GET" }), 28 | enabled: !!user, 29 | refetchInterval: 1000 * 60 * 1, // 5 minutes 30 | }); 31 | 32 | if (isError) { 33 | queryClient.removeQueries({ queryKey: ["auth", "check"] }); 34 | queryClient.removeQueries({ queryKey: ["self"] }); 35 | toast.warning(t("Your session has expired")); 36 | navigate(routeConfigMap.login.path); 37 | } 38 | 39 | return useMemo( 40 | () => ({ 41 | isPending, 42 | isError, 43 | error, 44 | }), 45 | [isPending, isError, error], 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /frontend/src/api/queries/useLogout.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import { performRequest } from "@/api/utils"; 3 | import { routeConfigMap } from "@/router"; 4 | import { 5 | type UseMutationResult, 6 | useMutation, 7 | useQueryClient, 8 | } from "@tanstack/react-query"; 9 | import { useLocation } from "wouter"; 10 | import type { ApiError } from "../types"; 11 | 12 | type UseLogout = () => UseMutationResult; 13 | 14 | export const useLogout: UseLogout = () => { 15 | const url = `${API_ROOT_URL}/auth/logout/`; 16 | const queryClient = useQueryClient(); 17 | const [, navigate] = useLocation(); 18 | 19 | return useMutation({ 20 | mutationFn: async (): Promise => 21 | await performRequest(url, { method: "POST" }), 22 | onSuccess: () => { 23 | queryClient.resetQueries({ queryKey: ["self"] }); 24 | navigate(routeConfigMap.login.path); 25 | }, 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/src/api/queries/useSelf.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { useMemo } from "react"; 4 | import type { ApiError } from "../types"; 5 | import { performRequest } from "../utils"; 6 | 7 | export type Self = { 8 | id: number; 9 | firstName: string; 10 | lastName: string; 11 | email: string; 12 | }; 13 | 14 | export type ApiSelf = { 15 | id: number; 16 | first_name: string; 17 | last_name: string; 18 | email: string; 19 | }; 20 | 21 | type UseSelfReturn = { 22 | isPending: boolean; 23 | isError: boolean; 24 | error: ApiError | null; 25 | data?: Self; 26 | }; 27 | 28 | export const deserializeSelf = (data: ApiSelf): Self => ({ 29 | id: data.id, 30 | firstName: data.first_name, 31 | lastName: data.last_name, 32 | email: data.email, 33 | }); 34 | 35 | export const useSelf = (): UseSelfReturn => { 36 | const url = `${API_ROOT_URL}/self/account/`; 37 | const { isPending, isError, error, data } = useQuery( 38 | { 39 | queryKey: ["self"], 40 | queryFn: () => performRequest(url, { method: "GET" }), 41 | select: deserializeSelf, 42 | }, 43 | ); 44 | 45 | return useMemo( 46 | () => ({ 47 | isPending, 48 | isError, 49 | error, 50 | data, 51 | }), 52 | [isPending, isError, error, data], 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /frontend/src/api/types.ts: -------------------------------------------------------------------------------- 1 | export type ApiError = { 2 | status: number; 3 | errors?: Record; 4 | text?: string; 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/api/utils.ts: -------------------------------------------------------------------------------- 1 | import { CSRF_TOKEN_COOKIE_NAME, CSRF_TOKEN_HEADER_NAME } from "@/api/config"; 2 | import Cookies from "js-cookie"; 3 | 4 | type FetchOptions = { 5 | data?: Record; 6 | formData?: FormData; 7 | method: string; 8 | }; 9 | 10 | export const performRequest = async ( 11 | url: string, 12 | { data, method }: FetchOptions, 13 | ): Promise => { 14 | const request = { 15 | method: method.toUpperCase(), 16 | headers: { 17 | [CSRF_TOKEN_HEADER_NAME]: Cookies.get(CSRF_TOKEN_COOKIE_NAME), 18 | "Content-Type": "application/json", 19 | Accept: "application/json", 20 | }, 21 | redirect: "follow", 22 | body: (data && JSON.stringify(data)) || undefined, 23 | }; 24 | 25 | // @ts-ignore 26 | const response = await fetch(url, request); 27 | const isJson = 28 | response.headers.get("content-type") === "application/json" && 29 | response.body !== null; 30 | 31 | // Exit if OK 32 | if (response?.ok) { 33 | return isJson ? response.json() : Promise.resolve({}); 34 | } 35 | 36 | // Handle errors 37 | const errorResponse = isJson ? await response.json() : {}; 38 | const errorPayload = { 39 | status: response.status, 40 | text: response.statusText, 41 | errors: errorResponse, 42 | }; 43 | return Promise.reject(errorPayload); 44 | }; 45 | -------------------------------------------------------------------------------- /frontend/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/assets/.gitkeep -------------------------------------------------------------------------------- /frontend/src/assets/externalAssets.ts: -------------------------------------------------------------------------------- 1 | export const jkdevLogoUrl = 2 | "https://jordan-kowal.github.io/assets/jkdev/logo.png"; 3 | -------------------------------------------------------------------------------- /frontend/src/assets/index.ts: -------------------------------------------------------------------------------- 1 | export { jkdevLogoUrl } from "./externalAssets"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/components/.gitkeep -------------------------------------------------------------------------------- /frontend/src/components/form/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/components/form/.gitkeep -------------------------------------------------------------------------------- /frontend/src/components/form/FieldsetInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { fireEvent, getByTestId } from "@testing-library/react"; 3 | import { LogOut } from "lucide-react"; 4 | import { describe, test, vi } from "vitest"; 5 | import { FieldsetInput } from "./FieldsetInput"; 6 | 7 | describe.concurrent("FieldsetInput", () => { 8 | test("should render the component", ({ expect }) => { 9 | const { container } = render( 10 | , 17 | ); 18 | const label = getByTestId(container, "fieldset-label"); 19 | const icon = label.querySelector("svg"); 20 | const input = getByTestId(container, "fieldset-input"); 21 | const error = getByTestId(container, "fieldset-error"); 22 | 23 | expect(label).toBeVisible(); 24 | expect(label).toHaveTextContent("Label"); 25 | expect(icon).toBeNull(); 26 | expect(input).toBeVisible(); 27 | expect(input.id).toBe("Email"); 28 | expect(input.name).toBe("Email"); 29 | expect(input.type).toBe("email"); 30 | expect(input.placeholder).toBe("Placeholder"); 31 | expect(input).toHaveClass("input w-full input-primary"); 32 | expect(error).toBeVisible(); 33 | expect(error).toBeEmptyDOMElement(); 34 | }); 35 | 36 | test("should handle onChange and onBlur", ({ expect }) => { 37 | const onChange = vi.fn(); 38 | const onBlur = vi.fn(); 39 | 40 | const { container } = render( 41 | , 49 | ); 50 | 51 | const input = getByTestId(container, "fieldset-input"); 52 | expect(input).toBeVisible(); 53 | 54 | fireEvent.blur(input); 55 | fireEvent.change(input, { target: { value: "test" } }); 56 | 57 | expect(onBlur).toHaveBeenCalledTimes(1); 58 | expect(onChange).toHaveBeenCalledTimes(1); 59 | }); 60 | 61 | test("should show the provided icon", ({ expect }) => { 62 | const { container } = render( 63 | } 69 | />, 70 | ); 71 | 72 | const label = getByTestId(container, "fieldset-label"); 73 | const icon = label.querySelector("svg"); 74 | expect(label).toBeVisible(); 75 | expect(icon).toBeVisible(); 76 | }); 77 | 78 | test("should display the error when it exists", ({ expect }) => { 79 | const { container } = render( 80 | , 87 | ); 88 | 89 | const input = getByTestId(container, "fieldset-input"); 90 | const error = getByTestId(container, "fieldset-error"); 91 | expect(error).toBeVisible(); 92 | expect(input).toHaveClass("input w-full input-error"); 93 | expect(error).toHaveTextContent("This is an error message"); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /frontend/src/components/form/FieldsetInput.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { forwardRef, memo } from "react"; 3 | 4 | export type FieldsetInputProps = { 5 | dataTestId?: string; 6 | icon?: React.ReactNode; 7 | label: string; 8 | errorMessage?: string; 9 | placeholder?: string; 10 | type: string; 11 | value?: string; 12 | name: string; 13 | onChange?: (e: React.ChangeEvent) => void; 14 | onBlur?: () => void; 15 | }; 16 | 17 | export const FieldsetInput = memo( 18 | forwardRef( 19 | ( 20 | { 21 | dataTestId, 22 | icon, 23 | label, 24 | errorMessage, 25 | placeholder, 26 | type, 27 | name, 28 | value, 29 | onChange, 30 | onBlur, 31 | }, 32 | ref, 33 | ) => { 34 | const inputColor = errorMessage ? "input-error" : "input-primary"; 35 | return ( 36 | <> 37 | 45 | 57 | 61 | {errorMessage} 62 | 63 | 64 | ); 65 | }, 66 | ), 67 | ); 68 | -------------------------------------------------------------------------------- /frontend/src/components/form/index.ts: -------------------------------------------------------------------------------- 1 | export { FieldsetInput } from "./FieldsetInput"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/layout/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/components/layout/.gitkeep -------------------------------------------------------------------------------- /frontend/src/components/layout/Main.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId, queryByTestId } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import { Main } from "./Main"; 5 | 6 | describe.concurrent("Main", () => { 7 | test("should render the component without navbar", ({ expect }) => { 8 | const { container } = render( 9 |
10 |
Content
11 |
, 12 | ); 13 | 14 | const main = getByTestId(container, "main"); 15 | const navbar = queryByTestId(container, "main-navbar"); 16 | 17 | expect(main).toBeVisible(); 18 | expect(main).toHaveTextContent("Content"); 19 | expect(main).toHaveStyle({ minHeight: "100vh" }); 20 | expect(navbar).toBeNull(); 21 | }); 22 | 23 | test("should handle extra classnames", ({ expect }) => { 24 | const { container } = render( 25 |
26 |
Content
27 |
, 28 | ); 29 | 30 | const main = getByTestId(container, "main"); 31 | expect(main).toBeVisible(); 32 | expect(main).toHaveClass("extra"); 33 | }); 34 | 35 | test("should render the component with the NavBar", ({ expect }) => { 36 | const { container } = render( 37 |
38 |
Content
39 |
, 40 | ); 41 | 42 | const main = getByTestId(container, "main"); 43 | const navbar = getByTestId(container, "navbar"); 44 | expect(main).toBeVisible(); 45 | expect(main).toHaveStyle({ marginTop: "64px" }); 46 | expect(navbar).toBeVisible(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /frontend/src/components/layout/Main.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { memo, useMemo } from "react"; 3 | import { FadeIn } from "../ui"; 4 | import { NavBar } from "./NavBar"; 5 | 6 | export type MainProps = { 7 | children: React.ReactNode; 8 | className?: string; 9 | dataTestId?: string; 10 | showNavBar?: boolean; 11 | }; 12 | 13 | const NAVBAR_HEIGHT = 64; 14 | 15 | export const Main: React.FC = memo( 16 | ({ children, className, dataTestId, showNavBar }) => { 17 | const style = useMemo(() => { 18 | return showNavBar 19 | ? { 20 | minHeight: `calc(100vh - ${NAVBAR_HEIGHT}px)`, 21 | marginTop: `${NAVBAR_HEIGHT}px`, 22 | } 23 | : { minHeight: "100vh" }; 24 | }, [showNavBar]); 25 | 26 | return ( 27 |
32 | {showNavBar && } 33 |
34 |
35 | {children} 36 |
37 |
38 |
39 | ); 40 | }, 41 | ); 42 | -------------------------------------------------------------------------------- /frontend/src/components/layout/NavBar.test.tsx: -------------------------------------------------------------------------------- 1 | import { performRequest } from "@/api/utils"; 2 | import { render } from "@/tests/utils"; 3 | import { getByTestId, waitFor } from "@testing-library/react"; 4 | import { describe, test } from "vitest"; 5 | import { NavBar } from "./NavBar"; 6 | 7 | describe.concurrent("NavBar", () => { 8 | test("should render the component", ({ expect }) => { 9 | const { container } = render(); 10 | const navbar = getByTestId(container, "navbar"); 11 | 12 | expect(navbar).toBeVisible(); 13 | 14 | expect(navbar).toHaveTextContent("Django React Starter"); 15 | }); 16 | 17 | test("should handle redirects", ({ expect }) => { 18 | const { container } = render(); 19 | 20 | const navbar = getByTestId(container, "navbar"); 21 | const logoLink = getByTestId( 22 | container, 23 | "navbar-logo-link", 24 | ); 25 | const homeLink = getByTestId( 26 | container, 27 | "navbar-home-link", 28 | ); 29 | const settingsLink = getByTestId( 30 | container, 31 | "navbar-settings-link", 32 | ); 33 | 34 | expect(navbar).toBeVisible(); 35 | expect(logoLink.href).toMatch(/\/$/); 36 | expect(homeLink.href).toMatch(/\/$/); 37 | expect(settingsLink.href).toMatch(/\/settings$/); 38 | }); 39 | 40 | test("should allow logout", async ({ expect }) => { 41 | const { container } = render(); 42 | const logoutButton = getByTestId( 43 | container, 44 | "navbar-logout-button", 45 | ); 46 | 47 | expect(logoutButton).toBeVisible(); 48 | 49 | logoutButton.click(); 50 | 51 | await waitFor(() => { 52 | expect(performRequest).toHaveBeenCalledWith("/api/v1/auth/logout/", { 53 | method: "POST", 54 | }); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /frontend/src/components/layout/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { useLogout } from "@/api/queries"; 2 | import { routeConfigMap } from "@/router"; 3 | import { LogOut, Settings } from "lucide-react"; 4 | import type React from "react"; 5 | import { memo, useCallback } from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | import { Link } from "wouter"; 8 | import { Logo } from "../ui"; 9 | 10 | export const NavBar: React.FC = memo(() => { 11 | const { t } = useTranslation(); 12 | const { mutateAsync: logout } = useLogout(); 13 | 14 | const onLogoutClick = useCallback(async () => { 15 | await logout(); 16 | }, [logout]); 17 | 18 | return ( 19 |
23 |
24 | 29 | 30 | 31 |
32 |
33 | 37 | {t("Django React Starter")} 38 | 39 |
40 |
41 |
42 | 48 | 49 | 50 |
51 |
52 | 60 |
61 |
62 |
63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /frontend/src/components/layout/index.ts: -------------------------------------------------------------------------------- 1 | export { Main } from "./Main"; 2 | export { NavBar } from "./NavBar"; 3 | -------------------------------------------------------------------------------- /frontend/src/components/ui/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/components/ui/.gitkeep -------------------------------------------------------------------------------- /frontend/src/components/ui/FadeIn.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId, waitFor } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import { FadeIn } from "./FadeIn"; 5 | 6 | describe.concurrent("FadeIn", () => { 7 | test("should render the component", async ({ expect }) => { 8 | const { container } = render( 9 | 10 |
Test Content
11 |
, 12 | ); 13 | 14 | const fadeInContainer = getByTestId(container, "fade-in"); 15 | const childElement = getByTestId(container, "child"); 16 | 17 | await waitFor(() => { 18 | expect(fadeInContainer).toBeInTheDocument(); 19 | }); 20 | 21 | expect(fadeInContainer).toHaveClass("opacity-0"); 22 | expect(fadeInContainer).not.toHaveClass("opacity-100"); 23 | expect(childElement).toBeVisible(); 24 | expect(childElement).toHaveTextContent("Test Content"); 25 | 26 | await waitFor(() => { 27 | expect(fadeInContainer).toHaveClass("opacity-100"); 28 | }); 29 | 30 | expect(fadeInContainer).not.toHaveClass("opacity-0"); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /frontend/src/components/ui/FadeIn.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { memo, useEffect, useState } from "react"; 3 | 4 | export type FadeInProps = { 5 | children: React.ReactNode; 6 | }; 7 | 8 | export const FadeIn: React.FC = memo(({ children }) => { 9 | const [isVisible, setIsVisible] = useState(false); 10 | 11 | useEffect(() => { 12 | const timer = setTimeout(() => { 13 | setIsVisible(true); 14 | }, 50); 15 | return () => clearTimeout(timer); 16 | }, []); 17 | 18 | const opacityClass = isVisible ? "opacity-100" : "opacity-0"; 19 | 20 | return ( 21 |
25 | {children} 26 |
27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /frontend/src/components/ui/LoadingRing.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import { LoadingRing } from "./LoadingRing"; 5 | 6 | describe.concurrent("LoadingRing", () => { 7 | test("should render the component", ({ expect }) => { 8 | const { container } = render(); 9 | const loadingRing = getByTestId(container, "loading-ring"); 10 | 11 | expect(loadingRing).toBeVisible(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /frontend/src/components/ui/LoadingRing.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { memo } from "react"; 3 | 4 | const style = { width: "60px" }; 5 | 6 | export const LoadingRing: React.FC = memo(() => { 7 | return ( 8 | 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Logo.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import { Logo } from "./Logo"; 5 | 6 | describe.concurrent("Logo", () => { 7 | test("should render the component", async ({ expect }) => { 8 | const { container } = render(); 9 | const logo = getByTestId(container, "logo"); 10 | expect(logo).toBeVisible(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { jkdevLogoUrl } from "@/assets"; 2 | import type React from "react"; 3 | import { memo } from "react"; 4 | 5 | export const Logo: React.FC = memo(() => { 6 | return JKDev Logo; 7 | }); 8 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, memo, useCallback, useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | type ModalProps = { 5 | onConfirm: () => Promise; 6 | children: React.ReactNode; 7 | closable?: boolean; 8 | }; 9 | 10 | export const Modal = memo( 11 | forwardRef( 12 | ({ children, onConfirm, closable }, ref) => { 13 | const [isLoading, setIsLoading] = useState(false); 14 | const { t } = useTranslation(); 15 | 16 | const closeModal = useCallback(() => { 17 | setIsLoading(false); 18 | // @ts-ignore 19 | ref?.current?.close(); 20 | }, [ref]); 21 | 22 | const handleConfirm = useCallback(async () => { 23 | try { 24 | setIsLoading(true); 25 | await onConfirm(); 26 | closeModal(); 27 | } catch (e) { 28 | setIsLoading(false); 29 | } 30 | }, [onConfirm, closeModal]); 31 | 32 | return ( 33 | 34 |
35 | {children} 36 | {closable && ( 37 | 45 | )} 46 |
47 | 55 | 67 |
68 |
69 |
70 | ); 71 | }, 72 | ), 73 | ); 74 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Toaster.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { describe, test } from "vitest"; 3 | import { Toaster } from "./Toaster"; 4 | 5 | describe.concurrent("Toaster", () => { 6 | test("should render the component", ({ expect }) => { 7 | render(); 8 | 9 | const { container } = render(); 10 | const item = container.querySelector(".Toastify"); 11 | expect(item).toBeVisible(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Toaster.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/contexts"; 2 | import type React from "react"; 3 | import { memo } from "react"; 4 | import { Bounce, ToastContainer } from "react-toastify"; 5 | 6 | export const Toaster: React.FC = memo(() => { 7 | const { isDarkMode } = useTheme(); 8 | 9 | return ( 10 | 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /frontend/src/components/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { FadeIn } from "./FadeIn"; 2 | export { LoadingRing } from "./LoadingRing"; 3 | export { Logo } from "./Logo"; 4 | export { Modal } from "./Modal"; 5 | export { Toaster } from "./Toaster"; 6 | -------------------------------------------------------------------------------- /frontend/src/config/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/config/.gitkeep -------------------------------------------------------------------------------- /frontend/src/config/daisyui.ts: -------------------------------------------------------------------------------- 1 | export type Theme = "bumblebee" | "coffee"; 2 | export const DEFAULT_THEME: Theme = "bumblebee"; 3 | export const THEME_STORAGE_KEY = "django-react-starter-theme"; 4 | -------------------------------------------------------------------------------- /frontend/src/config/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import "dayjs/locale/en"; 3 | import "dayjs/locale/fr"; 4 | import calendar from "dayjs/plugin/calendar"; 5 | import updateLocale from "dayjs/plugin/updateLocale"; 6 | import weekOfYear from "dayjs/plugin/weekOfYear"; 7 | 8 | dayjs.extend(calendar); 9 | dayjs.extend(updateLocale); 10 | dayjs.extend(weekOfYear); 11 | 12 | dayjs.updateLocale("fr", { 13 | calendar: { 14 | sameDay: "[Aujourd'hui à] HH:mm", 15 | nextDay: "[Demain à] HH:mm", 16 | nextWeek: "dddd [prochain à] HH:mm", 17 | lastDay: "[Hier à] HH:mm", 18 | lastWeek: "dddd [dernier à] HH:mm", 19 | sameElse: "[Le] DD/MM/YYYY [à] HH:mm", 20 | }, 21 | }); 22 | 23 | dayjs.updateLocale("en", { 24 | calendar: { 25 | sameDay: "[Today at] HH:mm", 26 | nextDay: "[Tomorrow at] HH:mm", 27 | nextWeek: "[Next] dddd [at] HH:mm", 28 | lastDay: "[Yesterday at] HH:mm", 29 | lastWeek: "[Last] dddd [at] HH:mm", 30 | sameElse: "[On] MM/DD/YYYY [at] HH:mm", 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /frontend/src/config/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import { z } from "zod"; 4 | import { zodI18nMap } from "zod-i18n-map"; 5 | import enZod from "zod-i18n-map/locales/en/zod.json"; 6 | import frZod from "zod-i18n-map/locales/fr/zod.json"; 7 | import en from "../../i18n/en.json"; 8 | import fr from "../../i18n/fr.json"; 9 | 10 | export type Locale = "en" | "fr"; 11 | 12 | export const DEFAULT_LOCALE: Locale = "en"; 13 | export const LOCALE_STORAGE_KEY = "django-react-starter-locale"; 14 | 15 | const resources: Record< 16 | Locale, 17 | { translation: Record; zod: any } 18 | > = { 19 | en: { 20 | translation: en, 21 | zod: enZod, 22 | }, 23 | fr: { 24 | translation: fr, 25 | zod: frZod, 26 | }, 27 | }; 28 | 29 | i18n.use(initReactI18next).init({ 30 | resources, 31 | lng: DEFAULT_LOCALE, 32 | interpolation: { 33 | escapeValue: false, 34 | }, 35 | }); 36 | 37 | z.setErrorMap(zodI18nMap); 38 | 39 | export default i18n; 40 | -------------------------------------------------------------------------------- /frontend/src/config/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/react"; 2 | 3 | // @ts-ignore 4 | const DSN = import.meta.env.VITE_SENTRY_DSN; 5 | // @ts-ignore 6 | const RELEASE = import.meta.env.VITE_APP_VERSION; 7 | // @ts-ignore 8 | const ENVIRONMENT = import.meta.env.VITE_ENVIRONMENT; 9 | 10 | Sentry.init({ 11 | dsn: DSN, 12 | release: `django_react_starter@${RELEASE}`, 13 | sendDefaultPii: false, // GDPR 14 | environment: ENVIRONMENT, 15 | sampleRate: 0.2, 16 | tracesSampleRate: 0.2, 17 | profilesSampleRate: 0.2, 18 | integrations: [ 19 | Sentry.browserTracingIntegration(), 20 | Sentry.browserProfilingIntegration(), 21 | Sentry.browserApiErrorsIntegration(), 22 | ], 23 | tracePropagationTargets: [ 24 | "localhost", 25 | /^https:\/\/django_react_starter\.jkdev\.app\/api/, 26 | /^https:\/\/django_react_starter.fly.dev\.jkdev\.app\/api/, 27 | ], 28 | }); 29 | -------------------------------------------------------------------------------- /frontend/src/contexts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/contexts/.gitkeep -------------------------------------------------------------------------------- /frontend/src/contexts/ThemeProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_THEME, THEME_STORAGE_KEY } from "@/config/daisyui"; 2 | import { getByTestId, render, waitFor } from "@testing-library/react"; 3 | import { beforeEach, describe, test } from "vitest"; 4 | import { ThemeProvider, useTheme } from "./ThemeProvider"; 5 | 6 | const TestComponent: React.FC = () => { 7 | const { theme, setTheme } = useTheme(); 8 | return ( 9 | 16 | ); 17 | }; 18 | 19 | describe.concurrent("ThemeProvider", () => { 20 | beforeEach(() => { 21 | localStorage.removeItem(THEME_STORAGE_KEY); 22 | }); 23 | 24 | test("renders children with default theme", ({ expect }) => { 25 | const { container } = render( 26 | 27 |
Test Child
28 |
, 29 | ); 30 | 31 | const provider = getByTestId(container, "theme-provider"); 32 | const child = getByTestId(container, "child"); 33 | 34 | expect(provider).toBeInTheDocument(); 35 | expect(provider).toHaveAttribute("data-theme", DEFAULT_THEME); 36 | expect(child).toBeInTheDocument(); 37 | }); 38 | 39 | test("should allow theme switching", async ({ expect }) => { 40 | const { container } = render( 41 | 42 | 43 | , 44 | ); 45 | 46 | const button = getByTestId(container, "button"); 47 | const provider = getByTestId(container, "theme-provider"); 48 | 49 | expect(provider).toHaveAttribute("data-theme", DEFAULT_THEME); 50 | expect(button).toHaveTextContent(DEFAULT_THEME); 51 | 52 | button.click(); 53 | 54 | await waitFor(() => { 55 | expect(button).toHaveTextContent("coffee"); 56 | }); 57 | 58 | expect(provider).toHaveAttribute("data-theme", "coffee"); 59 | }); 60 | 61 | test("persists theme in localStorage", ({ expect }) => { 62 | const { container } = render( 63 | 64 | 65 | , 66 | ); 67 | 68 | const button = getByTestId(container, "button"); 69 | 70 | expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull(); 71 | 72 | button.click(); 73 | 74 | expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe("coffee"); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /frontend/src/contexts/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_THEME, THEME_STORAGE_KEY, type Theme } from "@/config/daisyui"; 2 | import { type ReactNode, createContext, memo, useContext } from "react"; 3 | import { useLocalStorage } from "../hooks"; 4 | 5 | export type ThemeContextProps = { 6 | theme: Theme; 7 | isDarkMode: boolean; 8 | setTheme: (theme: Theme) => void; 9 | }; 10 | 11 | const ThemeContext = createContext(undefined); 12 | 13 | export const useTheme = (): ThemeContextProps => { 14 | const context = useContext(ThemeContext); 15 | if (!context) { 16 | throw new Error("useTheme must be used within a ThemeProvider"); 17 | } 18 | return context; 19 | }; 20 | 21 | export type ThemeProviderProps = { 22 | children: ReactNode; 23 | }; 24 | 25 | export const ThemeProvider: React.FC = memo( 26 | ({ children }) => { 27 | const [theme, changeTheme] = useLocalStorage( 28 | THEME_STORAGE_KEY, 29 | DEFAULT_THEME, 30 | ); 31 | const isDarkMode = theme === "coffee"; 32 | 33 | return ( 34 | 37 |
42 | {children} 43 |
44 |
45 | ); 46 | }, 47 | ); 48 | -------------------------------------------------------------------------------- /frontend/src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export { ThemeProvider, useTheme } from "./ThemeProvider"; 2 | -------------------------------------------------------------------------------- /frontend/src/features/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/features/.gitkeep -------------------------------------------------------------------------------- /frontend/src/features/home/pages/Homepage.test.tsx: -------------------------------------------------------------------------------- 1 | import { performRequest } from "@/api/utils"; 2 | import { render } from "@/tests/utils"; 3 | import { getByTestId, waitFor } from "@testing-library/react"; 4 | import { describe, test } from "vitest"; 5 | import Homepage from "./Homepage"; 6 | 7 | describe.concurrent("Homepage", () => { 8 | test("should render the page", ({ expect }) => { 9 | const { container } = render(); 10 | const homepage = getByTestId(container, "homepage"); 11 | 12 | expect(homepage).toBeVisible(); 13 | expect(homepage).toHaveTextContent("Django React Starter"); 14 | }); 15 | 16 | test("should redirect to settings", ({ expect }) => { 17 | const { container } = render(); 18 | const settingsLink = getByTestId( 19 | container, 20 | "settings-link", 21 | ); 22 | 23 | expect(settingsLink).toBeVisible(); 24 | expect(settingsLink.href).toMatch(/\/settings$/); 25 | }); 26 | 27 | test("should allow user to logout", async ({ expect }) => { 28 | const { container } = render(); 29 | const logoutButton = getByTestId( 30 | container, 31 | "logout-button", 32 | ); 33 | expect(logoutButton).toBeVisible(); 34 | 35 | logoutButton.click(); 36 | 37 | await waitFor(() => { 38 | expect(performRequest).toHaveBeenCalledWith("/api/v1/auth/logout/", { 39 | method: "POST", 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /frontend/src/features/home/pages/Homepage.tsx: -------------------------------------------------------------------------------- 1 | import { useLogout } from "@/api/queries"; 2 | import { Main } from "@/components/layout"; 3 | import { Logo } from "@/components/ui"; 4 | import { routeConfigMap } from "@/router"; 5 | import { LogOut, Settings } from "lucide-react"; 6 | import { memo, useCallback } from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | import { Link } from "wouter"; 9 | 10 | const Homepage: React.FC = memo(() => { 11 | const { t } = useTranslation(); 12 | const { mutateAsync: logout } = useLogout(); 13 | 14 | const onLogoutClick = useCallback(async () => { 15 | await logout(); 16 | }, [logout]); 17 | 18 | return ( 19 |
20 |
21 |
22 | 23 |
24 |

{t("Django React Starter")}

25 |

{t("An easy way to start a Django + React project")}

26 |
27 |
28 | 34 | {t("Go to settings")} 35 | 36 | 44 |
45 |
46 |
47 |
48 | ); 49 | }); 50 | 51 | export default Homepage; 52 | -------------------------------------------------------------------------------- /frontend/src/features/home/routes.ts: -------------------------------------------------------------------------------- 1 | import type { RouteConfig } from "@/router"; 2 | import Homepage from "./pages/Homepage"; 3 | 4 | export type HomeRouteKey = "homepage"; 5 | 6 | export const homeRoutes: Record = { 7 | homepage: { 8 | path: "/", 9 | // component: lazy(() => import("./pages/Homepage")), 10 | component: Homepage, 11 | key: "homepage", 12 | authAccess: "private", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/features/login/api/index.ts: -------------------------------------------------------------------------------- 1 | export { useLogin, type LoginRequestData } from "./useLogin"; 2 | export { 3 | usePasswordReset, 4 | type PasswordResetRequestData, 5 | } from "./usePasswordReset"; 6 | export { 7 | usePasswordResetConfirm, 8 | type PasswordResetConfirmRequestData, 9 | } from "./usePasswordResetConfirm"; 10 | export { useRegister, type RegisterRequestData } from "./useRegister"; 11 | -------------------------------------------------------------------------------- /frontend/src/features/login/api/useLogin.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import type { ApiError } from "@/api/types"; 3 | import { performRequest } from "@/api/utils"; 4 | import { routeConfigMap } from "@/router"; 5 | import { 6 | type UseMutationResult, 7 | useMutation, 8 | useQueryClient, 9 | } from "@tanstack/react-query"; 10 | import { useTranslation } from "react-i18next"; 11 | import { toast } from "react-toastify"; 12 | import { useLocation } from "wouter"; 13 | 14 | export type LoginRequestData = { 15 | email: string; 16 | password: string; 17 | }; 18 | 19 | type UseLogin = () => UseMutationResult< 20 | void, 21 | ApiError, 22 | LoginRequestData, 23 | unknown 24 | >; 25 | 26 | export const useLogin: UseLogin = () => { 27 | const url = `${API_ROOT_URL}/auth/login/`; 28 | const queryClient = useQueryClient(); 29 | const { t } = useTranslation(); 30 | const [, navigate] = useLocation(); 31 | 32 | return useMutation({ 33 | mutationFn: async (data: LoginRequestData): Promise => 34 | await performRequest(url, { method: "POST", data }), 35 | onSuccess: () => { 36 | queryClient.invalidateQueries({ queryKey: ["appConfig"] }); 37 | queryClient.invalidateQueries({ queryKey: ["self"] }); 38 | navigate(routeConfigMap.homepage.path); 39 | }, 40 | onError: ({ status }) => { 41 | if (status === 400) { 42 | toast.error(t("Invalid credentials")); 43 | } else { 44 | toast.error(t("Something went wrong")); 45 | } 46 | }, 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/features/login/api/usePasswordReset.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import type { ApiError } from "@/api/types"; 3 | import { performRequest } from "@/api/utils"; 4 | import { routeConfigMap } from "@/router"; 5 | import { type UseMutationResult, useMutation } from "@tanstack/react-query"; 6 | import { useTranslation } from "react-i18next"; 7 | import { toast } from "react-toastify"; 8 | import { useLocation } from "wouter"; 9 | 10 | export type PasswordResetRequestData = { 11 | email: string; 12 | }; 13 | 14 | type UsePasswordReset = () => UseMutationResult< 15 | void, 16 | ApiError, 17 | PasswordResetRequestData, 18 | unknown 19 | >; 20 | 21 | export const usePasswordReset: UsePasswordReset = () => { 22 | const url = `${API_ROOT_URL}/auth/password_reset/`; 23 | const { t } = useTranslation(); 24 | const [, navigate] = useLocation(); 25 | 26 | return useMutation({ 27 | mutationFn: async (data: PasswordResetRequestData): Promise => 28 | await performRequest(url, { method: "POST", data }), 29 | onSuccess: () => { 30 | toast.success(t("An email has been sent to reset your password")); 31 | navigate(routeConfigMap.login.path); 32 | }, 33 | onError: () => { 34 | toast.error(t("Something went wrong")); 35 | }, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/features/login/api/usePasswordResetConfirm.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import type { ApiError } from "@/api/types"; 3 | import { performRequest } from "@/api/utils"; 4 | import { routeConfigMap } from "@/router"; 5 | import { type UseMutationResult, useMutation } from "@tanstack/react-query"; 6 | import { useTranslation } from "react-i18next"; 7 | import { toast } from "react-toastify"; 8 | import { useLocation } from "wouter"; 9 | 10 | export type PasswordResetConfirmRequestData = { 11 | password: string; 12 | confirmPassword: string; 13 | }; 14 | 15 | type UsePasswordResetConfirm = ( 16 | uid?: string, 17 | token?: string, 18 | ) => UseMutationResult< 19 | void, 20 | ApiError, 21 | PasswordResetConfirmRequestData, 22 | unknown 23 | >; 24 | 25 | export const usePasswordResetConfirm: UsePasswordResetConfirm = ( 26 | uid, 27 | token, 28 | ) => { 29 | const url = `${API_ROOT_URL}/auth/password_reset_confirm/`; 30 | const [, navigate] = useLocation(); 31 | const { t } = useTranslation(); 32 | 33 | return useMutation({ 34 | mutationFn: async ( 35 | data: PasswordResetConfirmRequestData, 36 | ): Promise => { 37 | await performRequest(url, { 38 | method: "POST", 39 | data: { password: data.password, token, uid }, 40 | }); 41 | }, 42 | onSuccess: () => { 43 | toast.success(t("Password updated. You can now log in.")); 44 | navigate(routeConfigMap.login.path); 45 | }, 46 | onError: ({ status, errors }) => { 47 | if (status === 400) { 48 | if (errors?.password) { 49 | toast.error(t("Password is too weak")); 50 | } else { 51 | toast.error(t("Invalid token")); 52 | } 53 | } else { 54 | toast.error(t("Something went wrong")); 55 | } 56 | }, 57 | }); 58 | }; 59 | -------------------------------------------------------------------------------- /frontend/src/features/login/api/useRegister.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import type { ApiError } from "@/api/types"; 3 | import { performRequest } from "@/api/utils"; 4 | import { routeConfigMap } from "@/router"; 5 | import { 6 | type UseMutationResult, 7 | useMutation, 8 | useQueryClient, 9 | } from "@tanstack/react-query"; 10 | import { useTranslation } from "react-i18next"; 11 | import { toast } from "react-toastify"; 12 | import { useLocation } from "wouter"; 13 | 14 | export type RegisterRequestData = { 15 | email: string; 16 | password: string; 17 | confirmPassword: string; 18 | }; 19 | 20 | type UseRegister = () => UseMutationResult< 21 | void, 22 | ApiError, 23 | RegisterRequestData, 24 | unknown 25 | >; 26 | 27 | export const useRegister: UseRegister = () => { 28 | const url = `${API_ROOT_URL}/auth/register/`; 29 | const queryClient = useQueryClient(); 30 | const { t } = useTranslation(); 31 | const [, navigate] = useLocation(); 32 | 33 | return useMutation({ 34 | mutationFn: async (data: RegisterRequestData): Promise => { 35 | await performRequest(url, { 36 | method: "POST", 37 | data: { 38 | email: data.email, 39 | password: data.password, 40 | }, 41 | }); 42 | }, 43 | onSuccess: () => { 44 | queryClient.invalidateQueries({ queryKey: ["appConfig"] }); 45 | queryClient.invalidateQueries({ queryKey: ["self"] }); 46 | toast.success(t("Account created successfully")); 47 | navigate(routeConfigMap.homepage.path); 48 | }, 49 | onError: ({ status, errors }) => { 50 | if (status === 400) { 51 | if (errors?.email) { 52 | toast.error(t("Email already taken")); 53 | } else if (errors?.password) { 54 | toast.error(t("Password is too weak")); 55 | } else { 56 | toast.error(t("Registration failed")); 57 | } 58 | } else { 59 | toast.error(t("Something went wrong")); 60 | } 61 | }, 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /frontend/src/features/login/components/BaseForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId } from "@testing-library/react"; 3 | import { describe, test, vi } from "vitest"; 4 | import { BaseForm } from "./BaseForm"; 5 | 6 | const onSubmit = vi.fn(); 7 | 8 | describe.concurrent("LoginForm", () => { 9 | test("should render the component", ({ expect }) => { 10 | const { container } = render( 11 | 12 | Submit 13 | , 14 | ); 15 | 16 | const form = getByTestId(container, "base-form"); 17 | 18 | expect(form).toBeVisible(); 19 | expect(form).toHaveTextContent("Submit"); 20 | }); 21 | 22 | test("should call onSubmit on form submission", ({ expect }) => { 23 | const { container } = render( 24 | 25 | 28 | , 29 | ); 30 | 31 | const form = getByTestId(container, "base-form"); 32 | const submitButton = getByTestId(container, "submit-button"); 33 | 34 | expect(form).toBeVisible(); 35 | 36 | submitButton.click(); 37 | 38 | expect(onSubmit).toHaveBeenCalled(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /frontend/src/features/login/components/BaseForm.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | export type BaseFormProps = { 4 | children: React.ReactNode; 5 | onSubmit: (data: any) => void; 6 | dataTestId?: string; 7 | }; 8 | 9 | export const BaseForm: React.FC = memo( 10 | ({ children, onSubmit, dataTestId }) => { 11 | return ( 12 |
18 |
19 | {children} 20 |
21 |
22 | ); 23 | }, 24 | ); 25 | -------------------------------------------------------------------------------- /frontend/src/features/login/components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { FieldsetInput } from "@/components/form"; 2 | import { zodResolver } from "@hookform/resolvers/zod"; 3 | import { KeyRound, LogIn, Mail } from "lucide-react"; 4 | import { memo, useState } from "react"; 5 | import { Controller, useForm } from "react-hook-form"; 6 | import { useTranslation } from "react-i18next"; 7 | import { z } from "zod"; 8 | import { useLogin } from "../api"; 9 | import { BaseForm } from "./BaseForm"; 10 | 11 | const schema = z.object({ 12 | email: z.string().nonempty().email(), 13 | password: z.string().nonempty(), 14 | }); 15 | 16 | type Schema = z.infer; 17 | 18 | export const LoginForm: React.FC = memo(() => { 19 | const [isLoading, setIsLoading] = useState(false); 20 | 21 | const { t } = useTranslation(); 22 | const { mutateAsync: login } = useLogin(); 23 | 24 | const { 25 | control, 26 | handleSubmit, 27 | formState: { errors, isDirty, isValid }, 28 | } = useForm({ 29 | resolver: zodResolver(schema), 30 | mode: "onChange", 31 | }); 32 | 33 | const onSubmit = async (data: Schema) => { 34 | setIsLoading(true); 35 | try { 36 | await login(data); 37 | } catch (e) { 38 | setIsLoading(false); 39 | } 40 | }; 41 | 42 | return ( 43 | 44 | {t("Login")} 45 | ( 49 | } 51 | label={t("Email")} 52 | errorMessage={errors?.email?.message} 53 | placeholder={t("Enter your email address")} 54 | type="email" 55 | dataTestId="email" 56 | {...field} 57 | /> 58 | )} 59 | /> 60 | ( 64 | } 66 | label={t("Password")} 67 | errorMessage={errors?.password?.message} 68 | placeholder={t("Enter your password")} 69 | type="password" 70 | dataTestId="password" 71 | {...field} 72 | /> 73 | )} 74 | /> 75 | 84 | 85 | ); 86 | }); 87 | -------------------------------------------------------------------------------- /frontend/src/features/login/components/PasswordResetForm.tsx: -------------------------------------------------------------------------------- 1 | import { FieldsetInput } from "@/components/form"; 2 | import { routeConfigMap } from "@/router"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { ArrowLeftToLine, Mail, Send } from "lucide-react"; 5 | import { memo, useState } from "react"; 6 | import { Controller, useForm } from "react-hook-form"; 7 | import { useTranslation } from "react-i18next"; 8 | import { Link } from "wouter"; 9 | import { z } from "zod"; 10 | import { usePasswordReset } from "../api"; 11 | import { BaseForm } from "./BaseForm"; 12 | 13 | const schema = z.object({ 14 | email: z.string().nonempty().email(), 15 | }); 16 | 17 | type Schema = z.infer; 18 | 19 | export const PasswordResetForm: React.FC = memo(() => { 20 | const [isLoading, setIsLoading] = useState(false); 21 | 22 | const { t } = useTranslation(); 23 | const { mutateAsync: passwordReset } = usePasswordReset(); 24 | 25 | const { 26 | control, 27 | handleSubmit, 28 | formState: { errors, isDirty, isValid }, 29 | } = useForm({ 30 | resolver: zodResolver(schema), 31 | mode: "onChange", 32 | }); 33 | 34 | const onSubmit = async (data: Schema) => { 35 | setIsLoading(true); 36 | try { 37 | await passwordReset(data); 38 | } catch (e) { 39 | setIsLoading(false); 40 | } 41 | }; 42 | 43 | return ( 44 | 48 | {t("Password reset")} 49 | ( 53 | } 55 | label={t("Email")} 56 | errorMessage={errors?.email?.message} 57 | placeholder={t("Enter your email address")} 58 | type="email" 59 | dataTestId="email" 60 | {...field} 61 | /> 62 | )} 63 | /> 64 |
65 | 71 | 72 | {t("Go back")} 73 | 74 | 83 |
84 |
85 | ); 86 | }); 87 | -------------------------------------------------------------------------------- /frontend/src/features/login/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { LoginForm } from "./LoginForm"; 2 | export { PasswordResetConfirmForm } from "./PasswordResetConfirmForm"; 3 | export { PasswordResetForm } from "./PasswordResetForm"; 4 | export { RegisterForm } from "./RegisterForm"; 5 | -------------------------------------------------------------------------------- /frontend/src/features/login/pages/LoginPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { navigateMock } from "@/tests/mocks/globals"; 2 | import { render } from "@/tests/utils"; 3 | import { fireEvent, getByTestId } from "@testing-library/react"; 4 | import { describe, test } from "vitest"; 5 | import LoginPage from "./LoginPage"; 6 | 7 | describe.concurrent("LoginPage", () => { 8 | test("should render the page with login form by default", ({ expect }) => { 9 | const { container } = render(); 10 | const loginPage = getByTestId(container, "login-page"); 11 | 12 | expect(loginPage).toBeVisible(); 13 | expect(loginPage).toHaveTextContent("Django React Starter"); 14 | expect(getByTestId(container, "login-form")).toBeInTheDocument(); 15 | expect(navigateMock).not.toHaveBeenCalledWith("/"); 16 | }); 17 | 18 | test("should switch to register form when register toggle is clicked", ({ 19 | expect, 20 | }) => { 21 | const { container } = render(); 22 | const loginPage = getByTestId(container, "login-page"); 23 | 24 | expect(loginPage).toBeVisible(); 25 | // Initially should show login form 26 | expect(getByTestId(container, "login-form")).toBeInTheDocument(); 27 | // Click on register toggle 28 | fireEvent.click(getByTestId(container, "mode-register")); 29 | // Should now show register form 30 | expect(getByTestId(container, "register-form")).toBeInTheDocument(); 31 | // Switch back to login 32 | fireEvent.click(getByTestId(container, "mode-login")); 33 | // Should be back to login form 34 | expect(getByTestId(container, "login-form")).toBeInTheDocument(); 35 | }); 36 | 37 | test("should switch to password reset page when link is clicked", ({ 38 | expect, 39 | }) => { 40 | const { container } = render(); 41 | const loginPage = getByTestId(container, "login-page"); 42 | 43 | expect(loginPage).toBeVisible(); 44 | expect(getByTestId(container, "password-reset-link")).toHaveAttribute( 45 | "href", 46 | "/password-reset", 47 | ); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /frontend/src/features/login/pages/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { Main } from "@/components/layout"; 2 | import { Logo } from "@/components/ui"; 3 | import { memo, useState } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { Link } from "wouter"; 6 | import { LoginForm, RegisterForm } from "../components"; 7 | 8 | type AuthMode = "login" | "register"; 9 | 10 | const LoginPage: React.FC = memo(() => { 11 | const { t } = useTranslation(); 12 | const [mode, setMode] = useState("login"); 13 | 14 | return ( 15 |
16 |
17 | 18 |
19 |
20 |

{t("Django React Starter")}

21 |
22 | setMode("login")} 29 | data-testid="mode-login" 30 | /> 31 | setMode("register")} 38 | data-testid="mode-register" 39 | /> 40 |
41 |
42 | {mode === "login" ? : } 43 |
44 | 45 | {t("Forgot your password?")} 46 | 47 |
48 |
49 | ); 50 | }); 51 | 52 | export default LoginPage; 53 | -------------------------------------------------------------------------------- /frontend/src/features/login/pages/PasswordResetConfirmPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import PasswordResetConfirmPage from "./PasswordResetConfirmPage"; 5 | 6 | describe.concurrent("PasswordResetConfirmPage", () => { 7 | test("should render the page ", ({ expect }) => { 8 | const { container } = render(); 9 | const passwordResetConfirmPage = getByTestId( 10 | container, 11 | "password-reset-confirm-page", 12 | ); 13 | 14 | expect(passwordResetConfirmPage).toBeVisible(); 15 | expect(passwordResetConfirmPage).toHaveTextContent("Set your new password"); 16 | expect( 17 | getByTestId(container, "password-reset-confirm-form"), 18 | ).toBeInTheDocument(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /frontend/src/features/login/pages/PasswordResetConfirmPage.tsx: -------------------------------------------------------------------------------- 1 | import { Main } from "@/components/layout"; 2 | import { Logo } from "@/components/ui"; 3 | import { memo } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { PasswordResetConfirmForm } from "../components"; 6 | 7 | const PasswordResetConfirmPage: React.FC = memo(() => { 8 | const { t } = useTranslation(); 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 |

{t("Set your new password")}

16 |
17 | 18 |
19 | ); 20 | }); 21 | 22 | export default PasswordResetConfirmPage; 23 | -------------------------------------------------------------------------------- /frontend/src/features/login/pages/PasswordResetPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import PasswordResetPage from "./PasswordResetPage"; 5 | 6 | describe.concurrent("PasswordResetPage", () => { 7 | test("should render the page ", ({ expect }) => { 8 | const { container } = render(); 9 | const passwordResetPage = getByTestId( 10 | container, 11 | "password-reset-page", 12 | ); 13 | 14 | expect(passwordResetPage).toBeVisible(); 15 | expect(passwordResetPage).toHaveTextContent("Forgot your password?"); 16 | expect(getByTestId(container, "password-reset-form")).toBeInTheDocument(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /frontend/src/features/login/pages/PasswordResetPage.tsx: -------------------------------------------------------------------------------- 1 | import { Main } from "@/components/layout"; 2 | import { Logo } from "@/components/ui"; 3 | import { memo } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { PasswordResetForm } from "../components"; 6 | 7 | const PasswordResetPage: React.FC = memo(() => { 8 | const { t } = useTranslation(); 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 |

{t("Forgot your password?")}

16 |
17 | 18 |
19 | ); 20 | }); 21 | 22 | export default PasswordResetPage; 23 | -------------------------------------------------------------------------------- /frontend/src/features/login/routes.ts: -------------------------------------------------------------------------------- 1 | import type { RouteConfig } from "@/router"; 2 | import LoginPage from "./pages/LoginPage"; 3 | import PasswordResetConfirmPage from "./pages/PasswordResetConfirmPage"; 4 | import PasswordResetPage from "./pages/PasswordResetPage"; 5 | 6 | export type LoginRouteKey = "login" | "passwordReset" | "passwordResetConfirm"; 7 | 8 | export const loginRoutes: Record = { 9 | login: { 10 | path: "/login", 11 | // component: lazy(() => import("./pages/LoginPage")), 12 | component: LoginPage, 13 | key: "login", 14 | authAccess: "public-only", 15 | }, 16 | passwordReset: { 17 | path: "/password-reset", 18 | // component: lazy(() => import("./pages/PasswordResetPage")), 19 | component: PasswordResetPage, 20 | key: "passwordReset", 21 | authAccess: "public-only", 22 | }, 23 | passwordResetConfirm: { 24 | path: "/password-reset-confirm/:uid/:token", 25 | // component: lazy(() => import("./pages/PasswordResetConfirmPage")), 26 | component: PasswordResetConfirmPage, 27 | key: "passwordResetConfirm", 28 | authAccess: "public-only", 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/src/features/settings/api/index.ts: -------------------------------------------------------------------------------- 1 | export { useDeleteAccount } from "./useDeleteAccount"; 2 | export { 3 | useUpdatePassword, 4 | type UpdatePasswordRequestData, 5 | } from "./useUpdatePassword"; 6 | export { useUpdateSelf, type UpdateSelfRequestData } from "./useUpdateSelf"; 7 | -------------------------------------------------------------------------------- /frontend/src/features/settings/api/useDeleteAccount.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import type { ApiError } from "@/api/types"; 3 | import { performRequest } from "@/api/utils"; 4 | import { routeConfigMap } from "@/router"; 5 | import { 6 | type UseMutationResult, 7 | useMutation, 8 | useQueryClient, 9 | } from "@tanstack/react-query"; 10 | import { useTranslation } from "react-i18next"; 11 | import { toast } from "react-toastify"; 12 | import { useLocation } from "wouter"; 13 | 14 | type UseDeleteAccount = () => UseMutationResult; 15 | 16 | export const useDeleteAccount: UseDeleteAccount = () => { 17 | const url = `${API_ROOT_URL}/self/account/`; 18 | const queryClient = useQueryClient(); 19 | const { t } = useTranslation(); 20 | const [, navigate] = useLocation(); 21 | 22 | return useMutation({ 23 | mutationFn: async (): Promise => { 24 | await performRequest(url, { method: "DELETE" }); 25 | }, 26 | onSuccess: () => { 27 | queryClient.invalidateQueries({ queryKey: ["self"] }); 28 | queryClient.removeQueries({ queryKey: ["self"] }); 29 | toast.success(t("Your account has been deleted")); 30 | navigate(routeConfigMap.login.path); 31 | }, 32 | onError: () => { 33 | console.log("onError"); 34 | toast.error(t("Something went wrong")); 35 | }, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/features/settings/api/useUpdatePassword.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import type { ApiError } from "@/api/types"; 3 | import { performRequest } from "@/api/utils"; 4 | import { type UseMutationResult, useMutation } from "@tanstack/react-query"; 5 | import { useTranslation } from "react-i18next"; 6 | import { toast } from "react-toastify"; 7 | 8 | export type UpdatePasswordRequestData = { 9 | currentPassword: string; 10 | newPassword: string; 11 | confirmPassword: string; 12 | }; 13 | 14 | type UseUpdatePassword = () => UseMutationResult< 15 | void, 16 | ApiError, 17 | UpdatePasswordRequestData, 18 | unknown 19 | >; 20 | 21 | export const useUpdatePassword: UseUpdatePassword = () => { 22 | const { t } = useTranslation(); 23 | const url = `${API_ROOT_URL}/self/password/`; 24 | return useMutation({ 25 | mutationFn: async (data: UpdatePasswordRequestData): Promise => { 26 | await performRequest(url, { 27 | method: "PUT", 28 | data: { 29 | current_password: data.currentPassword, 30 | new_password: data.newPassword, 31 | }, 32 | }); 33 | }, 34 | onSuccess: () => { 35 | toast.success(t("Password updated")); 36 | }, 37 | onError: ({ status, errors }) => { 38 | if (status === 400) { 39 | if (errors?.current_password) { 40 | toast.error(t("Invalid current password")); 41 | } else if (errors?.new_password) { 42 | toast.error(t("Password is too weak")); 43 | } 44 | } else { 45 | toast.error(t("Something went wrong")); 46 | } 47 | }, 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /frontend/src/features/settings/api/useUpdateSelf.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import type { Self } from "@/api/queries"; 3 | import { deserializeSelf } from "@/api/queries/useSelf"; 4 | import type { ApiError } from "@/api/types"; 5 | import { performRequest } from "@/api/utils"; 6 | import { 7 | type UseMutationResult, 8 | useMutation, 9 | useQueryClient, 10 | } from "@tanstack/react-query"; 11 | import { useTranslation } from "react-i18next"; 12 | import { toast } from "react-toastify"; 13 | 14 | export type UpdateSelfRequestData = { 15 | email: string; 16 | firstName: string; 17 | lastName: string; 18 | }; 19 | 20 | type UseUpdateSelf = () => UseMutationResult< 21 | Self, 22 | ApiError, 23 | UpdateSelfRequestData, 24 | unknown 25 | >; 26 | 27 | export const useUpdateSelf: UseUpdateSelf = () => { 28 | const { t } = useTranslation(); 29 | const queryClient = useQueryClient(); 30 | const url = `${API_ROOT_URL}/self/account/`; 31 | 32 | return useMutation({ 33 | mutationFn: async (data: UpdateSelfRequestData): Promise => { 34 | const response = await performRequest(url, { 35 | method: "PUT", 36 | data: { 37 | email: data.email, 38 | first_name: data.firstName, 39 | last_name: data.lastName, 40 | }, 41 | }); 42 | return deserializeSelf(response); 43 | }, 44 | onSuccess: (data: Self) => { 45 | queryClient.setQueryData(["self"], data); 46 | toast.success(t("Information updated")); 47 | }, 48 | onError: ({ status }) => { 49 | if (status === 400) { 50 | toast.error(t("Failed to update information")); 51 | } else { 52 | toast.error(t("Something went wrong")); 53 | } 54 | }, 55 | }); 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/src/features/settings/components/DangerZone.test.tsx: -------------------------------------------------------------------------------- 1 | import { performRequest } from "@/api/utils"; 2 | import { 3 | navigateMock, 4 | toastErrorMock, 5 | toastSuccessMock, 6 | } from "@/tests/mocks/globals"; 7 | import { deleteAccountCrash } from "@/tests/mocks/handlers/settings"; 8 | import { server } from "@/tests/server"; 9 | import { render } from "@/tests/utils"; 10 | import { fireEvent, getByTestId, waitFor } from "@testing-library/react"; 11 | import { describe, test } from "vitest"; 12 | import { DangerZone } from "./DangerZone"; 13 | 14 | describe("DangerZone", () => { 15 | test("should render the component correctly", ({ expect }) => { 16 | const { container } = render(); 17 | const dangerZone = getByTestId(container, "danger-zone"); 18 | 19 | expect(dangerZone).toBeVisible(); 20 | expect(dangerZone).toHaveTextContent("Delete your account"); 21 | }); 22 | 23 | test("should delete account on confirm button click", async ({ expect }) => { 24 | const { container } = render(); 25 | const deleteButton = getByTestId(container, "delete-account-button"); 26 | const modal = getByTestId(container, "modal"); 27 | const confirmButton = getByTestId(container, "modal-confirm-button"); 28 | 29 | expect(deleteButton).toBeVisible(); 30 | 31 | fireEvent.click(deleteButton); 32 | 33 | expect(modal).toHaveAttribute("open"); 34 | expect(confirmButton).toBeVisible(); 35 | 36 | fireEvent.click(confirmButton); 37 | 38 | await waitFor(() => { 39 | expect(performRequest).toHaveBeenCalledWith("/api/v1/self/account/", { 40 | method: "DELETE", 41 | }); 42 | }); 43 | 44 | expect(navigateMock).toHaveBeenCalledWith("/login"); 45 | expect(toastSuccessMock).toHaveBeenCalledWith( 46 | "Your account has been deleted", 47 | ); 48 | }); 49 | 50 | test("should keep modal open and show toast on delete failure", async ({ 51 | expect, 52 | }) => { 53 | server.use(deleteAccountCrash); 54 | const { container } = render(); 55 | const deleteButton = getByTestId(container, "delete-account-button"); 56 | const modal = getByTestId(container, "modal"); 57 | const confirmButton = getByTestId(container, "modal-confirm-button"); 58 | 59 | expect(deleteButton).toBeVisible(); 60 | 61 | fireEvent.click(deleteButton); 62 | 63 | expect(modal).toHaveAttribute("open"); 64 | expect(confirmButton).toBeVisible(); 65 | 66 | fireEvent.click(confirmButton); 67 | 68 | await waitFor(() => { 69 | expect(performRequest).toHaveBeenCalledWith("/api/v1/self/account/", { 70 | method: "DELETE", 71 | }); 72 | }); 73 | 74 | expect(modal).toHaveAttribute("open"); 75 | expect(toastErrorMock).toHaveBeenCalledWith("Something went wrong"); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /frontend/src/features/settings/components/DangerZone.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from "@/components/ui"; 2 | import { Trash2 } from "lucide-react"; 3 | import { memo, useCallback, useRef } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { useDeleteAccount } from "../api"; 6 | 7 | export const DangerZone: React.FC = memo(() => { 8 | const { t } = useTranslation(); 9 | const modalRef = useRef(null); 10 | const { mutateAsync: deleteAccount } = useDeleteAccount(); 11 | 12 | const showModal = useCallback(() => { 13 | modalRef.current?.showModal(); 14 | }, []); 15 | 16 | const handleConfirmDelete = useCallback(async () => { 17 | await deleteAccount(); 18 | }, [deleteAccount]); 19 | 20 | return ( 21 |
25 |
26 | {t("Delete your account")} 27 | 36 |
37 | 38 | 39 |

40 | 41 | {t("Delete Account")} 42 |

43 |

44 | {t( 45 | "Are you sure you want to delete your account? This action cannot be undone.", 46 | )} 47 |

48 |
49 |
50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /frontend/src/features/settings/components/GoBackButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import { GoBackButton } from "./GoBackButton"; 5 | 6 | describe.concurrent("GoBackButton", () => { 7 | test("should render the component", ({ expect }) => { 8 | const { container } = render(); 9 | const goBackButton = getByTestId( 10 | container, 11 | "go-back-button", 12 | ); 13 | 14 | expect(goBackButton).toBeVisible(); 15 | expect(goBackButton).toHaveTextContent("Go back"); 16 | }); 17 | 18 | test("should go back home on click", ({ expect }) => { 19 | const { container } = render(); 20 | const goBackLink = getByTestId(container, "go-back-link"); 21 | 22 | expect(goBackLink).toBeVisible(); 23 | expect(goBackLink.href).toMatch(/\/$/); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/features/settings/components/GoBackButton.tsx: -------------------------------------------------------------------------------- 1 | import { routeConfigMap } from "@/router"; 2 | import { ArrowLeftToLine } from "lucide-react"; 3 | import { memo } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { Link } from "wouter"; 6 | 7 | export const GoBackButton: React.FC = memo(() => { 8 | const { t } = useTranslation(); 9 | 10 | return ( 11 |
16 | 22 | 23 | {t("Go back")} 24 | 25 |
26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /frontend/src/features/settings/components/UserSettings.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId, waitFor } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import { UserSettings } from "./UserSettings"; 5 | 6 | const getElements = ( 7 | container: HTMLElement, 8 | ): { 9 | userSettings: HTMLDivElement; 10 | localeFr: HTMLInputElement; 11 | localeEn: HTMLInputElement; 12 | themeLight: HTMLInputElement; 13 | themeDark: HTMLInputElement; 14 | } => ({ 15 | userSettings: getByTestId(container, "user-settings"), 16 | localeFr: getByTestId(container, "locale-fr"), 17 | localeEn: getByTestId(container, "locale-en"), 18 | themeLight: getByTestId(container, "theme-light"), 19 | themeDark: getByTestId(container, "theme-dark"), 20 | }); 21 | 22 | describe.concurrent("UserSettings", () => { 23 | test("should render the component", ({ expect }) => { 24 | const { container } = render(); 25 | const { userSettings } = getElements(container); 26 | 27 | expect(userSettings).toBeVisible(); 28 | expect(userSettings).toHaveTextContent("Language"); 29 | }); 30 | 31 | test("should change locale on click", async ({ expect }) => { 32 | const { container } = render(); 33 | const { userSettings, localeFr, localeEn } = getElements(container); 34 | 35 | expect(userSettings).toBeVisible(); 36 | expect(userSettings).toHaveTextContent("Language"); 37 | 38 | localeFr.click(); 39 | 40 | await waitFor(() => { 41 | expect(userSettings).toHaveTextContent("Langue"); 42 | }); 43 | 44 | localeEn.click(); 45 | 46 | await waitFor(() => { 47 | expect(userSettings).toHaveTextContent("Language"); 48 | }); 49 | }); 50 | 51 | test("should change theme on click", async ({ expect }) => { 52 | const { container } = render(); 53 | const themeProvider = getByTestId(container, "theme-provider"); 54 | const { userSettings, themeLight, themeDark } = getElements(container); 55 | 56 | expect(userSettings).toBeVisible(); 57 | expect(themeProvider).toHaveAttribute("data-theme", "bumblebee"); 58 | 59 | themeDark.click(); 60 | 61 | await waitFor(() => { 62 | expect(themeProvider).toHaveAttribute("data-theme", "coffee"); 63 | }); 64 | 65 | themeLight.click(); 66 | 67 | await waitFor(() => { 68 | expect(themeProvider).toHaveAttribute("data-theme", "bumblebee"); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /frontend/src/features/settings/components/UserSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/contexts"; 2 | import { useLocale } from "@/hooks"; 3 | import { Languages, SunMoon } from "lucide-react"; 4 | import { memo } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | export const UserSettings: React.FC = memo(() => { 8 | const { t } = useTranslation(); 9 | const { currentLocale, setLocale } = useLocale(); 10 | const { setTheme, isDarkMode } = useTheme(); 11 | 12 | return ( 13 |
17 |
18 | 19 | {t("Language")} 20 | 21 |
22 | setLocale("en")} 29 | data-testid="locale-en" 30 | /> 31 | setLocale("fr")} 38 | data-testid="locale-fr" 39 | /> 40 |
41 |
42 |
43 | 44 | {t("Color theme")} 45 | 46 |
47 | setTheme("bumblebee")} 53 | checked={!isDarkMode} 54 | data-testid="theme-light" 55 | /> 56 | setTheme("coffee")} 62 | checked={isDarkMode} 63 | data-testid="theme-dark" 64 | /> 65 |
66 |
67 |
68 | ); 69 | }); 70 | -------------------------------------------------------------------------------- /frontend/src/features/settings/components/index.ts: -------------------------------------------------------------------------------- 1 | export { DangerZone } from "./DangerZone"; 2 | export { GoBackButton } from "./GoBackButton"; 3 | export { PasswordForm } from "./PasswordForm"; 4 | export { UserForm } from "./UserForm"; 5 | export { UserSettings } from "./UserSettings"; 6 | -------------------------------------------------------------------------------- /frontend/src/features/settings/pages/SettingsPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@/tests/utils"; 2 | import { getByTestId } from "@testing-library/react"; 3 | import { describe, test } from "vitest"; 4 | import SettingsPage from "./SettingsPage"; 5 | 6 | describe.concurrent("SettingsPage", () => { 7 | test("should render the settings page with all sections", ({ expect }) => { 8 | const { container } = render(); 9 | const settingsPage = getByTestId( 10 | container, 11 | "settings-page", 12 | ); 13 | 14 | expect(settingsPage).toBeVisible(); 15 | expect(settingsPage).toHaveTextContent("Django React Starter"); 16 | expect(settingsPage).toHaveTextContent("Preferences"); 17 | expect(settingsPage).toHaveTextContent("Information"); 18 | expect(settingsPage).toHaveTextContent("Security"); 19 | expect(settingsPage).toHaveTextContent("Danger Zone"); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /frontend/src/features/settings/pages/SettingsPage.tsx: -------------------------------------------------------------------------------- 1 | import { Main } from "@/components/layout"; 2 | import { memo } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { 5 | DangerZone, 6 | GoBackButton, 7 | PasswordForm, 8 | UserForm, 9 | UserSettings, 10 | } from "../components"; 11 | 12 | const SettingsPage: React.FC = memo(() => { 13 | const { t } = useTranslation(); 14 | 15 | return ( 16 |
21 | 22 |

{t("Settings")}

23 |
24 | {t("Preferences")} 25 |
26 | 27 |
28 | {t("Information")} 29 |
30 | 31 |
32 | {t("Security")} 33 |
34 | 35 |
36 | {t("Danger Zone")} 37 |
38 | 39 |
40 | ); 41 | }); 42 | 43 | export default SettingsPage; 44 | -------------------------------------------------------------------------------- /frontend/src/features/settings/routes.ts: -------------------------------------------------------------------------------- 1 | import type { RouteConfig } from "@/router"; 2 | import SettingsPage from "./pages/SettingsPage"; 3 | 4 | export type SettingsRouteKey = "settings"; 5 | 6 | export const settingsRoutes: Record = { 7 | settings: { 8 | path: "/settings", 9 | // component: lazy(() => import("./pages/SettingsPage")), 10 | component: SettingsPage, 11 | key: "settings", 12 | authAccess: "private", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/hooks/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/hooks/.gitkeep -------------------------------------------------------------------------------- /frontend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useLocale } from "./useLocale"; 2 | export { useLocalStorage } from "./useLocalStorage"; 3 | -------------------------------------------------------------------------------- /frontend/src/hooks/useLocalStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { LOCALE_STORAGE_KEY } from "@/config/i18n"; 2 | import { renderHook } from "@/tests/utils"; 3 | import { beforeEach, describe, test } from "vitest"; 4 | import { useLocalStorage } from "./useLocalStorage"; 5 | 6 | const TEMPORARY_KEY = "django-react-starter-test-temp"; 7 | 8 | describe.concurrent("useLocalStorage", () => { 9 | beforeEach(() => { 10 | localStorage.removeItem(LOCALE_STORAGE_KEY); 11 | }); 12 | 13 | test("should use the default value if no value is stored", ({ expect }) => { 14 | const { result } = renderHook(() => 15 | useLocalStorage(TEMPORARY_KEY, "default"), 16 | ); 17 | const [value] = result.current; 18 | expect(value).toBe("default"); 19 | }); 20 | 21 | test("should use the stored value", ({ expect }) => { 22 | localStorage.setItem(TEMPORARY_KEY, "stored"); 23 | const { result } = renderHook(() => 24 | useLocalStorage(TEMPORARY_KEY, "default"), 25 | ); 26 | const [value] = result.current; 27 | expect(value).toBe("stored"); 28 | }); 29 | 30 | test("should correctly update the value", ({ expect }) => { 31 | localStorage.setItem(TEMPORARY_KEY, "stored"); 32 | // Update the value 33 | const { result: resultOne } = renderHook(() => 34 | useLocalStorage(TEMPORARY_KEY, "default"), 35 | ); 36 | const [_, setValue] = resultOne.current; 37 | setValue("updated"); 38 | // Refetch the value 39 | const { result: resultTwo } = renderHook(() => 40 | useLocalStorage(TEMPORARY_KEY, "default"), 41 | ); 42 | const [value] = resultTwo.current; 43 | expect(value).toBe("updated"); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /frontend/src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from "react"; 2 | 3 | export const useLocalStorage = ( 4 | key: string, 5 | defaultValue?: T, 6 | ): [T, (newValue: T) => void] => { 7 | const [value, setValue] = useState(localStorage.getItem(key) || defaultValue); 8 | 9 | const updateValue = useCallback( 10 | (newValue: T) => { 11 | localStorage.setItem(key, newValue); 12 | setValue(newValue); 13 | }, 14 | [key], 15 | ); 16 | 17 | return useMemo(() => [value, updateValue], [value, updateValue]) as [ 18 | T, 19 | (newValue: T) => void, 20 | ]; 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/hooks/useLocale.test.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY } from "@/config/i18n"; 2 | import { renderHook } from "@/tests/utils"; 3 | import { waitFor } from "@testing-library/react"; 4 | import { describe, test } from "vitest"; 5 | import { useLocale } from "./useLocale"; 6 | 7 | describe.concurrent("useLocale", () => { 8 | describe.concurrent("initLocale", () => { 9 | test("should use the default locale if no locale is stored", ({ 10 | expect, 11 | }) => { 12 | const { result } = renderHook(() => useLocale()); 13 | const initLocale = result.current?.initLocale; 14 | // Starts as default 15 | expect(result.current?.currentLocale).toBe(DEFAULT_LOCALE); 16 | // Does nothing 17 | initLocale(); 18 | expect(result.current?.currentLocale).toBe(DEFAULT_LOCALE); 19 | }); 20 | 21 | test("should use stored locale if valid", async ({ expect }) => { 22 | const { result } = renderHook(() => useLocale()); 23 | // Starts as default 24 | expect(result.current?.currentLocale).toBe(DEFAULT_LOCALE); 25 | // Should update 26 | localStorage.setItem(LOCALE_STORAGE_KEY, "fr"); 27 | const { result: newResult } = renderHook(() => useLocale()); 28 | const initLocale = newResult.current?.initLocale; 29 | initLocale(); 30 | await waitFor(() => { 31 | expect(newResult.current?.currentLocale).toBe("fr"); 32 | }); 33 | expect(newResult.current?.currentLocale).not.toBe(DEFAULT_LOCALE); 34 | }); 35 | }); 36 | 37 | describe.concurrent("setLocale", () => { 38 | test("should set the locale", async ({ expect }) => { 39 | const { result } = renderHook(() => useLocale()); 40 | const setLocale = result.current?.setLocale; 41 | // Starts as default 42 | await waitFor(() => { 43 | expect(result.current?.currentLocale).toBe(DEFAULT_LOCALE); 44 | }); 45 | // Should update 46 | setLocale("fr"); 47 | await waitFor(() => { 48 | expect(result.current?.currentLocale).toBe("fr"); 49 | }); 50 | expect(result.current?.currentLocale).not.toBe(DEFAULT_LOCALE); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /frontend/src/hooks/useLocale.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "@/config/i18n"; 2 | import dayjs from "dayjs"; 3 | import { useCallback, useMemo } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { useLocalStorage } from "./useLocalStorage"; 6 | 7 | export type UseLocaleReturn = { 8 | currentLocale: Locale; 9 | initLocale: () => void; 10 | setLocale: (locale: Locale) => void; 11 | }; 12 | 13 | export const useLocale = (): UseLocaleReturn => { 14 | const { i18n } = useTranslation(); 15 | const currentLocale = useMemo(() => i18n.language, [i18n.language]) as Locale; 16 | const [storedLocale, updateStoredLocale] = useLocalStorage( 17 | LOCALE_STORAGE_KEY, 18 | DEFAULT_LOCALE, 19 | ); 20 | 21 | const setLocale = useCallback( 22 | (locale: Locale) => { 23 | updateStoredLocale(locale); 24 | i18n.changeLanguage(locale); 25 | dayjs.locale(locale); 26 | }, 27 | [i18n, updateStoredLocale], 28 | ); 29 | 30 | const initLocale = useCallback(() => { 31 | setLocale(storedLocale); 32 | }, [storedLocale, setLocale]); 33 | 34 | return useMemo( 35 | () => ({ 36 | currentLocale, 37 | setLocale, 38 | initLocale, 39 | }), 40 | [currentLocale, setLocale, initLocale], 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import "@/config/sentry"; 2 | import { App } from "@/App"; 3 | import React from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | 6 | // @ts-ignore 7 | const container: HTMLDivElement = document.getElementById("root"); 8 | const root = ReactDOM.createRoot(container); 9 | root.render( 10 | 11 | 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /frontend/src/router/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/router/.gitkeep -------------------------------------------------------------------------------- /frontend/src/router/Routes.test.tsx: -------------------------------------------------------------------------------- 1 | import * as apiQueries from "@/api/queries"; 2 | import { navigateMock } from "@/tests/mocks/globals"; 3 | import { selfError } from "@/tests/mocks/handlers/shared"; 4 | import { server } from "@/tests/server"; 5 | import { render } from "@/tests/utils"; 6 | import { getByTestId } from "@testing-library/react"; 7 | import { describe, test, vi } from "vitest"; 8 | import { Routes } from "./Routes"; 9 | 10 | vi.mock("@/api/queries", async () => { 11 | const actual = await vi.importActual("@/api/queries"); 12 | return { 13 | ...actual, 14 | useAppConfig: vi.fn(), 15 | useSelf: vi.fn(), 16 | }; 17 | }); 18 | 19 | describe.concurrent("Routes", () => { 20 | test("should render the component", ({ expect }) => { 21 | const { container } = render(); 22 | expect(container).toBeDefined(); 23 | }); 24 | 25 | test("should show loading when appConfig is pending", ({ expect }) => { 26 | vi.spyOn(apiQueries, "useAppConfig").mockReturnValue({ 27 | isPending: true, 28 | isError: false, 29 | error: null, 30 | data: undefined, 31 | }); 32 | 33 | const { container } = render(); 34 | const loadingElement = getByTestId(container, "loading"); 35 | 36 | expect(loadingElement).toBeVisible(); 37 | }); 38 | 39 | test("should show loading when user is pending", ({ expect }) => { 40 | vi.spyOn(apiQueries, "useSelf").mockReturnValue({ 41 | isPending: true, 42 | isError: false, 43 | error: null, 44 | data: undefined, 45 | }); 46 | 47 | const { container } = render(); 48 | const loadingElement = getByTestId(container, "loading"); 49 | 50 | expect(loadingElement).toBeVisible(); 51 | }); 52 | 53 | test.sequential( 54 | "should render the login page when not authenticated", 55 | async ({ expect }) => { 56 | server.use(selfError); 57 | const { container } = render(); 58 | await new Promise((resolve) => setTimeout(resolve, 100)); 59 | const loginPage = getByTestId(container, "login-page"); 60 | 61 | expect(loginPage).toBeVisible(); 62 | expect(navigateMock).not.toHaveBeenCalledWith("/"); 63 | }, 64 | ); 65 | 66 | test("should render the homepage when authenticated", async ({ expect }) => { 67 | const { container } = render(); 68 | await new Promise((resolve) => setTimeout(resolve, 100)); 69 | const homepage = getByTestId(container, "homepage"); 70 | 71 | expect(homepage).toBeVisible(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /frontend/src/router/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { useAppConfig, useCheckAuth, useSelf } from "@/api/queries"; 2 | import { Main } from "@/components/layout"; 3 | import { LoadingRing } from "@/components/ui"; 4 | import { Suspense, memo, useMemo } from "react"; 5 | import { Redirect, Route, Switch } from "wouter"; 6 | import { useUpdateMetadata } from "./hooks"; 7 | import { routeConfigMap } from "./routeConfig"; 8 | 9 | export const Routes = memo(() => { 10 | const { isPending: isAppConfigPending } = useAppConfig(); 11 | const { data: user, isPending: isUserPending } = useSelf(); 12 | useUpdateMetadata(); 13 | useCheckAuth(); 14 | 15 | const isLoading = isAppConfigPending || isUserPending; 16 | const isAuthenticated = !!user; 17 | 18 | const routes = useMemo( 19 | () => 20 | Object.values(routeConfigMap) 21 | .filter((route) => { 22 | if (isAuthenticated) return route.authAccess !== "public-only"; 23 | return route.authAccess !== "private"; 24 | }) 25 | .map((route) => ( 26 | 27 | }> 28 | 29 | 30 | 31 | )), 32 | [isAuthenticated], 33 | ); 34 | 35 | const defaultRoute = isAuthenticated 36 | ? routeConfigMap.homepage 37 | : routeConfigMap.login; 38 | 39 | if (isLoading) { 40 | return ( 41 |
42 | 43 |
44 | ); 45 | } 46 | 47 | return ( 48 | 49 | {routes} 50 | 51 | 52 | ); 53 | }); 54 | -------------------------------------------------------------------------------- /frontend/src/router/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useUpdateMetadata } from "./useUpdateMetadata"; 2 | -------------------------------------------------------------------------------- /frontend/src/router/hooks/useUpdateMetadata.test.ts: -------------------------------------------------------------------------------- 1 | import { useLocationMock } from "@/tests/mocks/globals"; 2 | import { renderHook } from "@testing-library/react"; 3 | import { beforeEach, describe, it, vi } from "vitest"; 4 | import { useUpdateMetadata } from "./useUpdateMetadata"; 5 | 6 | describe.concurrent("useUpdateMetadata", () => { 7 | beforeEach(() => { 8 | document.title = "Title"; 9 | document.documentElement.lang = "fr"; 10 | }); 11 | 12 | it("should update document title based on route staticData", ({ expect }) => { 13 | useLocationMock.mockImplementation(() => ["/settings", vi.fn()]); 14 | document.title = "Initial Title"; 15 | renderHook(() => useUpdateMetadata()); 16 | 17 | expect(document.title).toBe("Settings"); 18 | }); 19 | 20 | it("should fallback to default title if route is not handled", ({ 21 | expect, 22 | }) => { 23 | useLocationMock.mockImplementation(() => ["/unknown", vi.fn()]); 24 | document.title = "Initial Title"; 25 | renderHook(() => useUpdateMetadata()); 26 | 27 | expect(document.title).toBe("Django React Starter"); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /frontend/src/router/hooks/useUpdateMetadata.ts: -------------------------------------------------------------------------------- 1 | import { useLocale } from "@/hooks"; 2 | import { useEffect, useMemo } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { useLocation } from "wouter"; 5 | import { type RouteKey, pathToRoute } from "../routeConfig"; 6 | 7 | export const useUpdateMetadata = () => { 8 | const { t } = useTranslation(); 9 | const { currentLocale } = useLocale(); 10 | const [location] = useLocation(); 11 | 12 | const routeTitles: Record = useMemo( 13 | () => ({ 14 | homepage: t("Django React Starter"), 15 | login: t("Login"), 16 | settings: t("Settings"), 17 | passwordReset: t("Password reset"), 18 | passwordResetConfirm: t("Password reset confirm"), 19 | }), 20 | [t], 21 | ); 22 | 23 | useEffect(() => { 24 | const routeKey = pathToRoute[location]?.key; 25 | document.title = routeTitles[routeKey] || t("Django React Starter"); 26 | document.documentElement.lang = currentLocale; 27 | }, [location, routeTitles, t, currentLocale]); 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/router/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | pathToRoute, 3 | routeConfigMap, 4 | type RouteConfig, 5 | type RouteConfigMap, 6 | type RouteKey, 7 | } from "./routeConfig"; 8 | export { Routes } from "./Routes"; 9 | -------------------------------------------------------------------------------- /frontend/src/router/routeConfig.ts: -------------------------------------------------------------------------------- 1 | import { type HomeRouteKey, homeRoutes } from "@/features/home/routes"; 2 | import { type LoginRouteKey, loginRoutes } from "@/features/login/routes"; 3 | import { 4 | type SettingsRouteKey, 5 | settingsRoutes, 6 | } from "@/features/settings/routes"; 7 | 8 | export type RouteKey = HomeRouteKey | LoginRouteKey | SettingsRouteKey; 9 | export type AuthAccess = "public" | "private" | "public-only"; 10 | 11 | export type RouteConfig = { 12 | path: string; 13 | component: React.ComponentType; 14 | key: RouteKey; 15 | authAccess: AuthAccess; 16 | }; 17 | 18 | export type RouteConfigMap = Record; 19 | 20 | export const routeConfigMap: RouteConfigMap = { 21 | ...homeRoutes, 22 | ...loginRoutes, 23 | ...settingsRoutes, 24 | }; 25 | 26 | export const pathToRoute: Record = Object.values( 27 | routeConfigMap, 28 | ).reduce( 29 | (acc, route) => { 30 | acc[route.path] = route; 31 | return acc; 32 | }, 33 | {} as Record, 34 | ); 35 | -------------------------------------------------------------------------------- /frontend/src/styles/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/styles/.gitkeep -------------------------------------------------------------------------------- /frontend/src/styles/base.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "@tailwindcss/typography"; 3 | 4 | @plugin "daisyui" { 5 | /* biome-ignore lint/correctness/noUnknownProperty: This is valid DaisyUI plugin syntax */ 6 | themes: bumblebee --default, coffee --prefersdark; 7 | } 8 | 9 | html { 10 | font-family: "Nunito Sans", sans-serif; 11 | } 12 | 13 | :root { 14 | --default-font-family: "Nunito Sans", sans-serif; 15 | --toastify-font-family: "Nunito Sans", sans-serif; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/tests/.gitkeep -------------------------------------------------------------------------------- /frontend/src/tests/mocks/globals.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | export const navigateMock = vi.fn(); 4 | export const useLocationMock = vi.fn(() => ["/", navigateMock]); 5 | 6 | export const toastErrorMock = vi.fn(); 7 | export const toastWarningMock = vi.fn(); 8 | export const toastInfoMock = vi.fn(); 9 | export const toastSuccessMock = vi.fn(); 10 | export const toastMock = { 11 | error: toastErrorMock, 12 | warning: toastWarningMock, 13 | success: toastSuccessMock, 14 | info: toastInfoMock, 15 | }; 16 | 17 | export const registerGlobalMocks = () => { 18 | // matchMedia 19 | global.matchMedia = 20 | global.matchMedia || 21 | ((query: string) => ({ 22 | matches: false, 23 | media: query, 24 | onchange: null, 25 | addListener: vi.fn(), 26 | removeListener: vi.fn(), 27 | addEventListener: vi.fn(), 28 | removeEventListener: vi.fn(), 29 | dispatchEvent: vi.fn(), 30 | })); 31 | 32 | // HTMLDialogElement 33 | HTMLDialogElement.prototype.showModal = vi.fn(function ( 34 | this: HTMLDialogElement, 35 | ) { 36 | this.setAttribute("open", ""); 37 | }); 38 | 39 | HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { 40 | this.removeAttribute("open"); 41 | }); 42 | 43 | // Toastify 44 | vi.mock("react-toastify", async (importOriginal) => { 45 | const mod = await importOriginal(); 46 | return { 47 | ...mod, 48 | toast: toastMock, 49 | }; 50 | }); 51 | 52 | // Wouter 53 | vi.mock("wouter", async (importOriginal) => { 54 | const mod = await importOriginal(); 55 | return { 56 | ...mod, 57 | useLocation: useLocationMock, 58 | }; 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/src/tests/mocks/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | loginSuccess, 3 | passwordResetConfirmSuccess, 4 | passwordResetSuccess, 5 | registerSuccess, 6 | } from "./login"; 7 | import { 8 | deleteAccountSuccess, 9 | updatePasswordSuccess, 10 | updateSelfSuccess, 11 | } from "./settings"; 12 | import { appConfig, checkAuthSuccess, logout, self } from "./shared"; 13 | 14 | export const defaultHandlers = [ 15 | // Shared 16 | appConfig, 17 | checkAuthSuccess, 18 | self, 19 | // Auth 20 | loginSuccess, 21 | registerSuccess, 22 | logout, 23 | passwordResetSuccess, 24 | passwordResetConfirmSuccess, 25 | // Settings 26 | updateSelfSuccess, 27 | deleteAccountSuccess, 28 | updatePasswordSuccess, 29 | ]; 30 | -------------------------------------------------------------------------------- /frontend/src/tests/mocks/handlers/login.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import { http, HttpResponse } from "msw"; 3 | 4 | export const loginSuccess = http.post(`${API_ROOT_URL}/auth/login/`, () => { 5 | return HttpResponse.json(null, { status: 201 }); 6 | }); 7 | 8 | export const loginError = http.post(`${API_ROOT_URL}/auth/login/`, () => { 9 | return HttpResponse.json(null, { status: 400 }); 10 | }); 11 | 12 | export const loginCrash = http.post(`${API_ROOT_URL}/auth/login/`, () => { 13 | return HttpResponse.json(null, { status: 500 }); 14 | }); 15 | 16 | export const registerSuccess = http.post( 17 | `${API_ROOT_URL}/auth/register/`, 18 | () => { 19 | return HttpResponse.json( 20 | { id: 1, email: "test@email.com" }, 21 | { status: 201 }, 22 | ); 23 | }, 24 | ); 25 | 26 | export const registerEmailError = http.post( 27 | `${API_ROOT_URL}/auth/register/`, 28 | () => { 29 | return HttpResponse.json( 30 | { email: ["This email is already used"] }, 31 | { status: 400 }, 32 | ); 33 | }, 34 | ); 35 | 36 | export const registerPasswordError = http.post( 37 | `${API_ROOT_URL}/auth/register/`, 38 | () => { 39 | return HttpResponse.json( 40 | { password: ["Password is too weak"] }, 41 | { status: 400 }, 42 | ); 43 | }, 44 | ); 45 | 46 | export const registerCrash = http.post(`${API_ROOT_URL}/auth/register/`, () => { 47 | return HttpResponse.json(null, { status: 500 }); 48 | }); 49 | 50 | export const passwordResetSuccess = http.post( 51 | `${API_ROOT_URL}/auth/password_reset/`, 52 | () => { 53 | return HttpResponse.json(null, { status: 204 }); 54 | }, 55 | ); 56 | 57 | export const passwordResetError = http.post( 58 | `${API_ROOT_URL}/auth/password_reset/`, 59 | () => { 60 | return HttpResponse.json(null, { status: 400 }); 61 | }, 62 | ); 63 | 64 | export const passwordResetConfirmSuccess = http.post( 65 | `${API_ROOT_URL}/auth/password_reset_confirm/`, 66 | () => { 67 | return HttpResponse.json(null, { status: 204 }); 68 | }, 69 | ); 70 | 71 | export const passwordResetConfirmPasswordError = http.post( 72 | `${API_ROOT_URL}/auth/password_reset_confirm/`, 73 | () => { 74 | return HttpResponse.json({ password: "error" }, { status: 400 }); 75 | }, 76 | ); 77 | 78 | export const passwordResetConfirmGenericError = http.post( 79 | `${API_ROOT_URL}/auth/password_reset_confirm/`, 80 | () => { 81 | return HttpResponse.json({}, { status: 400 }); 82 | }, 83 | ); 84 | 85 | export const passwordResetConfirmCrash = http.post( 86 | `${API_ROOT_URL}/auth/password_reset_confirm/`, 87 | () => { 88 | return HttpResponse.json(null, { status: 500 }); 89 | }, 90 | ); 91 | -------------------------------------------------------------------------------- /frontend/src/tests/mocks/handlers/settings.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import { SELF_MOCK } from "@/api/queries/__mocks__/useSelf"; 3 | import { http, HttpResponse } from "msw"; 4 | 5 | export const updateSelfSuccess = http.put( 6 | `${API_ROOT_URL}/self/account/`, 7 | () => { 8 | return HttpResponse.json(SELF_MOCK, { status: 200 }); 9 | }, 10 | ); 11 | 12 | export const updateSelfError = http.put(`${API_ROOT_URL}/self/account/`, () => { 13 | return HttpResponse.json({}, { status: 400 }); 14 | }); 15 | 16 | export const updateSelfCrash = http.put(`${API_ROOT_URL}/self/account/`, () => { 17 | return HttpResponse.json({}, { status: 500 }); 18 | }); 19 | 20 | export const updatePasswordSuccess = http.put( 21 | `${API_ROOT_URL}/self/password/`, 22 | () => { 23 | return HttpResponse.json(null, { status: 204 }); 24 | }, 25 | ); 26 | 27 | export const updatePasswordCurrentError = http.put( 28 | `${API_ROOT_URL}/self/password/`, 29 | () => { 30 | return HttpResponse.json({ current_password: "error" }, { status: 400 }); 31 | }, 32 | ); 33 | 34 | export const updatePasswordStrengthError = http.put( 35 | `${API_ROOT_URL}/self/password/`, 36 | () => { 37 | return HttpResponse.json({ new_password: "error" }, { status: 400 }); 38 | }, 39 | ); 40 | 41 | export const updatePasswordCrash = http.put( 42 | `${API_ROOT_URL}/self/password/`, 43 | () => { 44 | return HttpResponse.json({}, { status: 500 }); 45 | }, 46 | ); 47 | 48 | export const deleteAccountSuccess = http.delete( 49 | `${API_ROOT_URL}/self/account/`, 50 | () => { 51 | return HttpResponse.json(null, { status: 204 }); 52 | }, 53 | ); 54 | 55 | export const deleteAccountCrash = http.delete( 56 | `${API_ROOT_URL}/self/account/`, 57 | () => { 58 | return HttpResponse.json({}, { status: 500 }); 59 | }, 60 | ); 61 | -------------------------------------------------------------------------------- /frontend/src/tests/mocks/handlers/shared.ts: -------------------------------------------------------------------------------- 1 | import { API_ROOT_URL } from "@/api/config"; 2 | import { APP_CONFIG_MOCK } from "@/api/queries/__mocks__/useAppConfig"; 3 | import { SELF_MOCK } from "@/api/queries/__mocks__/useSelf"; 4 | import { http, HttpResponse } from "msw"; 5 | 6 | export const appConfig = http.get(`${API_ROOT_URL}/app/config/`, () => { 7 | return HttpResponse.json(APP_CONFIG_MOCK, { status: 200 }); 8 | }); 9 | 10 | export const checkAuthSuccess = http.get(`${API_ROOT_URL}/auth/check/`, () => { 11 | return HttpResponse.json(null, { status: 204 }); 12 | }); 13 | 14 | export const checkAuthError = http.get(`${API_ROOT_URL}/auth/check/`, () => { 15 | return HttpResponse.json(null, { status: 401 }); 16 | }); 17 | 18 | export const self = http.get(`${API_ROOT_URL}/self/account/`, () => { 19 | return HttpResponse.json(SELF_MOCK, { status: 200 }); 20 | }); 21 | 22 | export const selfError = http.get(`${API_ROOT_URL}/self/account/`, () => { 23 | return HttpResponse.json(null, { status: 401 }); 24 | }); 25 | 26 | export const logout = http.post(`${API_ROOT_URL}/auth/logout/`, () => { 27 | return HttpResponse.json(null, { status: 204 }); 28 | }); 29 | -------------------------------------------------------------------------------- /frontend/src/tests/mocks/index.ts: -------------------------------------------------------------------------------- 1 | export { registerGlobalMocks } from "./globals"; 2 | export { defaultHandlers } from "./handlers"; 3 | -------------------------------------------------------------------------------- /frontend/src/tests/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { defaultHandlers } from "./mocks"; 3 | 4 | export const server = setupServer(...defaultHandlers); 5 | -------------------------------------------------------------------------------- /frontend/src/tests/setup.ts: -------------------------------------------------------------------------------- 1 | import * as apiUtils from "@/api/utils"; 2 | import i18n, { DEFAULT_LOCALE } from "@/config/i18n"; 3 | import "@testing-library/jest-dom/vitest"; 4 | import { HttpResponse } from "msw"; 5 | import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; 6 | import { registerGlobalMocks } from "./mocks"; 7 | import { server } from "./server"; 8 | import { testQueryClient } from "./utils"; 9 | 10 | beforeAll(() => { 11 | server.listen({ onUnhandledRequest: "error" }); 12 | }); 13 | 14 | beforeEach(() => { 15 | registerGlobalMocks(); 16 | vi.spyOn(HttpResponse, "json"); 17 | vi.spyOn(apiUtils, "performRequest"); 18 | }); 19 | 20 | afterEach(() => { 21 | vi.restoreAllMocks(); 22 | testQueryClient.clear(); 23 | localStorage.clear(); 24 | i18n.changeLanguage(DEFAULT_LOCALE); 25 | server.resetHandlers(); 26 | }); 27 | 28 | afterAll(() => { 29 | server.close(); 30 | }); 31 | -------------------------------------------------------------------------------- /frontend/src/tests/utils.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "@/components/ui"; 2 | import "@/config/dayjs"; 3 | import "@/config/i18n"; 4 | import { ThemeProvider } from "@/contexts"; 5 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 6 | import { 7 | type RenderHookResult, 8 | render, 9 | renderHook, 10 | } from "@testing-library/react"; 11 | import type { ReactNode } from "react"; 12 | 13 | const resetAllStores = () => {}; 14 | 15 | const testQueryClient = new QueryClient({ 16 | defaultOptions: { 17 | queries: { 18 | refetchOnMount: false, 19 | refetchOnReconnect: false, 20 | refetchOnWindowFocus: false, 21 | retryOnMount: false, 22 | retry: false, 23 | staleTime: Number.POSITIVE_INFINITY, 24 | }, 25 | }, 26 | }); 27 | 28 | const wrapComponent = (children: ReactNode) => ( 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ); 36 | 37 | type ImprovedRender = ( 38 | node: Parameters[0], 39 | ) => ReturnType; 40 | 41 | const improvedRender: ImprovedRender = (node) => 42 | render(node, { wrapper: ({ children }) => wrapComponent(children) }); 43 | 44 | type ImprovedRenderHook = ( 45 | hook: (props: TProps) => TResult, 46 | ) => RenderHookResult; 47 | 48 | const improvedRenderHook: ImprovedRenderHook = (hook) => 49 | renderHook(hook, { 50 | wrapper: ({ children }) => wrapComponent(children), 51 | }); 52 | 53 | export { 54 | improvedRender as render, 55 | improvedRenderHook as renderHook, 56 | resetAllStores, 57 | testQueryClient, 58 | }; 59 | -------------------------------------------------------------------------------- /frontend/src/types/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/types/.gitkeep -------------------------------------------------------------------------------- /frontend/src/types/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.jpeg"; 2 | declare module "*.jpg"; 3 | declare module "*.png"; 4 | -------------------------------------------------------------------------------- /frontend/src/utils/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jordan-Kowal/django-react-starter/e3c4747fdae747e58f3ee10a2aae4ae45dd732da/frontend/src/utils/.gitkeep -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | "moduleResolution": "node", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitAny": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "esModuleInterop": true, 24 | 25 | "allowJs": true, 26 | "checkJs": false, 27 | 28 | "paths": { 29 | "@/*": ["./src/*"] 30 | } 31 | }, 32 | "include": ["src/**/*"], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import react from "@vitejs/plugin-react"; 4 | import million from "million/compiler"; 5 | import { visualizer } from "rollup-plugin-visualizer"; 6 | import { defineConfig } from "vite"; 7 | 8 | export default defineConfig(({ mode }) => ({ 9 | plugins: [ 10 | million.vite({ auto: true }), 11 | tailwindcss(), 12 | react(), 13 | visualizer({ 14 | filename: "bundle-stats.html", 15 | title: "Bundle Stats", 16 | gzipSize: true, 17 | open: true, 18 | }), 19 | ], 20 | server: { 21 | port: 3000, 22 | cors: true, 23 | proxy: { 24 | // Docker setup 25 | // "^/(api)|(media)|(static)/": { 26 | // target: "http://api:8000", 27 | // changeOrigin: true, 28 | // }, 29 | // Local setup 30 | "^/(api)|(media)|(static)/": { 31 | target: "http://localhost:8000", 32 | changeOrigin: true, 33 | }, 34 | }, 35 | }, 36 | esbuild: { 37 | loader: "tsx", 38 | }, 39 | optimizeDeps: { 40 | esbuildOptions: { 41 | loader: { 42 | ".js": "jsx", 43 | ".ts": "tsx", 44 | }, 45 | }, 46 | }, 47 | resolve: { 48 | alias: { "@": resolve(__dirname, "./src") }, 49 | }, 50 | build: { 51 | assetsDir: "static", 52 | }, 53 | test: { 54 | include: ["**/*.test.ts", "**/*.test.tsx"], 55 | setupFiles: ["src/tests/setup.ts"], 56 | environment: "jsdom", 57 | coverage: { 58 | exclude: [ 59 | "dist/**", 60 | "src/tests/**", 61 | "i18n/**", 62 | "i18next-parser.config.ts", 63 | "vite.config.ts", 64 | "src/config/sentry.ts", 65 | // Types 66 | "src/types/**", 67 | "src/api/types.ts", 68 | // Special cases 69 | "src/App.tsx", 70 | "src/main.tsx", 71 | ], 72 | all: true, 73 | thresholds: { 74 | perFile: false, 75 | branches: 90, 76 | functions: 90, 77 | lines: 90, 78 | statements: 90, 79 | }, 80 | }, 81 | css: true, 82 | isolate: true, 83 | retry: 1, 84 | }, 85 | })); 86 | -------------------------------------------------------------------------------- /ty.toml: -------------------------------------------------------------------------------- 1 | # Used by the ty server from the ty-vscode extension 2 | # Must have the same [rules] as the ones in "backend/pyproject.toml" 3 | # [src] and [environment] are set to help the correctly setup the server since the app is nested 4 | 5 | [src] 6 | root = "./backend" 7 | 8 | [environment] 9 | python = "./backend/.venv" 10 | 11 | [rules] 12 | unresolved-attribute = "ignore" 13 | --------------------------------------------------------------------------------