├── .env
├── .gitignore
├── README.md
├── backend
├── .dockerignore
├── .env
├── Dockerfile
├── entrypoint.sh
├── helloyou
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── migrations
│ │ └── __init__.py
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── mainapp
│ ├── __init__.py
│ ├── asgi.py
│ ├── local_settings.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
├── requirements.txt
└── users
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── models.py
│ ├── serializers.py
│ ├── signals.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── docker-compose.dev.yml
├── frontend
├── .babelrc
├── Dockerfile.deploy
├── Dockerfile.local
├── index.html
├── nginx_deploy.conf
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.tsx
│ ├── Theme.tsx
│ ├── axiosInterceptors.js
│ ├── components
│ │ ├── Home.tsx
│ │ ├── Layout
│ │ │ ├── DropdownMenu.tsx
│ │ │ ├── Layout.tsx
│ │ │ └── TopBar.tsx
│ │ └── Login
│ │ │ ├── ForgotPassword.tsx
│ │ │ ├── Login.tsx
│ │ │ ├── PasswordReset.tsx
│ │ │ ├── PasswordUpdate.tsx
│ │ │ └── styles.ts
│ ├── contexts
│ │ ├── AlertContext.tsx
│ │ └── DialogContext.tsx
│ ├── generic_logo.png
│ ├── helpers
│ │ ├── ValidationMessages.tsx
│ │ └── useQuery.tsx
│ ├── index.d.ts
│ ├── index.js
│ ├── interfaces
│ │ ├── Children.tsx
│ │ ├── axios
│ │ │ └── AxiosError.tsx
│ │ └── models
│ │ │ └── Response.tsx
│ ├── redux
│ │ ├── auth
│ │ │ ├── authSlice.ts
│ │ │ └── authThunks.ts
│ │ ├── darkMode
│ │ │ └── darkModeSlice.ts
│ │ ├── hooks.ts
│ │ └── store.ts
│ ├── routes
│ │ ├── Placeholder.tsx
│ │ ├── PrivateRoute.tsx
│ │ └── Routes.ts
│ ├── serviceWorker.js
│ └── settings.tsx
├── tsconfig.base.json
├── tsconfig.dev.json
├── tsconfig.json
├── webpack.config.base.js
├── webpack.config.dev.js
└── webpack.config.prod.js
└── postgres
└── .env
/.env:
--------------------------------------------------------------------------------
1 | #ENV_API_SERVER=www.example.com Note: for running locally on localhost or 127.0.0.1 this should be a blank string.
2 | #This is because for localhost or 127.0.0.1 Nginx redirects it by prefixing with current uri. See issue below:
3 | #https://serverfault.com/questions/379675/nginx-reverse-proxy-url-rewrite
4 |
5 | ENV_API_SERVER=
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Django ###
2 | **/env/*
3 | **/localPythonEnv/*
4 | /.vscode
5 | .vscode
6 | *.log
7 | *.pot
8 | *.pyc
9 | __pycache__/
10 | #**/migrations/*
11 | **/.ipynb_checkpoints/*
12 | #local_settings.py
13 | #db.sqlite3
14 | #/media/uploads
15 | #/media/profile_pics
16 | **/static/*
17 | #/media/article_pics/articles
18 |
19 | ### Node and React ###
20 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
21 |
22 | # dependencies
23 | **/node_modules/*
24 | **/.pnp
25 | .pnp.js
26 |
27 | # testing
28 | **/coverage/*
29 |
30 | # production
31 | **/build/*
32 |
33 | # misc
34 | .env.local
35 | .env.development.local
36 | .env.test.local
37 | .env.production.local
38 | package-lock.json
39 |
40 | # Webpack output
41 | **/dist/*
42 |
43 | npm-debug.log*
44 | yarn-debug.log*
45 | yarn-error.log*
46 |
47 |
48 | ### Apple Mac cleverness
49 | .DS_Store
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # django-react-postgres-boilerplate
2 |
3 | ## Prerequisites
4 | Install Docker Desktop
5 |
6 | ## Installation
7 |
8 | **_Make sure backend\entrypoint.sh has LF format and not CRLF format_**
9 |
10 | You shouldn't have to make any other changes to get the app up and running, but here's some things to note:
11 |
12 | - The default login credentials are admin and admin_password. These can be changed in backend/.env.
13 |
14 | - There are 3 .env files provided. Note in particular the .env files in backend/ and postgres/; there, you can adjust the database credentials, debug mode, secret key, allowed hosts, etc. The app should run just fine without any changes, but just know these files are there.
15 |
16 | - The included sample helloyou django app can be easily removed by removing 'helloyou' from INSTALLED_APPS in django mainapp/settings.py, removing the associated helloyou path in mainapp/urls.py and deleting the entire helloyou folder. There are no database migrations, so you don't need to worry about that. On the frontend, delete/replace the contents of Home.tsx.
17 |
18 | ## Running
19 |
20 | Run the following command:
21 |
22 | ```sh
23 | docker-compose -f "docker-compose.dev.yml" up -d --build
24 | ```
25 | The react frontend should be available at `http://localhost:3000/` and django backend at `http://localhost:8000/` (django admin at `http://localhost:8000/admin/`).
26 |
27 | ## Features
28 | ### Forgot Password:
29 | - The password reset feature is fully functional. In order to get the password reset url, you will need to open the backend django logs. For example (in Powershell):
30 | ```sh
31 | $id = $(docker ps -aqf "name=backend")
32 | docker logs --tail 1000 -f $id
33 | ```
34 | - Upon submitting a valid email (default is admin@example.com), you should get a path like `http://localhost:3000/password_reset?token=abcdefgxyz123`; paste this in your browser to access the password reset form. The password reset form first validates the token; if the token is valid, it presents the password reset interface and allows the user to provide a new password. If the token is invalid, it will redirect the user to the login page.
35 |
36 | Check out the Django docs starting [here](https://docs.djangoproject.com/en/3.1/topics/email/#smtp-backend) in order to update the Email Backend from a console output to an actual SMTP backend.
37 |
38 | ### Left Navigation Bar:
39 | - The left navigation bar (intially shown on the left with only the Home icon upon login) is auto-generated along with the associated React Router's private routes. These routes can be easily added/modified in routes/Routes.ts.
40 |
41 | ### Subroutes/Params:
42 | - There is a dummy component called Placeholder that gives an example on how to access parameters passed into the url. This is useful when ensuring the user can access a specific page given say a object's PK...even if the page is refreshed. See routes.ts on how to setup the routes to accept optional parameters in the url path.
43 |
44 | ### Alerts:
45 | - An alert setter at the context level is also included. An example of TriggerAlert is shown in Home.tsx (variants displayed after successful/failed submit). See AlertContext.tsx for typings.
46 |
47 |
48 | ### Modal/Dialog:
49 | - Similar to the alert setter, a context level modal/dialog is also provided. Use OpenDialog (basic example shown in Home.tsx) to open and set the modal title/contents/footer.
50 |
51 | ### Customization:
52 | - The app name (shown at login & header) is set by the constant APP_NAME in frontend/src/settings.tsx.
53 | - The default session duration is set to 5 hours in frontend/src/settings.tsx. The user will be logged out after 5 hours.
54 | - The Material UI Theme can be adjusted in frontend\src\Theme.tsx
55 |
56 | ###
57 |
58 | **TODO:**
59 | - [x] Readme (setup and how to remove remnants of dummy stuff)
60 | - [x] Material UI Theme
61 | - [x] Auto Generation of Left Nav based on Routes?
62 | - [x] Fix NGINX Docker Compose
63 | - [x] fix django admin not serving css files on admin page
64 | - [x] error context
65 | - [x] show password errors
66 | - [x] loading icon on login
67 | - [x] ensure a non-existing route redirects to home
68 | - [x] email support (for password reset)
69 | - [x] forgot password functionality (email)
70 | - [x] Add support for nested sub-routes off the main left-nav routes
71 | - [x] Ensure match params (i.e. /user/profile/1/) work correctly.
72 | - [x] Context level modal?
73 | - [x] Auto redirect to login with Failed Request
74 | - [ ] Reset session timeout with activity.
75 | - [ ] Swagger API Explorer
76 | - [ ] Backend Testing
77 | - [ ] Frontend Testing (React Testing Library)
78 | - [ ] Axios Interface for demo API
79 | - [ ] Update and Pin versions (remove anything unused)
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | *.log
3 | *.pot
4 | *.pyc
5 | __pycache__/
6 | **/static/*
--------------------------------------------------------------------------------
/backend/.env:
--------------------------------------------------------------------------------
1 | DJANGO_ENV=development
2 | DEBUG=1
3 | SECRET_KEY=secretsecretsecretsecretsecret
4 | DJANGO_ALLOWED_HOSTS=www.example.com localhost 127.0.0.1 [::1]
5 |
6 | DJANGO_ADMIN_USER=admin
7 | DJANGO_ADMIN_EMAIL=admin@example.com
8 | DJANGO_ADMIN_PASSWORD=admin_password
9 |
10 | DATABASE=postgres
11 |
12 | DB_ENGINE=django.db.backends.postgresql
13 | DB_DATABASE=boilerplatedb
14 | DB_USER=postgres_user
15 | DB_PASSWORD=postgres_password
16 | DB_HOST=db
17 | DB_PORT=5432
18 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use an official Python runtime as a parent image
2 | FROM python:3.8
3 |
4 | # Adding backend directory to make absolute filepaths consistent across services
5 | WORKDIR /app/backend
6 |
7 | # installing netcat (nc) since we are using that to listen to postgres server in entrypoint.sh
8 | RUN apt-get update && apt-get install -y --no-install-recommends netcat && \
9 | apt-get autoremove -y && \
10 | apt-get clean && \
11 | rm -rf /var/lib/apt/lists/*
12 |
13 | # Install Python dependencies
14 | COPY requirements.txt /app/backend
15 | RUN pip install --upgrade pip -r requirements.txt
16 |
17 | # Add the rest of the code
18 | COPY . /app/backend
19 |
20 | # Make port 8000 available for the app
21 | EXPOSE 8000
22 |
23 | CMD python manage.py runserver 0.0.0.0:8000
24 |
25 | # # copy entrypoint.sh
26 | COPY ./entrypoint.sh /app/entrypoint.sh
27 | # run entrypoint.sh
28 | RUN chmod +x /app/entrypoint.sh
29 | ENTRYPOINT ["/app/entrypoint.sh"]
30 |
--------------------------------------------------------------------------------
/backend/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | if [ "$DATABASE" = "postgres" ]
3 | then
4 | echo "Waiting for postgres..."
5 |
6 | while ! nc -z $DB_HOST $DB_PORT; do
7 | sleep 0.1
8 | done
9 |
10 | echo "PostgreSQL started"
11 | fi
12 |
13 | python manage.py collectstatic --noinput
14 | python manage.py migrate --noinput
15 | echo "from django.contrib.auth.models import User;
16 | User.objects.filter(email='$DJANGO_ADMIN_EMAIL').delete();
17 | User.objects.create_superuser('$DJANGO_ADMIN_USER', '$DJANGO_ADMIN_EMAIL', '$DJANGO_ADMIN_PASSWORD')" | python manage.py shell
18 |
19 | exec "$@"
--------------------------------------------------------------------------------
/backend/helloyou/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emilefrey/django-react-postgres-boilerplate/20447f9d0a2b409d1f5844cf81e1ae6516966057/backend/helloyou/__init__.py
--------------------------------------------------------------------------------
/backend/helloyou/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/backend/helloyou/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class HelloyouConfig(AppConfig):
5 | name = 'helloyou'
6 |
--------------------------------------------------------------------------------
/backend/helloyou/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emilefrey/django-react-postgres-boilerplate/20447f9d0a2b409d1f5844cf81e1ae6516966057/backend/helloyou/migrations/__init__.py
--------------------------------------------------------------------------------
/backend/helloyou/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | # Create your models here.
4 |
--------------------------------------------------------------------------------
/backend/helloyou/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/backend/helloyou/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | import helloyou.views as views
3 |
4 | urlpatterns = [
5 | path('helloyou/', views.HelloYouView.as_view(), name = 'helloyou'),
6 | ]
--------------------------------------------------------------------------------
/backend/helloyou/views.py:
--------------------------------------------------------------------------------
1 | from rest_framework import status
2 | from rest_framework.decorators import api_view
3 | from rest_framework.response import Response
4 | from rest_framework.views import APIView
5 |
6 | from rest_framework.authentication import TokenAuthentication
7 | from rest_framework.permissions import IsAuthenticated
8 |
9 | class HelloYouView(APIView):
10 | authentication_classes = [TokenAuthentication]
11 | permission_classes = [IsAuthenticated]
12 |
13 | def post(self, request, format=None):
14 | response_dict = {"response": "Hello " + request.data["name"] + '!'}
15 | return Response(response_dict, status=200)
--------------------------------------------------------------------------------
/backend/mainapp/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emilefrey/django-react-postgres-boilerplate/20447f9d0a2b409d1f5844cf81e1ae6516966057/backend/mainapp/__init__.py
--------------------------------------------------------------------------------
/backend/mainapp/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for mainapp project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mainapp.settings')
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/backend/mainapp/local_settings.py:
--------------------------------------------------------------------------------
1 | import os
2 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
3 |
4 | #################################################################
5 | ## Get Django environment set by docker (i.e either development or production), or else set it to local ##
6 | #################################################################
7 | try:
8 | DJANGO_ENV = os.environ.get("DJANGO_ENV")
9 | except:
10 | DJANGO_ENV = 'local'
11 |
12 | #################################################################
13 | ## If Django environement has been set by docker it would be either development or production otherwise it would be undefined or local ##
14 | #################################################################
15 | if DJANGO_ENV == 'development' or DJANGO_ENV == 'production':
16 |
17 | try:
18 | SECRET_KEY = os.environ.get("SECRET_KEY")
19 | except:
20 | SECRET_KEY = 'localsecret'
21 |
22 | try:
23 | DEBUG = int(os.environ.get("DEBUG", default=0))
24 | except:
25 | DEBUG = False
26 |
27 | try:
28 | ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ")
29 | except:
30 | ALLOWED_HOSTS = ['127.0.0.1', '0.0.0.0', 'localhost']
31 |
32 | DATABASES = {
33 | "default": {
34 | "ENGINE": os.environ.get("DB_ENGINE", "django.db.backends.sqlite3"),
35 | "NAME": os.environ.get("DB_DATABASE", os.path.join(BASE_DIR, "db.sqlite3")),
36 | "USER": os.environ.get("DB_USER", "user"),
37 | "PASSWORD": os.environ.get("DB_PASSWORD", "password"),
38 | "HOST": os.environ.get("DB_HOST", "localhost"),
39 | "PORT": os.environ.get("DB_PORT", "5432"),
40 | }
41 | }
42 | else:
43 | SECRET_KEY = 'localsecret'
44 | DEBUG = True
45 | ALLOWED_HOSTS = ['127.0.0.1', '0.0.0.0', 'localhost']
46 | DATABASES = {
47 | 'default': {
48 | 'ENGINE': 'django.db.backends.postgresql_psycopg2',
49 | 'NAME': 'boilerplatedb',
50 | 'USER': 'postgres_user',
51 | 'PASSWORD': 'postgres_password',
52 | 'HOST': '127.0.0.1',
53 | 'PORT': '5432',
54 | }
55 | }
56 |
57 | #################################################################
58 | ## (CORS) Cross-Origin Resource Sharing Settings ##
59 | #################################################################
60 | CORS_ORIGIN_ALLOW_ALL = True
61 |
62 |
63 | #################################################################
64 | ## STATIC FILES ROOT AND URL ##
65 | #################################################################
66 |
67 | STATIC_ROOT = os.path.join(BASE_DIR, 'static')
68 | STATIC_URL = '/static/'
--------------------------------------------------------------------------------
/backend/mainapp/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for mainapp project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.1.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.1/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/3.1/ref/settings/
11 | """
12 |
13 | from pathlib import Path
14 |
15 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
16 | BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = '9-yitn09*pi)q08z7r*d!d341uh+$a)ck-9^jezk=k67t*1)#^'
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | 'django.contrib.admin',
35 | 'django.contrib.auth',
36 | 'django.contrib.contenttypes',
37 | 'django.contrib.sessions',
38 | 'django.contrib.messages',
39 | 'django.contrib.staticfiles',
40 |
41 | 'rest_framework',
42 | 'django_rest_passwordreset',
43 | 'rest_framework.authtoken',
44 | 'rest_auth',
45 | 'corsheaders',
46 |
47 | 'helloyou',
48 | 'users',
49 | ]
50 |
51 | MIDDLEWARE = [
52 | 'corsheaders.middleware.CorsMiddleware',
53 | 'django.middleware.security.SecurityMiddleware',
54 | 'django.contrib.sessions.middleware.SessionMiddleware',
55 | 'django.middleware.common.CommonMiddleware',
56 | 'django.middleware.csrf.CsrfViewMiddleware',
57 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
58 | 'django.contrib.messages.middleware.MessageMiddleware',
59 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
60 | ]
61 |
62 | ROOT_URLCONF = 'mainapp.urls'
63 |
64 | TEMPLATES = [
65 | {
66 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
67 | 'DIRS': [],
68 | 'APP_DIRS': True,
69 | 'OPTIONS': {
70 | 'context_processors': [
71 | 'django.template.context_processors.debug',
72 | 'django.template.context_processors.request',
73 | 'django.contrib.auth.context_processors.auth',
74 | 'django.contrib.messages.context_processors.messages',
75 | ],
76 | },
77 | },
78 | ]
79 |
80 | WSGI_APPLICATION = 'mainapp.wsgi.application'
81 |
82 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
83 |
84 | DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE = True
85 |
86 | # Database
87 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases
88 |
89 | # Password validation
90 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
91 |
92 | AUTH_PASSWORD_VALIDATORS = [
93 | {
94 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
95 | },
96 | {
97 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
98 | },
99 | {
100 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
101 | },
102 | {
103 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
104 | },
105 | ]
106 |
107 |
108 | # Internationalization
109 | # https://docs.djangoproject.com/en/3.1/topics/i18n/
110 |
111 | LANGUAGE_CODE = 'en-us'
112 |
113 | TIME_ZONE = 'UTC'
114 |
115 | USE_I18N = True
116 |
117 | USE_L10N = True
118 |
119 | USE_TZ = True
120 |
121 |
122 | # Static files (CSS, JavaScript, Images)
123 | # https://docs.djangoproject.com/en/3.1/howto/static-files/
124 |
125 | STATIC_URL = '/static/'
126 |
127 |
128 | #########################################
129 | ## IMPORT LOCAL SETTINGS ##
130 | #########################################
131 |
132 | try:
133 | from .local_settings import *
134 | except ImportError:
135 | pass
136 |
--------------------------------------------------------------------------------
/backend/mainapp/urls.py:
--------------------------------------------------------------------------------
1 | """mainapp URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
15 | """
16 | from django.contrib import admin
17 | from django.urls import path, include
18 |
19 | urlpatterns = [
20 | path('admin/', admin.site.urls),
21 | path('api/auth/', include('users.urls')),
22 | path('api/', include('helloyou.urls')),
23 | path('api/password_reset/', include('django_rest_passwordreset.urls', namespace='password_reset')),
24 | ]
25 |
--------------------------------------------------------------------------------
/backend/mainapp/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for mainapp project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mainapp.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/backend/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mainapp.settings')
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == '__main__':
22 | main()
23 |
--------------------------------------------------------------------------------
/backend/requirements.txt:
--------------------------------------------------------------------------------
1 | Django==3.1.13
2 | djangorestframework==3.11.2
3 | django-rest-auth==0.9.5
4 | django-cors-headers==3.5.0
5 | psycopg2-binary==2.8.5
6 | gunicorn==20.0.4
7 | django-rest-passwordreset
--------------------------------------------------------------------------------
/backend/users/__init__.py:
--------------------------------------------------------------------------------
1 | # __init__.py
2 | import users.signals
--------------------------------------------------------------------------------
/backend/users/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/backend/users/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class UsersConfig(AppConfig):
5 | name = 'users'
6 |
--------------------------------------------------------------------------------
/backend/users/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | # Create your models here.
4 |
--------------------------------------------------------------------------------
/backend/users/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 | from django.contrib.auth.models import User
3 |
4 | class ChangePasswordSerializer(serializers.Serializer):
5 | model = User
6 |
7 | """
8 | Serializer for password change endpoint.
9 | """
10 | old_password = serializers.CharField(required=True)
11 | new_password1 = serializers.CharField(required=True)
12 | new_password2 = serializers.CharField(required=True)
--------------------------------------------------------------------------------
/backend/users/signals.py:
--------------------------------------------------------------------------------
1 | from django.dispatch import receiver
2 | from django.urls import reverse
3 | from django_rest_passwordreset.signals import reset_password_token_created
4 | from django.core.mail import send_mail
5 |
6 | @receiver(reset_password_token_created)
7 | def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs):
8 |
9 | # email_plaintext_message = "{}?token={}".format(reverse('password_reset:reset-password-request'), reset_password_token.key)
10 | email_plaintext_message = "http://localhost:3000{}?token={}".format('/password_reset/', reset_password_token.key)
11 | print(email_plaintext_message)
12 | print("!" * 50)
13 | send_mail(
14 | # title:
15 | "Password Reset for {title}".format(title="HelloYou"),
16 | # message:
17 | email_plaintext_message,
18 | # from:
19 | "noreply@somehost.local",
20 | # to:
21 | [reset_password_token.user.email]
22 | )
--------------------------------------------------------------------------------
/backend/users/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/backend/users/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path, include
2 | from users.views import APILoginView, APILogoutView, ChangePasswordView
3 |
4 | urlpatterns = [
5 | path('login/', APILoginView.as_view(), name='api_login'),
6 | path('logout/', APILogoutView.as_view(), name='api_logout'),
7 | path('change_password/', ChangePasswordView.as_view(), name='change_password'),
8 | ]
--------------------------------------------------------------------------------
/backend/users/views.py:
--------------------------------------------------------------------------------
1 | from rest_auth.views import (LoginView, LogoutView)
2 | from rest_framework.authentication import TokenAuthentication
3 | from rest_framework.permissions import IsAuthenticated
4 | from rest_framework import status
5 | from rest_framework import generics
6 | from rest_framework.response import Response
7 | from django.contrib.auth.models import User
8 | from .serializers import ChangePasswordSerializer
9 | from rest_framework.permissions import IsAuthenticated
10 |
11 | # Create your views here.
12 | class APILogoutView(LogoutView):
13 | authentication_classes = [TokenAuthentication]
14 | permission_classes = [IsAuthenticated]
15 |
16 | class APILoginView(LoginView):
17 | pass
18 |
19 | class ChangePasswordView(generics.UpdateAPIView):
20 | """
21 | An endpoint for changing password.
22 | """
23 | serializer_class = ChangePasswordSerializer
24 | model = User
25 | authentication_classes = [TokenAuthentication]
26 | permission_classes = (IsAuthenticated,)
27 |
28 | def get_object(self, queryset=None):
29 | obj = self.request.user
30 | return obj
31 |
32 | def post(self, request, *args, **kwargs):
33 | self.object = self.get_object()
34 | serializer = self.get_serializer(data=request.data)
35 |
36 | if serializer.is_valid():
37 | # Check old password
38 | if not self.object.check_password(serializer.data.get("old_password")):
39 | response = {
40 | 'data': [{'status': 'The old password is incorrect.'}]
41 | }
42 | return Response({"old_password": ["The old password you provided is incorrect."]}, status=status.HTTP_400_BAD_REQUEST)
43 | new_password1 = serializer.data.get('new_password1')
44 | new_password2 = serializer.data.get('new_password2')
45 | if new_password1 != new_password2:
46 | return Response({"error": ["Passwords do not match."]})
47 | # set_password also hashes the password that the user will get
48 | self.object.set_password(new_password1)
49 | self.object.save()
50 | response = {
51 | 'status': 'success',
52 | 'code': status.HTTP_200_OK,
53 | 'message': 'Password updated successfully',
54 | 'data': [{'status': 'Password updated successfully!'}]
55 | }
56 |
57 | return Response(response)
58 |
59 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | backend:
5 | container_name: backend
6 | build: ./backend
7 | volumes:
8 | - ./backend:/app/backend
9 | ports:
10 | - "8000:8000"
11 | env_file:
12 | - ./backend/.env
13 | stdin_open: true
14 | tty: true
15 | command: python manage.py runserver 0.0.0.0:8000
16 | depends_on:
17 | - db
18 | db:
19 | container_name: database
20 | image: postgres:12.0-alpine
21 | volumes:
22 | - postgres_data:/var/lib/postgresql/data/
23 | env_file:
24 | - ./postgres/.env
25 | frontend:
26 | container_name: frontend
27 | build:
28 | context: ./frontend
29 | dockerfile: Dockerfile.local
30 | stdin_open: true
31 | volumes:
32 | - ./frontend:/app
33 | # One-way volume to use node_modules from inside image
34 | - /app/node_modules
35 | ports:
36 | - "3000:3000"
37 | environment:
38 | - NODE_ENV=development
39 | depends_on:
40 | - backend
41 | command: npm start
42 |
43 | volumes:
44 | postgres_data:
45 | django_static_volume:
46 | react_static_volume:
47 |
--------------------------------------------------------------------------------
/frontend/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react"
5 | ],
6 | "plugins": [
7 | "@babel/plugin-syntax-dynamic-import",
8 | "@babel/plugin-proposal-class-properties"
9 | ]
10 | }
--------------------------------------------------------------------------------
/frontend/Dockerfile.deploy:
--------------------------------------------------------------------------------
1 | ###########
2 | # BUILDER #
3 | ###########
4 |
5 | # pull official base image
6 | FROM node:14-alpine as builder
7 |
8 | # set work directory
9 | WORKDIR /app
10 |
11 | # install dependencies and avoid `node-gyp rebuild` errors
12 | COPY . ./
13 | RUN apk add --no-cache --virtual .gyp \
14 | python3 \
15 | make \
16 | g++ \
17 | && npm install \
18 | && apk del .gyp
19 |
20 | # copy our react project
21 | # COPY . /app/
22 |
23 | # perform npm build
24 | ARG API_SERVER
25 | ENV REACT_APP_API_SERVER=${API_SERVER}
26 | RUN REACT_APP_API_SERVER=${API_SERVER} \
27 | npm run build
28 |
29 | #########
30 | # FINAL #
31 | #########
32 |
33 | # ---
34 | FROM fholzer/nginx-brotli:v1.12.2
35 |
36 | WORKDIR /etc/nginx
37 | ADD nginx_deploy.conf /etc/nginx/nginx.conf
38 |
39 | COPY --from=builder /app/dist /usr/share/nginx/html
40 | EXPOSE 8080
41 | CMD ["nginx", "-g", "daemon off;"]
42 |
--------------------------------------------------------------------------------
/frontend/Dockerfile.local:
--------------------------------------------------------------------------------
1 | FROM node:14-alpine
2 |
3 | WORKDIR /app
4 |
5 | # Install dependencies
6 | COPY package.json .
7 |
8 | RUN npm install
9 |
10 | # Add rest of the client code
11 | COPY . .
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | <%= htmlWebpackPlugin.options.title %>
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/frontend/nginx_deploy.conf:
--------------------------------------------------------------------------------
1 | user nginx;
2 | worker_processes 1;
3 |
4 | error_log /var/log/nginx/error.log warn;
5 | pid /var/run/nginx.pid;
6 |
7 |
8 | events {
9 | worker_connections 1024;
10 | }
11 |
12 | http {
13 | include /etc/nginx/mime.types;
14 | default_type application/octet-stream;
15 |
16 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
17 | '$status $body_bytes_sent "$http_referer" '
18 | '"$http_user_agent" "$http_x_forwarded_for"';
19 |
20 | access_log /var/log/nginx/access.log main;
21 |
22 | sendfile on;
23 | #tcp_nopush on;
24 |
25 | keepalive_timeout 65;
26 |
27 | gzip on;
28 | gzip_disable "msie6";
29 |
30 | gzip_vary on;
31 | gzip_proxied any;
32 | gzip_comp_level 6;
33 | gzip_buffers 16 8k;
34 | gzip_http_version 1.1;
35 | gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
36 |
37 | brotli on;
38 | brotli_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
39 | brotli_static on;
40 |
41 | server {
42 | listen 8080;
43 |
44 | root /usr/share/nginx/html;
45 | index index.html;
46 |
47 | server_name localhost;
48 |
49 | location ~* \.(?:manifest|appcache|html?|xml|json)$ {
50 | expires -1;
51 | }
52 |
53 | location ~* \.(?:css|js)$ {
54 | try_files $uri =404;
55 | expires 1y;
56 | access_log off;
57 | add_header Cache-Control "public";
58 | }
59 |
60 | location ~ ^.+\..+$ {
61 | try_files $uri =404;
62 | }
63 |
64 | location / {
65 | try_files $uri $uri/ /index.html;
66 | }
67 | }
68 | }
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "starter-app",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "webpack serve --config webpack.config.dev.js --mode development --host 0.0.0.0 --port 3000",
8 | "build": "webpack --config webpack.config.prod.js --mode production"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "@material-ui/core": "^4.11.0",
15 | "@material-ui/icons": "^4.9.1",
16 | "@material-ui/lab": "^4.0.0-alpha.57",
17 | "@testing-library/jest-dom": "^5.16.4",
18 | "@testing-library/react": "^13.2.0",
19 | "@testing-library/user-event": "^14.2.0",
20 | "axios": "^0.21.2",
21 | "framer-motion": "^2.0.1",
22 | "prop-types": "15.7.2",
23 | "react": "^18.1.0",
24 | "react-dom": "^18.1.0",
25 | "react-redux": "^7.2.4",
26 | "@reduxjs/toolkit": "^1.6.0",
27 | "react-router-dom": "^5.2.0",
28 | "redux-persist": "^6.0.0",
29 | "redux": "^4.0.5",
30 | "redux-thunk": "^2.3.0"
31 | },
32 | "devDependencies": {
33 | "@babel/core": "^7.14.6",
34 | "@babel/plugin-proposal-class-properties": "^7.12.13",
35 | "@babel/plugin-syntax-dynamic-import": "^7.8.3",
36 | "@babel/plugin-transform-runtime": "^7.14.5",
37 | "@babel/preset-env": "^7.14.7",
38 | "@babel/preset-react": "^7.14.5",
39 | "@babel/runtime": "7.14.6",
40 | "@types/react": "^18.0.9",
41 | "@types/react-dom": "^18.0.3",
42 | "@types/react-redux": "^7.1.16",
43 | "@types/react-router-dom": "^5.1.7",
44 | "babel-loader": "^8.2.2",
45 | "css-loader": "^5.0.1",
46 | "file-loader": "^6.2.0",
47 | "fork-ts-checker-webpack-plugin": "^6.5.0",
48 | "html-webpack-plugin": "^5.3.2",
49 | "style-loader": "^2.0.0",
50 | "ts-loader": "^8.2.0",
51 | "typescript": "^4.1.3",
52 | "webpack": "^5.45.1",
53 | "webpack-cli": "^4.7.2",
54 | "webpack-dev-server": "^3.11.2"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emilefrey/django-react-postgres-boilerplate/20447f9d0a2b409d1f5844cf81e1ae6516966057/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | HelloYou
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/frontend/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emilefrey/django-react-postgres-boilerplate/20447f9d0a2b409d1f5844cf81e1ae6516966057/frontend/public/logo192.png
--------------------------------------------------------------------------------
/frontend/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emilefrey/django-react-postgres-boilerplate/20447f9d0a2b409d1f5844cf81e1ae6516966057/frontend/public/logo512.png
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useMemo } from "react";
2 | import {
3 | BrowserRouter as Router,
4 | Switch,
5 | Route,
6 | useLocation,
7 | Redirect,
8 | useHistory,
9 | } from "react-router-dom";
10 | import {
11 | ThemeProvider,
12 | CssBaseline,
13 | Snackbar,
14 | Dialog,
15 | DialogTitle,
16 | DialogContent,
17 | DialogActions } from "@material-ui/core";
18 |
19 | import { useAppSelector } from './redux/hooks';
20 | import { Login } from './components/Login/Login';
21 | import { appArray } from "./routes/Routes";
22 | import PrivateRoute from "./routes/PrivateRoute";
23 | import Layout from "./components/Layout/Layout";
24 | import { theme } from "./Theme";
25 | import { DialogContext } from "./contexts/DialogContext";
26 | import { Alert } from "@material-ui/lab";
27 | import { AlertContext } from "./contexts/AlertContext";
28 | import PasswordUpdate from "./components/Login/PasswordUpdate";
29 | import PasswordReset from "./components/Login/PasswordReset";
30 |
31 | export function App() {
32 | const location = useLocation();
33 | const { authenticated } = useAppSelector(state => state.auth)
34 | const { darkMode } = useAppSelector(state => state.darkMode)
35 | const history = useHistory()
36 | const { showDialog, dialogTitle, dialogBody, dialogActions, handleDialogClose } = useContext(DialogContext);
37 | const { alertType, openAlert, alertMessage, handleAlertClose } = useContext(AlertContext);
38 |
39 | const publicRoutes = [
40 | { path: "login", component: Login, exact: true },
41 | { path: "password_reset", component: PasswordReset, exact: true }
42 | ]
43 |
44 | const atPublicRoute = publicRoutes.findIndex(route => location.pathname.includes(route.path)) !== -1
45 |
46 | useEffect(() => {
47 | // when un-authenticated, redirect to login (only for non-public routes)
48 | if (!authenticated && !atPublicRoute) {
49 | history.push({ pathname: "/login", state: { from: location } })
50 | }
51 | }, [authenticated, location.pathname])
52 |
53 | const generateAppRoutes = () => {
54 | return appArray
55 | .map((app, index1) => {
56 | let result = app.routes?.map((route, index2) => {
57 | const key = `${index1}_${index2}`
58 | return ;
59 | })
60 | return result
61 | })
62 | }
63 |
64 | const privateRoutes = useMemo(() => generateAppRoutes(), []);
65 |
66 | return (
67 |
68 |
69 |
70 |
71 | {alertMessage}
72 |
73 |
74 |
83 |
84 |
85 |
86 |
87 | {publicRoutes.map((route, index) =>
88 |
89 | )}
90 | {privateRoutes}
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | )
99 | }
100 |
101 | export function MainPage() {
102 | return (
103 | //@ts-ignore - TODO: NEED TO ADDRESS TS ERROR HERE
104 |
105 |
106 |
107 | )
108 | }
--------------------------------------------------------------------------------
/frontend/src/Theme.tsx:
--------------------------------------------------------------------------------
1 | import { createMuiTheme } from '@material-ui/core/styles';
2 |
3 | export const theme = (type: "dark" | "light") => createMuiTheme({
4 | palette: {
5 | type: type,
6 | primary: {
7 | main: "#3498db",
8 | contrastText: "#fff"
9 | },
10 | secondary: {
11 | main: "#ffaa00",
12 | contrastText: "#fff"
13 | },
14 | },
15 | });
--------------------------------------------------------------------------------
/frontend/src/axiosInterceptors.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { store } from './redux/store'
3 | import { forceLogout, selectToken } from './redux/auth/authSlice'
4 | import { API_SERVER } from './settings'
5 |
6 | export const axiosRequestInterceptor = axios.interceptors.request.use(config => {
7 | config.baseURL = API_SERVER;
8 | config.headers = {
9 | "Content-Type": "application/json",
10 | 'Authorization': `Token ${store.getState().auth.token}`
11 | }
12 | return config
13 | })
14 |
15 | const badResponseCodes = [401, 403] // unauthorized, forbidden
16 | export const axiosResponseInterceptor = axios.interceptors.response.use(
17 | response => response,
18 | error => {
19 | const status = error.response?.status;
20 | if (badResponseCodes.includes(status)) {
21 | store.dispatch(forceLogout());
22 | }
23 | return Promise.reject(error);
24 | }
25 | );
--------------------------------------------------------------------------------
/frontend/src/components/Home.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext, useRef } from 'react'
2 | import axios from 'axios';
3 |
4 | import { makeStyles } from '@material-ui/core/styles';
5 | import { Container, Grid, Paper, Typography, Button, TextField, Tooltip, DialogContentText } from '@material-ui/core';
6 | import { AlertContext } from '../contexts/AlertContext';
7 | import { DialogContext } from '../contexts/DialogContext';
8 | import { RouteComponentProps } from 'react-router';
9 |
10 | const useStyles = makeStyles((theme) => ({
11 | container: {
12 | maxWidth: "75%",
13 | marginTop: "4vh",
14 | marginBottom: "10vh",
15 | borderRadius: '6px',
16 | backgroundColor: theme.palette.action.disabledBackground,
17 | padding: '20px'
18 | },
19 | title: {
20 | marginTop: theme.spacing(2),
21 | marginBottom: theme.spacing(2),
22 | padding: theme.spacing(2), paddingLeft: theme.spacing(4),
23 | fontWeight: 700
24 | },
25 | textInput: {
26 | paddingTop: theme.spacing(2),
27 | paddingBottom: theme.spacing(2),
28 | paddingLeft: theme.spacing(4),
29 | paddingRight: theme.spacing(4),
30 | marginBottom: theme.spacing(2),
31 | },
32 |
33 | }));
34 |
35 | function Home(props: RouteComponentProps) {
36 |
37 | const [name, setName] = useState("")
38 | const [helloName, setHelloName] = useState("")
39 | const userInput = useRef(null)
40 | const { TriggerAlert } = useContext(AlertContext)
41 | const { OpenDialog } = useContext(DialogContext)
42 | const classes = useStyles()
43 |
44 | const handleSubmit = () => {
45 | let data = { "name": name }
46 | axios.post('/api/helloyou/', data)
47 | .then(response => {
48 | setHelloName(response.data["response"])
49 | TriggerAlert("Successful API Request!", "success")
50 | })
51 | .catch(
52 | (error: any) => { TriggerAlert(error.message, "error") })
53 | }
54 |
55 | const handleFormFieldChange = (event: React.ChangeEvent) => {
56 | setName(event.target.value)
57 | };
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
65 | Enter your name:
66 |
67 |
79 |
80 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | Backend Response:
90 |
91 |
92 | {helloName}
93 |
94 |
95 |
96 |
97 |
98 |
99 | Test Panel:
100 |
101 |
102 | Pass Param to Nested Subroute:
103 |
104 |
112 |
115 |
116 | Demo Dialog:
117 |
118 |
122 |
123 |
124 |
125 |
126 | )
127 | }
128 |
129 | export default Home
--------------------------------------------------------------------------------
/frontend/src/components/Layout/DropdownMenu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ClickAwayListener from '@material-ui/core/ClickAwayListener';
3 | import Grow from '@material-ui/core/Grow';
4 | import Paper from '@material-ui/core/Paper';
5 | import Popper from '@material-ui/core/Popper';
6 | import MenuList from '@material-ui/core/MenuList';
7 | import { Children } from '../../interfaces/Children'
8 | import { IconButton } from '@material-ui/core';
9 | import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
10 |
11 | interface DropdownMenuProps {
12 | children: Children
13 | dropdownButtonIcon: JSX.Element
14 | }
15 |
16 | export default function DropdownMenu(props: DropdownMenuProps) {
17 | const [open, setOpen] = React.useState(false);
18 | const anchorRef = React.useRef(null);
19 |
20 | const handleToggle = () => {
21 | setOpen((prevOpen) => !prevOpen);
22 | };
23 |
24 | const handleClose = (event: React.MouseEvent) => {
25 | if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) {
26 | return;
27 | }
28 | setOpen(false);
29 | };
30 |
31 | function handleListKeyDown(event: React.KeyboardEvent) {
32 | if (event.key === 'Tab') {
33 | event.preventDefault();
34 | setOpen(false);
35 | }
36 | }
37 |
38 | // return focus to the button when we transitioned from !open -> open
39 | const prevOpen = React.useRef(open);
40 | React.useEffect(() => {
41 | if (prevOpen.current === true && open === false) {
42 | anchorRef.current!.focus();
43 | }
44 |
45 | prevOpen.current = open;
46 | }, [open]);
47 |
48 | return (
49 | <>
50 |
57 | {props.dropdownButtonIcon}
58 |
59 |
60 |
61 | {({ TransitionProps, placement }) => (
62 |
66 |
67 |
68 |
71 |
72 |
73 |
74 | )}
75 |
76 | >
77 | );
78 | }
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import TopBar from "./TopBar"
3 | import { Box, Tooltip, ListItem, Divider, Container, makeStyles, List } from '@material-ui/core';
4 | import { useHistory, useLocation } from 'react-router-dom';
5 | import { appArray } from '../../routes/Routes'
6 | import { useAppSelector } from '../../redux/hooks';
7 |
8 | const useStyles = makeStyles((theme) => ({
9 | body: {
10 | backgroundColor: theme.palette.background.paper,
11 | minHeight: '100vh',
12 | display: 'flex',
13 | justifyContent: 'center',
14 | color: theme.palette.secondary.main,
15 | fontFamily: 'Calibri',
16 | },
17 | root: {
18 | display: 'flex',
19 | },
20 | appBarSpacer: theme.mixins.toolbar,
21 | content: {
22 | flexGrow: 1,
23 | height: '100vh',
24 | overflow: 'auto',
25 | },
26 | container: {
27 | paddingTop: theme.spacing(2),
28 | paddingLeft: theme.spacing(1),
29 | paddingRight: theme.spacing(1),
30 | paddingBottom: theme.spacing(4),
31 | },
32 | navBar: {
33 | backgroundColor: theme.palette.secondary.main,
34 | padding: 0,
35 | margin: 0,
36 | zIndex: 1102,
37 | },
38 | ListContainer: {
39 | height: '100vh',
40 | backgroundColor: theme.palette.primary.main,
41 | },
42 | ListItem: {
43 | padding: theme.spacing(2),
44 | color: theme.palette.primary.contrastText,
45 | },
46 | }));
47 |
48 |
49 | function Layout(props: { children: JSX.Element }) {
50 | const classes = useStyles();
51 | let location = useLocation();
52 | let history = useHistory();
53 | const { authenticated } = useAppSelector(state => state.auth)
54 |
55 | const mainListItems = (
56 |
57 | {appArray
58 | .filter(route => location.pathname !== 'login')
59 | .map((route, index) => {
60 | const { buttonTitle, baseRoute, Icon } = route;
61 | return (
62 |
63 | history.push(baseRoute)} selected={location.pathname.startsWith(baseRoute)}>
64 | {Icon && }
65 |
66 |
67 | );
68 | })}
69 |
70 | );
71 |
72 |
73 | return (
74 |
75 |
76 | {authenticated &&
{mainListItems}
}
77 |
78 |
79 | {authenticated && }
80 | {props.children &&
81 |
82 | {props.children}
83 |
84 | }
85 |
86 |
87 | )
88 | }
89 |
90 | export default Layout
--------------------------------------------------------------------------------
/frontend/src/components/Layout/TopBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import { AppBar, IconButton, Toolbar, Tooltip, Typography } from '@material-ui/core';
4 | import { APP_NAME } from '../../settings'
5 | import { AccountCircle } from '@material-ui/icons';
6 | import MenuItem from '@material-ui/core/MenuItem';
7 | import DropdownMenu from './DropdownMenu';
8 | import Brightness7Icon from '@material-ui/icons/Brightness7';
9 | import Brightness4Icon from '@material-ui/icons/Brightness4';
10 | import { useHistory } from 'react-router-dom';
11 | import { useAppDispatch, useAppSelector } from '../../redux/hooks';
12 | import { toggleDarkMode } from '../../redux/darkMode/darkModeSlice';
13 | import { forceLogout } from '../../redux/auth/authSlice';
14 | const useStyles = makeStyles((theme) => ({
15 | root: {
16 | flexGrow: 1,
17 | },
18 | title: {
19 | flexGrow: 1,
20 | },
21 | authToolbar: {
22 | paddingLeft: "96px"
23 | }
24 | }));
25 |
26 | export default function TopBar(props: any) {
27 | const classes = useStyles();
28 | const history = useHistory()
29 |
30 | const { authenticated } = useAppSelector(state => state.auth)
31 | const { darkMode } = useAppSelector(state => state.darkMode)
32 | const dispatch = useAppDispatch()
33 |
34 | return (
35 |
36 |
37 |
38 | {APP_NAME}
39 |
40 |
41 | dispatch(toggleDarkMode())} >
42 | {darkMode ? : }
43 |
44 |
45 | {authenticated && (
46 | }>
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/frontend/src/components/Login/ForgotPassword.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from 'react'
2 | import { FormHelperText, Button, TextField } from '@material-ui/core';
3 | import axios from 'axios';
4 | import { useStyles } from './styles'
5 |
6 | export const ForgotPassword = () => {
7 | const emailInput = useRef(null);
8 | const [submitted, setSubmitted] = useState(false)
9 | const [feedback, setFeedback] = useState("Please enter the email associated with your user account.")
10 | const classes = useStyles();
11 |
12 | const submitEmail = (e: React.FormEvent) => {
13 | e.preventDefault();
14 | if (emailInput.current !== null) {
15 | axios.post(`/api/password_reset/`, { email: emailInput.current.value })
16 | .then((response: any) => {
17 | setSubmitted(true)
18 | if (response.status === 200) {
19 | setFeedback('Thank you! If an account is associated with the email you provided, you will receive an email with instructions to reset your password.');
20 | }
21 | })
22 | .catch((error: any) => setFeedback('Error! Be sure to enter a valid email address.')
23 | )
24 | }
25 | }
26 |
27 |
28 | return (
29 | submitted ? {feedback}
:
30 |
59 |
60 | )
61 | }
--------------------------------------------------------------------------------
/frontend/src/components/Login/Login.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import { useState } from 'react'
3 | import { Redirect, RouteComponentProps } from 'react-router-dom'
4 | import genericLogo from '../../generic_logo.png'
5 | import Avatar from '@material-ui/core/Avatar';
6 | import Button from '@material-ui/core/Button';
7 | import TextField from '@material-ui/core/TextField';
8 | import LockOutlinedIcon from '@material-ui/icons/LockOutlined';
9 | import Typography from '@material-ui/core/Typography';
10 | import Container from '@material-ui/core/Container';
11 | import ValidationMessages from '../../helpers/ValidationMessages'
12 | import { Grid, LinearProgress, Link } from '@material-ui/core';
13 | import { useAppSelector, useAppDispatch } from '../../redux/hooks'
14 | import { login } from '../../redux/auth/authThunks'
15 | import { ForgotPassword } from './ForgotPassword'
16 | import { motion, useAnimation } from "framer-motion";
17 | import { useStyles } from './styles'
18 | import { APP_NAME } from '../../settings';
19 |
20 | export function Login(props: RouteComponentProps<{}, any, { from: string }>) {
21 | const classes = useStyles();
22 | const [username, setuserName] = useState("");
23 | const [password, setPassword] = useState("");
24 | const [passwordReset, setPasswordReset] = useState(false);
25 | const [validationErrors, setValidationErrors] = useState>({})
26 | const [isLoading, setIsLoading] = useState(false)
27 | const [imageStatus, setImageStatus] = useState<"loading" | "ready">("loading")
28 |
29 | const logoAnimation = useAnimation()
30 | const formAnimation = useAnimation()
31 |
32 | useEffect(() => {
33 | if (imageStatus === "ready") {
34 | logoAnimation.start(input => ({
35 | opacity: 1,
36 | transition: {
37 | ease: "easeIn",
38 | }
39 | }))
40 | formAnimation.start(input => ({
41 | opacity: 1,
42 | y: -18,
43 | }))
44 | }
45 | }, [imageStatus])
46 |
47 |
48 | const dispatch = useAppDispatch()
49 | const { authenticated } = useAppSelector(state => state.auth)
50 |
51 | const { from } = props.location.state || { from: { pathname: "/" } };
52 |
53 | if (authenticated) {
54 | return (
55 |
56 | )
57 | }
58 |
59 | const handleFormFieldChange = (event: React.ChangeEvent): any => {
60 | switch (event.target.id) {
61 | case 'username': setuserName(event.target.value); break;
62 | case 'password': setPassword(event.target.value); break;
63 | default: return null;
64 | }
65 | setValidationErrors({})
66 | };
67 |
68 | const handleSubmit = (e: React.ChangeEvent) => {
69 | e.preventDefault();
70 | setIsLoading(true)
71 | dispatch(login({ username: username, password: password }))
72 | .unwrap()
73 | .catch(errorData => {
74 | if (typeof (errorData) === "object") {
75 | setValidationErrors(errorData)
76 | } else {
77 | setValidationErrors({ "unknown": ["There was an unknown error"] })
78 | }
79 | })
80 | .finally(() => setIsLoading(false))
81 | }
82 |
83 | return (
84 |
85 |
86 |
setImageStatus("ready")} onError={() => setImageStatus("ready")} />
87 | {APP_NAME}
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | {passwordReset ? "Reset Password" : "Sign in"}
97 |
98 | {passwordReset ?
:
99 |
138 | }
139 |
140 |
141 |
142 | setPasswordReset(!passwordReset)} style={{ cursor: "pointer" }}>
143 | {passwordReset ? 'Back to Login' : 'Forgot password?'}
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 | )
153 | }
154 |
--------------------------------------------------------------------------------
/frontend/src/components/Login/PasswordReset.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField from '@material-ui/core/TextField';
4 | import Container from '@material-ui/core/Container';
5 | import FormHelperText from '@material-ui/core/FormHelperText';
6 | import Link from '@material-ui/core/Link';
7 |
8 | import axios from 'axios';
9 | import { Redirect } from 'react-router';
10 | import { Grid, Typography } from '@material-ui/core';
11 | import useQuery from '../../helpers/useQuery'
12 | import { useStyles } from './styles'
13 | import ValidationMessages from '../../helpers/ValidationMessages'
14 | import { APP_NAME } from '../../settings';
15 |
16 | export const PasswordReset = () => {
17 |
18 | let query = useQuery()
19 | const token = query.get("token")
20 | const [tokenInvalid, setTokenInvalid] = useState(null);
21 |
22 | useEffect(() => {
23 | axios.post(`/api/password_reset/validate_token/`, { token: token })
24 | .then((response: any) => {
25 | if (response.status === 200) {
26 | setTokenInvalid(false)
27 | }
28 | })
29 | .catch((error: any) => {
30 | if (error.response) {
31 | setTokenInvalid(true)
32 | }
33 | })
34 | }, [token])
35 |
36 | return tokenInvalid ? :
37 | }
38 |
39 | interface PasswordResetFormProps {
40 | token: string | null
41 | }
42 |
43 | export const PasswordResetForm: React.FC = (props: PasswordResetFormProps) => {
44 | const [password, setPassword] = useState('')
45 | const [passwordConfirmation, setPasswordConfirmation] = useState('')
46 | const [validationErrors, setValidationErrors] = useState>({})
47 | const [submitted, setSubmitted] = useState(false)
48 |
49 | const handleSubmit = (e: React.FormEvent) => {
50 | e.preventDefault();
51 | if (password === passwordConfirmation) {
52 | axios.post(`/api/password_reset/confirm/`, { password: password, token: props.token })
53 | .then((response: any) => {
54 | if (response.status === 200) {
55 | setSubmitted(true)
56 | }
57 | })
58 | .catch((error: any) => {
59 | if (error.response) {
60 | if (error.response.data)
61 | setValidationErrors(error.response.data)
62 | }
63 | }
64 | )
65 | } else {
66 | setValidationErrors({ "error": ["Passwords do not match!"] })
67 | }
68 | }
69 |
70 | const handleFormFieldChange = (event: React.ChangeEvent) => {
71 | setValidationErrors({})
72 | switch (event.target.id) {
73 | case 'new-password': setPassword(event.target.value); break;
74 | case 'confirm-password': setPasswordConfirmation(event.target.value); break;
75 | default: return null;
76 | }
77 | };
78 |
79 | const classes = useStyles();
80 |
81 | return (
82 |
83 | {APP_NAME}
84 |
132 | {submitted &&
133 |
134 |
135 |
136 |
137 | Success! Back to Login
138 |
139 |
140 |
141 |
142 | }
143 |
144 | )
145 | }
146 |
147 | export default PasswordReset
--------------------------------------------------------------------------------
/frontend/src/components/Login/PasswordUpdate.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import axios from 'axios';
3 |
4 | import { makeStyles } from '@material-ui/core/styles';
5 | import { Avatar, Button, Container, LinearProgress, TextField, Typography } from '@material-ui/core';
6 | import VpnKeyIcon from '@material-ui/icons/VpnKey';
7 | import { PasswordUpdateError } from '../../interfaces/axios/AxiosError';
8 | import ValidationMessages from '../../helpers/ValidationMessages'
9 | import { useAppSelector } from '../../redux/hooks';
10 |
11 | const useStyles = makeStyles((theme) => ({
12 | paper: {
13 | marginTop: theme.spacing(8),
14 | display: 'flex',
15 | flexDirection: 'column',
16 | alignItems: 'center',
17 | },
18 | avatar: {
19 | margin: theme.spacing(1),
20 | backgroundColor: theme.palette.success.main,
21 | },
22 | form: {
23 | width: '100%',
24 | marginTop: theme.spacing(1),
25 | },
26 | submit: {
27 | margin: theme.spacing(3, 0, 2),
28 | },
29 | success: {
30 | color: theme.palette.success.main,
31 | }
32 | }));
33 |
34 |
35 | function PasswordUpdate() {
36 | const classes = useStyles();
37 | const [new_password1, setNewPassword1] = useState("");
38 | const [new_password2, setNewPassword2] = useState("");
39 | const [oldPassword, setOldPassword] = useState("");
40 | const [success, setSuccess] = useState(false);
41 | const [validationErrors, setValidationErrors] = useState>({})
42 | const [isLoading, setIsLoading] = useState(false)
43 |
44 | const { token } = useAppSelector(state => state.auth)
45 | const handleFormFieldChange = (event: React.ChangeEvent) => {
46 | setSuccess(false);
47 | switch (event.target.id) {
48 | case 'new_password1': setNewPassword1(event.target.value); break;
49 | case 'new_password2': setNewPassword2(event.target.value); break;
50 | case 'old_password': setOldPassword(event.target.value); break;
51 | default: return null;
52 | }
53 | setValidationErrors({})
54 | };
55 |
56 | const handleSubmit = (e: React.ChangeEvent) => {
57 | e.preventDefault();
58 | setIsLoading(true)
59 | if (new_password1 !== new_password2) {
60 | setValidationErrors({ "error": ["Passwords do not match!"] })
61 | } else if (new_password1 === "") {
62 | setValidationErrors({ "error": ["Password can't be blank!"] })
63 | }
64 | else {
65 | let url = '/api/auth/change_password/';
66 | let passwordFormData = new FormData();
67 | passwordFormData.append("old_password", oldPassword);
68 | passwordFormData.append("new_password1", new_password1);
69 | passwordFormData.append("new_password2", new_password2);
70 | //Axios update_password API call
71 |
72 | axios.post(url, passwordFormData)
73 | .then(() => {
74 | setSuccess(true)
75 | })
76 | .catch(
77 | (error: PasswordUpdateError) => {
78 | setValidationErrors(error?.response?.data)
79 | })
80 | }
81 | setIsLoading(false)
82 | }
83 |
84 |
85 | return (
86 |
87 |
88 | {success ?
Password update successful! : null}
89 |
90 |
91 |
92 | {!success ?
93 | <>
94 |
95 | Update Password
96 |
97 |
143 | > :
144 |
150 | }
151 |
152 |
153 | );
154 | }
155 |
156 | export default PasswordUpdate;
--------------------------------------------------------------------------------
/frontend/src/components/Login/styles.ts:
--------------------------------------------------------------------------------
1 | import { makeStyles } from "@material-ui/core";
2 |
3 | export const useStyles = makeStyles((theme) => ({
4 | paper: {
5 | marginTop: theme.spacing(8),
6 | display: 'flex',
7 | flexDirection: 'column',
8 | alignItems: 'center',
9 | },
10 | avatar: {
11 | margin: theme.spacing(1),
12 | backgroundColor: theme.palette.secondary.main,
13 | },
14 | form: {
15 | width: '100%',
16 | marginTop: theme.spacing(1),
17 | },
18 | submit: {
19 | margin: theme.spacing(3, 0, 2),
20 | },
21 | helper: {
22 | margin: theme.spacing(1),
23 | },
24 | title: {
25 | marginTop: theme.spacing(2),
26 | marginBottom: theme.spacing(2),
27 | padding: theme.spacing(2), paddingLeft: theme.spacing(4),
28 | color: theme.palette.primary.main,
29 | fontWeight: 700
30 | },
31 | }));
--------------------------------------------------------------------------------
/frontend/src/contexts/AlertContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useState } from 'react';
2 | import { Color } from '@material-ui/lab/Alert';
3 |
4 | export type AlertContextProps = {
5 | TriggerAlert: (message: string, severity: Color) => void,
6 | openAlert: boolean,
7 | alertMessage: string,
8 | alertType: Color,
9 | handleAlertClose: (event?: React.SyntheticEvent | undefined, reason?: string | undefined) => void
10 | };
11 |
12 | export const AlertContext = createContext({
13 | TriggerAlert: () => {},
14 | openAlert: false,
15 | alertMessage: '',
16 | alertType: 'info',
17 | handleAlertClose: () => {}
18 | });
19 |
20 | const AlertContextProvider = (props: any) => {
21 | const [openAlert, setOpenAlert] = useState(false);
22 | const [alertMessage, setAlertMessage] = useState('');
23 | const [alertType, setAlertType] = useState('error');
24 |
25 | const TriggerAlert = (message: string, severity: Color = 'info') => {
26 | setOpenAlert(false);
27 | setAlertMessage(message);
28 | setAlertType(severity);
29 | setOpenAlert(true);
30 | }
31 |
32 | const handleAlertClose = (event?: React.SyntheticEvent, reason?: string) => {
33 | if (reason === 'clickaway') {
34 | return;
35 | }
36 | setOpenAlert(false);
37 | };
38 |
39 | return (
40 |
47 | {props.children}
48 |
49 | )
50 | }
51 |
52 | export default AlertContextProvider;
53 |
--------------------------------------------------------------------------------
/frontend/src/contexts/DialogContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useState } from 'react';
2 | import Button from '@material-ui/core/Button';
3 |
4 | export type DialogContextProps = {
5 | OpenDialog: (title: string, body: JSX.Element, actions?: any) => void,
6 | showDialog: boolean,
7 | dialogTitle: string,
8 | dialogBody: JSX.Element,
9 | dialogActions: JSX.Element,
10 | handleDialogClose: ((event: {}, reason: "backdropClick" | "escapeKeyDown") => void) | undefined
11 | };
12 |
13 | export const DialogContext = createContext({
14 | OpenDialog: () => { },
15 | showDialog: false,
16 | dialogTitle: '',
17 | dialogBody: <>>,
18 | dialogActions: <>>,
19 | handleDialogClose: () => { }
20 | });
21 |
22 | const DialogContextProvider = (props: any) => {
23 | const [showDialog, setShowDialog] = useState(false);
24 | const [dialogTitle, setDialogTitle] = useState('');
25 | const [dialogBody, setDialogBody] = useState(<>>);
26 | const [dialogActions, setDialogActions] = useState(<>>);
27 |
28 | const OpenDialog = (title: string, body: JSX.Element, actions: any = undefined) => {
29 | setDialogTitle(title);
30 | setDialogBody(body);
31 | if (actions)
32 | setDialogActions(actions);
33 | else
34 | setDialogActions()
35 | setShowDialog(true)
36 | }
37 |
38 | const handleDialogClose = (event: {}, reason: "backdropClick" | "escapeKeyDown") => {
39 | setShowDialog(false);
40 | };
41 |
42 | return (
43 |
51 | {props.children}
52 |
53 | )
54 | }
55 |
56 | export default DialogContextProvider;
57 |
--------------------------------------------------------------------------------
/frontend/src/generic_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emilefrey/django-react-postgres-boilerplate/20447f9d0a2b409d1f5844cf81e1ae6516966057/frontend/src/generic_logo.png
--------------------------------------------------------------------------------
/frontend/src/helpers/ValidationMessages.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 | import MuiAlert from '@material-ui/lab/Alert';
3 |
4 |
5 | const ValidationMessages = (props: { validationErrors: Record | undefined | string }) => {
6 | const { validationErrors } = props
7 | const [errorMessages, setErrorMessages] = useState([])
8 |
9 | useEffect(() => {
10 | let temp: string[] = []
11 | if (validationErrors && validationErrors !== {}) {
12 | const errorKey = Object.keys(validationErrors)
13 | if (typeof validationErrors === "object") {
14 | errorKey.forEach(key => {
15 | temp = [...temp, ...validationErrors[key]]
16 | })
17 | }
18 | setErrorMessages(temp)
19 | }
20 | }, [validationErrors])
21 |
22 | return (
23 | <>
24 | {errorMessages.map((value, index) =>
25 | {value}
26 | )}
27 | >
28 | )
29 | }
30 |
31 | export default ValidationMessages
--------------------------------------------------------------------------------
/frontend/src/helpers/useQuery.tsx:
--------------------------------------------------------------------------------
1 | import { useLocation } from "react-router-dom";
2 |
3 | export default function useQuery() {
4 | return new URLSearchParams(useLocation().search);
5 | }
--------------------------------------------------------------------------------
/frontend/src/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.png';
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { MainPage } from './App';
4 |
5 | import AlertContextProvider from './contexts/AlertContext'
6 | import DialogContextProvider from './contexts/DialogContext'
7 | import { persistor, store } from './redux/store'
8 | import { Provider } from "react-redux";
9 | import { PersistGate } from 'redux-persist/integration/react'
10 | import { axiosRequestInterceptor, axiosResponseInterceptor } from './axiosInterceptors'
11 |
12 | if (process.env.NODE_ENV === "development" && process.env.NODE_ENV !== "test") {
13 | module.hot.accept(); // hot reloading when in develop mode
14 | }
15 |
16 | ReactDOM.render(
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | ,
28 | document.getElementById('root')
29 | );
--------------------------------------------------------------------------------
/frontend/src/interfaces/Children.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export type Children = JSX.Element
--------------------------------------------------------------------------------
/frontend/src/interfaces/axios/AxiosError.tsx:
--------------------------------------------------------------------------------
1 | export interface AxiosError {
2 | message: string
3 | response: {
4 | data: {
5 | non_field_errors: string[]
6 | }
7 | }
8 | }
9 |
10 | export interface PasswordUpdateError {
11 | message: string
12 | response: {
13 | data: {
14 | new_password2: string[]
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/frontend/src/interfaces/models/Response.tsx:
--------------------------------------------------------------------------------
1 | export default interface PageResponse {
2 | count: number,
3 | next: string,
4 | previous: string,
5 | results: T[]
6 | }
7 |
8 | export interface Response {
9 | data: T,
10 | status: number,
11 | statusText: string,
12 | config: object
13 | };
--------------------------------------------------------------------------------
/frontend/src/redux/auth/authSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'
2 | import type { RootState } from '../store'
3 | import { login, logout } from './authThunks'
4 |
5 | type AuthState = {
6 | token: string | null
7 | authenticated: boolean
8 | }
9 |
10 | const slice = createSlice({
11 | name: 'auth',
12 | initialState: { token: null, authenticated: false } as AuthState,
13 | reducers: {
14 | setToken: (
15 | state,
16 | { payload: token }: PayloadAction
17 | ) => {
18 | state.token = token
19 | },
20 | forceLogout: (
21 | state
22 | ) => {
23 | state.authenticated = false
24 | state.token = null
25 | },
26 | },
27 | extraReducers: (builder) => {
28 | builder.addCase(login.fulfilled, (state, action) => {
29 | state.authenticated = true
30 | state.token = action.payload
31 | }),
32 | builder.addCase(logout.fulfilled, (state, action) => {
33 | state.authenticated = false
34 | })
35 | }
36 | })
37 |
38 | export const { setToken, forceLogout } = slice.actions
39 |
40 | export default slice.reducer
41 |
42 |
43 |
--------------------------------------------------------------------------------
/frontend/src/redux/auth/authThunks.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit'
2 | import axios, { AxiosError } from 'axios'
3 |
4 | export const login = createAsyncThunk(
5 | 'auth/login',
6 | async (credentials: { username: string, password: string }, thunkAPI) => {
7 | try {
8 | const response = await axios.post>('/api/auth/login/', { username: credentials.username, password: credentials.password });
9 | return response.data.key
10 | } catch (err) {
11 | // cast error to expected type
12 | const error = err as AxiosError>;
13 | return thunkAPI.rejectWithValue(error.response?.data)
14 | }
15 | }
16 | )
17 |
18 | export const logout = createAsyncThunk(
19 | 'auth/logout',
20 | async ({ }, thunkAPI) => {
21 | try {
22 | const response = await axios.post("/api/auth/logout/");
23 | return response.data;
24 | } catch (err) {
25 | // cast error to expected type
26 | const error = err as AxiosError>;
27 | return thunkAPI.rejectWithValue(error.response?.data)
28 | }
29 | }
30 | )
--------------------------------------------------------------------------------
/frontend/src/redux/darkMode/darkModeSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit'
2 |
3 | type DarkModeState = {
4 | darkMode: boolean
5 | }
6 |
7 | const slice = createSlice({
8 | name: 'darkMode',
9 | initialState: { darkMode: false } as DarkModeState,
10 | reducers: {
11 | toggleDarkMode: (
12 | state
13 | ) => {
14 | state.darkMode = !state.darkMode
15 | },
16 | },
17 | })
18 |
19 | export const { toggleDarkMode } = slice.actions
20 |
21 | export default slice.reducer
22 |
--------------------------------------------------------------------------------
/frontend/src/redux/hooks.ts:
--------------------------------------------------------------------------------
1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
2 | import type { RootState, AppDispatch } from './store'
3 |
4 | // Use throughout your app instead of plain `useDispatch` and `useSelector`
5 | export const useAppDispatch = () => useDispatch()
6 | export const useAppSelector: TypedUseSelectorHook = useSelector
--------------------------------------------------------------------------------
/frontend/src/redux/store.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers, configureStore } from "@reduxjs/toolkit"
2 | import authReducer from "./auth/authSlice"
3 | import darkModeReducer from "./darkMode/darkModeSlice"
4 | import {
5 | persistStore,
6 | persistReducer,
7 | FLUSH,
8 | REHYDRATE,
9 | PAUSE,
10 | PERSIST,
11 | PURGE,
12 | REGISTER,
13 | } from 'redux-persist'
14 | import storage from 'redux-persist/lib/storage'
15 |
16 | const persistConfig = {
17 | key: 'root',
18 | version: 1,
19 | storage,
20 | }
21 |
22 | const rootReducer = combineReducers({ auth: authReducer, darkMode: darkModeReducer })
23 |
24 | const persistedReducer = persistReducer(persistConfig, rootReducer)
25 |
26 | export const store = configureStore({
27 | reducer: persistedReducer,
28 | middleware: (getDefaultMiddleware) =>
29 | getDefaultMiddleware({
30 | serializableCheck: {
31 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
32 | },
33 | }),
34 | devTools: process.env.NODE_ENV === "development"
35 | })
36 |
37 | export let persistor = persistStore(store)
38 |
39 | export type RootState = ReturnType
40 |
41 | export type AppDispatch = typeof store.dispatch
--------------------------------------------------------------------------------
/frontend/src/routes/Placeholder.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Typography } from '@material-ui/core'
2 | import React from 'react'
3 | import { RouteComponentProps } from 'react-router-dom'
4 |
5 | interface MatchParams {
6 | userinput: string;
7 | }
8 |
9 | interface MatchRouteProps extends RouteComponentProps {
10 | }
11 |
12 | const Placeholder = (props: MatchRouteProps) => {
13 | return (
14 |
15 | The user input detected by the route is: {props.match.params.userinput}
16 |
17 |
18 | )
19 | }
20 |
21 | export default Placeholder
--------------------------------------------------------------------------------
/frontend/src/routes/PrivateRoute.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, RouteProps } from 'react-router-dom';
3 | import { useAppSelector } from '../redux/hooks';
4 |
5 | interface PrivateRouteProps extends Omit {
6 | component: React.ElementType
7 | }
8 |
9 | const PrivateRoute = ({ component: Component, ...rest }: PrivateRouteProps) => {
10 |
11 | const authenticated = useAppSelector(state => state.auth.authenticated)
12 |
13 | return (
14 | // Show the component only when the user is logged in
15 | // Otherwise, redirect the user to /login page
16 | (
17 | authenticated ?
18 | : <>>
19 | )} />
20 | );
21 | };
22 |
23 | export default PrivateRoute;
--------------------------------------------------------------------------------
/frontend/src/routes/Routes.ts:
--------------------------------------------------------------------------------
1 | import HomeIcon from '@material-ui/icons/Home';
2 | import Home from '../components/Home';
3 | import Placeholder from './Placeholder'
4 | export interface appInterface {
5 | buttonTitle?: string,
6 | pageTitle?: string,
7 | baseRoute: string,
8 | exact: boolean,
9 | Icon?: React.FC,
10 | headerContent?: any,
11 | routes?: Route[],
12 |
13 | };
14 |
15 |
16 | type Route = { path: string, component: React.ElementType, exact: boolean }
17 |
18 | export const HOME: appInterface = {
19 | buttonTitle: 'Home',
20 | pageTitle: 'Home',
21 | baseRoute: "/home",
22 | exact: true,
23 | Icon: HomeIcon,
24 | headerContent: null,
25 | routes: [
26 | { path: "/home", component: Home, exact: true },
27 | { path: "/home/nestedsubroute/:userinput?/", component: Placeholder, exact: false }
28 | ]
29 | }
30 |
31 | export const appArray = [HOME]
--------------------------------------------------------------------------------
/frontend/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' },
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/frontend/src/settings.tsx:
--------------------------------------------------------------------------------
1 | let API_SERVER_VAL = 'http://localhost:8000';
2 |
3 | export const API_SERVER = API_SERVER_VAL;
4 |
5 | export const SESSION_DURATION = 5*3600*1000;
6 |
7 | export const APP_NAME = "AppName"
8 |
--------------------------------------------------------------------------------
/frontend/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noImplicitAny": true,
4 | "module": "esnext",
5 | "target": "es5",
6 | "jsx": "react",
7 | "allowJs": true,
8 | "sourceMap": true,
9 | "moduleResolution": "node",
10 | "allowSyntheticDefaultImports": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "strictNullChecks": true,
14 | "types": [
15 | "node",
16 | ],
17 | "lib": [
18 | "dom",
19 | "esnext"
20 | ],
21 | "esModuleInterop": true
22 | },
23 | "include": [
24 | "src",
25 | "tests",
26 | "./setupTests.js"
27 | ],
28 | "exclude": [
29 | "node_modules",
30 | ]
31 | }
--------------------------------------------------------------------------------
/frontend/tsconfig.dev.json:
--------------------------------------------------------------------------------
1 | // This tsconfig file is used to transpile/compile src directory files.
2 | // It ignores test files by intent.
3 | {
4 | "extends": "./tsconfig.base.json",
5 | "exclude": [
6 | "node_modules",
7 | "*test*.js",
8 | "**/*.test.ts",
9 | "**/*.test.tsx",
10 | ]
11 | }
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base.json",
3 | }
--------------------------------------------------------------------------------
/frontend/webpack.config.base.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack");
2 | const path = require("path");
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 |
5 | module.exports = {
6 | entry: path.resolve(__dirname, "./src/index.js"),
7 | module: {
8 | rules: [
9 | {
10 | test: /\.(js|jsx)$/,
11 | exclude: /node_modules/,
12 | use: ["babel-loader"],
13 | },
14 | {
15 | test: /\.(ts|tsx)$/,
16 | exclude: /node_modules/,
17 | use: [{
18 | loader: "ts-loader",
19 | options: {
20 | configFile: "tsconfig.dev.json"
21 | }
22 | }],
23 | },
24 | {
25 | test: /\.css$/i,
26 | use: ['style-loader', 'css-loader']
27 | },
28 | {
29 | test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|mp3)$/,
30 | use: [{ loader: 'file-loader', options: { name: "[name].[ext]" } }] // ?name=[name].[ext] is only necessary to preserve the original file name
31 | }
32 | ],
33 | },
34 | resolve: {
35 | extensions: ["*", ".js", ".jsx", ".ts", ".tsx", ".css"],
36 | },
37 | output: {
38 | chunkFilename: (pathData) => {
39 | return pathData.chunk.name === 'main' ? '[name].js' : 'chunks/[name].js';
40 | },
41 | path: path.resolve(__dirname, "./dist"),
42 | filename: "[name]-[chunkhash].js",
43 | publicPath: "/",
44 | },
45 | stats: {
46 | colors: true,
47 | modules: true,
48 | reasons: true,
49 | errorDetails: true
50 | },
51 | optimization: {
52 | splitChunks: {
53 | cacheGroups: {
54 | vendors: {
55 | test: /[\\/]node_modules[\\/]/,
56 | name: 'vendors',
57 | chunks: 'all',
58 | },
59 | },
60 | }
61 | },
62 | plugins: [
63 | new HtmlWebpackPlugin({
64 | title: "StarterApp",
65 | template: path.resolve(__dirname, "./index.html"),
66 | path: path.resolve(__dirname, "./dist"),
67 | // favicon: path.resolve(__dirname, "./static/favicon.ico"),
68 | }),
69 | ]
70 | };
--------------------------------------------------------------------------------
/frontend/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack");
2 | const path = require("path");
3 | var config = require('./webpack.config.base.js')
4 |
5 | config.devServer = {
6 | host: '0.0.0.0',
7 | overlay: true,
8 | disableHostCheck: true,
9 | port: 3000,
10 | contentBase: path.resolve(__dirname, "./dist"),
11 | hot: true,
12 | historyApiFallback: true,
13 | publicPath: "/"
14 | }
15 |
16 | config.plugins = config.plugins.concat([
17 | new webpack.HotModuleReplacementPlugin(),
18 | new webpack.DefinePlugin({
19 | 'process.env.NODE_ENV': JSON.stringify('development')
20 | })
21 | ])
22 |
23 | config.devtool = "inline-source-map"
24 |
25 | config.mode = "development"
26 |
27 | module.exports = config
--------------------------------------------------------------------------------
/frontend/webpack.config.prod.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack");
2 | var config = require('./webpack.config.base.js')
3 |
4 | config.plugins = config.plugins.concat([
5 | new webpack.DefinePlugin({
6 | 'process.env.NODE_ENV': JSON.stringify('production')
7 | })
8 | ])
9 |
10 | config.mode = "production"
11 |
12 | module.exports = config
--------------------------------------------------------------------------------
/postgres/.env:
--------------------------------------------------------------------------------
1 | POSTGRES_USER=postgres_user
2 | POSTGRES_PASSWORD=postgres_password
3 | POSTGRES_DB=boilerplatedb
--------------------------------------------------------------------------------