├── .gitignore ├── README.md ├── backend ├── .env.sample ├── .gitignore ├── Dockerfile ├── gunicorn.conf.py ├── railway.json ├── requirements.dev.txt ├── requirements.txt └── src │ ├── accounts │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py │ ├── ai │ ├── __init__.py │ └── api.py │ ├── cfehome │ ├── __init__.py │ ├── api.py │ ├── asgi.py │ ├── installed.py │ ├── settings.py │ ├── urls.py │ ├── views.py │ └── wsgi.py │ ├── documents │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── apps.py │ ├── exceptions.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_docuser.py │ │ └── __init__.py │ ├── models.py │ ├── schemas.py │ ├── services.py │ ├── tests.py │ └── views.py │ ├── googler │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── oauth.py │ ├── schemas.py │ ├── security.py │ ├── services.py │ ├── tests.py │ └── urls.py │ ├── helpers │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── auth │ │ │ ├── __init__.py │ │ │ ├── controllers.py │ │ │ ├── permissions.py │ │ │ └── schemas.py │ │ └── users │ │ │ ├── __init__.py │ │ │ └── schemas.py │ └── dotenv │ │ ├── __init__.py │ │ └── loader.py │ ├── manage.py │ ├── profiles │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── signals.py │ ├── tests.py │ └── views.py │ ├── requirements │ ├── dev.in │ └── prod.in │ ├── staticfiles │ └── .git-keep │ └── templates │ └── .git-keep ├── compose.yaml ├── frontend ├── .env.sample ├── .gitignore ├── Dockerfile ├── Dockerfile.demo ├── components.json ├── eslint.config.mjs ├── jsconfig.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── django-nextjs-favicon.png │ ├── django-nextjs-text.svg │ ├── django-nextjs.svg │ ├── file.svg │ ├── globe.svg │ ├── login.png │ ├── signup.png │ └── window.svg ├── railway.demo.json ├── railway.json ├── src │ ├── app │ │ ├── api │ │ │ ├── [...path] │ │ │ │ └── route.js │ │ │ ├── backend │ │ │ │ └── healthz │ │ │ │ │ └── route.js │ │ │ ├── ckeditor │ │ │ │ └── route.js │ │ │ ├── google │ │ │ │ └── callback │ │ │ │ │ └── route.js │ │ │ ├── health │ │ │ │ └── route.js │ │ │ ├── login │ │ │ │ └── route.js │ │ │ ├── logout │ │ │ │ └── route.js │ │ │ └── signup │ │ │ │ └── route.js │ │ ├── docs │ │ │ ├── [docId] │ │ │ │ └── page.js │ │ │ ├── create │ │ │ │ └── page.js │ │ │ └── page.js │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── google │ │ │ ├── GoogleLoginButton.jsx │ │ │ └── callback │ │ │ │ └── page.jsx │ │ ├── healthz │ │ │ └── route.js │ │ ├── layout.js │ │ ├── login │ │ │ └── page.jsx │ │ ├── logout │ │ │ └── page.jsx │ │ ├── page.js │ │ └── signup │ │ │ └── page.jsx │ ├── components │ │ ├── apiProvider.jsx │ │ ├── authProvider.jsx │ │ ├── editor │ │ │ ├── DocEditor.jsx │ │ │ └── docEditor.css │ │ ├── layout │ │ │ ├── AccountDropdown.jsx │ │ │ ├── BaseLayout.jsx │ │ │ ├── BrandLink.jsx │ │ │ ├── MobileNavbar.jsx │ │ │ ├── NavLinks.jsx │ │ │ └── Navbar.jsx │ │ ├── themeProvider.jsx │ │ ├── ui │ │ │ ├── button.jsx │ │ │ ├── card.jsx │ │ │ ├── dropdown-menu.jsx │ │ │ ├── input.jsx │ │ │ ├── label.jsx │ │ │ ├── sheet.jsx │ │ │ ├── table.jsx │ │ │ └── textarea.jsx │ │ └── useMySWR.jsx │ └── lib │ │ ├── auth.js │ │ ├── fetcher.js │ │ ├── getApiEndpoint.js │ │ ├── tokenFetcher.js │ │ ├── urlJoin.js │ │ └── utils.js └── tailwind.config.js └── rav.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 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 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 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 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .env* 133 | !.env.sample 134 | .venv 135 | env/ 136 | venv/ 137 | ENV/ 138 | env.bak/ 139 | venv.bak/ 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | # mypy 152 | .mypy_cache/ 153 | .dmypy.json 154 | dmypy.json 155 | 156 | # Pyre type checker 157 | .pyre/ 158 | 159 | # pytype static type analyzer 160 | .pytype/ 161 | 162 | # Cython debug symbols 163 | cython_debug/ 164 | 165 | # PyCharm 166 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 167 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 168 | # and can be added to the global gitignore or merged into this file. For a more nuclear 169 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 170 | #.idea/ 171 | 172 | # Ruff stuff: 173 | .ruff_cache/ 174 | 175 | # PyPI configuration file 176 | .pypirc 177 | 178 | 179 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 180 | 181 | # dependencies 182 | frontend/.next 183 | frontend/node_modules 184 | /node_modules 185 | /.pnp 186 | .pnp.* 187 | .yarn/* 188 | !.yarn/patches 189 | !.yarn/plugins 190 | !.yarn/releases 191 | !.yarn/versions 192 | 193 | # testing 194 | /coverage 195 | 196 | # next.js 197 | /.next/ 198 | /out/ 199 | 200 | # production 201 | /build 202 | 203 | # misc 204 | .DS_Store 205 | *.pem 206 | 207 | # debug 208 | npm-debug.log* 209 | yarn-debug.log* 210 | yarn-error.log* 211 | .pnpm-debug.log* 212 | 213 | 214 | # vercel 215 | .vercel 216 | 217 | # typescript 218 | *.tsbuildinfo 219 | next-env.d.ts 220 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building a Modern Docs Platform with Django, Next.js, CKEditor, and Google OAuth 2 | 3 | ⭐️ Thanks to [CKEditor](https://ckeditor.com/?utm_campaign=devrel_cfe_course&utm_source=youtube&utm_medium=referral&utm_term=social) parterning with us on this course! 4 | 5 | ## Course Resources 6 | - 🎥 **YouTube video**: [Watch the course](https://youtu.be/OGCE3OUO4G8) 7 | - 💽 **GitHub code**: [Project repository](https://github.com/codingforentrepreneurs/google-docs-with-django-nextjs) 8 | - 🐍 **Boilerplate code** [Django x Next.js](https://djangonextjs.com) 9 | 10 | ## Course Topics 11 | 12 | ### Full-Stack Integration 13 | - ✅ Full-stack web development with Django and Next.js 14 | - ✅ Setting up Django backend with production-ready configuration 15 | - ✅ Configuring Next.js frontend for modern user experience 16 | - ✅ PostgreSQL database setup via Docker Compose 17 | 18 | ### Authentication & User Management 19 | - ✅ Complete Google OAuth implementation from scratch 20 | - ✅ OAuth state and PKCE token generation and management 21 | - ✅ Django caching for secure OAuth token handling 22 | - ✅ Custom Django user model with email-first authentication 23 | - ✅ User registration flows for both email and Google login 24 | - ✅ Token verification and refresh mechanisms 25 | 26 | ### Document Editor & Collaboration 27 | - ✅ CKEditor integration for rich document editing 28 | - ✅ Real-time collaboration with Django-based users via CKEditor 29 | - ✅ TailwindCSS configuration with CKEditor 30 | - ✅ AI assistance integration with custom adapters 31 | - ✅ Multi-user real-time document collaboration 32 | 33 | ### Security & API Development 34 | - ✅ JWT token signatures for secure user authentication 35 | - ✅ API endpoints for user tokens and document management 36 | 37 | ## Prerequisites 38 | 39 | ### Python 40 | - Knowledge of classes, functions, async/await, and working with HTTP requests 41 | - Understanding of virtual environments and package management 42 | 43 | ### Django Basics 44 | - Familiarity with views, URL routing, models, and the Django ORM 45 | - Understanding Django's authentication system and middleware 46 | 47 | ### JavaScript and React 48 | - ES6+ features, async/await, and working with APIs 49 | - React hooks, context, and component lifecycle 50 | 51 | ### Authentication Knowledge 52 | - Basic understanding of OAuth authentication flows 53 | - Knowledge of JWT tokens and authentication mechanisms 54 | 55 | --- 56 | 57 | This comprehensive course provides everything you need to build a production-ready Google-docs-like collaboration platform with modern authentication and real-time editing & collaboration features. 58 | 59 | 60 | 61 | ## Video Chapters 62 | 63 | ### Google Docs with Next.js, Django & CKEditor 64 | 65 | - [Google Docs with Next.js, Django & CKEditor](https://youtu.be/OGCE3OUO4G8?t=9) 66 | - [Getting started - Tech Stack](https://youtu.be/OGCE3OUO4G8?t=252) 67 | 68 | ### Django x Next.js 69 | - [Starting Django x Nextjs Integration](https://youtu.be/OGCE3OUO4G8?t=655) 70 | - [Django Backend Baseline Setup](https://youtu.be/OGCE3OUO4G8?t=728) 71 | - [Nextjs Frontend Setup](https://youtu.be/OGCE3OUO4G8?t=1009) 72 | - [Postgres Database via Docker Compose](https://youtu.be/OGCE3OUO4G8?t=1139) 73 | - [Register Users with Django & Next.js](https://youtu.be/OGCE3OUO4G8?t=1443) 74 | - [We Need Google Auth](https://youtu.be/OGCE3OUO4G8?t=1765) 75 | 76 | ### Google Login from Scratch with Django and OAuth 77 | - [Mini Django Project for Google Login](https://youtu.be/OGCE3OUO4G8?t=1960) 78 | - [Minimal Django Project Setup](https://youtu.be/OGCE3OUO4G8?t=2142) 79 | - [Google Cloud and the Google Auth Platform](https://youtu.be/OGCE3OUO4G8?t=2363) 80 | - [OAuth Flow + Django Views](https://youtu.be/OGCE3OUO4G8?t=2982) 81 | - [Generate OAuth State and PKCE Tokes](https://youtu.be/OGCE3OUO4G8?t=3231) 82 | - [OAuth Callback URL](https://youtu.be/OGCE3OUO4G8?t=3382) 83 | - [Genereate the Google OAuth Login URL](https://youtu.be/OGCE3OUO4G8?t=3598) 84 | - [Django Caching for OAuth State & PKCE](https://youtu.be/OGCE3OUO4G8?t=4083) 85 | - [Finalize Login URL with Google Auth Client](https://youtu.be/OGCE3OUO4G8?t=4352) 86 | - [Handle the Google OAuth Callback](https://youtu.be/OGCE3OUO4G8?t=4751) 87 | - [Verify Callback Token](https://youtu.be/OGCE3OUO4G8?t=5258) 88 | - [Create Django User from Google User](https://youtu.be/OGCE3OUO4G8?t=5638) 89 | - [Scopes for Google OAuth](https://youtu.be/OGCE3OUO4G8?t=6190) 90 | - [Finalize Django Login for Google User](https://youtu.be/OGCE3OUO4G8?t=6477) 91 | - [Unlocking Django App Portability](https://youtu.be/OGCE3OUO4G8?t=6746) 92 | 93 | ### Customize Django User Model with Google Login 94 | - [Email-based User Model in Django](https://youtu.be/OGCE3OUO4G8?t=7090) 95 | - [Before you replace the User model](https://youtu.be/OGCE3OUO4G8?t=7200) 96 | - [Replace the Default User Model](https://youtu.be/OGCE3OUO4G8?t=7742) 97 | - [Customizing the Custom User Model](https://youtu.be/OGCE3OUO4G8?t=8170) 98 | - [Modify Next.js Login Form](https://youtu.be/OGCE3OUO4G8?t=8517) 99 | - [Improved Login Flow from Django API](https://youtu.be/OGCE3OUO4G8?t=8811) 100 | - [Sign Up Flow for Email-based Users](https://youtu.be/OGCE3OUO4G8?t=9343) 101 | - [User Display Name](https://youtu.be/OGCE3OUO4G8?t=9563) 102 | - [Google Login API Endpoints](https://youtu.be/OGCE3OUO4G8?t=9739) 103 | - [Google Auth Client and Django Config](https://youtu.be/OGCE3OUO4G8?t=10199) 104 | - [Next.js Google Login Button & Redirect](https://youtu.be/OGCE3OUO4G8?t=10630) 105 | - [Handle the Google Callback in Next.js](https://youtu.be/OGCE3OUO4G8?t=10946) 106 | - [Active vs Inactive Accounts](https://youtu.be/OGCE3OUO4G8?t=11352) 107 | - [Verify User in Next.js with TokenFetcher](https://youtu.be/OGCE3OUO4G8?t=11533) 108 | - [Perform Token Refresh](https://youtu.be/OGCE3OUO4G8?t=12032) 109 | 110 | ### Basic Docs 111 | - [Documents App and Basic Model](https://youtu.be/OGCE3OUO4G8?t=12625) 112 | - [Doc Model Schema with Django Ninja](https://youtu.be/OGCE3OUO4G8?t=12943) 113 | - [API List View for User Documents](https://youtu.be/OGCE3OUO4G8?t=13092) 114 | - [Caching to Speed Up Django QuerySets](https://youtu.be/OGCE3OUO4G8?t=13439) 115 | - [List View for Docs in Next.js](https://youtu.be/OGCE3OUO4G8?t=13799) 116 | - [Client Side Login Required with useSWR](https://youtu.be/OGCE3OUO4G8?t=14203) 117 | - [Client-Side Docs Detail View](https://youtu.be/OGCE3OUO4G8?t=14472) 118 | - [Dynamic URL Routing in Django Ninja](https://youtu.be/OGCE3OUO4G8?t=14778) 119 | - [Get Document Detail Service](https://youtu.be/OGCE3OUO4G8?t=14907) 120 | - [Exception Handling for Permissions and Not Found](https://youtu.be/OGCE3OUO4G8?t=15045) 121 | - [API Endpoints for Updating Documents](https://youtu.be/OGCE3OUO4G8?t=15538) 122 | - [Frontend Form to Update Document](https://youtu.be/OGCE3OUO4G8?t=15849) 123 | - [Create Documents in Backend API & Frontend](https://youtu.be/OGCE3OUO4G8?t=16568) 124 | - [Challenge: Create a Delete View](https://youtu.be/OGCE3OUO4G8?t=17213) 125 | 126 | ### CKEditor as Docs Editor 127 | - [Intro to CKEditor](https://youtu.be/OGCE3OUO4G8?t=17300) 128 | - [Install CKEditor in NextJS](https://youtu.be/OGCE3OUO4G8?t=17417) 129 | - [Swap Textarea with CKEditor](https://youtu.be/OGCE3OUO4G8?t=17862) 130 | - [Save CKEditor Contents to Database](https://youtu.be/OGCE3OUO4G8?t=18086) 131 | - [Adding new features & the CKEditor Builder](https://youtu.be/OGCE3OUO4G8?t=18442) 132 | - [Using TailwindCSS with CKEditor](https://youtu.be/OGCE3OUO4G8?t=18684) 133 | - [Autosave with CKEditor](https://youtu.be/OGCE3OUO4G8?t=18940) 134 | - [Adding Any Standard Plugin](https://youtu.be/OGCE3OUO4G8?t=19353) 135 | - [Managing the CKEditor License Key](https://youtu.be/OGCE3OUO4G8?t=19490) 136 | - [Using AI Assistant Plugin with a Custom Adapter](https://youtu.be/OGCE3OUO4G8?t=19745) 137 | - [Proxied AI Responses with Django](https://youtu.be/OGCE3OUO4G8?t=20137) 138 | 139 | ### Multi-User Collaboration 140 | - [Collaboration Basics with CKEditor](https://youtu.be/OGCE3OUO4G8?t=20726) 141 | - [Multiple Users for the Django Documents Model](https://youtu.be/OGCE3OUO4G8?t=21112) 142 | - [Creating the CKEditor User Token Payload](https://youtu.be/OGCE3OUO4G8?t=21571) 143 | - [JWT Token Signature for CKEditor](https://youtu.be/OGCE3OUO4G8?t=22109) 144 | - [Django Ninja API Endpoint for CKEditor User Tokens](https://youtu.be/OGCE3OUO4G8?t=22552) 145 | - [CKEditor Loading our Custom JWT User Tokens](https://youtu.be/OGCE3OUO4G8?t=22713) 146 | - [A final challenge](https://youtu.be/OGCE3OUO4G8?t=23067) 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /backend/.env.sample: -------------------------------------------------------------------------------- 1 | # your nextjs frontend url 2 | # use a live url for production 3 | FRONTEND_URL=http://localhost:3000 4 | # python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' 5 | DJANGO_SECRET_KEY=django-insecure-*s_thxsz7nu5nx^bjc9xlpdbu3sv#fxn$2sp-6u_rb)a1hu+rd 6 | DJANGO_DEBUG=True 7 | DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 8 | DJANGO_APPEND_SLASH=True 9 | DJANGO_SITE_ID=1 10 | GOOGLE_CLIENT_ID=abc 11 | GOOGLE_SECRET_KEY=abc 12 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | .DS_Store 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # UV 101 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | #uv.lock 105 | 106 | # poetry 107 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 108 | # This is especially recommended for binary packages to ensure reproducibility, and is more 109 | # commonly ignored for libraries. 110 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 111 | #poetry.lock 112 | 113 | # pdm 114 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 115 | #pdm.lock 116 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 117 | # in version control. 118 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 119 | .pdm.toml 120 | .pdm-python 121 | .pdm-build/ 122 | 123 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 124 | __pypackages__/ 125 | 126 | # Celery stuff 127 | celerybeat-schedule 128 | celerybeat.pid 129 | 130 | # SageMath parsed files 131 | *.sage.py 132 | 133 | # Environments 134 | !.env.example 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ 173 | 174 | # Ruff stuff: 175 | .ruff_cache/ 176 | 177 | # PyPI configuration file 178 | .pypirc 179 | 180 | 181 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 182 | 183 | # dependencies 184 | frontend/.next 185 | frontend/node_modules 186 | /node_modules 187 | /.pnp 188 | .pnp.* 189 | .yarn/* 190 | !.yarn/patches 191 | !.yarn/plugins 192 | !.yarn/releases 193 | !.yarn/versions 194 | 195 | # testing 196 | /coverage 197 | 198 | # next.js 199 | /.next/ 200 | /out/ 201 | 202 | # production 203 | /build 204 | 205 | # misc 206 | .DS_Store 207 | *.pem 208 | 209 | # debug 210 | npm-debug.log* 211 | yarn-debug.log* 212 | yarn-error.log* 213 | .pnpm-debug.log* 214 | 215 | 216 | # vercel 217 | .vercel 218 | 219 | # typescript 220 | *.tsbuildinfo 221 | next-env.d.ts 222 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | ARG PYTHON_VERSION=3.12-slim-bullseye 3 | FROM python:${PYTHON_VERSION} AS builder 4 | 5 | # Combine venv creation and build dependencies 6 | RUN python -m venv /opt/venv && \ 7 | apt-get update && apt-get install -y \ 8 | libpq-dev \ 9 | zlib1g-dev \ 10 | libjpeg-dev \ 11 | libfreetype6-dev \ 12 | python3-dev \ 13 | gcc \ 14 | g++ \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | ENV PATH=/opt/venv/bin:$PATH 18 | 19 | # Install packages and cleanup in same layer 20 | COPY requirements.txt /tmp/ 21 | RUN pip install --upgrade pip && \ 22 | pip install -r /tmp/requirements.txt gunicorn && \ 23 | rm -rf /root/.cache/pip/* /tmp/requirements.txt 24 | 25 | # Final stage 26 | FROM python:${PYTHON_VERSION} 27 | 28 | # Copy virtual env from builder 29 | COPY --from=builder /opt/venv /opt/venv 30 | ENV PATH=/opt/venv/bin:$PATH 31 | 32 | # Install only runtime dependencies 33 | RUN apt-get update && apt-get install -y \ 34 | libpq5 \ 35 | curl \ 36 | libcairo2 \ 37 | && rm -rf /var/lib/apt/lists/* 38 | 39 | # Create runtime script 40 | ARG PROJ_NAME="cfehome" 41 | # Set Python environment variables 42 | ENV PYTHONDONTWRITEBYTECODE=1 \ 43 | PYTHONUNBUFFERED=1 \ 44 | DJANGO_SETTINGS_MODULE=${PROJ_NAME}.settings 45 | 46 | WORKDIR /app 47 | COPY ./src /app 48 | 49 | # Add this line to copy gunicorn config 50 | COPY gunicorn.conf.py /app/ 51 | 52 | RUN printf "#!/bin/bash\n" > ./paracord_runner.sh && \ 53 | printf "RUN_PORT=\"\${PORT:-8080}\"\n\n" >> ./paracord_runner.sh && \ 54 | printf "python manage.py collectstatic --no-input\n" >> ./paracord_runner.sh && \ 55 | printf "python manage.py migrate --no-input\n" >> ./paracord_runner.sh && \ 56 | printf "gunicorn ${PROJ_NAME}.wsgi:application -c gunicorn.conf.py --bind \"[::]:\$RUN_PORT\"\n" >> ./paracord_runner.sh 57 | 58 | # Setup non-root user 59 | RUN groupadd -r paracord && useradd -r -g paracord paracord && \ 60 | chown -R paracord:paracord /app && \ 61 | chown -R paracord:paracord /opt/venv && \ 62 | chmod +x paracord_runner.sh && \ 63 | chown paracord:paracord paracord_runner.sh 64 | 65 | USER paracord 66 | 67 | CMD ./paracord_runner.sh -------------------------------------------------------------------------------- /backend/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | # Recommended: 2-4 workers per CPU core for Django 4 | cpu_count = multiprocessing.cpu_count() 5 | max_workers = 10 6 | workers = min(cpu_count * 2 + 1, max_workers) 7 | 8 | # Prevent stuck workers 9 | timeout = 60 10 | keepalive = 5 11 | 12 | # Graceful handling 13 | graceful_timeout = 30 14 | max_requests = 1000 15 | max_requests_jitter = 50 16 | 17 | # Logging 18 | loglevel = "info" # debug in prod is too verbose 19 | errorlog = "-" 20 | accesslog = "-" 21 | 22 | # Custom access log format that includes host header 23 | access_log_format = ( 24 | '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" host="%({Host}i)s"' 25 | ) 26 | 27 | # Performance 28 | worker_class = "sync" # Django works best with sync workers 29 | preload_app = True 30 | -------------------------------------------------------------------------------- /backend/railway.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "builder": "DOCKERFILE", 4 | "dockerfilePath": "./Dockerfile", 5 | "watchPatterns": [ 6 | "cfe.toml", 7 | "gunicorn.conf.py", 8 | "requirements.txt", 9 | "src/**", 10 | "railway.toml", 11 | "Dockerfile" 12 | ] 13 | }, 14 | "deploy": { 15 | "healthcheckPath": "/api/healthz/", 16 | "healthcheckTimeout": 300, 17 | "startupTimeout": 300, 18 | "restartPolicyType": "always", 19 | "restartPolicyMaxRetries": 10 20 | } 21 | } -------------------------------------------------------------------------------- /backend/requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.dev.txt src/requirements/dev.in 6 | # 7 | fire==0.7.0 8 | # via rav 9 | markdown-it-py==3.0.0 10 | # via rich 11 | mdurl==0.1.2 12 | # via markdown-it-py 13 | pygments==2.19.1 14 | # via rich 15 | pyyaml==6.0.2 16 | # via rav 17 | rav==0.0.9 18 | # via -r src/requirements/dev.in 19 | rich==13.9.4 20 | # via rav 21 | termcolor==2.5.0 22 | # via fire 23 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.txt src/requirements/prod.in 6 | # 7 | annotated-types==0.7.0 8 | # via pydantic 9 | asgiref==3.8.1 10 | # via 11 | # django 12 | # django-cors-headers 13 | # django-ninja-extra 14 | cachetools==5.5.2 15 | # via google-auth 16 | certifi==2025.1.31 17 | # via requests 18 | cffi==1.17.1 19 | # via cryptography 20 | charset-normalizer==3.4.1 21 | # via requests 22 | contextlib2==21.6.0 23 | # via django-ninja-extra 24 | cryptography==44.0.0 25 | # via 26 | # django-ninja-jwt 27 | # pyjwt 28 | dj-database-url==2.3.0 29 | # via -r src/requirements/prod.in 30 | django==5.1.5 31 | # via 32 | # -r src/requirements/prod.in 33 | # dj-database-url 34 | # django-cors-headers 35 | # django-ninja 36 | # django-ninja-extra 37 | # django-ninja-jwt 38 | django-cors-headers==4.6.0 39 | # via -r src/requirements/prod.in 40 | django-ninja==1.3.0 41 | # via 42 | # -r src/requirements/prod.in 43 | # django-ninja-extra 44 | django-ninja-extra==0.22.3 45 | # via django-ninja-jwt 46 | django-ninja-jwt[crypto]==5.3.5 47 | # via -r src/requirements/prod.in 48 | dnspython==2.7.0 49 | # via email-validator 50 | email-validator==2.2.0 51 | # via pydantic 52 | google-auth==2.38.0 53 | # via 54 | # -r src/requirements/prod.in 55 | # google-auth-httplib2 56 | # google-auth-oauthlib 57 | google-auth-httplib2==0.2.0 58 | # via -r src/requirements/prod.in 59 | google-auth-oauthlib==1.2.1 60 | # via -r src/requirements/prod.in 61 | httplib2==0.22.0 62 | # via google-auth-httplib2 63 | idna==3.10 64 | # via 65 | # email-validator 66 | # requests 67 | injector==0.22.0 68 | # via django-ninja-extra 69 | oauthlib==3.2.2 70 | # via requests-oauthlib 71 | psycopg[binary]==3.2.4 72 | # via -r src/requirements/prod.in 73 | psycopg-binary==3.2.4 74 | # via psycopg 75 | pyasn1==0.6.1 76 | # via 77 | # pyasn1-modules 78 | # rsa 79 | pyasn1-modules==0.4.1 80 | # via google-auth 81 | pycparser==2.22 82 | # via cffi 83 | pydantic[email]==2.10.6 84 | # via 85 | # -r src/requirements/prod.in 86 | # django-ninja 87 | pydantic-core==2.27.2 88 | # via pydantic 89 | pyjwt[crypto]==2.10.1 90 | # via django-ninja-jwt 91 | pyparsing==3.2.1 92 | # via httplib2 93 | python-decouple==3.8 94 | # via -r src/requirements/prod.in 95 | requests==2.32.3 96 | # via requests-oauthlib 97 | requests-oauthlib==2.0.0 98 | # via google-auth-oauthlib 99 | rsa==4.9 100 | # via google-auth 101 | sqlparse==0.5.3 102 | # via django 103 | typing-extensions==4.12.2 104 | # via 105 | # dj-database-url 106 | # psycopg 107 | # pydantic 108 | # pydantic-core 109 | urllib3==2.3.0 110 | # via requests 111 | -------------------------------------------------------------------------------- /backend/src/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/accounts/__init__.py -------------------------------------------------------------------------------- /backend/src/accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin 3 | from django.contrib.auth.models import Group 4 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 5 | from django.contrib.auth.forms import ReadOnlyPasswordHashField 6 | from django.core.exceptions import ValidationError 7 | 8 | from .models import MyUser 9 | 10 | 11 | class UserCreationForm(forms.ModelForm): 12 | """A form for creating new users. Includes all the required 13 | fields, plus a repeated password.""" 14 | 15 | password1 = forms.CharField(label="Password", widget=forms.PasswordInput) 16 | password2 = forms.CharField( 17 | label="Password confirmation", widget=forms.PasswordInput 18 | ) 19 | 20 | class Meta: 21 | model = MyUser 22 | fields = ["email", ] 23 | 24 | def clean_password2(self): 25 | # Check that the two password entries match 26 | password1 = self.cleaned_data.get("password1") 27 | password2 = self.cleaned_data.get("password2") 28 | if password1 and password2 and password1 != password2: 29 | raise ValidationError("Passwords don't match") 30 | return password2 31 | 32 | def save(self, commit=True): 33 | # Save the provided password in hashed format 34 | user = super().save(commit=False) 35 | user.set_password(self.cleaned_data["password1"]) 36 | if commit: 37 | user.save() 38 | return user 39 | 40 | 41 | class UserChangeForm(forms.ModelForm): 42 | """A form for updating users. Includes all the fields on 43 | the user, but replaces the password field with admin's 44 | disabled password hash display field. 45 | """ 46 | 47 | password = ReadOnlyPasswordHashField() 48 | 49 | class Meta: 50 | model = MyUser 51 | fields = ["email", "password", "is_active", "is_admin"] 52 | 53 | 54 | class UserAdmin(BaseUserAdmin): 55 | # The forms to add and change user instances 56 | form = UserChangeForm 57 | add_form = UserCreationForm 58 | 59 | # The fields to be used in displaying the User model. 60 | # These override the definitions on the base UserAdmin 61 | # that reference specific fields on auth.User. 62 | list_display = ["email", "is_admin"] 63 | list_filter = ["is_admin"] 64 | fieldsets = [ 65 | (None, {"fields": ["email", "password"]}), 66 | ("Personal info", {"fields": []}), 67 | ("Permissions", {"fields": ["is_active", "is_admin"]}), 68 | ] 69 | # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin 70 | # overrides get_fieldsets to use this attribute when creating a user. 71 | add_fieldsets = [ 72 | ( 73 | None, 74 | { 75 | "classes": ["wide"], 76 | "fields": ["email", "password1", "password2"], 77 | }, 78 | ), 79 | ] 80 | search_fields = ["email"] 81 | ordering = ["email"] 82 | filter_horizontal = [] 83 | 84 | 85 | # Now register the new UserAdmin... 86 | admin.site.register(MyUser, UserAdmin) 87 | # ... and, since we're not using Django's built-in permissions, 88 | # unregister the Group model from admin. 89 | admin.site.unregister(Group) -------------------------------------------------------------------------------- /backend/src/accounts/api.py: -------------------------------------------------------------------------------- 1 | import jwt # django-ninja 2 | from datetime import datetime, timezone 3 | from django.conf import settings 4 | from django.contrib.auth import get_user_model 5 | from helpers.api.auth.permissions import user_required 6 | 7 | 8 | from ninja import Router 9 | 10 | User = get_user_model() 11 | 12 | CKEDITOR_ENVIRONMENT_ID = "mOrAUyJPWgnlK37ARIKU" 13 | 14 | def get_user_token(user_id): 15 | try: 16 | user_instance = User.objects.get(id=user_id) 17 | except Exception: 18 | raise Exception("User failed") 19 | 20 | username = f"{user_instance.display_name}" 21 | iat = int(datetime.now(timezone.utc).timestamp()) 22 | algo = "HS256" 23 | headers = { 24 | "typ": "JWT", 25 | "alg": algo 26 | } 27 | payload = { 28 | "aud": f"{CKEDITOR_ENVIRONMENT_ID}", 29 | "iat": iat, 30 | "sub": username, 31 | "user": { 32 | "email": f"{user_instance.email}", 33 | "name": f"{user_instance.display_name}", 34 | }, 35 | "auth": { 36 | "collaboration": { 37 | "*": { 38 | "role": "writer" 39 | } 40 | } 41 | } 42 | } 43 | signed_token = jwt.encode( 44 | payload=payload, 45 | key=settings.CKEDITOR_ACCESS_CREDS, 46 | # key=settings.SECRET_KEY, 47 | algorithm=algo, 48 | headers=headers, 49 | ) 50 | return signed_token 51 | 52 | # users are required 53 | router = Router(auth=user_required) 54 | 55 | 56 | @router.get("/ckeditor/token/") 57 | def ckeditor_token_view(request): 58 | user = request.user 59 | token = get_user_token(user.id) 60 | return {"myUserToken": token} -------------------------------------------------------------------------------- /backend/src/accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'accounts' 7 | -------------------------------------------------------------------------------- /backend/src/accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-02-27 18:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='MyUser', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('password', models.CharField(max_length=128, verbose_name='password')), 19 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 20 | ('email', models.EmailField(max_length=255, unique=True, verbose_name='email address')), 21 | ('is_active', models.BooleanField(default=True)), 22 | ('is_admin', models.BooleanField(default=False)), 23 | ], 24 | options={ 25 | 'abstract': False, 26 | }, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /backend/src/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /backend/src/accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import BaseUserManager, AbstractBaseUser 3 | 4 | 5 | class MyUserManager(BaseUserManager): 6 | def create_user(self, email, password=None, is_active:bool = True): 7 | """ 8 | Creates and saves a User with the given email and password. 9 | """ 10 | if not email: 11 | raise ValueError("Users must have an email address") 12 | 13 | user = self.model( 14 | email=self.normalize_email(email), 15 | ) 16 | 17 | user.set_password(password) 18 | user.is_active = is_active 19 | user.save(using=self._db) 20 | return user 21 | 22 | def create_superuser(self, email, password=None): 23 | """ 24 | Creates and saves a superuser with the given email and password. 25 | """ 26 | user = self.create_user( 27 | email, 28 | password=password, 29 | ) 30 | user.is_admin = True 31 | user.save(using=self._db) 32 | return user 33 | 34 | 35 | class MyUser(AbstractBaseUser): 36 | email = models.EmailField( 37 | verbose_name="email address", 38 | max_length=255, 39 | unique=True, 40 | ) 41 | is_active = models.BooleanField(default=True) 42 | is_admin = models.BooleanField(default=False) 43 | 44 | objects = MyUserManager() 45 | 46 | USERNAME_FIELD = "email" 47 | REQUIRED_FIELDS = [] 48 | 49 | def __str__(self): 50 | return self.email 51 | 52 | def has_perm(self, perm, obj=None): 53 | "Does the user have a specific permission?" 54 | # Simplest possible answer: Yes, always 55 | return True 56 | 57 | def has_module_perms(self, app_label): 58 | "Does the user have permissions to view the app `app_label`?" 59 | # Simplest possible answer: Yes, always 60 | return True 61 | 62 | @property 63 | def is_staff(self): 64 | "Is the user a member of staff?" 65 | # Simplest possible answer: All admins are staff 66 | return self.is_admin 67 | 68 | @property 69 | def display_name(self): 70 | email_user = self.email.split("@")[0] 71 | return f"{email_user}" -------------------------------------------------------------------------------- /backend/src/accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/src/accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/src/ai/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/ai/__init__.py -------------------------------------------------------------------------------- /backend/src/ai/api.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any 2 | from ninja import Schema, Router 3 | import random 4 | import time 5 | from helpers.api.auth.permissions import user_required 6 | 7 | router = Router(auth=user_required) 8 | 9 | class AIPayload(Schema): 10 | query: str 11 | context: Optional[Any] 12 | 13 | 14 | # /api/ai/ 15 | @router.post("/") 16 | def ai_echo_view(request, payload: AIPayload): 17 | # openai 18 | # claude 19 | # langchain 20 | # langgraph 21 | # pydantic ai 22 | # python -> llm 23 | sleep_for = 3.0 * ((random.randint(1,20) / 10)) 24 | time.sleep(sleep_for) 25 | print(payload.query, payload.context, type(payload.context)) 26 | return {"message": f"AI Says: {payload.query}"} -------------------------------------------------------------------------------- /backend/src/cfehome/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/cfehome/__init__.py -------------------------------------------------------------------------------- /backend/src/cfehome/api.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model, authenticate 2 | from helpers.api.auth.controllers import DjangoNextCustomController 3 | from helpers.api.auth.permissions import anon_required, user_or_anon 4 | from helpers.api.auth.schemas import ( 5 | UsernameMandatoryEmailMandatorySchema, 6 | EmailLoginSchema 7 | ) 8 | from helpers.api.users.schemas import UserSchema 9 | from ninja.errors import HttpError 10 | from ninja_extra import NinjaExtraAPI 11 | from ninja_extra.permissions import AllowAny 12 | from ninja_jwt.authentication import JWTAuth 13 | from ninja_jwt.controller import NinjaJWTDefaultController 14 | from ninja_jwt.tokens import RefreshToken 15 | 16 | from django.conf import settings 17 | from django.contrib.auth import login 18 | from django.http import HttpResponse 19 | from django.shortcuts import redirect, render 20 | 21 | from googler import ( 22 | oauth as googler_oauth, 23 | services as googler_services, 24 | schemas as googler_schemas 25 | ) 26 | 27 | from accounts.api import router as accounts_router 28 | from ai.api import router as ai_router 29 | from documents.api import router as document_router 30 | 31 | LOGIN_REDIRECT_URL = settings.LOGIN_REDIRECT_URL 32 | 33 | 34 | User = get_user_model() 35 | 36 | api = NinjaExtraAPI(auth=user_or_anon) 37 | 38 | # adds /api/token/refresh/ 39 | api.register_controllers(DjangoNextCustomController) 40 | api.add_router('/accounts', accounts_router) 41 | api.add_router('/ai', ai_router) 42 | api.add_router('/documents', document_router) 43 | 44 | 45 | @api.get("/hello/", auth=user_or_anon) 46 | def hello(request): 47 | print(request.auth, request.user) 48 | if request.auth: 49 | user = request.user 50 | if user.is_authenticated: 51 | return { 52 | "username": user.display_name, 53 | "email": user.email, 54 | } 55 | # print(request) 56 | return {"message": "Hello World"} 57 | 58 | 59 | @api.post("/login/", response=UserSchema, auth=anon_required) 60 | def login(request, payload: EmailLoginSchema): 61 | user = authenticate(email=payload.email, password=payload.password) 62 | if not user: 63 | raise HttpError(400, "Could not login user try again") 64 | if not user.is_active: 65 | raise HttpError(400, "User is not active") 66 | try: 67 | token = RefreshToken.for_user(user) 68 | return { 69 | "username": user.display_name, 70 | "email": user.email, 71 | "is_authenticated": True, 72 | "access_token": str(token.access_token), 73 | "refresh_token": str(token), 74 | } 75 | except Exception as e: 76 | raise HttpError(500, "Could not create user. Please try again later") 77 | 78 | 79 | @api.post("/signup/", response=UserSchema, auth=anon_required) 80 | def signup(request, payload: EmailLoginSchema): 81 | try: 82 | user = User.objects.create_user( 83 | email=payload.email, 84 | password=payload.password, 85 | is_active=True, 86 | ) 87 | user.save() 88 | token = RefreshToken.for_user(user) 89 | return { 90 | "username": user.display_name, 91 | "email": user.email, 92 | "is_authenticated": True, 93 | "access_token": str(token.access_token), 94 | "refresh_token": str(token), 95 | } 96 | except Exception as e: 97 | raise HttpError(500, "Could not create user. Please try again later") 98 | 99 | 100 | 101 | # /api/google/login/ 102 | @api.get("/google/login/", 103 | response=googler_schemas.GoogleLoginSchema, 104 | auth=anon_required) 105 | def google_login_view(request): 106 | google_oauth2_url = googler_oauth.generate_auth_url() 107 | return { 108 | "redirect_url": google_oauth2_url 109 | } 110 | 111 | @api.post("/google/callback/", 112 | response=UserSchema, 113 | auth=anon_required) 114 | def google_login_callback_view(request, payload: googler_schemas.GoogleCallbackSchema): 115 | # print(request.GET) 116 | state = payload.state 117 | code = payload.code 118 | try: 119 | token_json = googler_oauth.verify_google_oauth_callback(state, code) 120 | except Exception as e: 121 | raise HttpError(500, "Could login user. Please try again later") 122 | google_user_info = googler_oauth.verify_token_json(token_json) 123 | user = googler_services.get_or_create_google_user(google_user_info) 124 | if not user: 125 | raise HttpError(400, "Could not login user try again") 126 | if not user.is_active: 127 | raise HttpError(400, "User is not active") 128 | token = RefreshToken.for_user(user) 129 | return { 130 | "username": user.display_name, 131 | "email": user.email, 132 | "is_authenticated": True, 133 | "access_token": str(token.access_token), 134 | "refresh_token": str(token), 135 | } 136 | 137 | -------------------------------------------------------------------------------- /backend/src/cfehome/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for cfehome 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/5.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", "cfehome.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /backend/src/cfehome/installed.py: -------------------------------------------------------------------------------- 1 | # Application definition 2 | DJANGO_INSTALLED_APPS = [ 3 | "django.contrib.admin", 4 | "django.contrib.auth", 5 | "django.contrib.contenttypes", 6 | "django.contrib.sessions", 7 | "django.contrib.messages", 8 | "django.contrib.staticfiles", 9 | "django.contrib.sites", 10 | ] 11 | 12 | 13 | THIRD_PARTY_INSTALLED_APPS = [ 14 | "corsheaders", 15 | "ninja", 16 | "ninja_extra", 17 | "ninja_jwt", 18 | ] 19 | 20 | MY_APPS = [ 21 | "accounts", 22 | "documents", 23 | "profiles" 24 | ] 25 | 26 | 27 | INSTALLED_APPS = list(set(DJANGO_INSTALLED_APPS + THIRD_PARTY_INSTALLED_APPS + MY_APPS)) 28 | -------------------------------------------------------------------------------- /backend/src/cfehome/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for cfehome project using Django 5.1.5. 3 | """ 4 | 5 | from pathlib import Path 6 | from datetime import timedelta 7 | from django.core.management.utils import get_random_secret_key 8 | 9 | # uses python-decouple 10 | # loads environment variables from .env.local and .env 11 | from helpers import config 12 | 13 | from .installed import INSTALLED_APPS 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = config("DJANGO_SECRET_KEY", cast=str, default=get_random_secret_key()) 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = config("DJANGO_DEBUG", cast=bool, default=False) 27 | 28 | FRONTEND_URL = config("FRONTEND_URL", cast=str, default="https://djangonext.js") 29 | 30 | ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=list, default=[]) 31 | CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", cast=list, default=[]) 32 | 33 | APPEND_SLASH = config("DJANGO_APPEND_SLASH", cast=bool, default=True) 34 | 35 | if DEBUG: 36 | ALLOWED_HOSTS = ["localhost", "127.0.0.1"] 37 | CSRF_TRUSTED_ORIGINS = [ 38 | "http://localhost:3000", 39 | "http://127.0.0.1:3000", 40 | "http://localhost:8000", 41 | "http://127.0.0.1:8000", 42 | ] 43 | 44 | # Application definition 45 | SITE_ID = 1 46 | INSTALLED_APPS = INSTALLED_APPS 47 | AUTH_USER_MODEL = "accounts.MyUser" # accounts.models.MyUser 48 | 49 | MIDDLEWARE = [ 50 | "django.middleware.security.SecurityMiddleware", 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.middleware.common.CommonMiddleware", 53 | "corsheaders.middleware.CorsMiddleware", 54 | "django.middleware.csrf.CsrfViewMiddleware", 55 | "django.contrib.auth.middleware.AuthenticationMiddleware", 56 | "django.contrib.messages.middleware.MessageMiddleware", 57 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 58 | ] 59 | 60 | ROOT_URLCONF = "cfehome.urls" 61 | 62 | TEMPLATES = [ 63 | { 64 | "BACKEND": "django.template.backends.django.DjangoTemplates", 65 | "DIRS": [BASE_DIR / "templates"], 66 | "APP_DIRS": True, 67 | "OPTIONS": { 68 | "context_processors": [ 69 | "django.template.context_processors.debug", 70 | "django.template.context_processors.request", 71 | "django.contrib.auth.context_processors.auth", 72 | "django.contrib.messages.context_processors.messages", 73 | ], 74 | }, 75 | }, 76 | ] 77 | 78 | WSGI_APPLICATION = "cfehome.wsgi.application" 79 | 80 | 81 | # Database 82 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases 83 | 84 | DATABASES = { 85 | "default": { 86 | "ENGINE": "django.db.backends.sqlite3", 87 | "NAME": BASE_DIR / "db.sqlite3", 88 | } 89 | } 90 | 91 | DATABASE_URL = config("DATABASE_URL", cast=str, default="") 92 | if DATABASE_URL: 93 | if DATABASE_URL.startswith("postgres://") or DATABASE_URL.startswith("postgresql://"): 94 | import dj_database_url 95 | 96 | DATABASES = { 97 | "default": dj_database_url.config( 98 | default=DATABASE_URL, 99 | conn_max_age=60, 100 | conn_health_checks=True, 101 | ) 102 | } 103 | else: 104 | raise Exception("DATABASE_URL only supports PostgreSQL at this time") 105 | 106 | # Password validation 107 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators 108 | 109 | AUTH_PASSWORD_VALIDATORS = [ 110 | { 111 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 112 | }, 113 | { 114 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 115 | }, 116 | { 117 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 118 | }, 119 | { 120 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 121 | }, 122 | ] 123 | 124 | 125 | # Internationalization 126 | # https://docs.djangoproject.com/en/5.1/topics/i18n/ 127 | 128 | LANGUAGE_CODE = "en-us" 129 | 130 | TIME_ZONE = "UTC" 131 | 132 | USE_I18N = True 133 | 134 | USE_TZ = True 135 | 136 | 137 | # Static files (CSS, JavaScript, Images) 138 | # https://docs.djangoproject.com/en/5.1/howto/static-files/ 139 | 140 | STATIC_URL = "static/" 141 | STATICFILES_BASE_DIR = BASE_DIR / "staticfiles" 142 | STATICFILES_DIRS = [STATICFILES_BASE_DIR] 143 | 144 | # Default primary key field type 145 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field 146 | 147 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 148 | 149 | 150 | 151 | ### GOOGLE API OAUTH LOGIN 152 | 153 | GOOGLE_CLIENT_ID = config("GOOGLE_CLIENT_ID", default="", cast=str) 154 | GOOGLE_SECRET_KEY = config("GOOGLE_SECRET_KEY", default="", cast=str) 155 | GOOGLE_AUTH_BASE_URL = FRONTEND_URL 156 | GOOGLE_AUTH_CALLBACK_PATH = config("GOOGLE_AUTH_CALLBACK_PATH", default='/google/callback') 157 | print(GOOGLE_AUTH_BASE_URL, GOOGLE_AUTH_CALLBACK_PATH) 158 | 159 | 160 | ##### NINJA JWT SETTINGS 161 | 162 | NINJA_JWT = { 163 | 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), 164 | 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 165 | } 166 | 167 | 168 | 169 | #### CKEDITOR SETTINGS 170 | CKEDITOR_ACCESS_CREDS = config("CKEDITOR_ACCESS_CREDS", default="", cast=str) -------------------------------------------------------------------------------- /backend/src/cfehome/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for cfehome project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.1/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import path 20 | 21 | from . import views as base_views 22 | from .api import api 23 | 24 | urlpatterns = [ 25 | path("api/healthz/", base_views.healthz_view), 26 | path("api/", api.urls), 27 | path("admin/", admin.site.urls), 28 | ] 29 | -------------------------------------------------------------------------------- /backend/src/cfehome/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.http import JsonResponse 5 | from django.views.decorators.csrf import csrf_exempt 6 | 7 | 8 | @csrf_exempt 9 | def healthz_view(request): 10 | query_params = request.GET 11 | return JsonResponse( 12 | { 13 | "status": "ok", 14 | "frontend_url": settings.FRONTEND_URL, 15 | "query_params": dict(query_params), 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /backend/src/cfehome/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for cfehome 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/5.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", "cfehome.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/src/documents/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/documents/__init__.py -------------------------------------------------------------------------------- /backend/src/documents/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import Doc, DocUser 5 | 6 | 7 | class DocUserInline(admin.TabularInline): 8 | model = DocUser 9 | extra = 0 10 | 11 | class DocAdmin(admin.ModelAdmin): 12 | inlines = [DocUserInline] 13 | 14 | 15 | admin.site.register(Doc, DocAdmin) -------------------------------------------------------------------------------- /backend/src/documents/api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from ninja import Router 3 | from ninja.errors import HttpError 4 | 5 | from helpers.api.auth.permissions import user_required 6 | 7 | from .models import Doc 8 | from .schemas import DocSchema, DocUpdateSchema, DocCreateSchema 9 | from . import exceptions as doc_exceptions 10 | from . import services as doc_services 11 | 12 | router = Router() 13 | 14 | 15 | @router.get("/", response=List[DocSchema], auth=user_required) 16 | def document_list_view(request): 17 | qs = doc_services.list_documents(request.user) 18 | return qs 19 | 20 | @router.post("/", response={201: DocSchema}, auth=user_required) 21 | def document_create_view(request, payload:DocCreateSchema): 22 | obj = doc_services.create_document(user=request.user, title=payload.title) 23 | if obj is None: 24 | raise HttpError(400, "Invalid data, try again.") 25 | return 201, obj 26 | 27 | def http_document_detail(request, document_id): 28 | try: 29 | obj = doc_services.get_document(user=request.user, document_id=document_id) 30 | except doc_exceptions.DocumentNotFound as e: 31 | raise HttpError(404, f"{e}") 32 | except doc_exceptions.UserNoPermissionNotAllowed as e: 33 | raise HttpError(403, f"{e}") 34 | except: 35 | raise HttpError(500, "Unknown server error") 36 | if obj is None: 37 | raise HttpError(404, f"{document_id} is not found") 38 | return obj 39 | 40 | @router.get("/{document_id}/", response=DocSchema, auth=user_required) 41 | def document_detail_view(request, document_id): 42 | obj = http_document_detail(request, document_id) 43 | return obj 44 | 45 | 46 | @router.put("/{document_id}/", response=DocSchema, auth=user_required) 47 | def document_update_view(request, document_id, payload:DocUpdateSchema): 48 | obj = http_document_detail(request, document_id) 49 | update_data = payload.model_dump() 50 | for key, val in update_data.items(): 51 | setattr(obj, key, val) 52 | # obj.last_updated_by = request.user 53 | obj.save() 54 | return obj 55 | 56 | 57 | """ 58 | @router.delete("/{document_id}/", response={204: Any}, auth=user_required) 59 | def document_update_view(request, document_id, payload:DocUpdateSchema): 60 | obj = http_document_detail(request, document_id) 61 | return 204, "Item delete" 62 | """ -------------------------------------------------------------------------------- /backend/src/documents/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DocumentsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'documents' 7 | -------------------------------------------------------------------------------- /backend/src/documents/exceptions.py: -------------------------------------------------------------------------------- 1 | class DocumentNotFound(Exception): 2 | pass 3 | 4 | class UserNoPermissionNotAllowed(Exception): 5 | pass -------------------------------------------------------------------------------- /backend/src/documents/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-02-28 21:09 2 | 3 | import django.db.models.deletion 4 | import uuid 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Doc', 20 | fields=[ 21 | ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 22 | ('title', models.CharField(blank=True, max_length=120, null=True)), 23 | ('content', models.TextField(blank=True, null=True)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('updated_at', models.DateTimeField(auto_now=True)), 26 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /backend/src/documents/migrations/0002_docuser.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-03-06 00:01 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('documents', '0001_initial'), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='DocUser', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('active', models.BooleanField(default=True)), 21 | ('inactive_at', models.DateTimeField(blank=True, null=True)), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('updated_at', models.DateTimeField(auto_now=True)), 24 | ('doc', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='documents.doc')), 25 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /backend/src/documents/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/documents/migrations/__init__.py -------------------------------------------------------------------------------- /backend/src/documents/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.conf import settings 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | # Create your models here. 7 | 8 | User = settings.AUTH_USER_MODEL 9 | 10 | class Doc(models.Model): 11 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, db_index=True, editable=False) 12 | user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) 13 | title = models.CharField(max_length=120, blank=True, null=True) 14 | content = models.TextField(blank=True, null=True) 15 | created_at = models.DateTimeField(auto_now_add=True) 16 | updated_at = models.DateTimeField(auto_now=True) 17 | 18 | 19 | class DocUser(models.Model): 20 | doc = models.ForeignKey(Doc, null=True, on_delete=models.SET_NULL) 21 | user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) 22 | active = models.BooleanField(default=True) 23 | inactive_at = models.DateTimeField(auto_now=False, auto_now_add=False, null=True, blank=True) 24 | created_at = models.DateTimeField(auto_now_add=True) 25 | updated_at = models.DateTimeField(auto_now=True) 26 | 27 | def save(self, *args, **kwargs): 28 | if not self.active and self.inactive_at is None: 29 | self.inactive_at = timezone.now() 30 | super().save(*args, **kwargs) -------------------------------------------------------------------------------- /backend/src/documents/schemas.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from ninja import Schema, Field 3 | 4 | # Schema -> Pydantic BaseModel 5 | 6 | class DocSchema(Schema): 7 | id: uuid.UUID 8 | title: str 9 | content: str | None = Field(default="") 10 | 11 | 12 | class DocCreateSchema(Schema): 13 | title: str 14 | 15 | class DocUpdateSchema(Schema): 16 | title: str 17 | content: str -------------------------------------------------------------------------------- /backend/src/documents/services.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.core.cache import cache 3 | 4 | from . import exceptions 5 | from .models import Doc 6 | 7 | DOC_CACHE_KEY = "documents:list:{user_id}" 8 | DOC_CACHE_TIMEOUT = 300 9 | 10 | 11 | def create_document(user=None, title=None): 12 | if user is None or title is None: 13 | return None 14 | return Doc.objects.create(user=user, title=title) 15 | 16 | 17 | def list_documents(user=None, force=False): 18 | if user is None: 19 | return [] 20 | cache_key = DOC_CACHE_KEY.format(user_id=user.id) 21 | cached_qs = cache.get(cache_key) 22 | if cached_qs and not force: 23 | return cached_qs 24 | qs = Doc.objects.filter( 25 | Q(user=user) | 26 | Q(docuser__user=user) 27 | ).values('id', 'content', 'title') 28 | cache.set(cache_key, qs, timeout=DOC_CACHE_TIMEOUT) 29 | return qs 30 | 31 | def get_document(user=None, document_id=None): 32 | if user is None or document_id is None: 33 | return None 34 | try: 35 | obj = Doc.objects.get(id=document_id) 36 | except Doc.DoesNotExist: 37 | raise exceptions.DocumentNotFound(f"{document_id} not found.") 38 | except: 39 | raise exceptions.DocumentNotFound(f"{document_id} not found.") 40 | is_owner = obj.user == user 41 | is_doc_user = obj.docuser_set.filter(user=user, active=True).exists() 42 | has_permission = is_owner or is_doc_user 43 | if not has_permission: 44 | raise exceptions.UserNoPermissionNotAllowed(f"{user} needs access.") 45 | return obj -------------------------------------------------------------------------------- /backend/src/documents/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/src/documents/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/src/googler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/googler/__init__.py -------------------------------------------------------------------------------- /backend/src/googler/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /backend/src/googler/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GooglerConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'googler' 7 | -------------------------------------------------------------------------------- /backend/src/googler/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/googler/migrations/__init__.py -------------------------------------------------------------------------------- /backend/src/googler/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /backend/src/googler/oauth.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin, urlencode 2 | from django.conf import settings 3 | from django.core.cache import cache 4 | from google.auth.transport import requests as google_requests 5 | from google.oauth2 import id_token 6 | from django.urls import reverse 7 | 8 | import requests 9 | 10 | from . import security 11 | 12 | GOOGLE_AUTH_CACHE_KEY_PREFIX = "google:auth:state" 13 | GOOGLE_CLIENT_ID = settings.GOOGLE_CLIENT_ID 14 | GOOGLE_SECRET_KEY = settings.GOOGLE_SECRET_KEY 15 | GOOGLE_AUTH_BASE_URL = settings.GOOGLE_AUTH_BASE_URL 16 | 17 | def get_google_auth_callback_path(): 18 | return reverse("googler:callback") 19 | 20 | 21 | def get_google_oauth_callback_url(drop_https=False, force_https=False): 22 | callback_path = getattr(settings, 'GOOGLE_AUTH_CALLBACK_PATH', None) or get_google_auth_callback_path() 23 | url = urljoin(GOOGLE_AUTH_BASE_URL, callback_path) 24 | if drop_https: 25 | url = url.replace("https://", "http://") 26 | if force_https: 27 | url = url.replace("http://", "https://") 28 | return url 29 | 30 | 31 | def generate_auth_url(): 32 | redirect_uri = get_google_oauth_callback_url() 33 | # public state item 34 | state = security.generate_state() 35 | 36 | # private, public 37 | code_verifier, code_challenge = security.generate_pkce_pair() 38 | # request.session['some_val'] = code_verifier 39 | 40 | cache_key = f"{GOOGLE_AUTH_CACHE_KEY_PREFIX}:{state}" 41 | # use redis caching key-val 42 | cache.set(cache_key, code_verifier, 30) 43 | # cache.get(cache_key) 44 | 45 | # google cloud auth platform client id 46 | google_auth_client_id = GOOGLE_CLIENT_ID 47 | 48 | scope = " ".join([ 49 | "openid", 50 | "email", 51 | "profile", 52 | ]) 53 | 54 | auth_params = { 55 | "client_id": google_auth_client_id, 56 | "redirect_uri": redirect_uri, 57 | "response_type": "code", 58 | "scope": scope, 59 | "state": state, 60 | "code_challenge": code_challenge, 61 | "code_challenge_method": "S256", 62 | "access_type": "offline", 63 | # "prompt": "consent", 64 | "prompt": "select_account", 65 | } 66 | encoded_params = urlencode(auth_params) 67 | google_oauth_url = "https://accounts.google.com/o/oauth2/v2/auth" 68 | return urljoin(google_oauth_url, f"?{encoded_params}") 69 | 70 | 71 | def verify_google_oauth_callback(state, code): 72 | redirect_uri = get_google_oauth_callback_url() 73 | cache_key = f"{GOOGLE_AUTH_CACHE_KEY_PREFIX}:{state}" 74 | code_verifier = cache.get(cache_key) 75 | if code_verifier is None or code is None or state is None: 76 | raise Exception("Invalid code or expired code.") 77 | 78 | token_endpoint = "https://oauth2.googleapis.com/token" 79 | token_data = { 80 | "client_id": GOOGLE_CLIENT_ID, 81 | "client_secret": GOOGLE_SECRET_KEY, 82 | "redirect_uri": redirect_uri, 83 | "grant_type": "authorization_code", 84 | "code": code, 85 | "code_verifier": code_verifier, 86 | } 87 | r = requests.post(token_endpoint, data=token_data) 88 | r.raise_for_status() 89 | return r.json() 90 | 91 | 92 | def verify_token_json(token_json): 93 | id_token_jwt = token_json.get('id_token') 94 | google_user_info = id_token.verify_oauth2_token( 95 | id_token_jwt, google_requests.Request(), GOOGLE_CLIENT_ID 96 | ) 97 | if google_user_info["iss"] not in [ 98 | "accounts.google.com", 99 | "https://accounts.google.com", 100 | ]: 101 | raise Exception("Invalid issuer") 102 | return google_user_info -------------------------------------------------------------------------------- /backend/src/googler/schemas.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class GoogleLoginSchema(Schema): 5 | redirect_url: str 6 | 7 | 8 | class GoogleCallbackSchema(Schema): 9 | code: str 10 | state: str -------------------------------------------------------------------------------- /backend/src/googler/security.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import os 4 | 5 | def generate_state(): 6 | state = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").replace("=", "") 7 | return state 8 | 9 | def generate_pkce_pair(): 10 | """Generate PKCE code verifier and challenge pair.""" 11 | code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") 12 | code_verifier = code_verifier.replace("=", "") # Remove padding 13 | 14 | code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest() 15 | code_challenge = base64.urlsafe_b64encode(code_challenge).decode("utf-8") 16 | code_challenge = code_challenge.replace("=", "") # Remove padding 17 | 18 | return code_verifier, code_challenge 19 | -------------------------------------------------------------------------------- /backend/src/googler/services.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.contrib.auth import get_user_model 3 | 4 | User = get_user_model() 5 | 6 | def get_or_create_google_user(google_user_info): 7 | email = google_user_info.get("email") 8 | if not email: 9 | raise ValueError("No email provided by Google") 10 | 11 | try: 12 | user = User.objects.get(email=email) 13 | except User.DoesNotExist: 14 | # Create new user 15 | user = User.objects.create_user( 16 | email=email, 17 | # first_name=google_user_info.get("given_name", ""), 18 | # last_name=google_user_info.get("family_name", ""), 19 | ) 20 | user.set_unusable_password() 21 | # You might want to mark this user as having been created through Google OAuth 22 | user.save() 23 | 24 | return user -------------------------------------------------------------------------------- /backend/src/googler/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/src/googler/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from googler import views 4 | 5 | app_name = 'googler' 6 | urlpatterns = [ 7 | path('login/', views.google_login_view, name='login'), 8 | path('callback/', views.google_login_callback_view, name='callback'), 9 | ] 10 | -------------------------------------------------------------------------------- /backend/src/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .dotenv.loader import config 2 | 3 | __all__ = ["config"] 4 | -------------------------------------------------------------------------------- /backend/src/helpers/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/helpers/api/__init__.py -------------------------------------------------------------------------------- /backend/src/helpers/api/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/helpers/api/auth/__init__.py -------------------------------------------------------------------------------- /backend/src/helpers/api/auth/controllers.py: -------------------------------------------------------------------------------- 1 | from ninja_extra import api_controller, http_post 2 | from ninja_jwt.controller import TokenObtainPairController 3 | from ninja_jwt.schema_control import SchemaControl 4 | from ninja_jwt.settings import api_settings 5 | 6 | schema = SchemaControl(api_settings) 7 | 8 | 9 | class DjangoNextTokenObtainPairController(TokenObtainPairController): 10 | @http_post( 11 | "/pair/", 12 | response=schema.obtain_pair_schema.get_response_schema(), 13 | url_name="token_obtain_pair_slash", 14 | operation_id="token_obtain_pair", 15 | ) 16 | def obtain_token(self, user_token: schema.obtain_pair_schema): 17 | user_token.check_user_authentication_rule() 18 | return user_token.to_response_schema() 19 | 20 | @http_post( 21 | "/refresh/", 22 | response=schema.obtain_pair_refresh_schema.get_response_schema(), 23 | url_name="token_refresh_slash", 24 | operation_id="token_refresh", 25 | ) 26 | def refresh_token(self, refresh_token: schema.obtain_pair_refresh_schema): 27 | return refresh_token.to_response_schema() 28 | 29 | 30 | @api_controller("token", tags=["Auth"]) 31 | class DjangoNextCustomController(DjangoNextTokenObtainPairController): 32 | """obtain token and refresh_token with support for trailing slashes""" 33 | 34 | pass 35 | pass 36 | -------------------------------------------------------------------------------- /backend/src/helpers/api/auth/permissions.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from ninja_jwt.authentication import JWTAuth 3 | 4 | 5 | def allow_anon(request: HttpRequest) -> bool: 6 | """Check if user is anonymous (not authenticated)""" 7 | return not request.user.is_authenticated 8 | 9 | 10 | def verify_authenticated(request: HttpRequest) -> bool: 11 | """Check if user is authenticated""" 12 | return request.user.is_authenticated 13 | 14 | 15 | def allow_staff(request: HttpRequest) -> bool: 16 | """Check if user is staff member""" 17 | return request.user.is_authenticated and request.user.is_staff 18 | 19 | 20 | def allow_superuser(request: HttpRequest) -> bool: 21 | """Check if user is superuser""" 22 | return request.user.is_authenticated and request.user.is_superuser 23 | 24 | 25 | def allow_any(request: HttpRequest) -> bool: 26 | """Allow both authenticated and anonymous users""" 27 | return True 28 | 29 | 30 | # Common permission combinations 31 | user_required = [JWTAuth(), verify_authenticated] 32 | anon_required = [JWTAuth(), allow_anon] 33 | staff_required = [JWTAuth(), allow_staff] 34 | superuser_required = [JWTAuth(), allow_superuser] 35 | user_or_anon = [JWTAuth(), allow_any] # Allows both authenticated and anonymous users 36 | -------------------------------------------------------------------------------- /backend/src/helpers/api/auth/schemas.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.core.validators import EmailValidator 5 | from ninja import Schema 6 | from ninja.errors import HttpError 7 | 8 | 9 | class BaseUserSchema(Schema): 10 | """Base schema with common validation methods""" 11 | 12 | @staticmethod 13 | def validate_username(value: str | None) -> str | None: 14 | if value is None: 15 | return None 16 | if len(value) < 3: 17 | raise HttpError(400, "Username must be at least 3 characters long") 18 | if not re.match("^[a-zA-Z0-9_]+$", value): 19 | raise HttpError( 20 | 400, "Username can only contain letters, numbers, and underscores" 21 | ) 22 | return value 23 | 24 | @staticmethod 25 | def validate_email(value: str | None) -> str | None: 26 | if value is None: 27 | return None 28 | email_validator = EmailValidator() 29 | try: 30 | email_validator(value) 31 | except: 32 | raise HttpError(400, "Invalid email address") 33 | return value 34 | 35 | @staticmethod 36 | def validate_password(value: str) -> str: 37 | if len(value) < 8: 38 | raise HttpError(400, "Password must be at least 8 characters long") 39 | if not any(c.isupper() for c in value): 40 | raise HttpError(400, "Password must contain at least one uppercase letter") 41 | if not any(c.islower() for c in value): 42 | raise HttpError(400, "Password must contain at least one lowercase letter") 43 | if not any(c.isdigit() for c in value): 44 | raise HttpError(400, "Password must contain at least one number") 45 | return value 46 | 47 | def check_availability(self, username: str | None, email: str | None) -> None: 48 | User = get_user_model() 49 | conditions = [] 50 | 51 | if username: 52 | conditions.append(User.objects.filter(username__iexact=username).exists()) 53 | if email: 54 | conditions.append(User.objects.filter(email__iexact=email).exists()) 55 | 56 | if any(conditions): 57 | raise HttpError( 58 | 400, "Username and/or email are not available. Please try again" 59 | ) 60 | 61 | 62 | class BaseRegistrationSchema(BaseUserSchema): 63 | """Base registration schema with common password validation""" 64 | 65 | password: str 66 | confirm_password: str 67 | 68 | def validate(self, data: dict) -> dict: 69 | if data["password"] != data["confirm_password"]: 70 | raise HttpError(400, "Passwords do not match") 71 | self.check_availability(username=data.get("username"), email=data.get("email")) 72 | return data 73 | 74 | 75 | class UsernameOnlySchema(BaseRegistrationSchema): 76 | username: str 77 | 78 | 79 | class EmailOnlySchema(BaseRegistrationSchema): 80 | email: str 81 | 82 | 83 | class UsernameMandatoryEmailOptionalSchema(BaseRegistrationSchema): 84 | username: str 85 | email: str | None = None 86 | 87 | 88 | class EmailMandatoryUsernameOptionalSchema(BaseRegistrationSchema): 89 | username: str | None = None 90 | email: str 91 | 92 | 93 | class UsernameMandatoryEmailMandatorySchema(BaseRegistrationSchema): 94 | username: str 95 | email: str 96 | 97 | 98 | 99 | class EmailLoginSchema(BaseUserSchema): 100 | email: str 101 | password: str -------------------------------------------------------------------------------- /backend/src/helpers/api/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/helpers/api/users/__init__.py -------------------------------------------------------------------------------- /backend/src/helpers/api/users/schemas.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class UserSchema(Schema): 5 | username: str | None 6 | email: str 7 | is_authenticated: bool 8 | access_token: str | None 9 | refresh_token: str | None 10 | -------------------------------------------------------------------------------- /backend/src/helpers/dotenv/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/helpers/dotenv/__init__.py -------------------------------------------------------------------------------- /backend/src/helpers/dotenv/loader.py: -------------------------------------------------------------------------------- 1 | # using python-decouple to load environment variables 2 | import logging 3 | import pathlib 4 | from functools import lru_cache 5 | 6 | from decouple import Config, RepositoryEnv 7 | 8 | THIS_DIR = pathlib.Path(__file__).parent 9 | HELPERS_DIR = THIS_DIR.parent # src/helpers/ 10 | BASE_DIR = HELPERS_DIR.parent # src/ 11 | PROJECT_DIR = BASE_DIR.parent # / 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @lru_cache(maxsize=1) 17 | def get_config(allowed_envs: list[str] = [".env", ".env.local"]): 18 | for env in allowed_envs: 19 | path = PROJECT_DIR / env 20 | if path.exists(): 21 | try: 22 | logger.info(f"Loading {env}") 23 | return Config(RepositoryEnv(path)) 24 | except FileNotFoundError: 25 | continue 26 | from decouple import config 27 | 28 | return config 29 | 30 | 31 | config = get_config() 32 | -------------------------------------------------------------------------------- /backend/src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | """Run administrative tasks.""" 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cfehome.settings") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /backend/src/profiles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/profiles/__init__.py -------------------------------------------------------------------------------- /backend/src/profiles/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import Profile 5 | 6 | admin.site.register(Profile) -------------------------------------------------------------------------------- /backend/src/profiles/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ProfilesConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'profiles' 7 | 8 | 9 | def ready(self): 10 | import profiles.signals # noqa 11 | -------------------------------------------------------------------------------- /backend/src/profiles/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-02-27 17:54 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 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='Profile', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('bio', models.TextField(blank=True, null=True)), 22 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /backend/src/profiles/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/profiles/migrations/__init__.py -------------------------------------------------------------------------------- /backend/src/profiles/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | User = settings.AUTH_USER_MODEL # "auth.User" 5 | 6 | class Profile(models.Model): 7 | user = models.OneToOneField(User, on_delete=models.CASCADE) 8 | bio = models.TextField(blank=True, null=True) -------------------------------------------------------------------------------- /backend/src/profiles/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | 4 | from django.conf import settings 5 | from .models import Profile 6 | 7 | User = settings.AUTH_USER_MODEL # "auth.User" 8 | 9 | 10 | @receiver(post_save, sender=User) 11 | def create_user_profile_post_save(sender, instance, created, *args, **kwargs): 12 | if created: 13 | Profile.objects.get_or_create(user=instance) -------------------------------------------------------------------------------- /backend/src/profiles/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/src/profiles/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/src/requirements/dev.in: -------------------------------------------------------------------------------- 1 | rav -------------------------------------------------------------------------------- /backend/src/requirements/prod.in: -------------------------------------------------------------------------------- 1 | django 2 | django-cors-headers 3 | django-ninja 4 | django-ninja-jwt[crypto] 5 | pydantic[email] 6 | python-decouple 7 | dj-database-url 8 | psycopg[binary] 9 | google-auth 10 | google-auth-oauthlib 11 | google-auth-httplib2 -------------------------------------------------------------------------------- /backend/src/staticfiles/.git-keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/staticfiles/.git-keep -------------------------------------------------------------------------------- /backend/src/templates/.git-keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/templates/.git-keep -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:17 4 | environment: 5 | POSTGRES_USER: postgres 6 | POSTGRES_PASSWORD: postgres 7 | POSTGRES_DB: postgres 8 | ports: 9 | - "5430:5432" 10 | volumes: 11 | - postgres_data:/var/lib/postgresql/data 12 | 13 | volumes: 14 | postgres_data: 15 | -------------------------------------------------------------------------------- /frontend/.env.sample: -------------------------------------------------------------------------------- 1 | DJANGO_API_URL=http://localhost:8000/api/ -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 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 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 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 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .env* 133 | !.env.sample 134 | .venv 135 | env/ 136 | venv/ 137 | ENV/ 138 | env.bak/ 139 | venv.bak/ 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | # mypy 152 | .mypy_cache/ 153 | .dmypy.json 154 | dmypy.json 155 | 156 | # Pyre type checker 157 | .pyre/ 158 | 159 | # pytype static type analyzer 160 | .pytype/ 161 | 162 | # Cython debug symbols 163 | cython_debug/ 164 | 165 | # PyCharm 166 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 167 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 168 | # and can be added to the global gitignore or merged into this file. For a more nuclear 169 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 170 | #.idea/ 171 | 172 | # Ruff stuff: 173 | .ruff_cache/ 174 | 175 | # PyPI configuration file 176 | .pypirc 177 | 178 | 179 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 180 | 181 | # dependencies 182 | frontend/.next 183 | frontend/node_modules 184 | /node_modules 185 | /.pnp 186 | .pnp.* 187 | .yarn/* 188 | !.yarn/patches 189 | !.yarn/plugins 190 | !.yarn/releases 191 | !.yarn/versions 192 | 193 | # testing 194 | /coverage 195 | 196 | # next.js 197 | /.next/ 198 | /out/ 199 | 200 | # production 201 | /build 202 | 203 | # misc 204 | .DS_Store 205 | *.pem 206 | 207 | # debug 208 | npm-debug.log* 209 | yarn-debug.log* 210 | yarn-error.log* 211 | .pnpm-debug.log* 212 | 213 | 214 | # vercel 215 | .vercel 216 | 217 | # typescript 218 | *.tsbuildinfo 219 | next-env.d.ts 220 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Node.js 22 as the base image 2 | FROM node:22.6.0-alpine 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Install curl 8 | RUN apk add --no-cache curl 9 | 10 | # Copy package.json and package-lock.json to the working directory 11 | COPY package*.json ./ 12 | 13 | # Install dependencies 14 | RUN npm install 15 | 16 | # Copy the rest of the application code 17 | COPY . . 18 | 19 | # Build the Next.js application 20 | RUN npm run build 21 | 22 | # Use an environment variable for the port, defaulting to 3000 23 | ENV PORT=3000 24 | 25 | # Expose the port that Next.js runs on 26 | EXPOSE ${PORT} 27 | 28 | # Start the application, passing the PORT environment variable 29 | CMD ["sh", "-c", "npm start -- -p ${PORT}"] -------------------------------------------------------------------------------- /frontend/Dockerfile.demo: -------------------------------------------------------------------------------- 1 | # Used for Railway demo with this monorepo 2 | 3 | # Use Node.js 22 as the base image 4 | FROM node:22.6.0-alpine 5 | 6 | # Set the working directory in the container 7 | WORKDIR /app 8 | 9 | # Install curl 10 | RUN apk add --no-cache curl 11 | 12 | # Copy files conditionally based on directory structure 13 | # mostly for Railway demo 14 | COPY . /tmp/source/ 15 | RUN if [ -d "/tmp/source/frontend" ]; then \ 16 | echo "Using /tmp/source/frontend directory" && \ 17 | cp -r /tmp/source/frontend/* .; \ 18 | else \ 19 | echo "Using /tmp/source directory" && \ 20 | cp -r /tmp/source/* .; \ 21 | fi && \ 22 | rm -rf /tmp/source 23 | 24 | RUN cat package.json 25 | 26 | # Install dependencies 27 | RUN npm install 28 | 29 | # Build the Next.js application 30 | RUN npm run build 31 | 32 | # Use an environment variable for the port, defaulting to 3000 33 | ENV PORT=3000 34 | 35 | # Expose the port that Next.js runs on 36 | EXPOSE ${PORT} 37 | 38 | # Start the application, passing the PORT environment variable 39 | CMD ["sh", "-c", "npm start -- -p ${PORT}"] -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": false, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [...compat.extends("next/core-web-vitals")]; 13 | 14 | export default eslintConfig; 15 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "rm -rf .next && next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ckeditor/ckeditor5-react": "^9.5.0", 13 | "@radix-ui/react-dialog": "^1.1.5", 14 | "@radix-ui/react-dropdown-menu": "^2.1.5", 15 | "@radix-ui/react-icons": "^1.3.2", 16 | "@radix-ui/react-label": "^2.1.1", 17 | "@radix-ui/react-slot": "^1.1.1", 18 | "@tailwindcss/typography": "^0.5.16", 19 | "ckeditor5": "^44.2.1", 20 | "ckeditor5-premium-features": "^44.3.0", 21 | "class-variance-authority": "^0.7.1", 22 | "clsx": "^2.1.1", 23 | "lucide-react": "^0.474.0", 24 | "next": "15.1.6", 25 | "next-themes": "^0.4.4", 26 | "react": "^19.0.0", 27 | "react-dom": "^19.0.0", 28 | "swr": "^2.3.0", 29 | "tailwind-merge": "^2.6.0", 30 | "tailwindcss-animate": "^1.0.7" 31 | }, 32 | "devDependencies": { 33 | "@eslint/eslintrc": "^3.2.0", 34 | "eslint": "^9.19.0", 35 | "eslint-config-next": "15.1.6", 36 | "postcss": "^8.5.1", 37 | "tailwindcss": "^3.4.17" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /frontend/public/django-nextjs-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/frontend/public/django-nextjs-favicon.png -------------------------------------------------------------------------------- /frontend/public/django-nextjs-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/django-nextjs.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/frontend/public/login.png -------------------------------------------------------------------------------- /frontend/public/signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/frontend/public/signup.png -------------------------------------------------------------------------------- /frontend/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/railway.demo.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "builder": "DOCKERFILE", 4 | "dockerfilePath": "/frontend/Dockerfile.demo" 5 | }, 6 | "deploy": { 7 | "healthcheckPath": "/healthz" 8 | } 9 | } -------------------------------------------------------------------------------- /frontend/railway.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "builder": "DOCKERFILE", 4 | "dockerfilePath": "./Dockerfile", 5 | "watchPatterns": [ 6 | "public/**", 7 | "src/**", 8 | "Dockerfile", 9 | "jsconfig.json", 10 | "next.config.js", 11 | "package-lock.json", 12 | "package.json", 13 | "postcss.config.js", 14 | "railway.toml", 15 | "tailwind.config.js" 16 | ] 17 | }, 18 | "deploy": { 19 | "healthcheckPath": "/healthz" 20 | } 21 | } -------------------------------------------------------------------------------- /frontend/src/app/api/[...path]/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import {urlJoin} from '@/lib/urlJoin'; 3 | import { TokenFetch } from '@/lib/tokenFetcher'; 4 | 5 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 6 | 7 | export async function GET(request, { params }) { 8 | const path = (await params).path; 9 | const url = urlJoin(DJANGO_API_URL, path) + request.nextUrl.search; 10 | try { 11 | const response = await TokenFetch.fetch(url, { 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | method: 'GET', 16 | }); 17 | if (!response.ok) { 18 | return NextResponse.json( 19 | { error: 'Failed to fetch from backend' }, 20 | { status: response.status } 21 | ); 22 | } 23 | const data = await response.json(); 24 | return NextResponse.json(data, { status: response.status }); 25 | } catch (error) { 26 | return NextResponse.json( 27 | { error: 'Failed to fetch from backend' }, 28 | { status: 500 } 29 | ); 30 | } 31 | } 32 | 33 | export async function POST(request, { params }) { 34 | const path = (await params).path; 35 | const url = urlJoin(DJANGO_API_URL, path) + request.nextUrl.search; 36 | try { 37 | const body = await request.json(); 38 | const response = await TokenFetch.fetch(url, { 39 | method: 'POST', 40 | headers: { 41 | 'Content-Type': 'application/json', 42 | // Forward other headers as needed 43 | }, 44 | body: JSON.stringify(body), 45 | }); 46 | if (!response.ok) { 47 | return NextResponse.json( 48 | { error: 'Failed to fetch from backend' }, 49 | { status: response.status } 50 | ); 51 | } 52 | 53 | const data = await response.json(); 54 | return NextResponse.json(data, { status: response.status }); 55 | } catch (error) { 56 | return NextResponse.json( 57 | { error: 'Failed to post to backend' }, 58 | { status: 500 } 59 | ); 60 | } 61 | } 62 | 63 | 64 | export async function PUT(request, { params }) { 65 | const path = (await params).path; 66 | const url = urlJoin(DJANGO_API_URL, path) + request.nextUrl.search; 67 | try { 68 | const body = await request.json(); 69 | const response = await TokenFetch.fetch(url, { 70 | method: 'put', 71 | headers: { 72 | 'Content-Type': 'application/json', 73 | // Forward other headers as needed 74 | }, 75 | body: JSON.stringify(body), 76 | }); 77 | const data = await response.json(); 78 | if (!response.ok) { 79 | return NextResponse.json( 80 | data, 81 | { status: response.status } 82 | ); 83 | } 84 | 85 | 86 | return NextResponse.json(data, { status: response.status }); 87 | } catch (error) { 88 | return NextResponse.json( 89 | { error: 'Failed to post to backend' }, 90 | { status: 500 } 91 | ); 92 | } 93 | } -------------------------------------------------------------------------------- /frontend/src/app/api/backend/healthz/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import {urlJoin} from '@/lib/urlJoin'; 3 | 4 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 5 | 6 | export async function GET(request) { 7 | const url = urlJoin(DJANGO_API_URL, "/healthz") 8 | try { 9 | const response = await fetch(url, { 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | }, 13 | method: 'GET', 14 | }); 15 | 16 | if (response.ok) { 17 | return NextResponse.json({ status: 'healthy' }, { status: 200 }); 18 | } else { 19 | return NextResponse.json({ status: 'unhealthy' }, { status: 503 }); 20 | } 21 | } catch (error) { 22 | return NextResponse.json( 23 | { status: 'unhealthy' }, 24 | { status: 503 } 25 | ); 26 | } 27 | } -------------------------------------------------------------------------------- /frontend/src/app/api/ckeditor/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | // You can configure this to point to your backend 4 | const CKEDITOR_LICENSE_KEY = process.env.CKEDITOR_LICENSE_KEY; 5 | 6 | export async function GET(request) { 7 | return NextResponse.json({"license": CKEDITOR_LICENSE_KEY}); 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/api/google/callback/route.js: -------------------------------------------------------------------------------- 1 | "use server" 2 | import { setRefreshToken, setToken } from '@/lib/auth' 3 | import { NextResponse } from 'next/server' 4 | import { urlJoin } from '@/lib/urlJoin' 5 | 6 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 7 | 8 | export async function POST(request) { 9 | const requestData = await request.json() 10 | const url = urlJoin(DJANGO_API_URL, "google/callback") 11 | const jsonData = JSON.stringify(requestData) 12 | const requestOptions = { 13 | method: "POST", 14 | headers: { 15 | "Content-Type": "application/json" 16 | }, 17 | body: jsonData 18 | } 19 | const response = await fetch(url, requestOptions) 20 | const responseData = await response.json() 21 | if (response.ok) { 22 | const accessToken = responseData.access_token || responseData.access 23 | const refreshToken = responseData.refresh_token || responseData.refresh 24 | if (!accessToken || !refreshToken) { 25 | return NextResponse.json({"detail": "Invalid response from server. Please try again."}, {status: 400}) 26 | } 27 | await setToken(accessToken) 28 | await setRefreshToken(refreshToken) 29 | return NextResponse.json({"loggedIn": true, "username": responseData.username}, {status: 200}) 30 | } 31 | return NextResponse.json({"loggedIn": false, ...responseData}, {status: 400}) 32 | } -------------------------------------------------------------------------------- /frontend/src/app/api/health/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | // You can configure this to point to your backend 4 | const BACKEND_URL = process.env.DJANGO_BASE_URL; 5 | 6 | export async function GET(request) { 7 | return NextResponse.json({"django_base_url": BACKEND_URL, "status": "ok"}); 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/api/login/route.js: -------------------------------------------------------------------------------- 1 | "use server" 2 | import { setRefreshToken, setToken } from '@/lib/auth' 3 | import { NextResponse } from 'next/server' 4 | import { urlJoin } from '@/lib/urlJoin' 5 | 6 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 7 | 8 | export async function POST(request) { 9 | const requestData = await request.json() 10 | const url = urlJoin(DJANGO_API_URL, "login") 11 | const jsonData = JSON.stringify(requestData) 12 | const requestOptions = { 13 | method: "POST", 14 | headers: { 15 | "Content-Type": "application/json" 16 | }, 17 | body: jsonData 18 | } 19 | const response = await fetch(url, requestOptions) 20 | const responseData = await response.json() 21 | if (response.ok) { 22 | const accessToken = responseData.access_token || responseData.access 23 | const refreshToken = responseData.refresh_token || responseData.refresh 24 | if (!accessToken || !refreshToken) { 25 | return NextResponse.json({"detail": "Invalid response from server. Please try again."}, {status: 400}) 26 | } 27 | await setToken(accessToken) 28 | await setRefreshToken(refreshToken) 29 | return NextResponse.json({"loggedIn": true, "username": responseData.username}, {status: 200}) 30 | } 31 | return NextResponse.json({"loggedIn": false, ...responseData}, {status: 400}) 32 | } -------------------------------------------------------------------------------- /frontend/src/app/api/logout/route.js: -------------------------------------------------------------------------------- 1 | import { deleteTokens } from "@/lib/auth"; 2 | import { NextResponse } from "next/server"; 3 | 4 | 5 | export async function POST(request) { 6 | await deleteTokens() 7 | return NextResponse.json({}, {status: 200}) 8 | } -------------------------------------------------------------------------------- /frontend/src/app/api/signup/route.js: -------------------------------------------------------------------------------- 1 | "use server" 2 | import { setRefreshToken, setToken } from '@/lib/auth' 3 | import { NextResponse } from 'next/server' 4 | import { urlJoin } from '@/lib/urlJoin' 5 | 6 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 7 | 8 | export async function POST(request) { 9 | const requestData = await request.json() 10 | const url = urlJoin(DJANGO_API_URL, "signup") 11 | const jsonData = JSON.stringify(requestData) 12 | const requestOptions = { 13 | method: "POST", 14 | headers: { 15 | "Content-Type": "application/json" 16 | }, 17 | body: jsonData 18 | } 19 | const response = await fetch(url, requestOptions) 20 | const responseData = await response.json() 21 | if (response.ok) { 22 | const accessToken = responseData.access_token || responseData.token 23 | const refreshToken = responseData.refresh_token || responseData.refresh 24 | if (!accessToken || !refreshToken) { 25 | return NextResponse.json({"detail": "Invalid response from server. Please try again."}, {status: 400}) 26 | } 27 | await setToken(accessToken) 28 | await setRefreshToken(refreshToken) 29 | return NextResponse.json({"loggedIn": true, "username": responseData.username}, {status: 200}) 30 | } 31 | return NextResponse.json({"loggedIn": false, ...responseData}, {status: 400}) 32 | } -------------------------------------------------------------------------------- /frontend/src/app/docs/[docId]/page.js: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | 4 | import { useAuth } from "@/components/authProvider"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | import { Textarea } from "@/components/ui/textarea"; 8 | import fetcher from "@/lib/fetcher"; 9 | import dynamic from "next/dynamic"; 10 | import Link from "next/link"; 11 | import { useParams } from "next/navigation"; 12 | import { useRef, useState } from "react"; 13 | 14 | import useSWR from "swr"; 15 | 16 | const DocEditor = dynamic( () => import( '@/components/editor/DocEditor' ), { ssr: false } ); 17 | 18 | 19 | export default function DocDetailPage() { 20 | const {docId} = useParams() 21 | const editorRef = useRef(null) 22 | const submitBtnRef = useRef(null) 23 | const [saving, setSaving] = useState(false) 24 | const {isAuthenticated} = useAuth() 25 | const apiEndpoint = `/api/documents/${docId}` 26 | const {data:doc, isLoading, error, mutate} = useSWR(apiEndpoint, fetcher) 27 | const [formError, setFormError] = useState("") 28 | 29 | 30 | if (isLoading) { 31 | return
Saving...
} 104 | 105 |Please wait while we complete your sign-in.
59 |54 | Enter your email below to login to your account 55 |
56 |74 | Create an account to get started 75 |
76 |