├── .babelrc ├── .gitignore ├── README.md ├── common ├── actions.js ├── constants.js ├── credentials.js ├── data.js ├── middleware.js ├── strings.js ├── styles │ └── global.js └── utilities.js ├── components ├── Form.js ├── GoogleButton.js ├── PageState.js └── Text.js ├── db.js ├── index.js ├── knexfile.js ├── nodemon.json ├── package.json ├── pages ├── _app.js ├── index.js ├── organization.js ├── sign-in-confirm.js ├── sign-in-error.js ├── sign-in-success.js └── sign-out.js ├── public └── static │ ├── .gitkeep │ └── SFMono-Medium.woff ├── routes ├── api │ ├── sign-in.js │ └── viewer-delete.js ├── index.js ├── sign-in-confirm.js ├── sign-in-success.js ├── sign-in.js └── target-organization.js ├── scripts ├── drop-database.js ├── index.js ├── seed-database.js └── setup-database.js └── server.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "next/babel", 5 | { 6 | "transform-runtime": { 7 | "useESModules": false 8 | } 9 | } 10 | ], 11 | "@emotion/babel-preset-css-prop", 12 | ], 13 | "plugins": [ 14 | ["module-resolver", { 15 | "alias": { 16 | "~": "./" 17 | } 18 | }] 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | .env 3 | .DS_STORE 4 | package-lock.json 5 | node_modules 6 | DS_STORE 7 | 8 | /**/*/package-lock.json 9 | /**/*/.DS_STORE 10 | /**/*/node_modules 11 | /**/*/.next 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## DEPRECATION NOTICE 2 | 3 | This template is no longer up to date. For an updated template, either as a team or individually, we encourage you to explore our [latest template](https://github.com/internet-development/nextjs-sass-starter) produced by [INTDEV](https://internet.dev). Thank you for your interest in our work! 4 | 5 | # next-postgres 6 | 7 | > **January 4th, 2022** ➝ _I recommend you use [www-react-postgres](https://github.com/jimmylee/www-react-postgres) instead because it does not have an `express` server or a need for `babel`, therefore the template has less dependencies. That means there will be less attention cost required._ 8 | 9 | This setup is using: 10 | 11 | - [NextJS 12.0.7](https://nextjs.org/) 12 | - [Postgres 11](https://github.com/brianc/node-postgres) 13 | - [Express 4.17.2](https://github.com/expressjs/express) 14 | - [Emotion CSS-in-JS 11.7.1](https://5bb1495273f2cf57a2cf39cc--emotion.netlify.com/) 15 | - [GoogleAPIs 92.0.0](https://github.com/googleapis/google-api-nodejs-client#readme) 16 | - [Knex 0.95.15](https://knexjs.org/) 17 | 18 | It is for: 19 | 20 | - Running a website with users. 21 | - Using [Google web browser OAuth](https://developers.google.com/identity). 22 | - Replacing my old work with [next-postgres-sequelize](https://github.com/jimmylee/next-postgres-sequelize). 23 | - Deploying with [https://render.com](https://render.com) or something like it. 24 | 25 | ## Setup 26 | 27 | #### Step 1 28 | 29 | Clone this repository! 30 | 31 | #### Step 2 32 | 33 | Create an `.env` file at your project root. 34 | 35 | ```sh 36 | CLIENT_ID=GET_ME_FROM_GOOGLE 37 | CLIENT_SECRET=GET_ME_FROM_GOOGLE 38 | JWT_SECRET=74b8b454-29a6-4282-bdec-7e2895c835eb 39 | PASSWORD_SECRET=\$2b\$10\$oaBusYfHLawNiFDqsqkTM. 40 | ``` 41 | 42 | - Generate your own `PASSWORD_SECRET` with `BCrypt.genSaltSync(10)`. You need to escape `$` signs. 43 | - Generate your own `JWT_SECRET`. 44 | - Obtain `CLIENT_ID` and `CLIENT_SECRET` from [https://console.developers.google.com](https://console.developers.google.com) after you setup your application. 45 | - Use `CMD+F` to find `REDIRECT_URIS` in `~/common/credentials`. Google needs this string for the **Authorized redirect URIs** setting. The default is: `http://localhost:1337/sign-in-confirm`. 46 | 47 | #### Step 3 48 | 49 | This is important. Enable [People API](https://console.developers.google.com/apis/api/people.googleapis.com/overview). Otherwise Google Auth will not work. 50 | 51 | ## Setup: Running the website (OSX) 52 | 53 | All steps assume you have [Homebrew](https://brew.sh/) installed on your machine. You might want to install [iTerm](https://iterm2.com/) since you need multiple terminal windows open as well. 54 | 55 | Using another version of Postgres? That may be okay. I use Postgres 11 to share versions with [Render](https://render.com/) but I have tried these steps with Postgres 9 as well. 56 | 57 | #### Installing Postgres 11 58 | 59 | Mileage may vary with a different version. 60 | 61 | ```sh 62 | brew uninstall postgresql 63 | brew install postgresql@11 64 | brew link postgresql@11 --force 65 | ``` 66 | 67 | #### Installing Node 68 | 69 | Make sure NodeJS version 10+ is installed on your machine. 70 | 71 | ```sh 72 | brew install node 73 | ``` 74 | 75 | #### Installing nodemon 76 | 77 | We use `nodemon` to reload the site whenever changes are made locally. 78 | 79 | ```sh 80 | npm install -g nodemon 81 | ``` 82 | 83 | #### Installing Node packages 84 | 85 | Once you have Postgres and Node, run these commands: 86 | 87 | ```sh 88 | npm install 89 | npm run dev 90 | ``` 91 | 92 | #### Run Postgres 93 | 94 | In a seperate terminal tab run your postgres version, in this case the command below is referencing Postgres 11. 95 | 96 | ```sh 97 | postgres -D /usr/local/var/postgresql@11 -p 1334 98 | ``` 99 | 100 | You may need to run `brew services stop postgresql@11` since we're running postgres on a different port. 101 | 102 | If you get an error that `lock file "postmaster.pid already exists` like I did, you can delete that file with something like `rm /usr/local/var/postgresql@11/postmaster.pid`. 103 | 104 | #### Create a new database 105 | 106 | - Start with creating an admin user. 107 | - Finish with creating a database for testing. 108 | 109 | ```sh 110 | # Enter Postgres console 111 | psql postgres -p 1334 112 | 113 | # Create a new user for yourself 114 | CREATE ROLE admin WITH LOGIN PASSWORD 'oblivion'; 115 | 116 | # Allow yourself to create databases 117 | ALTER ROLE admin CREATEDB; 118 | 119 | # You need to do this to install uuid-ossp in a later step 120 | ALTER USER admin WITH SUPERUSER; 121 | 122 | # Exit Postgres console 123 | \q 124 | 125 | # Log in as your new user. 126 | psql postgres -p 1334 -U admin 127 | 128 | # Create a database named: nptdb. 129 | # If you change this, update knexfile.js 130 | CREATE DATABASE nptdb; 131 | 132 | # Give your self privileges 133 | GRANT ALL PRIVILEGES ON DATABASE nptdb TO admin; 134 | 135 | # List all of your databases 136 | \list 137 | 138 | # Connect to your newly created DB as a test 139 | \connect nptdb 140 | 141 | # Exit Postgres console 142 | \q 143 | ``` 144 | 145 | ## Setup: Fill database with tables 146 | 147 | Run the following commands: 148 | 149 | ```sh 150 | npm run do-setup-database 151 | npm run do-seed-database 152 | ``` 153 | 154 | ## View the website 155 | 156 | View `http://localhost:1337` in your browser. 157 | 158 | ### Scripts 159 | 160 | If you need to run node script without running the server, use this example to get started 161 | 162 | ```sh 163 | npm run script example 164 | ``` 165 | 166 | ## Setup: Production deploy 167 | 168 | Coming soon. 169 | 170 | ## Questions? 171 | 172 | Feel free to slang any feels to [@wwwjim](https://twitter.com/wwwjim). 173 | -------------------------------------------------------------------------------- /common/actions.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | import Cookies from 'universal-cookie'; 4 | 5 | import * as Constants from '~/common/constants'; 6 | 7 | const cookies = new Cookies(); 8 | 9 | const REQUEST_HEADERS = { 10 | Accept: 'application/json', 11 | 'Content-Type': 'application/json', 12 | }; 13 | 14 | const SERVER_PATH = ''; 15 | 16 | const getHeaders = () => { 17 | const jwt = cookies.get(Constants.session.key); 18 | 19 | if (jwt) { 20 | return { 21 | ...REQUEST_HEADERS, 22 | authorization: `Bearer ${jwt}`, 23 | }; 24 | } 25 | 26 | return REQUEST_HEADERS; 27 | }; 28 | 29 | export const onDeleteViewer = async e => { 30 | const options = { 31 | method: 'POST', 32 | headers: getHeaders(), 33 | credentials: 'include', 34 | body: JSON.stringify({}), 35 | }; 36 | 37 | const response = await fetch(`${SERVER_PATH}/api/users/delete`, options); 38 | const json = await response.json(); 39 | 40 | if (json.error) { 41 | console.log(json.error); 42 | return; 43 | } 44 | 45 | window.location.href = '/'; 46 | }; 47 | 48 | export const onLocalSignIn = async (e, props, auth) => { 49 | const options = { 50 | method: 'POST', 51 | headers: getHeaders(), 52 | credentials: 'include', 53 | body: JSON.stringify({ 54 | ...auth, 55 | }), 56 | }; 57 | 58 | const response = await fetch(`${SERVER_PATH}/api/sign-in`, options); 59 | const json = await response.json(); 60 | 61 | if (json.error) { 62 | console.log(json.error); 63 | return; 64 | } 65 | 66 | if (json.token) { 67 | cookies.set(Constants.session.key, json.token); 68 | } 69 | 70 | window.location.href = '/sign-in-success'; 71 | }; 72 | -------------------------------------------------------------------------------- /common/constants.js: -------------------------------------------------------------------------------- 1 | export const zindex = { 2 | sidebar: 1, 3 | editor: { 4 | menu: 2, 5 | }, 6 | }; 7 | 8 | export const session = { 9 | key: 'WEB_SERVICE_SESSION_KEY', 10 | }; 11 | 12 | export const colors = { 13 | gray: '#F7F8FA', 14 | black: '#000000', 15 | white: '#ffffff', 16 | }; 17 | 18 | export const theme = { 19 | buttonBackground: '#C6C6C6', 20 | buttonBackgroundHover: '#E0E0E0', 21 | buttonBackgroundActive: '#A8A8A8', 22 | pageBackground: colors.gray, 23 | pageText: colors.black, 24 | }; 25 | -------------------------------------------------------------------------------- /common/credentials.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV !== "production") { 2 | require("dotenv").config(); 3 | } 4 | 5 | export const CLIENT_ID = process.env.CLIENT_ID; 6 | export const CLIENT_SECRET = process.env.CLIENT_SECRET; 7 | export const PASSWORD_SECRET = process.env.PASSWORD_SECRET; 8 | export const REDIRECT_URIS = "http://localhost:1337/sign-in-confirm"; 9 | export const JWT_SECRET = process.env.JWT_SECRET; 10 | -------------------------------------------------------------------------------- /common/data.js: -------------------------------------------------------------------------------- 1 | import * as Credentials from '~/common/credentials'; 2 | import * as Utilities from '~/common/utilities'; 3 | 4 | import DB from '~/db'; 5 | import JWT, { decode } from 'jsonwebtoken'; 6 | 7 | const google = require('googleapis').google; 8 | const OAuth2 = google.auth.OAuth2; 9 | 10 | const runQuery = async ({ queryFn, errorFn, label }) => { 11 | let response; 12 | try { 13 | response = await queryFn(); 14 | } catch (e) { 15 | response = errorFn(e); 16 | } 17 | 18 | console.log('[ database-query ]', { query: label }); 19 | return response; 20 | }; 21 | 22 | export const deleteUserById = async ({ id }) => { 23 | return await runQuery({ 24 | label: 'DELETE_USER_BY_ID', 25 | queryFn: async () => { 26 | const data = await DB.from('users') 27 | .where({ id }) 28 | .del(); 29 | 30 | return 1 === data; 31 | }, 32 | errorFn: async e => { 33 | return { 34 | error: 'DELETE_USER_BY_ID', 35 | source: e, 36 | }; 37 | }, 38 | }); 39 | }; 40 | 41 | export const deleteUserFromOrganizationByUserId = async ({ organizationId, userId }) => { 42 | return await runQuery({ 43 | label: 'DELETE_USER_FROM_ORGANIZATION_BY_USER_ID', 44 | queryFn: async () => { 45 | const o = await DB.select('*') 46 | .from('organizations') 47 | .where({ id: organizationId }) 48 | .first(); 49 | 50 | if (!o || !o.id) { 51 | return null; 52 | } 53 | 54 | if (o.data && o.data.ids && o.data.ids.length === 1) { 55 | const data = await DB.from('organizations') 56 | .where({ id: organizationId }) 57 | .del(); 58 | 59 | return 1 === data; 60 | } 61 | 62 | const data = await DB.from('organizations') 63 | .where('id', o.id) 64 | .update({ 65 | data: { 66 | ...o.data, 67 | ids: o.data.ids.filter(each => userId !== each), 68 | }, 69 | }) 70 | .returning('*'); 71 | 72 | const index = data ? data.pop() : null; 73 | return index; 74 | }, 75 | errorFn: async e => { 76 | return { 77 | error: 'DELETE_USER_FROM_ORGANIZATION_BY_USER_ID', 78 | source: e, 79 | }; 80 | }, 81 | }); 82 | }; 83 | 84 | export const getOrganizationByUserId = async ({ id }) => { 85 | return await runQuery({ 86 | label: 'GET_ORGANIZATION_BY_USER_ID', 87 | queryFn: async () => { 88 | const hasUser = userId => DB.raw(`?? @> ?::jsonb`, ['data', JSON.stringify({ ids: [userId] })]); 89 | 90 | const query = await DB.select('*') 91 | .from('organizations') 92 | .where(hasUser(id)) 93 | .first(); 94 | 95 | if (!query || query.error) { 96 | return null; 97 | } 98 | 99 | if (query.id) { 100 | return query; 101 | } 102 | 103 | return null; 104 | }, 105 | errorFn: async e => { 106 | return { 107 | error: 'GET_ORGANIZATION_BY_USER_ID', 108 | source: e, 109 | }; 110 | }, 111 | }); 112 | }; 113 | 114 | export const getViewer = async (req, existingToken = undefined) => { 115 | let viewer = null; 116 | 117 | try { 118 | let token = existingToken; 119 | if (!token) { 120 | token = Utilities.getToken(req); 121 | } 122 | 123 | let decode = JWT.verify(token, Credentials.JWT_SECRET); 124 | viewer = await getUserByEmail({ email: decode.email }); 125 | } catch (e) {} 126 | 127 | if (!viewer || viewer.error) { 128 | viewer = null; 129 | } 130 | 131 | return { viewer }; 132 | }; 133 | 134 | export const getOrganizationByDomain = async ({ domain }) => { 135 | return await runQuery({ 136 | label: 'GET_ORGANIZATION_BY_DOMAIN', 137 | queryFn: async () => { 138 | const query = await DB.select('*') 139 | .from('organizations') 140 | .where({ domain }) 141 | .first(); 142 | 143 | if (!query || query.error) { 144 | return null; 145 | } 146 | 147 | if (query.id) { 148 | return query; 149 | } 150 | 151 | return null; 152 | }, 153 | errorFn: async e => { 154 | return { 155 | error: 'GET_ORGANIZATION_BY_DOMAIN', 156 | source: e, 157 | }; 158 | }, 159 | }); 160 | }; 161 | 162 | export const getUserByEmail = async ({ email }) => { 163 | return await runQuery({ 164 | label: 'GET_USER_BY_EMAIL', 165 | queryFn: async () => { 166 | const query = await DB.select('*') 167 | .from('users') 168 | .where({ email }) 169 | .first(); 170 | 171 | if (!query || query.error) { 172 | return null; 173 | } 174 | 175 | if (query.id) { 176 | return query; 177 | } 178 | 179 | return null; 180 | }, 181 | errorFn: async e => { 182 | return { 183 | error: 'GET_USER_BY_EMAIL', 184 | source: e, 185 | }; 186 | }, 187 | }); 188 | }; 189 | 190 | export const createOrganization = async ({ domain, data = {} }) => { 191 | return await runQuery({ 192 | label: 'CREATE_ORGANIZATION', 193 | queryFn: async () => { 194 | const query = await DB.insert({ 195 | domain, 196 | data, 197 | }) 198 | .into('organizations') 199 | .returning('*'); 200 | 201 | const index = query ? query.pop() : null; 202 | return index; 203 | }, 204 | errorFn: async e => { 205 | return { 206 | error: 'CREATE_ORGANIZATION', 207 | source: e, 208 | }; 209 | }, 210 | }); 211 | }; 212 | 213 | export const createUser = async ({ email, password, salt, data = {} }) => { 214 | return await runQuery({ 215 | label: 'CREATE_USER', 216 | queryFn: async () => { 217 | const query = await DB.insert({ 218 | email, 219 | password, 220 | salt, 221 | data, 222 | }) 223 | .into('users') 224 | .returning('*'); 225 | 226 | const index = query ? query.pop() : null; 227 | return index; 228 | }, 229 | errorFn: async e => { 230 | return { 231 | error: 'CREATE_USER', 232 | source: e, 233 | }; 234 | }, 235 | }); 236 | }; 237 | -------------------------------------------------------------------------------- /common/middleware.js: -------------------------------------------------------------------------------- 1 | import * as Strings from '~/common/strings'; 2 | import * as Constants from '~/common/constants'; 3 | import * as Data from '~/common/data'; 4 | import * as Credentials from '~/common/credentials'; 5 | 6 | import JWT from 'jsonwebtoken'; 7 | 8 | export const CORS = async (req, res, next) => { 9 | res.header('Access-Control-Allow-Origin', '*'); 10 | res.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS'); 11 | res.header('Access-Control-Allow-Headers', 'Origin, Accept, Content-Type, Authorization'); 12 | 13 | if (req.method === 'OPTIONS') { 14 | return res.status(200).end(); 15 | } 16 | 17 | next(); 18 | }; 19 | 20 | export const RequireCookieAuthentication = async (req, res, next) => { 21 | if (Strings.isEmpty(req.headers.cookie)) { 22 | return res.redirect('/sign-in-error'); 23 | } 24 | 25 | const token = req.headers.cookie.replace(/(?:(?:^|.*;\s*)WEB_SERVICE_SESSION_KEY\s*\=\s*([^;]*).*$)|^.*$/, '$1'); 26 | 27 | try { 28 | var decoded = JWT.verify(token, Credentials.JWT_SECRET); 29 | const user = await Data.getUserByEmail({ email: decoded.email }); 30 | 31 | if (!user || user.error) { 32 | return res.redirect('/sign-in-error'); 33 | } 34 | } catch (err) { 35 | console.log(err); 36 | return res.redirect('/sign-in-error'); 37 | } 38 | 39 | next(); 40 | }; 41 | -------------------------------------------------------------------------------- /common/strings.js: -------------------------------------------------------------------------------- 1 | export const isEmpty = string => { 2 | return !string || !string.toString().trim(); 3 | }; 4 | 5 | export const pluralize = (text, count) => { 6 | return count > 1 || count === 0 ? `${text}s` : text; 7 | }; 8 | 9 | export const elide = (string, length = 140, emptyState = '...') => { 10 | if (isEmpty(string)) { 11 | return emptyState; 12 | } 13 | 14 | if (string.length < length) { 15 | return string.trim(); 16 | } 17 | 18 | return `${string.substring(0, length)}...`; 19 | }; 20 | 21 | export const toDate = data => { 22 | const date = new Date(data); 23 | return `${date.getMonth() + 1}-${date.getDate()}-${date.getFullYear()}`; 24 | }; 25 | 26 | export const getDomainFromEmail = email => { 27 | return email.replace(/.*@/, ''); 28 | }; 29 | 30 | export const capitalizeFirstLetter = word => { 31 | return word.charAt(0).toUpperCase() + word.substring(1); 32 | }; 33 | -------------------------------------------------------------------------------- /common/styles/global.js: -------------------------------------------------------------------------------- 1 | import * as Constants from "~/common/constants"; 2 | 3 | import { css } from "@emotion/react"; 4 | 5 | /* prettier-ignore */ 6 | const GlobalStyles = () => css` 7 | @font-face { 8 | font-family: 'mono'; 9 | src: url('/static/SFMono-Medium.woff'); 10 | } 11 | 12 | html, body, div, span, applet, object, iframe, 13 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 14 | a, abbr, acronym, address, big, cite, code, 15 | del, dfn, em, img, ins, kbd, q, s, samp, 16 | small, strike, strong, sub, sup, tt, var, 17 | b, u, i, center, 18 | dl, dt, dd, ol, ul, li, 19 | fieldset, form, label, legend, 20 | table, caption, tbody, tfoot, thead, tr, th, td, 21 | article, aside, canvas, details, embed, 22 | figure, figcaption, footer, header, hgroup, 23 | menu, nav, output, ruby, section, summary, 24 | time, mark, audio, video { 25 | box-sizing: border-box; 26 | margin: 0; 27 | padding: 0; 28 | border: 0; 29 | vertical-align: baseline; 30 | } 31 | 32 | article, aside, details, figcaption, figure, 33 | footer, header, hgroup, menu, nav, section { 34 | display: block; 35 | } 36 | 37 | html, body { 38 | background: ${Constants.theme.pageBackground}; 39 | color: ${Constants.theme.pageText}; 40 | font-size: 16px; 41 | font-family: 'body', -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue, helvetica, 42 | ubuntu, roboto, noto, segoe ui, arial, sans-serif; 43 | 44 | @media (max-width: 768px) { 45 | font-size: 12px; 46 | } 47 | 48 | ::-webkit-scrollbar { 49 | display: none; 50 | } 51 | } 52 | `; 53 | 54 | export default GlobalStyles; 55 | -------------------------------------------------------------------------------- /common/utilities.js: -------------------------------------------------------------------------------- 1 | import * as Strings from '~/common/strings'; 2 | 3 | // TODO(jim): Refactor this Regex so you can bind the string. 4 | export const getToken = req => { 5 | if (Strings.isEmpty(req.headers.cookie)) { 6 | return null; 7 | } 8 | 9 | return req.headers.cookie.replace( 10 | /(?:(?:^|.*;\s*)WEB_SERVICE_SESSION_KEY\s*\=\s*([^;]*).*$)|^.*$/, 11 | '$1' 12 | ); 13 | }; 14 | 15 | export const parseAuthHeader = value => { 16 | if (typeof value !== 'string') { 17 | return null; 18 | } 19 | 20 | var matches = value.match(/(\S+)\s+(\S+)/); 21 | return matches && { scheme: matches[1], value: matches[2] }; 22 | }; 23 | -------------------------------------------------------------------------------- /components/Form.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as Constants from "~/common/constants"; 3 | 4 | import { css } from "@emotion/react"; 5 | 6 | const STYLES_BUTTON = css` 7 | background: ${Constants.theme.buttonBackground}; 8 | transition: 200ms ease background; 9 | font-weight: 700; 10 | border: none; 11 | margin: 0; 12 | padding: 0 24px 0 24px; 13 | height: 48px; 14 | border-radius: 48px; 15 | width: auto; 16 | overflow: visible; 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | color: inherit; 21 | font: inherit; 22 | line-height: normal; 23 | cursor: pointer; 24 | white-space: nowrap; 25 | position: relative; 26 | text-decoration: none; 27 | 28 | -webkit-font-smoothing: inherit; 29 | -moz-osx-font-smoothing: inherit; 30 | -webkit-appearance: none; 31 | 32 | ::-moz-focus-inner { 33 | border: 0; 34 | outline: 0; 35 | padding: 0; 36 | } 37 | 38 | :hover { 39 | background: ${Constants.theme.buttonBackgroundHover}; 40 | } 41 | 42 | :focus { 43 | border: 0; 44 | outline: 0; 45 | } 46 | 47 | :active { 48 | background: ${Constants.theme.buttonBackgroundActive}; 49 | border: 0; 50 | outline: 0; 51 | } 52 | 53 | min-width: 280px; 54 | font-size: 18px; 55 | `; 56 | 57 | export const Button = ({ children, style, onClick, href }) => { 58 | if (href) { 59 | return ( 60 | 61 | {children} 62 | 63 | ); 64 | } 65 | 66 | return ( 67 | 70 | ); 71 | }; 72 | 73 | const STYLES_INPUT = css` 74 | border: none; 75 | outline: 0; 76 | margin: 0; 77 | padding: 0 24px 0 24px; 78 | background: ${Constants.colors.gray3}; 79 | 80 | :focus { 81 | border: 0; 82 | outline: 0; 83 | } 84 | 85 | :active { 86 | border: 0; 87 | outline: 0; 88 | } 89 | 90 | -webkit-font-smoothing: inherit; 91 | -moz-osx-font-smoothing: inherit; 92 | -webkit-appearance: none; 93 | 94 | ::-moz-focus-inner { 95 | border: 0; 96 | padding: 0; 97 | } 98 | 99 | height: 48px; 100 | width: 100%; 101 | box-sizing: border-box; 102 | font-weight: 400; 103 | font-size: 18px; 104 | `; 105 | 106 | export const Input = ({ 107 | children, 108 | style, 109 | value, 110 | name, 111 | placeholder, 112 | type = "text", 113 | autoComplete = "input-autocomplete-off", 114 | onBlur = (e) => {}, 115 | onFocus = (e) => {}, 116 | onChange = (e) => {}, 117 | }) => { 118 | return ( 119 | 130 | {children} 131 | 132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /components/GoogleButton.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { css } from "@emotion/react"; 4 | 5 | const STYLES_GOOGLE = css` 6 | height: 48px; 7 | padding: 0 24px 0 0; 8 | border-radius: 32px; 9 | background: #000; 10 | color: #fff; 11 | display: inline-flex; 12 | align-items: center; 13 | justify-content: center; 14 | font-weight: 600; 15 | cursor: pointer; 16 | text-decoration: none; 17 | transition: 200ms ease all; 18 | transition-property: color; 19 | 20 | :visited { 21 | color: #fff; 22 | } 23 | 24 | :hover { 25 | color: #fff; 26 | background: #222; 27 | } 28 | `; 29 | 30 | const STYLES_LOGO = css` 31 | height: 32px; 32 | width: 32px; 33 | border-radius: 32px; 34 | display: inline-flex; 35 | background-size: cover; 36 | background-position: 50% 50%; 37 | background-image: url("/static/logos/google.jpg"); 38 | margin-right: 16px; 39 | margin-left: 8px; 40 | `; 41 | 42 | export default class GoogleButton extends React.Component { 43 | render() { 44 | return ( 45 | 46 | 47 | Sign in with Google 48 | 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /components/PageState.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as Constants from "~/common/constants"; 3 | 4 | import { css } from "@emotion/react"; 5 | 6 | const STYLES_PAGE_STATE = css` 7 | font-family: "mono"; 8 | width: 100%; 9 | background: ${Constants.colors.black}; 10 | color: ${Constants.colors.white}; 11 | font-size: 10px; 12 | `; 13 | 14 | const STYLES_SECTION = css` 15 | width: 100%; 16 | white-space: pre-wrap; 17 | padding: 24px; 18 | `; 19 | 20 | const STYLES_TITLE_SECTION = css` 21 | background: #111111; 22 | padding: 24px; 23 | `; 24 | 25 | export default class PageState extends React.Component { 26 | render() { 27 | const testData = { 28 | viewer: this.props.data.viewer, 29 | organization: this.props.data.organization, 30 | }; 31 | return ( 32 |
33 |
NEXT-POSTGRES 0.1 - DATA VIEWER
34 |
{JSON.stringify(testData, null, 2)}
35 |
36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /components/Text.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as Constants from "~/common/constants"; 3 | 4 | import { css } from "@emotion/react"; 5 | 6 | const MAX_WIDTH = 768; 7 | 8 | const STYLES_HEADING = css` 9 | overflow-wrap: break-word; 10 | white-space: pre-wrap; 11 | font-weight: 400; 12 | font-size: 3.052rem; 13 | position: relative; 14 | max-width: ${MAX_WIDTH}px; 15 | width: 100%; 16 | padding: 0 24px 0 24px; 17 | margin: 0 auto 0 auto; 18 | `; 19 | 20 | export const H1 = (props) => { 21 | return

; 22 | }; 23 | 24 | const STYLES_HEADING_TWO = css` 25 | overflow-wrap: break-word; 26 | white-space: pre-wrap; 27 | font-weight: 400; 28 | font-size: 1.728rem; 29 | position: relative; 30 | max-width: ${MAX_WIDTH}px; 31 | width: 100%; 32 | padding: 0 24px 0 24px; 33 | margin: 0 auto 0 auto; 34 | `; 35 | 36 | export const H2 = (props) => { 37 | return

; 38 | }; 39 | 40 | const STYLES_PARAGRAPH = css` 41 | overflow-wrap: break-word; 42 | white-space: pre-wrap; 43 | font-weight: 400; 44 | font-size: 1.44rem; 45 | line-height: 1.5; 46 | position: relative; 47 | max-width: ${MAX_WIDTH}px; 48 | width: 100%; 49 | padding: 0 24px 0 24px; 50 | margin: 0 auto 0 auto; 51 | `; 52 | 53 | export const P = (props) => { 54 | return

; 55 | }; 56 | 57 | const STYLES_BODY_TEXT = css` 58 | overflow-wrap: break-word; 59 | white-space: pre-wrap; 60 | font-weight: 400; 61 | font-size: 1rem; 62 | line-height: 1.5; 63 | position: relative; 64 | `; 65 | 66 | export const BODY = (props) => { 67 | return

; 68 | }; 69 | -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV !== 'production') { 2 | require('dotenv').config(); 3 | } 4 | 5 | import configs from '~/knexfile'; 6 | import knex from 'knex'; 7 | 8 | const environment = 9 | process.env.NODE_ENV !== 'production' ? 'development' : 'production'; 10 | const envConfig = configs[environment]; 11 | const db = knex(envConfig); 12 | 13 | module.exports = db; 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('@babel/register')({ 2 | presets: ['@babel/preset-env'], 3 | ignore: ['node_modules', '.next'], 4 | }); 5 | 6 | module.exports = require('./server.js'); 7 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | /* prettier-ignore */ 2 | module.exports = { 3 | development: { 4 | client: 'pg', 5 | connection: { 6 | port: 1334, 7 | host: '127.0.0.1', 8 | database: 'nptdb', 9 | user: 'admin', 10 | password: 'oblivion' 11 | } 12 | }, 13 | production: { 14 | client: 'pg', 15 | connection: { 16 | port: 1334, 17 | host: '127.0.0.1', 18 | database: 'nptdb', 19 | user: 'admin', 20 | password: 'oblivion' 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": ["node_modules", ".next"], 4 | "watch": [ 5 | "routes/**/*", 6 | "common/**/*", 7 | "components/**/*", 8 | "pages/**/*", 9 | "public/**/*", 10 | "index.js", 11 | "server.js" 12 | ], 13 | "ext": "js json" 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-postgres", 3 | "version": "0.0.2", 4 | "scripts": { 5 | "dev": "nodemon .", 6 | "build": "next build", 7 | "start": "NODE_ENV=production node .", 8 | "script": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --max-old-space-size=8192 node script", 9 | "do-setup-database": "node ./scripts setup-database", 10 | "do-seed-database": "node ./scripts seed-database", 11 | "do-drop-database": "node ./scripts drop-database" 12 | }, 13 | "dependencies": { 14 | "@babel/preset-env": "^7.16.5", 15 | "@babel/register": "^7.16.5", 16 | "@emotion/babel-preset-css-prop": "11.2.0", 17 | "@emotion/react": "11.7.1", 18 | "babel-plugin-module-resolver": "^4.1.0", 19 | "bcrypt": "^5.0.1", 20 | "body-parser": "^1.19.0", 21 | "compression": "^1.7.4", 22 | "cookie-parser": "^1.4.6", 23 | "dotenv": "^10.0.0", 24 | "express": "^4.17.2", 25 | "googleapis": "^92.0.0", 26 | "isomorphic-fetch": "^3.0.0", 27 | "jsonwebtoken": "^8.5.1", 28 | "knex": "^0.95.15", 29 | "next": "^12.0.7", 30 | "pg": "^8.7.1", 31 | "react": "^17.0.2", 32 | "react-dom": "^17.0.2", 33 | "universal-cookie": "^4.0.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { Global } from "@emotion/react"; 4 | 5 | import App from "next/app"; 6 | import injectGlobalStyles from "~/common/styles/global"; 7 | 8 | function MyApp({ Component, pageProps }) { 9 | return ( 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | MyApp.getInitialProps = async (appContext) => { 18 | const appProps = await App.getInitialProps(appContext); 19 | return { ...appProps }; 20 | }; 21 | 22 | export default MyApp; 23 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Cookies from "universal-cookie"; 3 | 4 | import * as React from "react"; 5 | import * as Actions from "~/common/actions"; 6 | import * as Constants from "~/common/constants"; 7 | import * as Strings from "~/common/strings"; 8 | 9 | import { H1, H2, P } from "~/components/Text"; 10 | import { Input, Button } from "~/components/Form"; 11 | import { css } from "@emotion/react"; 12 | 13 | import PageState from "~/components/PageState"; 14 | 15 | const STYLES_FORM = css` 16 | padding: 24px; 17 | width: 100%; 18 | margin: 48px auto 0 auto; 19 | max-width: 768px; 20 | `; 21 | 22 | const STYLES_TOP = css` 23 | margin-top: 48px; 24 | `; 25 | 26 | const STYLES_LAYOUT = css` 27 | padding: 24px 24px 88px 24px; 28 | `; 29 | 30 | function Page(props) { 31 | const [auth, setAuth] = React.useState({ email: "", password: "" }); 32 | 33 | return ( 34 | 35 | 36 | next-postgres 37 | 38 | 39 |
40 |

Sign in

41 |

42 | Create an account through Google. 43 |

44 |

45 | View an authenticated page. 46 |

47 | {props.viewer ? ( 48 |

49 | 50 | View an organization page. 51 | 52 |

53 | ) : null} 54 |

55 | Sign out. 56 |

57 |

Actions.onDeleteViewer(e)} 60 | > 61 | 62 | Delete yourself (Must be authenticated). 63 | 64 |

65 |
66 |

E-mail

67 | 72 | setAuth({ ...auth, [e.target.name]: e.target.value }) 73 | } 74 | /> 75 |

Password

76 | 82 | setAuth({ ...auth, [e.target.name]: e.target.value }) 83 | } 84 | /> 85 |
86 | 89 |
90 |
91 |
92 |
93 | ); 94 | } 95 | 96 | Page.getInitialProps = async (ctx) => { 97 | return { 98 | googleURL: ctx.query.googleURL, 99 | viewer: ctx.query.viewer, 100 | error: ctx.err, 101 | }; 102 | }; 103 | 104 | export default Page; 105 | -------------------------------------------------------------------------------- /pages/organization.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | import * as React from "react"; 4 | import * as Constants from "~/common/constants"; 5 | 6 | import { H1, H2, P } from "~/components/Text"; 7 | import { css } from "@emotion/react"; 8 | 9 | import PageState from "~/components/PageState"; 10 | 11 | const STYLES_LAYOUT = css` 12 | padding: 24px 24px 88px 24px; 13 | `; 14 | 15 | function Page(props) { 16 | return ( 17 | 18 | 19 | next-postgres 20 | 21 | 22 | {props.organization ? ( 23 |

{props.organization.data.name}

24 | ) : null} 25 |

26 | View index page. 27 |

28 |

29 | View an authenticated only page. 30 |

31 |
32 | ); 33 | } 34 | 35 | Page.getInitialProps = async (ctx) => { 36 | return { 37 | error: ctx.err, 38 | viewer: ctx.query.viewer, 39 | organization: ctx.query.organization, 40 | data: ctx.query.data, 41 | }; 42 | }; 43 | 44 | export default Page; 45 | -------------------------------------------------------------------------------- /pages/sign-in-confirm.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Cookies from "universal-cookie"; 3 | 4 | import * as React from "react"; 5 | import * as Constants from "~/common/constants"; 6 | 7 | import { H1, H2, P } from "~/components/Text"; 8 | import { css } from "@emotion/react"; 9 | 10 | import PageState from "~/components/PageState"; 11 | 12 | const cookies = new Cookies(); 13 | 14 | const STYLES_LAYOUT = css` 15 | padding: 24px 24px 88px 24px; 16 | `; 17 | 18 | function Page(props) { 19 | React.useEffect(() => { 20 | if (props.jwt) { 21 | cookies.set(Constants.session.key, props.jwt); 22 | return; 23 | } 24 | }, []); 25 | 26 | return ( 27 | 28 | 29 | next-postgres 30 | 31 | 32 |
33 |

Sign in confirm

34 |

35 | View index page. 36 |

37 |

38 | View an authenticated page. 39 |

40 |
41 |
42 | ); 43 | } 44 | 45 | Page.getInitialProps = async (ctx) => { 46 | return { 47 | error: ctx.err, 48 | viewer: ctx.query.viewer, 49 | jwt: ctx.query.jwt, 50 | }; 51 | }; 52 | 53 | export default Page; 54 | -------------------------------------------------------------------------------- /pages/sign-in-error.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Cookies from "universal-cookie"; 3 | 4 | import * as React from "react"; 5 | import * as Constants from "~/common/constants"; 6 | 7 | import { H1, H2, P } from "~/components/Text"; 8 | import { css } from "@emotion/react"; 9 | 10 | import PageState from "~/components/PageState"; 11 | 12 | const cookies = new Cookies(); 13 | 14 | const STYLES_LAYOUT = css` 15 | padding: 24px 24px 88px 24px; 16 | `; 17 | 18 | function Page(props) { 19 | return ( 20 | 21 | 22 | next-postgres 23 | 24 | 25 |
26 |

Error

27 |

28 | View index page. 29 |

30 |

31 | View an organization page. 32 |

33 |

34 | View an authenticated page. 35 |

36 |
37 |
38 | ); 39 | } 40 | 41 | Page.getInitialProps = async (ctx) => { 42 | return { 43 | error: ctx.err, 44 | viewer: ctx.query.viewer, 45 | }; 46 | }; 47 | 48 | export default Page; 49 | -------------------------------------------------------------------------------- /pages/sign-in-success.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | import * as React from "react"; 4 | import * as Constants from "~/common/constants"; 5 | 6 | import { H1, H2, P } from "~/components/Text"; 7 | import { css } from "@emotion/react"; 8 | 9 | import PageState from "~/components/PageState"; 10 | 11 | const STYLES_LAYOUT = css` 12 | padding: 24px 24px 88px 24px; 13 | `; 14 | 15 | function Page(props) { 16 | return ( 17 | 18 | 19 | next-postgres 20 | 21 | 22 |
23 |

You can only see this authenticated.

24 |

25 | View index page. 26 |

27 |

28 | Return to sign in page. 29 |

30 |

31 | Sign out. 32 |

33 |
34 |
35 | ); 36 | } 37 | 38 | Page.getInitialProps = async (ctx) => { 39 | return { 40 | error: ctx.err, 41 | viewer: ctx.query.viewer, 42 | data: ctx.query.data, 43 | }; 44 | }; 45 | 46 | export default Page; 47 | -------------------------------------------------------------------------------- /pages/sign-out.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Cookies from "universal-cookie"; 3 | 4 | import * as React from "react"; 5 | import * as Constants from "~/common/constants"; 6 | 7 | import { H1, H2, P } from "~/components/Text"; 8 | import { css } from "@emotion/react"; 9 | 10 | import PageState from "~/components/PageState"; 11 | 12 | const cookies = new Cookies(); 13 | 14 | const STYLES_LAYOUT = css` 15 | padding: 24px 24px 88px 24px; 16 | `; 17 | 18 | function Page(props) { 19 | React.useEffect(() => { 20 | const jwt = cookies.get(Constants.session.key); 21 | if (jwt) { 22 | cookies.remove(Constants.session.key); 23 | return; 24 | } 25 | }, []); 26 | 27 | return ( 28 | 29 | 30 | next-postgres 31 | 32 | 33 |
34 |

Signed out

35 |

36 | Sign in. 37 |

38 |
39 |
40 | ); 41 | } 42 | 43 | Page.getInitialProps = async (ctx) => { 44 | return { 45 | error: ctx.err, 46 | viewer: ctx.query.viewer, 47 | jwt: ctx.query.jwt, 48 | }; 49 | }; 50 | 51 | export default Page; 52 | -------------------------------------------------------------------------------- /public/static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmylee/next-postgres/6f6e93e97b95502d3bb83073b78346adfd9f7721/public/static/.gitkeep -------------------------------------------------------------------------------- /public/static/SFMono-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmylee/next-postgres/6f6e93e97b95502d3bb83073b78346adfd9f7721/public/static/SFMono-Medium.woff -------------------------------------------------------------------------------- /routes/api/sign-in.js: -------------------------------------------------------------------------------- 1 | import * as Strings from "~/common/strings"; 2 | import * as Data from "~/common/data"; 3 | import * as Utilities from "~/common/utilities"; 4 | import * as Credentials from "~/common/credentials"; 5 | 6 | import JWT from "jsonwebtoken"; 7 | import BCrypt from "bcrypt"; 8 | import { verifiedaccess } from "googleapis/build/src/apis/verifiedaccess"; 9 | 10 | export default async (req, res) => { 11 | if (Strings.isEmpty(req.body.email)) { 12 | return res 13 | .status(500) 14 | .send({ error: "An e-mail address was not provided." }); 15 | } 16 | 17 | if (Strings.isEmpty(req.body.password)) { 18 | return res.status(500).send({ error: "A password was not provided." }); 19 | } 20 | 21 | let user = await Data.getUserByEmail({ email: req.body.email }); 22 | if (!user) { 23 | const salt = BCrypt.genSaltSync(10); 24 | const hash = BCrypt.hashSync(req.body.password, salt); 25 | const double = BCrypt.hashSync(hash, salt); 26 | const triple = BCrypt.hashSync(double, Credentials.PASSWORD_SECRET); 27 | 28 | user = await Data.createUser({ 29 | email: req.body.email, 30 | password: triple, 31 | salt, 32 | data: { verified: false }, 33 | }); 34 | } else { 35 | if (user.error) { 36 | return res 37 | .status(500) 38 | .send({ error: "We could not authenticate you (1)." }); 39 | } 40 | 41 | const phaseOne = BCrypt.hashSync(req.body.password, user.salt); 42 | const phaseTwo = BCrypt.hashSync(phaseOne, user.salt); 43 | const phaseThree = BCrypt.hashSync(phaseTwo, Credentials.PASSWORD_SECRET); 44 | 45 | if (phaseThree !== user.password) { 46 | return res 47 | .status(500) 48 | .send({ error: "We could not authenticate you (2)." }); 49 | } 50 | } 51 | 52 | const authorization = Utilities.parseAuthHeader(req.headers.authorization); 53 | if (authorization && !Strings.isEmpty(authorization.value)) { 54 | const verfied = JWT.verify(authorization.value, Credentials.JWT_SECRET); 55 | 56 | if (user.email === verfied.email) { 57 | return res.status(200).send({ 58 | message: "You are already authenticated. Welcome back!", 59 | viewer: user, 60 | }); 61 | } 62 | } 63 | 64 | const token = JWT.sign( 65 | { user: user.id, email: user.email }, 66 | Credentials.JWT_SECRET 67 | ); 68 | 69 | return res.status(200).send({ token }); 70 | }; 71 | -------------------------------------------------------------------------------- /routes/api/viewer-delete.js: -------------------------------------------------------------------------------- 1 | import * as Strings from '~/common/strings'; 2 | import * as Data from '~/common/data'; 3 | import * as Utilities from '~/common/utilities'; 4 | import * as Credentials from '~/common/credentials'; 5 | 6 | import JWT from 'jsonwebtoken'; 7 | 8 | export default async (req, res) => { 9 | const authorization = Utilities.parseAuthHeader(req.headers.authorization); 10 | 11 | if (!authorization) { 12 | return res.status(500).send({ error: 'viewer-delete (1)' }); 13 | } 14 | 15 | const v = JWT.verify(authorization.value, Credentials.JWT_SECRET); 16 | 17 | if (!v || !v.email) { 18 | return res.status(500).send({ error: 'viewer-delete (2)' }); 19 | } 20 | 21 | const user = await Data.getUserByEmail({ email: v.email }); 22 | 23 | if (!user) { 24 | return res.status(500).send({ error: 'viewer-delete (3)' }); 25 | } 26 | 27 | const organization = await Data.getOrganizationByUserId({ id: user.id }); 28 | 29 | if (organization && organization.data && organization.data.ids && organization.data.ids.length === 1) { 30 | const co = await Data.deleteUserFromOrganizationByUserId({ 31 | organizationId: organization.id, 32 | userId: user.id, 33 | }); 34 | 35 | if (!co) { 36 | return res.status(500).send({ error: 'viewer-delete (4)' }); 37 | } 38 | } 39 | 40 | const d = await Data.deleteUserById({ id: user.id }); 41 | if (!d) { 42 | return res.status(500).send({ error: 'viewer-delete (5)' }); 43 | } 44 | 45 | return res.status(200).send({ operation: true }); 46 | }; 47 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | import signIn from '~/routes/sign-in'; 2 | import signInConfirm from '~/routes/sign-in-confirm'; 3 | import signInSuccess from '~/routes/sign-in-success'; 4 | import targetOrganization from '~/routes/target-organization'; 5 | 6 | import apiSignIn from '~/routes/api/sign-in'; 7 | import apiViewerDelete from '~/routes/api/viewer-delete'; 8 | 9 | module.exports = { 10 | signIn, 11 | signInConfirm, 12 | signInSuccess, 13 | targetOrganization, 14 | api: { 15 | viewerDelete: apiViewerDelete, 16 | signIn: apiSignIn, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /routes/sign-in-confirm.js: -------------------------------------------------------------------------------- 1 | import * as Credentials from "~/common/credentials"; 2 | import * as Data from "~/common/data"; 3 | import * as Strings from "~/common/strings"; 4 | 5 | import JWT from "jsonwebtoken"; 6 | import BCrypt from "bcrypt"; 7 | 8 | const google = require("googleapis").google; 9 | const OAuth2 = google.auth.OAuth2; 10 | 11 | export default async (req, res, app) => { 12 | const client = new OAuth2( 13 | Credentials.CLIENT_ID, 14 | Credentials.CLIENT_SECRET, 15 | Credentials.REDIRECT_URIS 16 | ); 17 | 18 | if (req.query.error) { 19 | return res.redirect("/sign-in-error"); 20 | } 21 | 22 | client.getToken(req.query.code, async (error, token) => { 23 | if (error) { 24 | return res.redirect("/sign-in-error"); 25 | } 26 | 27 | const jwt = JWT.sign(token, Credentials.JWT_SECRET); 28 | const client = new OAuth2( 29 | Credentials.CLIENT_ID, 30 | Credentials.CLIENT_SECRET, 31 | Credentials.REDIRECT_URIS 32 | ); 33 | client.credentials = JWT.verify(jwt, Credentials.JWT_SECRET); 34 | 35 | const people = google.people({ 36 | version: "v1", 37 | auth: client, 38 | }); 39 | 40 | const response = await people.people.get({ 41 | resourceName: "people/me", 42 | personFields: "emailAddresses,names,organizations,memberships", 43 | }); 44 | 45 | const email = response.data.emailAddresses[0].value; 46 | const name = response.data.names[0].displayName; 47 | const password = BCrypt.genSaltSync(10); 48 | 49 | let user = await Data.getUserByEmail({ email }); 50 | 51 | if (!user) { 52 | const salt = BCrypt.genSaltSync(10); 53 | const hash = BCrypt.hashSync(password, salt); 54 | const double = BCrypt.hashSync(hash, salt); 55 | const triple = BCrypt.hashSync(double, Credentials.PASSWORD_SECRET); 56 | 57 | user = await Data.createUser({ 58 | email, 59 | password: triple, 60 | salt, 61 | data: { name, verified: true }, 62 | }); 63 | 64 | // NOTE(jim): Because the domain comes from google. 65 | // If the organization doesn't exist. create it. 66 | const domain = Strings.getDomainFromEmail(email); 67 | const organization = await Data.getOrganizationByDomain({ domain }); 68 | 69 | if (!organization) { 70 | const companyName = domain.split(".")[0]; 71 | await Data.createOrganization({ 72 | domain, 73 | data: { 74 | name: Strings.capitalizeFirstLetter(companyName), 75 | tier: 0, 76 | ids: [user.id], 77 | admins: [], 78 | }, 79 | }); 80 | } 81 | } 82 | 83 | if (user.error) { 84 | return app.render(req, res, "/sign-in-error", { 85 | jwt: null, 86 | viewer: null, 87 | }); 88 | } 89 | 90 | const authToken = JWT.sign( 91 | { user: user.id, email: user.email }, 92 | Credentials.JWT_SECRET 93 | ); 94 | 95 | return app.render(req, res, "/sign-in-confirm", { 96 | jwt: authToken, 97 | viewer: user, 98 | }); 99 | }); 100 | }; 101 | -------------------------------------------------------------------------------- /routes/sign-in-success.js: -------------------------------------------------------------------------------- 1 | import * as Data from '~/common/data'; 2 | 3 | export default async (req, res, app) => { 4 | const { viewer } = await Data.getViewer(req); 5 | 6 | if (!viewer || viewer.error) { 7 | return app.render(req, res, '/sign-in-error', { viewer: null }); 8 | } 9 | 10 | return app.render(req, res, '/sign-in-success', { viewer }); 11 | }; 12 | -------------------------------------------------------------------------------- /routes/sign-in.js: -------------------------------------------------------------------------------- 1 | import * as Credentials from "~/common/credentials"; 2 | import * as Data from "~/common/data"; 3 | 4 | const google = require("googleapis").google; 5 | const OAuth2 = google.auth.OAuth2; 6 | 7 | export default async (req, res, app) => { 8 | const client = new OAuth2( 9 | Credentials.CLIENT_ID, 10 | Credentials.CLIENT_SECRET, 11 | Credentials.REDIRECT_URIS 12 | ); 13 | 14 | const googleURL = client.generateAuthUrl({ 15 | access_type: "offline", 16 | scope: [ 17 | "https://www.googleapis.com/auth/userinfo.email", 18 | "https://www.googleapis.com/auth/userinfo.profile", 19 | "https://www.googleapis.com/auth/user.organization.read", 20 | ], 21 | prompt: "consent", 22 | }); 23 | 24 | const { viewer } = await Data.getViewer(req); 25 | 26 | if (!viewer || viewer.error) { 27 | return app.render(req, res, "/", { googleURL, viewer: null }); 28 | } 29 | 30 | app.render(req, res, "/", { googleURL, viewer }); 31 | }; 32 | -------------------------------------------------------------------------------- /routes/target-organization.js: -------------------------------------------------------------------------------- 1 | import * as Data from '~/common/data'; 2 | 3 | // TODO(jim): Do this based on the ruote. 4 | export default async (req, res, app) => { 5 | const { viewer } = await Data.getViewer(req); 6 | const organization = await Data.getOrganizationByDomain({ domain: req.params.name }); 7 | 8 | return app.render(req, res, '/organization', { viewer, organization }); 9 | }; 10 | -------------------------------------------------------------------------------- /scripts/drop-database.js: -------------------------------------------------------------------------------- 1 | import configs from '~/knexfile'; 2 | import knex from 'knex'; 3 | 4 | const environment = process.env.NODE_ENV !== 'local-production' ? 'development' : 'production'; 5 | const envConfig = configs[environment]; 6 | 7 | console.log(`SETUP: database`, envConfig); 8 | 9 | const db = knex(envConfig); 10 | 11 | console.log(`RUNNING: drop-database.js NODE_ENV=${environment}`); 12 | 13 | // -------------------------- 14 | // SCRIPTS 15 | // -------------------------- 16 | 17 | const dropUserTable = db.schema.dropTable('users'); 18 | const dropOrganizationsTable = db.schema.dropTable('organizations'); 19 | 20 | // -------------------------- 21 | // RUN 22 | // -------------------------- 23 | 24 | Promise.all([dropUserTable, dropOrganizationsTable]); 25 | 26 | console.log(`FINISHED: drop-database.js NODE_ENV=${environment}`); 27 | -------------------------------------------------------------------------------- /scripts/index.js: -------------------------------------------------------------------------------- 1 | require('@babel/register')({ 2 | presets: ['@babel/preset-env'], 3 | ignore: ['node_modules', '.next'], 4 | }); 5 | 6 | module.exports = require('./' + process.argv[2] + '.js'); 7 | -------------------------------------------------------------------------------- /scripts/seed-database.js: -------------------------------------------------------------------------------- 1 | import configs from '~/knexfile'; 2 | import knex from 'knex'; 3 | 4 | const environment = process.env.NODE_ENV !== 'local-production' ? 'development' : 'production'; 5 | const envConfig = configs[environment]; 6 | 7 | console.log(`SETUP: database`, envConfig); 8 | 9 | const db = knex(envConfig); 10 | 11 | console.log(`RUNNING: seed-database.js NODE_ENV=${environment}`); 12 | 13 | // -------------------------- 14 | // SCRIPTS 15 | // -------------------------- 16 | 17 | const createUserTable = db.schema.createTable('users', function(table) { 18 | table 19 | .uuid('id') 20 | .primary() 21 | .unique() 22 | .notNullable() 23 | .defaultTo(db.raw('uuid_generate_v4()')); 24 | 25 | table 26 | .timestamp('created_at') 27 | .notNullable() 28 | .defaultTo(db.raw('now()')); 29 | 30 | table 31 | .timestamp('updated_at') 32 | .notNullable() 33 | .defaultTo(db.raw('now()')); 34 | 35 | table 36 | .string('email') 37 | .unique() 38 | .notNullable(); 39 | 40 | table.string('password').nullable(); 41 | table.string('salt').nullable(); 42 | table.jsonb('data').nullable(); 43 | }); 44 | 45 | const createOrganizationsTable = db.schema.createTable('organizations', function(table) { 46 | table 47 | .uuid('id') 48 | .primary() 49 | .unique() 50 | .notNullable() 51 | .defaultTo(db.raw('uuid_generate_v4()')); 52 | 53 | table 54 | .timestamp('created_at') 55 | .notNullable() 56 | .defaultTo(db.raw('now()')); 57 | 58 | table 59 | .timestamp('updated_at') 60 | .notNullable() 61 | .defaultTo(db.raw('now()')); 62 | 63 | table 64 | .string('domain') 65 | .unique() 66 | .notNullable(); 67 | 68 | table.jsonb('data').nullable(); 69 | }); 70 | 71 | // -------------------------- 72 | // RUN 73 | // -------------------------- 74 | 75 | Promise.all([createUserTable, createOrganizationsTable]); 76 | 77 | console.log(`FINISHED: seed-database.js NODE_ENV=${environment}`); 78 | -------------------------------------------------------------------------------- /scripts/setup-database.js: -------------------------------------------------------------------------------- 1 | import configs from '~/knexfile'; 2 | import knex from 'knex'; 3 | 4 | const environment = 5 | process.env.NODE_ENV !== 'local-production' ? 'development' : 'production'; 6 | const envConfig = configs[environment]; 7 | 8 | console.log(`SETUP: database`, envConfig); 9 | 10 | const db = knex(envConfig); 11 | 12 | console.log(`RUNNING: setup-database.js NODE_ENV=${environment}`); 13 | 14 | Promise.all([db.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')]); 15 | 16 | console.log(`FINISHED: setup-database.js NODE_ENV=${environment}`); 17 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import * as Middleware from "~/common/middleware"; 2 | import * as Credentials from "~/common/credentials"; 3 | import * as Data from "~/common/data"; 4 | import * as Routes from "~/routes"; 5 | 6 | import express from "express"; 7 | import next from "next"; 8 | import bodyParser from "body-parser"; 9 | import compression from "compression"; 10 | 11 | const dev = process.env.NODE_ENV !== "production"; 12 | const port = process.env.PORT || 1337; 13 | const app = next({ dev, quiet: false }); 14 | const nextRequestHandler = app.getRequestHandler(); 15 | 16 | app.prepare().then(async () => { 17 | const server = express(); 18 | 19 | if (!dev) { 20 | server.use(compression()); 21 | } 22 | 23 | server.use(Middleware.CORS); 24 | server.use("/public", express.static("public")); 25 | server.use(bodyParser.json()); 26 | server.use( 27 | bodyParser.urlencoded({ 28 | extended: false, 29 | }) 30 | ); 31 | 32 | server.post("/api/sign-in", async (req, res) => { 33 | return await Routes.api.signIn(req, res); 34 | }); 35 | 36 | server.post("/api/users/delete", async (req, res) => { 37 | return await Routes.api.viewerDelete(req, res); 38 | }); 39 | 40 | server.get("/", async (req, res) => { 41 | return await Routes.signIn(req, res, app); 42 | }); 43 | 44 | server.get("/sign-in-confirm", async (req, res) => { 45 | return await Routes.signInConfirm(req, res, app); 46 | }); 47 | 48 | server.get( 49 | "/sign-in-success", 50 | Middleware.RequireCookieAuthentication, 51 | async (req, res) => { 52 | return await Routes.signInSuccess(req, res, app); 53 | } 54 | ); 55 | 56 | server.get("/sign-in-error", async (req, res) => { 57 | const { viewer } = await Data.getViewer(req); 58 | 59 | if (!viewer || viewer.error) { 60 | return app.render(req, res, "/sign-in-error", { viewer: null }); 61 | } 62 | 63 | return app.render(req, res, "/sign-in-error", { viewer }); 64 | }); 65 | 66 | server.get("/sign-out", async (req, res) => { 67 | const { viewer } = await Data.getViewer(req); 68 | 69 | if (!viewer || viewer.error) { 70 | return app.render(req, res, "/sign-in-error", { viewer: null }); 71 | } 72 | 73 | return app.render(req, res, "/sign-out", { viewer }); 74 | }); 75 | 76 | /* prettier-ignore */ 77 | server.get('/([\$]):name', async (req, res) => { 78 | return await Routes.targetOrganization(req, res, app); 79 | }); 80 | 81 | server.get("*", async (req, res) => { 82 | return nextRequestHandler(req, res, req.url); 83 | }); 84 | 85 | server.listen(port, (err) => { 86 | if (err) { 87 | throw err; 88 | } 89 | 90 | console.log(`[ next-postgres server ] http://localhost:${port}`); 91 | }); 92 | }); 93 | --------------------------------------------------------------------------------