├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/.run/Django.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
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 |
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
;
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------