├── .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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------