├── .env.sample ├── .gitignore ├── README.md ├── compenents ├── Todo.js └── TodoForm.js ├── contexts └── TodosContext.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── api │ ├── callback.js │ ├── createTodo.js │ ├── deleteTodo.js │ ├── getTodos.js │ ├── hello.js │ ├── login.js │ ├── logout.js │ ├── middleware │ │ └── OwnsRecord.js │ ├── updateTodo.js │ └── utils │ │ ├── airtable.js │ │ └── auth0.js ├── index.js └── test.js ├── postcss.config.js ├── public ├── favicon.ico ├── screenshot.jpg └── vercel.svg ├── styles ├── Home.module.css ├── globals.css └── index.css ├── tailwind.config.js └── yarn.lock /.env.sample: -------------------------------------------------------------------------------- 1 | AIRTABLE_API_KEY= 2 | AIRTABLE_BASE_ID= 3 | AIRTABLE_TABLE_NAME= 4 | AUTH0_DOMAIN= 5 | AUTH0_SECRET= 6 | AUTH0_CLIENT_ID= 7 | COOKIE_SECRET= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is an authenticated Todo app that uses Next.js, Airtable, Tailwind CSS, and Auth0. 2 | 3 | ![Screenshot of Todo App](./public/screenshot.jpg) 4 | 5 | ## Getting Started 6 | 7 | You'll need to add a `.env.local` file to the root of the repository and include appropriate environment variables for Airtable, Auth0, and a cookie secret. 8 | 9 | ```bash 10 | AIRTABLE_API_KEY= 11 | AIRTABLE_BASE_ID= 12 | AIRTABLE_TABLE_NAME= 13 | AUTH0_DOMAIN= 14 | AUTH0_SECRET= 15 | AUTH0_CLIENT_ID= 16 | COOKIE_SECRET= 17 | ``` 18 | 19 | First, run the development server: 20 | 21 | ```bash 22 | npm run dev 23 | # or 24 | yarn dev 25 | ``` 26 | 27 | Open [http://localhost:3000](http://localhost:3000) to view the app. 28 | -------------------------------------------------------------------------------- /compenents/Todo.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { TodosContext } from '../contexts/TodosContext.js'; 3 | 4 | export default function Todo({ todo }) { 5 | const { updateTodo, deleteTodo } = useContext(TodosContext); 6 | const handleToggleCompleted = async () => { 7 | const updatedFields = { 8 | ...todo.fields, 9 | completed: !todo.fields.completed, 10 | }; 11 | const updatedTodo = { id: todo.id, fields: updatedFields }; 12 | updateTodo(updatedTodo); 13 | }; 14 | 15 | return ( 16 |
  • 17 | 24 | 29 | {todo.fields.description} 30 | 31 | 38 |
  • 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /compenents/TodoForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import { TodosContext } from '../contexts/TodosContext'; 3 | 4 | export default function TodoForm() { 5 | const [todo, setTodo] = useState(''); 6 | const { addTodo } = useContext(TodosContext); 7 | const handleSubmit = (e) => { 8 | e.preventDefault(); 9 | addTodo(todo); 10 | setTodo(''); 11 | }; 12 | return ( 13 |
    14 |
    15 | 18 | setTodo(e.target.value)} 25 | /> 26 |
    27 | 33 |
    34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /contexts/TodosContext.js: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from 'react'; 2 | 3 | const TodosContext = createContext(); 4 | 5 | const TodosProvider = ({ children }) => { 6 | const [todos, setTodos] = useState([]); 7 | 8 | const refreshTodos = async () => { 9 | try { 10 | const res = await fetch('/api/getTodos'); 11 | const latestTodos = await res.json(); 12 | setTodos(latestTodos); 13 | } catch (err) { 14 | console.error(err); 15 | } 16 | }; 17 | 18 | const addTodo = async (todo) => { 19 | try { 20 | const res = await fetch('/api/createTodo', { 21 | method: 'POST', 22 | body: JSON.stringify({ description: todo }), 23 | headers: { 'Content-Type': 'application/json' }, 24 | }); 25 | const newTodo = await res.json(); 26 | setTodos((prevTodos) => { 27 | const updatedTodos = [newTodo, ...prevTodos]; 28 | return updatedTodos; 29 | }); 30 | } catch (err) { 31 | console.error(err); 32 | } 33 | }; 34 | 35 | const updateTodo = async (updatedTodo) => { 36 | try { 37 | await fetch('/api/updateTodo', { 38 | method: 'PUT', 39 | body: JSON.stringify(updatedTodo), 40 | headers: { 41 | 'content-type': 'application/json', 42 | }, 43 | }); 44 | 45 | setTodos((prevTodos) => { 46 | const existingTodos = [...prevTodos]; 47 | const existingTodo = existingTodos.find( 48 | (todo) => todo.id === updatedTodo.id 49 | ); 50 | existingTodo.fields = updatedTodo.fields; 51 | return existingTodos; 52 | }); 53 | } catch (err) { 54 | console.error(err); 55 | } 56 | }; 57 | 58 | const deleteTodo = async (id) => { 59 | try { 60 | await fetch('/api/deleteTodo', { 61 | method: 'Delete', 62 | body: JSON.stringify({ id }), 63 | headers: { 'Content-Type': 'application/json' }, 64 | }); 65 | 66 | setTodos((prevTodos) => { 67 | return prevTodos.filter((todo) => todo.id !== id); 68 | }); 69 | } catch (err) { 70 | console.error(err); 71 | } 72 | }; 73 | 74 | return ( 75 | 85 | {children} 86 | 87 | ); 88 | }; 89 | 90 | export { TodosProvider, TodosContext }; 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "authenticated-todo-app-with-nextjs-airtable-and-auth0", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@auth0/nextjs-auth0": "^0.15.0", 12 | "airtable": "^0.9.0", 13 | "next": "9.5.2", 14 | "react": "16.13.1", 15 | "react-dom": "16.13.1", 16 | "swr": "^0.3.0" 17 | }, 18 | "devDependencies": { 19 | "postcss-preset-env": "^6.7.0", 20 | "tailwindcss": "^1.6.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import { TodosProvider } from '../contexts/TodosContext.js'; 3 | import '../styles/index.css'; 4 | 5 | function MyApp({ Component, pageProps }) { 6 | return ( 7 | 8 |
    9 | 10 |
    11 |
    12 | ); 13 | } 14 | 15 | export default MyApp; 16 | -------------------------------------------------------------------------------- /pages/api/callback.js: -------------------------------------------------------------------------------- 1 | import auth0 from './utils/auth0'; 2 | 3 | export default async function callback(req, res) { 4 | try { 5 | await auth0.handleCallback(req, res, { redirectTo: '/' }); 6 | } catch (error) { 7 | console.error(error); 8 | res.status(error.status || 400).end(error.message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /pages/api/createTodo.js: -------------------------------------------------------------------------------- 1 | import { table } from './utils/airtable.js'; 2 | import auth0 from './utils/auth0'; 3 | 4 | export default auth0.requireAuthentication(async (req, res) => { 5 | const { user } = await auth0.getSession(req); 6 | 7 | const { description } = req.body; 8 | try { 9 | const createdRecords = await table.create([ 10 | { fields: { description, userId: user.sub } }, 11 | ]); 12 | const createdRecord = { 13 | id: createdRecords[0].id, 14 | fields: createdRecords[0].fields, 15 | }; 16 | res.statusCode = 200; 17 | res.json(createdRecord); 18 | } catch (error) { 19 | console.error(error); 20 | res.statusCode = 500; 21 | res.json({ msg: 'Something went wrong' }); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /pages/api/deleteTodo.js: -------------------------------------------------------------------------------- 1 | import { table } from './utils/airtable.js'; 2 | import auth0 from './utils/auth0'; 3 | import OwnsRecord from './middleware/OwnsRecord.js'; 4 | 5 | const handler = async (req, res) => { 6 | const { id } = req.body; 7 | 8 | try { 9 | const deletedRecord = await table.destroy([id]); 10 | res.statusCode = 200; 11 | res.json(deletedRecord); 12 | } catch (error) { 13 | console.error(error); 14 | res.statusCode = 500; 15 | res.json({ msg: 'Something went wrong' }); 16 | } 17 | }; 18 | 19 | export default auth0.requireAuthentication(OwnsRecord(handler)); 20 | -------------------------------------------------------------------------------- /pages/api/getTodos.js: -------------------------------------------------------------------------------- 1 | import { table, getMinifiedRecord, minifyRecords } from './utils/airtable.js'; 2 | import auth0 from './utils/auth0'; 3 | 4 | export default auth0.requireAuthentication(async (req, res) => { 5 | const { user } = await auth0.getSession(req); 6 | try { 7 | const records = await table 8 | .select({ filterByFormula: `userId = '${user.sub}'` }) 9 | .firstPage(); 10 | const formattedRecords = minifyRecords(records); 11 | res.statusCode = 200; 12 | res.json(formattedRecords); 13 | } catch (error) { 14 | console.error(error); 15 | res.statusCode = 500; 16 | res.json({ msg: 'Something went wrong' }); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default (req, res) => { 4 | res.statusCode = 200; 5 | res.json({ name: 'John Doe' }); 6 | }; 7 | -------------------------------------------------------------------------------- /pages/api/login.js: -------------------------------------------------------------------------------- 1 | import auth0 from './utils/auth0'; 2 | 3 | export default async function login(req, res) { 4 | try { 5 | await auth0.handleLogin(req, res); 6 | } catch (error) { 7 | res.status(error.status || 500).end(error.message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pages/api/logout.js: -------------------------------------------------------------------------------- 1 | import auth0 from './utils/auth0'; 2 | 3 | export default async function logout(req, res) { 4 | try { 5 | await auth0.handleLogout(req, res); 6 | } catch (error) { 7 | console.error(error); 8 | res.status(error.status || 400).end(error.message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /pages/api/middleware/OwnsRecord.js: -------------------------------------------------------------------------------- 1 | import auth0 from '../utils/auth0'; 2 | import { table } from '../utils/airtable.js'; 3 | const ownsRecord = (handler) => async (req, res) => { 4 | const { user } = await auth0.getSession(req); 5 | 6 | if (!user) { 7 | res.statusCode = 401; 8 | return res.json({ msg: 'Not logged in' }); 9 | } 10 | 11 | const { id } = req.body; 12 | try { 13 | const existingRecord = await table.find(id); 14 | if (!existingRecord || user.sub !== existingRecord.fields.userId) { 15 | res.statusCode = 404; 16 | return res.json({ msg: 'Record not found' }); 17 | } 18 | req.record = existingRecord; 19 | return handler(req, res); 20 | } catch (err) { 21 | console.error(err); 22 | res.statusCode = 500; 23 | return res.json({ msg: 'Something went wrong' }); 24 | } 25 | }; 26 | 27 | export default ownsRecord; 28 | -------------------------------------------------------------------------------- /pages/api/updateTodo.js: -------------------------------------------------------------------------------- 1 | import { table } from './utils/airtable.js'; 2 | import auth0 from './utils/auth0'; 3 | import OwnsRecord from './middleware/OwnsRecord.js'; 4 | 5 | const handler = async (req, res) => { 6 | const { user } = await auth0.getSession(req); 7 | const { id, fields } = req.body; 8 | 9 | try { 10 | const newFields = { ...fields, userId: user.sub }; 11 | const updatedRecord = await table.update([{ id, fields: newFields }]); 12 | res.statusCode = 200; 13 | res.json(updatedRecord); 14 | } catch (error) { 15 | console.error(error); 16 | res.statusCode = 500; 17 | res.json({ msg: 'Something went wrong' }); 18 | } 19 | }; 20 | 21 | export default auth0.requireAuthentication(OwnsRecord(handler)); 22 | -------------------------------------------------------------------------------- /pages/api/utils/airtable.js: -------------------------------------------------------------------------------- 1 | import Airtable from 'airtable'; 2 | Airtable.configure({ 3 | apiKey: process.env.AIRTABLE_API_KEY, 4 | }); 5 | const base = Airtable.base(process.env.AIRTABLE_BASE_ID); 6 | const table = base(process.env.AIRTABLE_TABLE_NAME); 7 | 8 | const getMinifiedRecord = (record) => { 9 | if (!record.fields.completed) { 10 | record.fields.completed = false; 11 | } 12 | return { 13 | id: record.id, 14 | fields: record.fields, 15 | }; 16 | }; 17 | 18 | const minifyRecords = (records) => { 19 | return records.map((record) => getMinifiedRecord(record)); 20 | }; 21 | 22 | export { table, getMinifiedRecord, minifyRecords }; 23 | -------------------------------------------------------------------------------- /pages/api/utils/auth0.js: -------------------------------------------------------------------------------- 1 | import { initAuth0 } from '@auth0/nextjs-auth0'; 2 | 3 | export default initAuth0({ 4 | domain: process.env.AUTH0_DOMAIN, 5 | clientId: process.env.AUTH0_CLIENT_ID, 6 | clientSecret: process.env.AUTH0_SECRET, 7 | redirectUri: 'http://localhost:3000/api/callback', 8 | postLogoutRedirectUri: 'http://localhost:3000/', 9 | scope: 'openid profile', 10 | session: { 11 | // The secret used to encrypt the cookie. 12 | cookieSecret: process.env.COOKIE_SECRET, 13 | // The cookie lifetime (expiration) in seconds. Set to 8 hours by default. 14 | cookieLifetime: 60 * 60 * 8, 15 | // (Optional) Store the id_token in the session. Defaults to false. 16 | storeIdToken: false, 17 | // (Optional) Store the access_token in the session. Defaults to false. 18 | storeAccessToken: false, 19 | // (Optional) Store the refresh_token in the session. Defaults to false. 20 | storeRefreshToken: false, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { table, minifyRecords } from './api/utils/airtable'; 3 | import Todo from '../compenents/Todo'; 4 | import { useEffect, useContext } from 'react'; 5 | import { TodosContext } from '../contexts/TodosContext'; 6 | import TodoForm from '../compenents/TodoForm'; 7 | import auth0 from './api/utils/auth0'; 8 | 9 | export default function Home({ initialTodos, user }) { 10 | const { todos, setTodos } = useContext(TodosContext); 11 | useEffect(() => { 12 | setTodos(initialTodos); 13 | }, []); 14 | 15 | return ( 16 |
    17 | 18 | My Todo CRUD App 19 | 20 | 21 |
    22 | 48 | {user ? ( 49 | <> 50 | 51 |
      52 | {todos && 53 | todos.map((todo) => ( 54 | 55 | ))} 56 |
    57 | 58 | ) : ( 59 |

    60 | Please login to save todos! 61 |

    62 | )} 63 |
    64 |
    65 | ); 66 | } 67 | 68 | export async function getServerSideProps(context) { 69 | const session = await auth0.getSession(context.req); 70 | let todos = []; 71 | if (session?.user) { 72 | todos = await table 73 | .select({ filterByFormula: `userId = '${session.user.sub}'` }) 74 | .firstPage(); 75 | } 76 | return { 77 | props: { 78 | initialTodos: minifyRecords(todos), 79 | user: session?.user || null, 80 | }, 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /pages/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function test({ msg }) { 4 | return ( 5 |
    6 |

    test

    7 |

    {msg}

    8 |
    9 | ); 10 | } 11 | 12 | export async function getServerSideProps() { 13 | return { props: { msg: 'Hey' } }; 14 | } 15 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['tailwindcss', 'postcss-preset-env'], 3 | }; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesqquick/Authenticated-Todo-App-with-NextJS-Airtable-and-Auth0/ff6e02c6a47636507b273906017186cbc2d26289/public/favicon.ico -------------------------------------------------------------------------------- /public/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesqquick/Authenticated-Todo-App-with-NextJS-Airtable-and-Auth0/ff6e02c6a47636507b273906017186cbc2d26289/public/screenshot.jpg -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | } 8 | 9 | .main { 10 | padding: 5rem 0; 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | align-items: center; 15 | } 16 | 17 | .footer { 18 | width: 100%; 19 | height: 100px; 20 | border-top: 1px solid #eaeaea; 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | } 25 | 26 | .footer img { 27 | margin-left: 0.5rem; 28 | } 29 | 30 | .footer a { 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | } 35 | 36 | .title a { 37 | color: #0070f3; 38 | text-decoration: none; 39 | } 40 | 41 | .title a:hover, 42 | .title a:focus, 43 | .title a:active { 44 | text-decoration: underline; 45 | } 46 | 47 | .title { 48 | margin: 0; 49 | line-height: 1.15; 50 | font-size: 4rem; 51 | } 52 | 53 | .title, 54 | .description { 55 | text-align: center; 56 | } 57 | 58 | .description { 59 | line-height: 1.5; 60 | font-size: 1.5rem; 61 | } 62 | 63 | .code { 64 | background: #fafafa; 65 | border-radius: 5px; 66 | padding: 0.75rem; 67 | font-size: 1.1rem; 68 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, 69 | DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace; 70 | } 71 | 72 | .grid { 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | flex-wrap: wrap; 77 | 78 | max-width: 800px; 79 | margin-top: 3rem; 80 | } 81 | 82 | .card { 83 | margin: 1rem; 84 | flex-basis: 45%; 85 | padding: 1.5rem; 86 | text-align: left; 87 | color: inherit; 88 | text-decoration: none; 89 | border: 1px solid #eaeaea; 90 | border-radius: 10px; 91 | transition: color 0.15s ease, border-color 0.15s ease; 92 | } 93 | 94 | .card:hover, 95 | .card:focus, 96 | .card:active { 97 | color: #0070f3; 98 | border-color: #0070f3; 99 | } 100 | 101 | .card h3 { 102 | margin: 0 0 1rem 0; 103 | font-size: 1.5rem; 104 | } 105 | 106 | .card p { 107 | margin: 0; 108 | font-size: 1.25rem; 109 | line-height: 1.5; 110 | } 111 | 112 | .logo { 113 | height: 1em; 114 | } 115 | 116 | @media (max-width: 600px) { 117 | .grid { 118 | width: 100%; 119 | flex-direction: column; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply bg-gray-300; 7 | } 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | theme: { 4 | extend: {}, 5 | }, 6 | variants: {}, 7 | plugins: [], 8 | }; 9 | --------------------------------------------------------------------------------