├── frontend ├── src │ ├── index.css │ ├── components │ │ ├── login │ │ │ ├── LoginTypes.js │ │ │ ├── LoginReducer.js │ │ │ ├── Login.js │ │ │ └── LoginActions.js │ │ ├── notes │ │ │ ├── NotesTypes.js │ │ │ ├── NotesReducer.js │ │ │ ├── NotesList.js │ │ │ ├── NotesActions.js │ │ │ ├── AddNote.js │ │ │ └── Note.js │ │ ├── Home.js │ │ ├── dashboard │ │ │ └── Dashboard.js │ │ └── account │ │ │ ├── ActivateAccount.js │ │ │ ├── ResetPassword.js │ │ │ ├── ResendActivation.js │ │ │ ├── ResetPasswordConfirm.js │ │ │ └── Signup.js │ ├── setupTests.js │ ├── App.test.js │ ├── reportWebVitals.js │ ├── Reducer.js │ ├── index.js │ ├── utils │ │ ├── Utils.js │ │ └── RequireAuth.js │ ├── Root.js │ └── App.js ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── .gitignore ├── package.json └── README.md ├── backend ├── server │ ├── server │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── asgi.py │ │ ├── wsgi.py │ │ └── settings.py │ ├── apps │ │ ├── notes │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ │ ├── __init__.py │ │ │ │ └── 0001_initial.py │ │ │ ├── tests.py │ │ │ ├── admin.py │ │ │ ├── apps.py │ │ │ ├── urls.py │ │ │ ├── models.py │ │ │ ├── serializers.py │ │ │ └── views.py │ │ └── accounts │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ └── __init__.py │ │ │ ├── admin.py │ │ │ ├── views.py │ │ │ ├── apps.py │ │ │ ├── urls.py │ │ │ ├── models.py │ │ │ ├── serializers.py │ │ │ └── tests.py │ └── manage.py └── requirements.txt ├── docker ├── backend │ ├── Dockerfile │ └── wsgi-entrypoint.sh └── nginx │ ├── Dockerfile │ ├── development │ └── default.conf │ └── production │ └── default.conf ├── docker-compose-dev.yml ├── LICENSE ├── docker-compose.yml ├── .gitignore ├── init-letsencrypt.sh └── README.md /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/server/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/server/apps/notes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/server/apps/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/server/apps/notes/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/server/apps/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/server/apps/notes/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /backend/server/apps/accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /backend/server/apps/accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/server/apps/notes/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasitive/django-react-boilerplate/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /backend/server/apps/notes/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NotesConfig(AppConfig): 5 | name = 'notes' 6 | -------------------------------------------------------------------------------- /backend/server/apps/accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = 'accounts' 6 | -------------------------------------------------------------------------------- /frontend/src/components/login/LoginTypes.js: -------------------------------------------------------------------------------- 1 | export const SET_TOKEN = "SET_TOKEN"; 2 | export const SET_CURRENT_USER = "SET_CURRENT_USER"; 3 | export const UNSET_CURRENT_USER = "UNSET_CURRENT_USER"; 4 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | django==3.1.2 2 | djoser==2.0.5 3 | djangorestframework==3.11.2 # https://github.com/sunscrapers/djoser/issues/541 4 | markdown==3.3 5 | django-filter==2.4.0 6 | django_cors_headers==3.5.0 -------------------------------------------------------------------------------- /frontend/src/components/notes/NotesTypes.js: -------------------------------------------------------------------------------- 1 | export const GET_NOTES = "GET_NOTES"; 2 | export const ADD_NOTE = "ADD_NOTE"; 3 | export const DELETE_NOTE = "DELETE_NOTE"; 4 | export const UPDATE_NOTE = "UPDATE_NOTE"; 5 | -------------------------------------------------------------------------------- /backend/server/apps/accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | 3 | accounts_urlpatterns = [ 4 | url(r'^api/v1/', include('djoser.urls')), 5 | url(r'^api/v1/', include('djoser.urls.authtoken')), 6 | ] -------------------------------------------------------------------------------- /backend/server/apps/accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | User._meta.get_field('email')._unique = True 4 | User._meta.get_field('email').blank = False 5 | User._meta.get_field('email').null = False -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /docker/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.3-alpine 2 | 3 | WORKDIR /app 4 | ADD ./backend/requirements.txt /app/backend/ 5 | 6 | 7 | RUN pip install --upgrade pip 8 | RUN pip install gunicorn 9 | RUN pip install -r backend/requirements.txt 10 | 11 | ADD ./backend /app/backend 12 | ADD ./docker /app/docker -------------------------------------------------------------------------------- /backend/server/apps/notes/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from rest_framework.routers import DefaultRouter 3 | from apps.notes.views import NoteViewSet 4 | 5 | router = DefaultRouter() 6 | router.register("notes", NoteViewSet, basename="notes") 7 | notes_urlpatterns = [url("api/v1/", include(router.urls))] -------------------------------------------------------------------------------- /backend/server/apps/notes/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth import get_user_model 3 | 4 | User = get_user_model() 5 | 6 | class Note(models.Model): 7 | created_at = models.DateTimeField(auto_now_add=True) 8 | created_by = models.ForeignKey(User, on_delete=models.CASCADE) 9 | content = models.TextField(blank=True) 10 | -------------------------------------------------------------------------------- /backend/server/server/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from apps.accounts.urls import accounts_urlpatterns 5 | from apps.notes.urls import notes_urlpatterns 6 | 7 | urlpatterns = [ 8 | path('admin/', admin.site.urls), 9 | ] 10 | 11 | urlpatterns += accounts_urlpatterns # add URLs for authentication 12 | urlpatterns += notes_urlpatterns # notes URLs -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "SaaSitive", 3 | "name": "SaaSitive Django+React Boilerplate", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } -------------------------------------------------------------------------------- /docker/backend/wsgi-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | until cd /app/backend/server 4 | do 5 | echo "Waiting for server volume..." 6 | done 7 | 8 | until ./manage.py migrate 9 | do 10 | echo "Waiting for db to be ready..." 11 | sleep 2 12 | done 13 | 14 | ./manage.py collectstatic --noinput 15 | 16 | gunicorn server.wsgi --bind 0.0.0.0:8000 --workers 4 --threads 4 17 | #./manage.py runserver 0.0.0.0:8003 -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /backend/server/server/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for server 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', 'server.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /backend/server/server/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for server 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', 'server.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /frontend/src/Reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { connectRouter } from "connected-react-router"; 3 | 4 | import { loginReducer } from "./components/login/LoginReducer"; 5 | import { notesReducer } from "./components/notes/NotesReducer"; 6 | 7 | const createRootReducer = history => 8 | combineReducers({ 9 | router: connectRouter(history), 10 | auth: loginReducer, 11 | notes: notesReducer 12 | }); 13 | 14 | export default createRootReducer; 15 | -------------------------------------------------------------------------------- /docker/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | # The first stage 3 | # Build React static files 4 | FROM node:13.12.0-alpine as build 5 | 6 | WORKDIR /app/frontend 7 | COPY ./frontend/package.json ./ 8 | COPY ./frontend/package-lock.json ./ 9 | RUN npm ci --silent 10 | COPY ./frontend/ ./ 11 | RUN npm run build 12 | 13 | # The second stage 14 | # Copy React static files and start nginx 15 | FROM nginx:stable-alpine 16 | COPY --from=build /app/frontend/build /usr/share/nginx/html 17 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /backend/server/apps/notes/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from apps.notes.models import Note 3 | 4 | class NoteSerializer(serializers.ModelSerializer): 5 | 6 | class Meta: 7 | model = Note 8 | read_only_fields = ( 9 | "id", 10 | "created_at", 11 | "created_by", 12 | ) 13 | fields = ( 14 | "id", 15 | "created_at", 16 | "created_by", 17 | "content" 18 | ) -------------------------------------------------------------------------------- /backend/server/apps/notes/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | from apps.notes.models import Note 3 | from apps.notes.serializers import NoteSerializer 4 | 5 | class NoteViewSet(viewsets.ModelViewSet): 6 | 7 | serializer_class = NoteSerializer 8 | queryset = Note.objects.all() 9 | 10 | def perform_create(self, serializer): 11 | serializer.save(created_by=self.request.user) 12 | 13 | def get_queryset(self): 14 | return self.queryset.filter(created_by=self.request.user) -------------------------------------------------------------------------------- /frontend/src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { Container } from "react-bootstrap"; 4 | 5 | class Home extends Component { 6 | render() { 7 | return ( 8 | 9 |

Home

10 |

11 | Login 12 |

13 |

14 | Sign up 15 |

16 |

17 | Dashboard 18 |

19 |
20 | ); 21 | } 22 | } 23 | 24 | export default Home; 25 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "bootstrap/dist/css/bootstrap.css"; 4 | import "react-toastify/dist/ReactToastify.css"; 5 | import "./index.css"; 6 | import App from "./App"; 7 | import reportWebVitals from "./reportWebVitals"; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById("root") 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /frontend/src/components/login/LoginReducer.js: -------------------------------------------------------------------------------- 1 | import { SET_TOKEN, SET_CURRENT_USER, UNSET_CURRENT_USER } from "./LoginTypes"; 2 | 3 | const initialState = { 4 | isAuthenticated: false, 5 | user: {}, 6 | token: "" 7 | }; 8 | 9 | export const loginReducer = (state = initialState, action) => { 10 | switch (action.type) { 11 | case SET_TOKEN: 12 | return { 13 | ...state, 14 | isAuthenticated: true, 15 | token: action.payload 16 | }; 17 | case SET_CURRENT_USER: 18 | return { 19 | ...state, 20 | user: action.payload 21 | }; 22 | case UNSET_CURRENT_USER: 23 | return initialState; 24 | default: 25 | return state; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /backend/server/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', 'server.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 | -------------------------------------------------------------------------------- /docker/nginx/development/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name _; 4 | server_tokens off; 5 | 6 | client_max_body_size 20M; 7 | 8 | location / { 9 | root /usr/share/nginx/html; 10 | index index.html index.htm; 11 | try_files $uri $uri/ /index.html; 12 | } 13 | 14 | location /api { 15 | try_files $uri @proxy_api; 16 | } 17 | location /admin { 18 | try_files $uri @proxy_api; 19 | } 20 | 21 | location @proxy_api { 22 | proxy_set_header Host $http_host; 23 | proxy_redirect off; 24 | proxy_pass http://backend:8000; 25 | } 26 | 27 | location /django_static/ { 28 | autoindex on; 29 | alias /app/backend/server/django_static/; 30 | } 31 | } -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | nginx: 5 | restart: unless-stopped 6 | build: 7 | context: . 8 | dockerfile: ./docker/nginx/Dockerfile 9 | ports: 10 | - 80:80 11 | volumes: 12 | - static_volume:/app/backend/server/django_static 13 | - ./docker/nginx/development:/etc/nginx/conf.d 14 | depends_on: 15 | - backend 16 | backend: 17 | restart: unless-stopped 18 | build: 19 | context: . 20 | dockerfile: ./docker/backend/Dockerfile 21 | volumes: 22 | 23 | entrypoint: /app/docker/backend/wsgi-entrypoint.sh 24 | volumes: 25 | - .:/app 26 | - static_volume:/app/backend/server/django_static 27 | expose: 28 | - 8000 29 | 30 | volumes: 31 | static_volume: {} -------------------------------------------------------------------------------- /backend/server/apps/accounts/serializers.py: -------------------------------------------------------------------------------- 1 | 2 | from django.contrib.auth import authenticate, get_user_model 3 | from djoser.conf import settings 4 | from djoser.serializers import TokenCreateSerializer 5 | 6 | User = get_user_model() 7 | 8 | class CustomTokenCreateSerializer(TokenCreateSerializer): 9 | 10 | def validate(self, attrs): 11 | password = attrs.get("password") 12 | params = {settings.LOGIN_FIELD: attrs.get(settings.LOGIN_FIELD)} 13 | self.user = authenticate( 14 | request=self.context.get("request"), **params, password=password 15 | ) 16 | if not self.user: 17 | self.user = User.objects.filter(**params).first() 18 | if self.user and not self.user.check_password(password): 19 | self.fail("invalid_credentials") 20 | if self.user: # and self.user.is_active: 21 | return attrs 22 | self.fail("invalid_credentials") -------------------------------------------------------------------------------- /backend/server/apps/notes/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-09 10:42 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Note', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('created_at', models.DateTimeField(auto_now_add=True)), 22 | ('content', models.TextField(blank=True)), 23 | ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /frontend/src/utils/Utils.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { toast } from "react-toastify"; 3 | 4 | export const setAxiosAuthToken = token => { 5 | if (typeof token !== "undefined" && token) { 6 | // Apply for every request 7 | axios.defaults.headers.common["Authorization"] = "Token " + token; 8 | } else { 9 | // Delete auth header 10 | delete axios.defaults.headers.common["Authorization"]; 11 | } 12 | }; 13 | 14 | export const toastOnError = error => { 15 | if (error.response) { 16 | // known error 17 | toast.error(JSON.stringify(error.response.data)); 18 | } else if (error.message) { 19 | toast.error(JSON.stringify(error.message)); 20 | } else { 21 | toast.error(JSON.stringify(error)); 22 | } 23 | }; 24 | 25 | export const isEmpty = value => 26 | value === undefined || 27 | value === null || 28 | (typeof value === "object" && Object.keys(value).length === 0) || 29 | (typeof value === "string" && value.trim().length === 0); 30 | -------------------------------------------------------------------------------- /frontend/src/components/notes/NotesReducer.js: -------------------------------------------------------------------------------- 1 | import { GET_NOTES, ADD_NOTE, UPDATE_NOTE, DELETE_NOTE } from "./NotesTypes"; 2 | 3 | const initialState = { 4 | notes: [] 5 | }; 6 | 7 | export const notesReducer = (state = initialState, action) => { 8 | switch (action.type) { 9 | case GET_NOTES: 10 | return { 11 | ...state, 12 | notes: action.payload 13 | }; 14 | case ADD_NOTE: 15 | return { 16 | ...state, 17 | notes: [...state.notes, action.payload] 18 | }; 19 | case DELETE_NOTE: 20 | return { 21 | ...state, 22 | notes: state.notes.filter((item, index) => item.id !== action.payload) 23 | }; 24 | case UPDATE_NOTE: 25 | const updatedNotes = state.notes.map(item => { 26 | if (item.id === action.payload.id) { 27 | return { ...item, ...action.payload }; 28 | } 29 | return item; 30 | }); 31 | return { 32 | ...state, 33 | notes: updatedNotes 34 | }; 35 | default: 36 | return state; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 SaaSitive 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/components/notes/NotesList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { withRouter } from "react-router-dom"; 5 | import { getNotes } from "./NotesActions"; 6 | 7 | import Note from "./Note"; 8 | 9 | class NotesList extends Component { 10 | componentDidMount() { 11 | this.props.getNotes(); 12 | } 13 | 14 | render() { 15 | const { notes } = this.props.notes; 16 | 17 | if (notes.length === 0) { 18 | return

Please add your first note

; 19 | } 20 | 21 | let items = notes.map(note => { 22 | return ; 23 | }); 24 | 25 | return ( 26 |
27 |

Notes

28 | {items} 29 |
{/* a */} 30 |
31 | ); 32 | } 33 | } 34 | 35 | NotesList.propTypes = { 36 | getNotes: PropTypes.func.isRequired, 37 | notes: PropTypes.object.isRequired 38 | }; 39 | 40 | const mapStateToProps = state => ({ 41 | notes: state.notes 42 | }); 43 | 44 | export default connect(mapStateToProps, { 45 | getNotes 46 | })(withRouter(NotesList)); 47 | -------------------------------------------------------------------------------- /frontend/src/Root.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import thunk from "redux-thunk"; 3 | import { Provider } from "react-redux"; 4 | import { createBrowserHistory } from "history"; 5 | import { applyMiddleware, createStore } from "redux"; 6 | import { routerMiddleware, ConnectedRouter } from "connected-react-router"; 7 | 8 | import rootReducer from "./Reducer"; 9 | import { setCurrentUser, setToken } from "./components/login/LoginActions"; 10 | import { isEmpty } from "./utils/Utils"; 11 | 12 | const Root = ({ children, initialState = {} }) => { 13 | const history = createBrowserHistory(); 14 | const middleware = [thunk, routerMiddleware(history)]; 15 | 16 | const store = createStore( 17 | rootReducer(history), 18 | initialState, 19 | applyMiddleware(...middleware) 20 | ); 21 | 22 | if (!isEmpty(localStorage.getItem("token"))) { 23 | store.dispatch(setToken(localStorage.getItem("token"))); 24 | } 25 | if (!isEmpty(localStorage.getItem("user"))) { 26 | const user = JSON.parse(localStorage.getItem("user")); 27 | store.dispatch(setCurrentUser(user, "")); 28 | } 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | }; 36 | 37 | export default Root; 38 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "axios": "^0.21.0", 10 | "bootstrap": "^4.5.3", 11 | "connected-react-router": "^6.8.0", 12 | "react": "^17.0.1", 13 | "react-bootstrap": "^1.4.0", 14 | "react-dom": "^17.0.1", 15 | "react-redux": "^7.2.2", 16 | "react-router-dom": "^5.2.0", 17 | "react-scripts": "4.0.0", 18 | "react-toastify": "^6.0.9", 19 | "redux": "^4.0.5", 20 | "redux-thunk": "^2.3.0", 21 | "web-vitals": "^0.2.4" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | nginx: 5 | restart: unless-stopped 6 | build: 7 | context: . 8 | dockerfile: ./docker/nginx/Dockerfile 9 | ports: 10 | - 80:80 11 | - 443:443 12 | volumes: 13 | - static_volume:/app/backend/server/django_static 14 | - ./docker/nginx/production:/etc/nginx/conf.d 15 | - ./docker/nginx/certbot/conf:/etc/letsencrypt 16 | - ./docker/nginx/certbot/www:/var/www/certbot 17 | depends_on: 18 | - backend 19 | certbot: 20 | image: certbot/certbot 21 | restart: unless-stopped 22 | volumes: 23 | - ./docker/nginx/certbot/conf:/etc/letsencrypt 24 | - ./docker/nginx/certbot/www:/var/www/certbot 25 | entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" 26 | backend: 27 | restart: unless-stopped 28 | build: 29 | context: . 30 | dockerfile: ./docker/backend/Dockerfile 31 | entrypoint: /app/docker/backend/wsgi-entrypoint.sh 32 | volumes: 33 | - static_volume:/app/backend/server/django_static 34 | expose: 35 | - 8000 36 | 37 | volumes: 38 | static_volume: {} -------------------------------------------------------------------------------- /frontend/src/utils/RequireAuth.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { push } from "connected-react-router"; 4 | import PropTypes from "prop-types"; 5 | 6 | export default function requireAuth(Component) { 7 | class AuthenticatedComponent extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.checkAuth(); 11 | } 12 | 13 | componentDidUpdate(prevProps, prevState) { 14 | this.checkAuth(); 15 | } 16 | 17 | checkAuth() { 18 | if (!this.props.isAuthenticated) { 19 | const redirectAfterLogin = this.props.location.pathname; 20 | this.props.dispatch(push(`/login?next=${redirectAfterLogin}`)); 21 | } 22 | } 23 | 24 | render() { 25 | return ( 26 |
27 | {this.props.isAuthenticated === true ? ( 28 | 29 | ) : null} 30 |
31 | ); 32 | } 33 | } 34 | AuthenticatedComponent.propTypes = { 35 | isAuthenticated: PropTypes.bool.isRequired, 36 | location: PropTypes.shape({ 37 | pathname: PropTypes.string.isRequired 38 | }).isRequired, 39 | dispatch: PropTypes.func.isRequired 40 | }; 41 | 42 | const mapStateToProps = state => { 43 | return { 44 | isAuthenticated: state.auth.isAuthenticated, 45 | token: state.auth.token 46 | }; 47 | }; 48 | 49 | return connect(mapStateToProps)(AuthenticatedComponent); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/components/notes/NotesActions.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { toastOnError } from "../../utils/Utils"; 3 | import { GET_NOTES, ADD_NOTE, DELETE_NOTE, UPDATE_NOTE } from "./NotesTypes"; 4 | 5 | export const getNotes = () => dispatch => { 6 | axios 7 | .get("/api/v1/notes/") 8 | .then(response => { 9 | dispatch({ 10 | type: GET_NOTES, 11 | payload: response.data 12 | }); 13 | }) 14 | .catch(error => { 15 | toastOnError(error); 16 | }); 17 | }; 18 | 19 | export const addNote = note => dispatch => { 20 | axios 21 | .post("/api/v1/notes/", note) 22 | .then(response => { 23 | dispatch({ 24 | type: ADD_NOTE, 25 | payload: response.data 26 | }); 27 | }) 28 | .catch(error => { 29 | toastOnError(error); 30 | }); 31 | }; 32 | 33 | export const deleteNote = id => dispatch => { 34 | axios 35 | .delete(`/api/v1/notes/${id}/`) 36 | .then(response => { 37 | dispatch({ 38 | type: DELETE_NOTE, 39 | payload: id 40 | }); 41 | }) 42 | .catch(error => { 43 | toastOnError(error); 44 | }); 45 | }; 46 | 47 | export const updateNote = (id, note) => dispatch => { 48 | axios 49 | .patch(`/api/v1/notes/${id}/`, note) 50 | .then(response => { 51 | dispatch({ 52 | type: UPDATE_NOTE, 53 | payload: response.data 54 | }); 55 | }) 56 | .catch(error => { 57 | toastOnError(error); 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { withRouter } from "react-router-dom"; 5 | 6 | import { Container, Navbar, Nav } from "react-bootstrap"; 7 | import { logout } from "../login/LoginActions"; 8 | 9 | import NotesList from "../notes/NotesList"; 10 | import AddNote from "../notes/AddNote"; 11 | 12 | class Dashboard extends Component { 13 | onLogout = () => { 14 | this.props.logout(); 15 | }; 16 | 17 | render() { 18 | const { user } = this.props.auth; 19 | return ( 20 |
21 | 22 | Home 23 | 24 | 25 | 26 | User: {user.username} 27 | 28 | Logout 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | ); 37 | } 38 | } 39 | 40 | Dashboard.propTypes = { 41 | logout: PropTypes.func.isRequired, 42 | auth: PropTypes.object.isRequired 43 | }; 44 | 45 | const mapStateToProps = state => ({ 46 | auth: state.auth 47 | }); 48 | 49 | export default connect(mapStateToProps, { 50 | logout 51 | })(withRouter(Dashboard)); 52 | -------------------------------------------------------------------------------- /docker/nginx/production/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name boilerplate.saasitive.com; 4 | server_tokens off; 5 | 6 | location /.well-known/acme-challenge/ { 7 | root /var/www/certbot; 8 | } 9 | 10 | location / { 11 | return 301 https://$host$request_uri; 12 | } 13 | } 14 | 15 | server { 16 | listen 443 ssl; 17 | server_name boilerplate.saasitive.com; 18 | server_tokens off; 19 | 20 | ssl_certificate /etc/letsencrypt/live/boilerplate.saasitive.com/fullchain.pem; 21 | ssl_certificate_key /etc/letsencrypt/live/boilerplate.saasitive.com/privkey.pem; 22 | include /etc/letsencrypt/options-ssl-nginx.conf; 23 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 24 | 25 | client_max_body_size 20M; 26 | 27 | location / { 28 | root /usr/share/nginx/html; 29 | index index.html index.htm; 30 | try_files $uri $uri/ /index.html; 31 | } 32 | 33 | location /api { 34 | try_files $uri @proxy_api; 35 | } 36 | location /admin { 37 | try_files $uri @proxy_api; 38 | } 39 | 40 | location @proxy_api { 41 | proxy_set_header X-Forwarded-Proto https; 42 | proxy_set_header X-Url-Scheme $scheme; 43 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 44 | proxy_set_header Host $http_host; 45 | proxy_redirect off; 46 | proxy_pass http://backend:8000; 47 | } 48 | 49 | location /django_static/ { 50 | autoindex on; 51 | alias /app/backend/server/django_static/; 52 | } 53 | } -------------------------------------------------------------------------------- /frontend/src/components/notes/AddNote.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { withRouter } from "react-router-dom"; 5 | import { Button, Form } from "react-bootstrap"; 6 | import { addNote } from "./NotesActions"; 7 | 8 | class AddNote extends Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | content: "" 13 | }; 14 | } 15 | onChange = e => { 16 | this.setState({ [e.target.name]: e.target.value }); 17 | }; 18 | 19 | onAddClick = () => { 20 | const note = { 21 | content: this.state.content 22 | }; 23 | this.props.addNote(note); 24 | }; 25 | 26 | render() { 27 | return ( 28 |
29 |

Add new note

30 |
31 | 32 | Note 33 | 41 | 42 |
43 | 46 |
47 | ); 48 | } 49 | } 50 | 51 | AddNote.propTypes = { 52 | addNote: PropTypes.func.isRequired 53 | }; 54 | 55 | const mapStateToProps = state => ({}); 56 | 57 | export default connect(mapStateToProps, { addNote })(withRouter(AddNote)); 58 | -------------------------------------------------------------------------------- /frontend/src/components/notes/Note.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { withRouter } from "react-router-dom"; 5 | import { deleteNote, updateNote } from "./NotesActions"; 6 | import { Button } from "react-bootstrap"; 7 | 8 | class Note extends Component { 9 | onDeleteClick = () => { 10 | const { note } = this.props; 11 | this.props.deleteNote(note.id); 12 | }; 13 | onUpperCaseClick = () => { 14 | const { note } = this.props; 15 | this.props.updateNote(note.id, { 16 | content: note.content.toUpperCase() 17 | }); 18 | }; 19 | onLowerCaseClick = () => { 20 | const { note } = this.props; 21 | this.props.updateNote(note.id, { 22 | content: note.content.toLowerCase() 23 | }); 24 | }; 25 | render() { 26 | const { note } = this.props; 27 | return ( 28 |
29 |
30 |

31 | (id:{note.id}) {note.content} 32 |

33 | {" "} 36 | {" "} 39 | 42 |
43 | ); 44 | } 45 | } 46 | 47 | Note.propTypes = { 48 | note: PropTypes.object.isRequired 49 | }; 50 | const mapStateToProps = state => ({}); 51 | 52 | export default connect(mapStateToProps, { deleteNote, updateNote })( 53 | withRouter(Note) 54 | ); 55 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Root from "./Root"; 3 | import { Route, Switch } from "react-router-dom"; 4 | import { ToastContainer } from "react-toastify"; 5 | import Home from "./components/Home"; 6 | import Signup from "./components/account/Signup"; 7 | import Login from "./components/login/Login"; 8 | import ResendActivation from "./components/account/ResendActivation"; 9 | import ActivateAccount from "./components/account/ActivateAccount"; 10 | import ResetPassword from "./components/account/ResetPassword"; 11 | import ResetPasswordConfirm from "./components/account/ResetPasswordConfirm"; 12 | 13 | import Dashboard from "./components/dashboard/Dashboard"; 14 | 15 | import requireAuth from "./utils/RequireAuth"; 16 | 17 | import axios from "axios"; 18 | 19 | if (window.location.origin === "http://localhost:3000") { 20 | axios.defaults.baseURL = "http://127.0.0.1:8000"; 21 | } else { 22 | axios.defaults.baseURL = window.location.origin; 23 | } 24 | 25 | class App extends Component { 26 | render() { 27 | return ( 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 |
46 | ); 47 | } 48 | } 49 | 50 | export default App; 51 | -------------------------------------------------------------------------------- /frontend/src/components/account/ActivateAccount.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter, Link } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | import axios from "axios"; 5 | import { Alert, Container, Row, Col } from "react-bootstrap"; 6 | 7 | class ActivateAccount extends Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | status: "" 12 | }; 13 | } 14 | onChange = e => { 15 | this.setState({ [e.target.name]: e.target.value }); 16 | }; 17 | 18 | componentDidMount() { 19 | const { uid, token } = this.props.match.params; 20 | 21 | axios 22 | .post("/api/v1/users/activation/", { uid, token }) 23 | .then(response => { 24 | this.setState({ status: "success" }); 25 | }) 26 | .catch(error => { 27 | this.setState({ status: "error" }); 28 | }); 29 | } 30 | 31 | render() { 32 | let errorAlert = ( 33 | 34 | Problem during account activation 35 | Please try again or contact service support for further help. 36 | 37 | ); 38 | 39 | let successAlert = ( 40 | 41 | Your account has been activated 42 |

43 | You can Login to your account. 44 |

45 |
46 | ); 47 | 48 | let alert = ""; 49 | if (this.state.status === "error") { 50 | alert = errorAlert; 51 | } else if (this.state.status === "success") { 52 | alert = successAlert; 53 | } 54 | 55 | return ( 56 | 57 | 58 | 59 |

Activate Account

60 | {alert} 61 | 62 |
63 |
64 | ); 65 | } 66 | } 67 | 68 | ActivateAccount.propTypes = {}; 69 | 70 | const mapStateToProps = state => ({}); 71 | 72 | export default connect(mapStateToProps)(withRouter(ActivateAccount)); 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .certbot 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | -------------------------------------------------------------------------------- /frontend/src/components/login/Login.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | import PropTypes from "prop-types"; 5 | import { Link } from "react-router-dom"; 6 | import { Container, Button, Row, Col, Form } from "react-bootstrap"; 7 | 8 | import { login } from "./LoginActions.js"; 9 | 10 | class Login extends Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | email: "", 15 | password: "" 16 | }; 17 | } 18 | onChange = e => { 19 | this.setState({ [e.target.name]: e.target.value }); 20 | }; 21 | 22 | onLoginClick = () => { 23 | const userData = { 24 | email: this.state.email, 25 | password: this.state.password 26 | }; 27 | this.props.login(userData, "/dashboard"); 28 | }; 29 | render() { 30 | return ( 31 | 32 | 33 | 34 |

Login

35 |
36 | 37 | Your Email 38 | 45 | 46 | 47 | 48 | Your password 49 | 56 | 57 |
58 | 61 |

62 | Don't have account? Signup 63 |

64 |

65 | Forget password?{" "} 66 | Reset Password 67 |

68 | 69 |
70 |
71 | ); 72 | } 73 | } 74 | 75 | //export default Login; 76 | Login.propTypes = { 77 | login: PropTypes.func.isRequired, 78 | auth: PropTypes.object.isRequired 79 | }; 80 | 81 | const mapStateToProps = state => ({ 82 | auth: state.auth 83 | }); 84 | 85 | export default connect(mapStateToProps, { 86 | login 87 | })(withRouter(Login)); 88 | -------------------------------------------------------------------------------- /frontend/src/components/login/LoginActions.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { push } from "connected-react-router"; 3 | import { toast } from "react-toastify"; 4 | import { SET_TOKEN, SET_CURRENT_USER, UNSET_CURRENT_USER } from "./LoginTypes"; 5 | import { setAxiosAuthToken, toastOnError } from "../../utils/Utils"; 6 | 7 | export const login = (userData, redirectTo) => dispatch => { 8 | axios 9 | .post("/api/v1/token/login/", userData) 10 | .then(response => { 11 | const { auth_token } = response.data; 12 | setAxiosAuthToken(auth_token); 13 | dispatch(setToken(auth_token)); 14 | dispatch(getCurrentUser(redirectTo)); 15 | }) 16 | .catch(error => { 17 | dispatch(unsetCurrentUser()); 18 | toastOnError(error); 19 | }); 20 | }; 21 | 22 | export const getCurrentUser = redirectTo => dispatch => { 23 | axios 24 | .get("/api/v1/users/me/") 25 | .then(response => { 26 | const user = { 27 | username: response.data.username, 28 | email: response.data.email 29 | }; 30 | dispatch(setCurrentUser(user, redirectTo)); 31 | }) 32 | .catch(error => { 33 | dispatch(unsetCurrentUser()); 34 | if (error.response) { 35 | if ( 36 | error.response.status === 401 && 37 | error.response.hasOwnProperty("data") && 38 | error.response.data.hasOwnProperty("detail") && 39 | error.response.data["detail"] === "User inactive or deleted." 40 | ) { 41 | dispatch(push("/resend_activation")); 42 | } 43 | } else { 44 | toastOnError(error); 45 | } 46 | }); 47 | }; 48 | 49 | export const setCurrentUser = (user, redirectTo) => dispatch => { 50 | localStorage.setItem("user", JSON.stringify(user)); 51 | dispatch({ 52 | type: SET_CURRENT_USER, 53 | payload: user 54 | }); 55 | 56 | if (redirectTo !== "") { 57 | dispatch(push(redirectTo)); 58 | } 59 | }; 60 | 61 | export const setToken = token => dispatch => { 62 | setAxiosAuthToken(token); 63 | localStorage.setItem("token", token); 64 | dispatch({ 65 | type: SET_TOKEN, 66 | payload: token 67 | }); 68 | }; 69 | 70 | export const unsetCurrentUser = () => dispatch => { 71 | setAxiosAuthToken(""); 72 | localStorage.removeItem("token"); 73 | localStorage.removeItem("user"); 74 | dispatch({ 75 | type: UNSET_CURRENT_USER 76 | }); 77 | }; 78 | 79 | export const logout = () => dispatch => { 80 | axios 81 | .post("/api/v1/token/logout/") 82 | .then(response => { 83 | dispatch(unsetCurrentUser()); 84 | dispatch(push("/")); 85 | toast.success("Logout successful."); 86 | }) 87 | .catch(error => { 88 | dispatch(unsetCurrentUser()); 89 | toastOnError(error); 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /init-letsencrypt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if ! [ -x "$(command -v docker-compose)" ]; then 4 | echo 'Error: docker-compose is not installed.' >&2 5 | exit 1 6 | fi 7 | 8 | domains=(boilerplate.saasitive.com www.boilerplate.saasitive.com) 9 | rsa_key_size=4096 10 | data_path="./docker/nginx/certbot" 11 | email="" # Adding a valid address is strongly recommended 12 | staging=1 # Set to 1 if you're testing your setup to avoid hitting request limits 13 | 14 | if [ -d "$data_path" ]; then 15 | read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision 16 | if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then 17 | exit 18 | fi 19 | fi 20 | 21 | 22 | if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then 23 | echo "### Downloading recommended TLS parameters ..." 24 | mkdir -p "$data_path/conf" 25 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" 26 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" 27 | echo 28 | fi 29 | 30 | echo "### Creating dummy certificate for $domains ..." 31 | path="/etc/letsencrypt/live/$domains" 32 | mkdir -p "$data_path/conf/live/$domains" 33 | docker-compose run --rm --entrypoint "\ 34 | openssl req -x509 -nodes -newkey rsa:1024 -days 1\ 35 | -keyout '$path/privkey.pem' \ 36 | -out '$path/fullchain.pem' \ 37 | -subj '/CN=localhost'" certbot 38 | echo 39 | 40 | 41 | echo "### Starting nginx ..." 42 | docker-compose up --force-recreate -d nginx 43 | echo 44 | 45 | echo "### Deleting dummy certificate for $domains ..." 46 | docker-compose run --rm --entrypoint "\ 47 | rm -Rf /etc/letsencrypt/live/$domains && \ 48 | rm -Rf /etc/letsencrypt/archive/$domains && \ 49 | rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot 50 | echo 51 | 52 | 53 | echo "### Requesting Let's Encrypt certificate for $domains ..." 54 | #Join $domains to -d args 55 | domain_args="" 56 | for domain in "${domains[@]}"; do 57 | domain_args="$domain_args -d $domain" 58 | done 59 | 60 | # Select appropriate email arg 61 | case "$email" in 62 | "") email_arg="--register-unsafely-without-email" ;; 63 | *) email_arg="--email $email" ;; 64 | esac 65 | 66 | # Enable staging mode if needed 67 | if [ $staging != "0" ]; then staging_arg="--staging"; fi 68 | 69 | docker-compose run --rm --entrypoint "\ 70 | certbot certonly --webroot -w /var/www/certbot \ 71 | $staging_arg \ 72 | $email_arg \ 73 | $domain_args \ 74 | --rsa-key-size $rsa_key_size \ 75 | --agree-tos \ 76 | --force-renewal" certbot 77 | echo 78 | 79 | echo "### Reloading nginx ..." 80 | docker-compose exec nginx nginx -s reload -------------------------------------------------------------------------------- /frontend/src/components/account/ResetPassword.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | import axios from "axios"; 5 | import { 6 | Alert, 7 | Container, 8 | Button, 9 | Row, 10 | Col, 11 | Form, 12 | FormControl 13 | } from "react-bootstrap"; 14 | 15 | class ResetPassword extends Component { 16 | constructor(props) { 17 | super(props); 18 | this.state = { 19 | email: "", 20 | emailError: "", 21 | status: "" 22 | }; 23 | } 24 | onChange = e => { 25 | this.setState({ [e.target.name]: e.target.value }); 26 | }; 27 | 28 | onSendClick = () => { 29 | this.setState({ emailError: "" }); 30 | this.setState({ status: "" }); 31 | 32 | const userData = { 33 | email: this.state.email 34 | }; 35 | axios 36 | .post("/api/v1/users/reset_password/", userData) 37 | .then(response => { 38 | this.setState({ status: "success" }); 39 | }) 40 | .catch(error => { 41 | if (error.response && error.response.data.hasOwnProperty("email")) { 42 | this.setState({ emailError: error.response.data["email"] }); 43 | } else { 44 | this.setState({ status: "error" }); 45 | } 46 | }); 47 | }; 48 | render() { 49 | let errorAlert = ( 50 | 51 | Problem during reset password email send 52 | Please try again or contact service support for further help. 53 | 54 | ); 55 | 56 | let successAlert = ( 57 | 58 | Email sent 59 |

60 | We send you an email with reset password link. Please check your 61 | email. 62 |

63 |

64 | Please try again or contact us if you do not receive it within a few 65 | minutes. 66 |

67 |
68 | ); 69 | 70 | let form = ( 71 |
72 |
73 | 74 | Your Email 75 | 83 | 84 | {this.state.emailError} 85 | 86 | 87 |
88 | 91 |
92 | ); 93 | 94 | let alert = ""; 95 | if (this.state.status === "error") { 96 | alert = errorAlert; 97 | } else if (this.state.status === "success") { 98 | alert = successAlert; 99 | } 100 | 101 | return ( 102 | 103 | 104 | 105 |

Reset Password

106 | {alert} 107 | {this.state.status !== "success" && form} 108 | 109 |
110 |
111 | ); 112 | } 113 | } 114 | 115 | ResetPassword.propTypes = {}; 116 | 117 | const mapStateToProps = state => ({}); 118 | 119 | export default connect(mapStateToProps)(withRouter(ResetPassword)); 120 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `yarn build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /frontend/src/components/account/ResendActivation.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | import axios from "axios"; 5 | import { 6 | Alert, 7 | Container, 8 | Button, 9 | Row, 10 | Col, 11 | Form, 12 | FormControl 13 | } from "react-bootstrap"; 14 | 15 | class ResendActivation extends Component { 16 | constructor(props) { 17 | super(props); 18 | this.state = { 19 | email: "", 20 | emailError: "", 21 | status: "" 22 | }; 23 | } 24 | onChange = e => { 25 | this.setState({ [e.target.name]: e.target.value }); 26 | }; 27 | 28 | onResendClick = () => { 29 | this.setState({ emailError: "" }); 30 | this.setState({ status: "" }); 31 | 32 | const userData = { 33 | email: this.state.email 34 | }; 35 | 36 | axios 37 | .post("/api/v1/users/resend_activation/", userData) 38 | .then(response => { 39 | this.setState({ status: "success" }); 40 | }) 41 | .catch(error => { 42 | if (error.response && error.response.data.hasOwnProperty("email")) { 43 | this.setState({ emailError: error.response.data["email"] }); 44 | } else { 45 | this.setState({ status: "error" }); 46 | } 47 | }); 48 | }; 49 | render() { 50 | let errorAlert = ( 51 | 52 | Problem during activation email send 53 | Please try again or contact service support for further help. 54 | 55 | ); 56 | 57 | let successAlert = ( 58 | 59 | Email sent 60 |

61 | We send you an email with activation link. Please check your email. 62 |

63 |

64 | Please try again or contact us if you do not receive it within a few 65 | minutes. 66 |

67 |
68 | ); 69 | 70 | let form = ( 71 |
72 |
73 | 74 | 75 | Your account is inactive. Please activate account by sending the 76 | email with activation link. 77 | 78 | 86 | 87 | {this.state.emailError} 88 | 89 | 90 |
91 | 94 |
95 | ); 96 | 97 | let alert = ""; 98 | if (this.state.status === "error") { 99 | alert = errorAlert; 100 | } else if (this.state.status === "success") { 101 | alert = successAlert; 102 | } 103 | 104 | return ( 105 | 106 | 107 | 108 |

Resend Activation Email

109 | {alert} 110 | {this.state.status !== "success" && form} 111 | 112 |
113 |
114 | ); 115 | } 116 | } 117 | 118 | ResendActivation.propTypes = {}; 119 | 120 | const mapStateToProps = state => ({}); 121 | 122 | export default connect(mapStateToProps)(withRouter(ResendActivation)); 123 | -------------------------------------------------------------------------------- /frontend/src/components/account/ResetPasswordConfirm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter, Link } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | import axios from "axios"; 5 | import { 6 | Alert, 7 | Container, 8 | Button, 9 | Row, 10 | Col, 11 | Form, 12 | FormControl 13 | } from "react-bootstrap"; 14 | 15 | class ResetPasswordConfirm extends Component { 16 | constructor(props) { 17 | super(props); 18 | this.state = { 19 | new_password: "", 20 | passwordError: "", 21 | status: "" 22 | }; 23 | } 24 | onChange = e => { 25 | this.setState({ [e.target.name]: e.target.value }); 26 | }; 27 | 28 | onSaveClick = () => { 29 | this.setState({ passwordError: "" }); 30 | this.setState({ status: "" }); 31 | 32 | const { uid, token } = this.props.match.params; 33 | const data = { 34 | uid: uid, 35 | token: token, 36 | new_password: this.state.new_password 37 | }; 38 | axios 39 | .post("/api/v1/users/reset_password_confirm/", data) 40 | .then(response => { 41 | this.setState({ status: "success" }); 42 | }) 43 | .catch(error => { 44 | if ( 45 | error.response && 46 | error.response.data.hasOwnProperty("new_password") 47 | ) { 48 | this.setState({ passwordError: error.response.data["new_password"] }); 49 | } else { 50 | this.setState({ status: "error" }); 51 | } 52 | }); 53 | }; 54 | 55 | render() { 56 | const errorAlert = ( 57 | 58 | Problem during new password set 59 |

60 | Please try reset password again 61 | or contact service support for further help. 62 |

63 |
64 | ); 65 | 66 | const successAlert = ( 67 | 68 | New Password Set 69 |

70 | You can Login to your account with new 71 | password. 72 |

73 |
74 | ); 75 | 76 | const form = ( 77 |
78 |
79 | 80 | Your New Password 81 | 89 | 90 | {this.state.passwordError} 91 | 92 | 93 |
94 | 97 |
98 | ); 99 | 100 | let alert = ""; 101 | if (this.state.status === "error") { 102 | alert = errorAlert; 103 | } else if (this.state.status === "success") { 104 | alert = successAlert; 105 | } 106 | 107 | return ( 108 | 109 | 110 | 111 |

Set a New Password

112 | {alert} 113 | {this.state.status !== "success" && form} 114 | 115 |
116 |
117 | ); 118 | } 119 | } 120 | 121 | ResetPasswordConfirm.propTypes = {}; 122 | 123 | const mapStateToProps = state => ({}); 124 | 125 | export default connect(mapStateToProps)(withRouter(ResetPasswordConfirm)); 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DIY Django and React Boilerplate for SaaS 2 | 3 | It is a do-it-yourself Django + React Boilerplate for starting your SaaS application. In existing boilerplates for Django+React (or just for Django), there are very often too many features provided (do you really need traffik for MVP?). So before starting, you need to remove a lot of functionality that you don't need or simply don't understand. 4 | 5 | I decided to create a simple Django+React boilerplate with step-by-step instructions on how to build it. During building, you can decide what you need or not. And for sure, you learn a lot about Django and React. I hope it will provide a good and quick starting point for creating SaaS applications. I want to create real SaaS applications based on this boilerplate with step-by-step tutorials on how they were created. They will be available on [SaaSitive](https://saasitive.com) website. 6 | 7 | ## Features (already implemented or planned) 8 | 9 | - Backend with Django Rest Framework 10 | - Frontend with React 11 | - Bootstrap for styling 12 | - Deployment with docker-compose on a single VPS 13 | - SSL certificate from Let's encrypt 14 | - PostgreSQL database (not yet configured) 15 | - Authentication with DRF `authtoken` and [Djoser](https://djoser.readthedocs.io/en/latest/) 16 | - AWS SES for Email sending (not yet implemented) 17 | - python-decuple for secrets 18 | - Payments with Stripe (not yet implemented) 19 | - Step-by-step instructions on how to deploy and how to update the application 20 | 21 | 22 | ## Step-by-step instructions: 23 | 1. [Starting SaaS with Django and React](https://saasitive.com/tutorial/django-react-boilerplate-saas/) (tag [v1](https://github.com/saasitive/django-react-boilerplate/tree/v1)) 24 | 2. [React Routing and Components for Signup and Login](https://saasitive.com/tutorial/react-routing-components-signup-login/) (tag [v2](https://github.com/saasitive/django-react-boilerplate/tree/v2)) 25 | 3. [Token Based Authentication with Django Rest Framework and Djoser](https://saasitive.com/tutorial/token-based-authentication-django-rest-framework-djoser/) (tag [v3](https://github.com/saasitive/django-react-boilerplate/tree/v3)) 26 | 4. [React Token Based Authentication to Django REST API Backend](https://saasitive.com/tutorial/react-token-based-authentication-django/) (tag [v4](https://github.com/saasitive/django-react-boilerplate/tree/v4)) 27 | 5. [React Authenticated Component](https://saasitive.com/tutorial/react-authenticated-component/) (tag [v5](https://github.com/saasitive/django-react-boilerplate/tree/v5)) 28 | 6. [CRUD in Django Rest Framework and React](https://saasitive.com/tutorial/crud-django-rest-framework-react/) (tag [v6](https://github.com/saasitive/django-react-boilerplate/tree/v6)) 29 | 7. [Docker-Compose for Django and React with Nginx reverse-proxy and Let's encrypt certificate](https://saasitive.com/tutorial/docker-compose-django-react-nginx-let-s-encrypt/) (tag [v7](https://github.com/saasitive/django-react-boilerplate/tree/v7)) 30 | 8. [Django Rest Framework Email Verification](https://saasitive.com/tutorial/django-rest-framework-email-verification/) (tag [v8](https://github.com/saasitive/django-react-boilerplate/tree/v8)) 31 | 9. [Django Rest Framework Reset Password](https://saasitive.com/tutorial/django-rest-framework-reset-password/) (tag [v9](https://github.com/saasitive/django-react-boilerplate/tree/v9)) 32 | 33 | 34 | More articles coming soon! 35 | 36 | ## Screenshots 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /backend/server/server/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for server project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.3. 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().parent.parent 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "j+qxsyi2xv!6lkv-hn)c$qxeo+t#1r#)0s)o^yh)ds#k%%19tm" 23 | 24 | # 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ['127.0.0.1', '0.0.0.0', 'boilerplate.saasitive.com', 'www.boilerplate.saasitive.com'] 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 | 'rest_framework.authtoken', 43 | 'djoser', 44 | 'corsheaders', 45 | # 46 | 'apps.accounts', 47 | 'apps.notes' 48 | ] 49 | 50 | #configure DRF 51 | REST_FRAMEWORK = { 52 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 53 | 'rest_framework.authentication.TokenAuthentication', 54 | ), 55 | 'DEFAULT_PERMISSION_CLASSES': [ 56 | 'rest_framework.permissions.IsAuthenticated', 57 | ] 58 | } 59 | 60 | # configure Djoser 61 | DJOSER = { 62 | "USER_ID_FIELD": "username", 63 | "LOGIN_FIELD": "email", 64 | "SEND_ACTIVATION_EMAIL": True, 65 | "ACTIVATION_URL": "activate/{uid}/{token}", 66 | "PASSWORD_RESET_CONFIRM_URL": "reset_password/{uid}/{token}", 67 | 'SERIALIZERS': { 68 | 'token_create': 'apps.accounts.serializers.CustomTokenCreateSerializer', 69 | }, 70 | } 71 | 72 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 73 | SITE_NAME = "SaaSitive" 74 | 75 | PROTOCOL = "http" 76 | DOMAIN = "localhost:3000" 77 | if not DEBUG: 78 | PROTOCOL = "https" 79 | DOMAIN = "boilerplate.saasitive.com" 80 | 81 | 82 | CORS_ALLOWED_ORIGINS = [ 83 | "http://localhost:3000", 84 | "http://127.0.0.1:3000" 85 | ] 86 | 87 | MIDDLEWARE = [ 88 | 'django.middleware.security.SecurityMiddleware', 89 | 'django.contrib.sessions.middleware.SessionMiddleware', 90 | 'corsheaders.middleware.CorsMiddleware', 91 | 'django.middleware.common.CommonMiddleware', 92 | 'django.middleware.csrf.CsrfViewMiddleware', 93 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 94 | 'django.contrib.messages.middleware.MessageMiddleware', 95 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 96 | ] 97 | 98 | ROOT_URLCONF = 'server.urls' 99 | 100 | TEMPLATES = [ 101 | { 102 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 103 | 'DIRS': [], 104 | 'APP_DIRS': True, 105 | 'OPTIONS': { 106 | 'context_processors': [ 107 | 'django.template.context_processors.debug', 108 | 'django.template.context_processors.request', 109 | 'django.contrib.auth.context_processors.auth', 110 | 'django.contrib.messages.context_processors.messages', 111 | ], 112 | }, 113 | }, 114 | ] 115 | 116 | WSGI_APPLICATION = 'server.wsgi.application' 117 | 118 | 119 | # Database 120 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 121 | 122 | DATABASES = { 123 | 'default': { 124 | 'ENGINE': 'django.db.backends.sqlite3', 125 | 'NAME': BASE_DIR / 'db.sqlite3', 126 | } 127 | } 128 | 129 | 130 | # Password validation 131 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 132 | 133 | AUTH_PASSWORD_VALIDATORS = [ 134 | { 135 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 136 | }, 137 | { 138 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 139 | }, 140 | { 141 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 142 | }, 143 | { 144 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 145 | }, 146 | ] 147 | 148 | 149 | # Internationalization 150 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 151 | 152 | LANGUAGE_CODE = 'en-us' 153 | 154 | TIME_ZONE = 'UTC' 155 | 156 | USE_I18N = True 157 | 158 | USE_L10N = True 159 | 160 | USE_TZ = True 161 | 162 | 163 | # Static files (CSS, JavaScript, Images) 164 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 165 | 166 | MEDIA_URL = '/media/' 167 | STATIC_URL = '/django_static/' 168 | STATIC_ROOT = BASE_DIR / 'django_static' -------------------------------------------------------------------------------- /frontend/src/components/account/Signup.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter, Link } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | 5 | import axios from "axios"; 6 | import { setAxiosAuthToken } from "../../utils/Utils"; 7 | import { 8 | Alert, 9 | Container, 10 | Button, 11 | Row, 12 | Col, 13 | Form, 14 | FormControl 15 | } from "react-bootstrap"; 16 | 17 | class Signup extends Component { 18 | constructor(props) { 19 | super(props); 20 | this.state = { 21 | username: "", 22 | password: "", 23 | email: "", 24 | usernameError: "", 25 | passwordError: "", 26 | emailError: "", 27 | status: "" 28 | }; 29 | } 30 | onChange = e => { 31 | this.setState({ [e.target.name]: e.target.value }); 32 | }; 33 | 34 | onSignupClick = () => { 35 | this.setState({ 36 | usernameError: "", 37 | emailError: "", 38 | passwordError: "", 39 | status: "" 40 | }); 41 | 42 | const userData = { 43 | username: this.state.username, 44 | password: this.state.password, 45 | email: this.state.email 46 | }; 47 | 48 | setAxiosAuthToken(""); // send request with empty token 49 | axios 50 | .post("/api/v1/users/", userData) 51 | .then(response => { 52 | this.setState({ status: "success" }); 53 | }) 54 | .catch(error => { 55 | if (error.response) { 56 | if (error.response.data.hasOwnProperty("username")) { 57 | this.setState({ usernameError: error.response.data["username"] }); 58 | } 59 | if (error.response.data.hasOwnProperty("email")) { 60 | this.setState({ emailError: error.response.data["email"] }); 61 | } 62 | if (error.response.data.hasOwnProperty("password")) { 63 | this.setState({ passwordError: error.response.data["password"] }); 64 | } 65 | if (error.response.data.hasOwnProperty("detail")) { 66 | this.setState({ status: "error" }); 67 | } 68 | } else { 69 | this.setState({ status: "error" }); 70 | } 71 | }); 72 | }; 73 | 74 | render() { 75 | let errorAlert = ( 76 | 77 | Problem during account creation 78 | Please try again or contact service support for further help. 79 | 80 | ); 81 | 82 | let successAlert = ( 83 | 84 | Account created 85 |

86 | We send you an email with activation link. Please check your email. 87 |

88 |
89 | ); 90 | 91 | const form = ( 92 |
93 |
94 | 95 | User name 96 | 104 | 105 | {this.state.usernameError} 106 | 107 | 108 | 109 | 110 | Your Email 111 | 119 | 120 | {this.state.emailError} 121 | 122 | 123 | 124 | 125 | Your password 126 | 134 | 135 | {this.state.passwordError} 136 | 137 | 138 |
139 | 142 |
143 | ); 144 | 145 | let alert = ""; 146 | if (this.state.status === "error") { 147 | alert = errorAlert; 148 | } else if (this.state.status === "success") { 149 | alert = successAlert; 150 | } 151 | 152 | return ( 153 | 154 | 155 | 156 |

Sign up

157 | {alert} 158 | {this.state.status !== "success" && form} 159 |

160 | Already have account? Login 161 |

162 | 163 |
164 |
165 | ); 166 | } 167 | } 168 | 169 | Signup.propTypes = {}; 170 | 171 | const mapStateToProps = state => ({}); 172 | 173 | export default connect(mapStateToProps)(withRouter(Signup)); 174 | -------------------------------------------------------------------------------- /backend/server/apps/accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.core import mail 2 | from rest_framework import status 3 | from rest_framework.test import APITestCase 4 | 5 | class PasswordResetTest(APITestCase): 6 | 7 | # endpoints needed 8 | register_url = "/api/v1/users/" 9 | activate_url = "/api/v1/users/activation/" 10 | login_url = "/api/v1/token/login/" 11 | send_reset_password_email_url = "/api/v1/users/reset_password/" 12 | confirm_reset_password_url = "/api/v1/users/reset_password_confirm/" 13 | 14 | # user infofmation 15 | user_data = { 16 | "email": "test@example.com", 17 | "username": "test_user", 18 | "password": "verysecret" 19 | } 20 | login_data = { 21 | "email": "test@example.com", 22 | "password": "verysecret" 23 | } 24 | 25 | def test_reset_password(self): 26 | # register the new user 27 | response = self.client.post(self.register_url, self.user_data, format="json") 28 | # expected response 29 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 30 | # expected one email to be send 31 | self.assertEqual(len(mail.outbox), 1) 32 | 33 | # parse email to get uid and token 34 | email_lines = mail.outbox[0].body.splitlines() 35 | activation_link = [l for l in email_lines if "/activate/" in l][0] 36 | uid, token = activation_link.split("/")[-2:] 37 | 38 | # verify email 39 | data = {"uid": uid, "token": token} 40 | response = self.client.post(self.activate_url, data, format="json") 41 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 42 | 43 | # reset password 44 | data = {"email": self.user_data["email"]} 45 | response = self.client.post(self.send_reset_password_email_url, data, format="json") 46 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 47 | 48 | # parse reset-password email to get uid and token 49 | # it is a second email! 50 | email_lines = mail.outbox[1].body.splitlines() 51 | reset_link = [l for l in email_lines if "/reset_password/" in l][0] 52 | uid, token = activation_link.split("/")[-2:] 53 | 54 | # confirm reset password 55 | data = {"uid": uid, "token": token, "new_password": "new_verysecret"} 56 | response = self.client.post(self.confirm_reset_password_url, data, format="json") 57 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 58 | 59 | # login to get the authentication token with old password 60 | response = self.client.post(self.login_url, self.login_data, format="json") 61 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 62 | 63 | # login to get the authentication token with new password 64 | login_data = dict(self.login_data) 65 | login_data["password"] = "new_verysecret" 66 | response = self.client.post(self.login_url, login_data, format="json") 67 | self.assertEqual(response.status_code, status.HTTP_200_OK) 68 | 69 | 70 | def test_reset_password_inactive_user(self): 71 | # register the new user 72 | response = self.client.post(self.register_url, self.user_data, format="json") 73 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 74 | 75 | # reset password for inactive user 76 | data = {"email": self.user_data["email"]} 77 | response = self.client.post(self.send_reset_password_email_url, data, format="json") 78 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 79 | # the email wasnt send 80 | self.assertEqual(len(mail.outbox), 1) 81 | 82 | 83 | def test_reset_password_wrong_email(self): 84 | data = {"email": "wrong@email.com"} 85 | response = self.client.post(self.send_reset_password_email_url, data, format="json") 86 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 87 | # the email wasnt send 88 | self.assertEqual(len(mail.outbox), 0) 89 | 90 | 91 | class EmailVerificationTest(APITestCase): 92 | 93 | # endpoints needed 94 | register_url = "/api/v1/users/" 95 | activate_url = "/api/v1/users/activation/" 96 | resend_verification_url = "/api/v1/users/resend_activation/" 97 | login_url = "/api/v1/token/login/" 98 | user_details_url = "/api/v1/users/" 99 | # user infofmation 100 | user_data = { 101 | "email": "test@example.com", 102 | "username": "test_user", 103 | "password": "verysecret" 104 | } 105 | login_data = { 106 | "email": "test@example.com", 107 | "password": "verysecret" 108 | } 109 | 110 | def test_register_with_email_verification(self): 111 | # register the new user 112 | response = self.client.post(self.register_url, self.user_data, format="json") 113 | # expected response 114 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 115 | # expected one email to be send 116 | self.assertEqual(len(mail.outbox), 1) 117 | 118 | # parse email to get uid and token 119 | email_lines = mail.outbox[0].body.splitlines() 120 | # you can print email to check it 121 | # print(mail.outbox[0].subject) 122 | # print(mail.outbox[0].body) 123 | activation_link = [l for l in email_lines if "/activate/" in l][0] 124 | uid, token = activation_link.split("/")[-2:] 125 | 126 | # verify email 127 | data = {"uid": uid, "token": token} 128 | response = self.client.post(self.activate_url, data, format="json") 129 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 130 | 131 | # login to get the authentication token 132 | response = self.client.post(self.login_url, self.login_data, format="json") 133 | self.assertTrue("auth_token" in response.json()) 134 | token = response.json()["auth_token"] 135 | 136 | # set token in the header 137 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + token) 138 | # get user details 139 | response = self.client.get(self.user_details_url, format="json") 140 | self.assertEqual(response.status_code, status.HTTP_200_OK) 141 | self.assertEqual(len(response.json()), 1) 142 | self.assertEqual(response.json()[0]["email"], self.user_data["email"]) 143 | self.assertEqual(response.json()[0]["username"], self.user_data["username"]) 144 | 145 | 146 | def test_register_resend_verification(self): 147 | # register the new user 148 | response = self.client.post(self.register_url, self.user_data, format="json") 149 | # expected response 150 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 151 | # expected one email to be send 152 | self.assertEqual(len(mail.outbox), 1) 153 | 154 | # login to get the authentication token 155 | response = self.client.post(self.login_url, self.login_data, format="json") 156 | self.assertTrue("auth_token" in response.json()) 157 | token = response.json()["auth_token"] 158 | 159 | # set token in the header 160 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + token) 161 | # try to get user details 162 | response = self.client.get(self.user_details_url, format="json") 163 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 164 | 165 | # clear the auth_token in header 166 | self.client.credentials() 167 | # resend the verification email 168 | data = {"email": self.user_data["email"]} 169 | response = self.client.post(self.resend_verification_url, data, format="json") 170 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 171 | 172 | # there should be two emails in the outbox 173 | self.assertEqual(len(mail.outbox), 2) 174 | 175 | # parse the last email 176 | email_lines = mail.outbox[1].body.splitlines() 177 | activation_link = [l for l in email_lines if "/activate/" in l][0] 178 | uid, token = activation_link.split("/")[-2:] 179 | 180 | # verify the email 181 | data = {"uid": uid, "token": token} 182 | response = self.client.post(self.activate_url, data, format="json") 183 | # email verified 184 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 185 | 186 | 187 | def test_resend_verification_wrong_email(self): 188 | # register the new user 189 | response = self.client.post(self.register_url, self.user_data, format="json") 190 | # expected response 191 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 192 | 193 | # resend the verification email but with WRONG email 194 | data = {"email": self.user_data["email"]+"_this_email_is_wrong"} 195 | response = self.client.post(self.resend_verification_url, data, format="json") 196 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 197 | 198 | 199 | 200 | def test_activate_with_wrong_uid_token(self): 201 | # register the new user 202 | response = self.client.post(self.register_url, self.user_data, format="json") 203 | # expected response 204 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 205 | 206 | # verify the email with wrong data 207 | data = {"uid": "wrong-uid", "token": "wrong-token"} 208 | response = self.client.post(self.activate_url, data, format="json") 209 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 210 | --------------------------------------------------------------------------------