├── .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 | 75 | {dialogTitle} 76 | 77 | {dialogBody} 78 | 79 | 80 | {dialogActions} 81 | 82 | 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 | 69 | {props.children} 70 | 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 | history.push('/change_password/')}>Change Password 49 | dispatch(forceLogout())}>Logout 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 |
31 | 35 | {feedback} 36 | 37 | 48 | 58 | 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 | Image not Found 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 |
100 | 112 | 124 | 125 | {isLoading && } 126 | 137 | 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 |
85 | {!submitted && 86 | <> 87 | 91 | Please enter a new password. 92 | 93 | 106 | 118 | } 119 | 120 | {!submitted && 121 | } 131 | 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 |
98 | 109 | 120 | 131 | 132 | {isLoading && } 133 | 142 | 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 --------------------------------------------------------------------------------