├── 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 |
32 | Note
33 |
41 |
42 |
43 |
44 | Add note
45 |
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 |
34 | Upper case
35 | {" "}
36 |
37 | Lower case
38 | {" "}
39 |
40 | Delete
41 |
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 | You need to enable JavaScript to run this app.
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 |
37 | Your Email
38 |
45 |
46 |
47 |
48 | Your password
49 |
56 |
57 |
58 |
59 | Login
60 |
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 |
74 | Your Email
75 |
83 |
84 | {this.state.emailError}
85 |
86 |
87 |
88 |
89 | Send email with reset link
90 |
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 |
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 |
92 | Send activation email
93 |
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 |
80 | Your New Password
81 |
89 |
90 | {this.state.passwordError}
91 |
92 |
93 |
94 |
95 | Save
96 |
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 |
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 |
140 | Sign up
141 |
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 |
--------------------------------------------------------------------------------