├── .gitignore ├── .prettierrc ├── README.md ├── common ├── actions.ts ├── constants.ts ├── requests.ts ├── server.ts ├── strings.ts ├── utilities-authentication.ts └── utilities.ts ├── components ├── App.tsx ├── Button.module.scss ├── Button.tsx ├── Content.module.scss ├── Content.tsx ├── H1.module.scss ├── H1.tsx ├── H2.module.scss ├── H2.tsx ├── Header.module.scss ├── Header.tsx ├── Input.module.scss ├── Input.tsx ├── Layout.module.scss ├── Layout.tsx ├── LayoutLeft.module.scss ├── LayoutLeft.tsx ├── LayoutRight.module.scss ├── LayoutRight.tsx ├── LineItem.module.scss ├── LineItem.tsx ├── P.module.scss ├── P.tsx ├── StatePreview.module.scss ├── StatePreview.tsx ├── Tip.module.scss └── Tip.tsx ├── data ├── .gitkeep ├── environment.ts ├── node-authentication.ts ├── node-data.ts ├── node-ethereum.ts ├── node-google.ts └── node-solana.ts ├── db.ts ├── global.scss ├── knexfile.js ├── modules ├── cors.ts ├── object-assign.ts └── vary.ts ├── next-env.d.ts ├── package.json ├── pages ├── _app.tsx ├── api │ ├── ethereum │ │ ├── [address].ts │ │ └── create.ts │ ├── index.ts │ ├── sign-in.ts │ ├── solana │ │ ├── [address].ts │ │ └── create.ts │ └── viewer │ │ └── delete.ts ├── google-authentication.tsx └── index.tsx ├── public ├── favicon-16x16.png ├── favicon-32x32.png └── favicon.ico ├── scenes ├── .gitkeep ├── SceneHome.module.scss └── SceneHome.tsx ├── scripts ├── database-drop.js ├── database-seed.js ├── database-setup.js ├── example.js └── index.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | .nova 3 | .env 4 | .env.local 5 | .env-custom-development 6 | .env-development 7 | .env-textile 8 | .env-production 9 | .DS_STORE 10 | DS_STORE 11 | yarn.lock 12 | node_modules 13 | dist 14 | analytics.txt 15 | package-lock.json 16 | 17 | /**/*/.DS_STORE 18 | /**/*/node_modules 19 | /**/*/.next 20 | /**/*/.data -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth":180, 3 | "tabWidth":2, 4 | "useTabs":false, 5 | "semi":true, 6 | "singleQuote":true, 7 | "trailingComma":"es5", 8 | "bracketSpacing":true, 9 | "jsxBracketSameLine":false, 10 | "arrowParens":"always", 11 | "requirePragma":false, 12 | "insertPragma":false, 13 | "proseWrap":"preserve", 14 | "parser":"babel", 15 | "overrides": [ 16 | { 17 | "files": "*.js", 18 | "options": { 19 | "parser": "babel" 20 | } 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /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 | # WWW-REACT-POSTGRES 6 | 7 | www-react-postgres 8 | 9 | #### What is this for? 10 | 11 | This template is for 12 | 13 | - making a React website 14 | - making a React web application with a database 15 | 16 | If you are a beginner and you just want to make a simple React project with no database, try [next-sass](https://github.com/application-research/next-sass). 17 | 18 | #### Why would I use this? 19 | 20 | You want to... 21 | 22 | - use [React](https://reactjs.org/). 23 | - use [SASS](https://sass-lang.com/), like the good old days. 24 | - use [https://nextjs.org/](NextJS) and `dotenv` for things like server side rendering and obfuscating secrets on a server. 25 | - You should never expose client secrets in the browser. 26 | - use Postgres 14 (latest as of June 19th, 2022) to manage local data or local authentication. 27 | - have templated SEO metatags. 28 | - get the minimum code involved to make a production website 29 | - **[OPTIONAL]** start with a [Google Authentication](https://github.com/googleapis/google-api-nodejs-client) example to create. 30 | - start with an example of "organizations", each organization is created with an e-mail's domain name. 31 | - **[OPTIONAL]** authenticate your Ethereum addresses from [Metamask](https://metamask.io/) to build a DAPP or DAO. This example keeps a table of Ethereum addresses where you can store local information in the `jsonb` column. 32 | - You'll need your own strategy for joining your Ethereum address to your local account. 33 | - **[OPTIONAL]** authenticate your Solana address (public key) from [Phantom](https://phantom.app) to build a DAPP or DAO. This example keeps a table of Solana addresses where you can store local information in the `jsonb` column. 34 | - You'll need your own strategy for joining your Solana address to your local account. 35 | 36 | ## Setup (MacOS) 37 | 38 | All steps assume you have 39 | 40 | - installed [Homebrew](https://brew.sh/) 41 | - installed [iTerm](https://iterm2.com/), because you will need multiple terminal windows open. 42 | 43 | #### Step 1 44 | 45 | Clone this repository! 46 | 47 | #### Step 2 48 | 49 | Create an `.env` file in your project root. 50 | 51 | ```sh 52 | JWT_SECRET=74b8b454-29a6-4282-bdec-7e2895c835eb 53 | SERVICE_PASSWORD_SALT=\$2b\$10\$JBb8nz6IIrIXKeySeuY3aO 54 | PASSWORD_ROUNDS=10 55 | ``` 56 | 57 | - Generate your own `SERVICE_PASSWORD_SALT` with `BCrypt.genSaltSync(10)`. 58 | - You need to use `\` to escape the `$` values as shown above. Also make sure you're using the correct amount of rounds. 59 | - Generate your own `JWT_SECRET`. 60 | 61 | #### **[OPTIONAL]** Step 3 62 | 63 | To get google auth support to work, add the following to your `.env` file in your project root directory. 64 | 65 | ```sh 66 | GOOGLE_CLIENT_ID=GET_ME_FROM_GOOGLE 67 | GOOGLE_CLIENT_SECRET=GET_ME_FROM_GOOGLE 68 | GOOGLE_REDIRECT_URIS=http://localhost:3005/google-authentication 69 | ``` 70 | 71 | - Obtain `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` from [https://console.developers.google.com](https://console.developers.google.com) after you setup your application. 72 | - Enable [People API](https://console.developers.google.com/apis/api/people.googleapis.com/overview). Otherwise Google Auth will not work for this example. 73 | - Use `CMD+F` to find `GOOGLE_REDIRECT_URIS` in `@data/environment`. Google needs this string for the **Authorized redirect URIs** setting. The default is: `http://localhost:3005/google-authentication`. 74 | 75 | #### Step 4 76 | 77 | Install Postgres 14 locally 78 | 79 | ```sh 80 | brew uninstall postgresql 81 | brew install postgresql 82 | brew link postgresql --force 83 | ``` 84 | 85 | At the time of writing this (June 19th, 2022), `postgresql` is version 14. 86 | 87 | If you see 88 | 89 | ```sh 90 | # already linked, don't worry, nothing to worry about 91 | 92 | Warning: Already linked: /usr/local/Cellar/postgresql/14.4 93 | To relink, run: 94 | brew unlink postgresql && brew link postgresql 95 | 96 | # run brew postgresql-upgrade-database 97 | 98 | Postgres - FATAL: database files are incompatible with server 99 | ``` 100 | 101 | Everything is fine. 102 | 103 | Next make sure NodeJS version 10+ is installed on your machine. 104 | 105 | ```sh 106 | brew install node 107 | ``` 108 | 109 | Install dependencies 110 | 111 | ```sh 112 | npm install 113 | npm run dev 114 | ``` 115 | 116 | #### Step 5 117 | 118 | Run Postgres 14. 119 | 120 | I prefer the option to start and stop postgres through the command line. 121 | - If you have another way of doing things, just make sure your port is set to `1334`. 122 | 123 | In a seperate terminal tab run 124 | 125 | ```sh 126 | postgres -D /usr/local/var/postgres -p 1334 127 | ``` 128 | 129 | Now your development environment is setup. 130 | 131 | #### Step 5 132 | 133 | You need to create a user named `admin` and database named `wwwdb`. 134 | 135 | ```sh 136 | # Enter Postgres console 137 | psql postgres -p 1334 138 | 139 | # Create a new user for yourself 140 | CREATE ROLE admin WITH LOGIN PASSWORD 'oblivion'; 141 | 142 | # Allow yourself to create databases 143 | ALTER ROLE admin CREATEDB; 144 | 145 | # You need to do this to install uuid-ossp in a later step 146 | ALTER USER admin WITH SUPERUSER; 147 | 148 | # Exit Postgres console 149 | \q 150 | 151 | # Log in as your new user. 152 | psql postgres -p 1334 -U admin 153 | 154 | # Create a database named: nptdb. 155 | # If you change this, update knexfile.js 156 | CREATE DATABASE wwwdb; 157 | 158 | # Give your self privileges 159 | GRANT ALL PRIVILEGES ON DATABASE wwwdb TO admin; 160 | 161 | # List all of your databases 162 | \list 163 | 164 | # Connect to your newly created DB as a test 165 | \connect wwwdb 166 | 167 | # Exit Postgres console 168 | \q 169 | ``` 170 | 171 | #### Step 6 172 | 173 | Setup and install the necessary Postgres plugins. Aftewards seed the database with the necessary tables. 174 | 175 | ```sh 176 | npm run script database-setup 177 | npm run script database-seed 178 | ``` 179 | 180 | There is also `npm run script database-drop` if you just want to drop your tables for testing. 181 | 182 | #### **[OPTIONAL]** Step 7 183 | 184 | If you need to run a node script without running the node server, an example is provided for your convenience 185 | 186 | ```sh 187 | npm run script example 188 | ``` 189 | 190 | #### Finish 191 | 192 | View `http://localhost:3005` in your browser. You should be able to use the full example end-to-end and modify the code however you like. 193 | 194 | #### Production deployment 195 | 196 | You will need to add production environment variables. If you set up your Postgres database on [Render](https://render.com) the values will look something like this 197 | 198 | ```env 199 | PRODUCTION_DATABASE_PORT=5432 200 | PRODUCTION_DATABASE_HOST=oregon-postgres.render.com 201 | PRODUCTION_DATABASE_NAME=yourdatabasename 202 | PRODUCTION_DATABASE_USERNAME=yourdatabasename_user 203 | PRODUCTION_DATABASE_PASSWORD=XXXXXXXXXXXXXXXXXXXXX 204 | ``` 205 | 206 | Then you will need to run production scripts 207 | 208 | ```sh 209 | npm run production-script database-setup 210 | npm run production-script database-seed 211 | ``` 212 | 213 | For deploying your new website, I recommend any of the following choices: 214 | 215 | - [Render](https://render.com/i/internet-gift-from-jim) 216 | - [Vercel](https://vercel.com/) 217 | - [Heroku](https://heroku.com) 218 | 219 | #### Questions? 220 | 221 | Contact [@wwwjim](https://twitter.com/wwwjim). 222 | -------------------------------------------------------------------------------- /common/actions.ts: -------------------------------------------------------------------------------- 1 | import * as Requests from "@common/requests"; 2 | import * as Constants from "@common/constants"; 3 | import * as Strings from "@common/strings"; 4 | 5 | // TODO(jim): Remove this dependency at some point. 6 | import Cookies from "universal-cookie"; 7 | 8 | const cookies = new Cookies(); 9 | 10 | declare const window: any; 11 | 12 | const signIn = async (body: any) => { 13 | cookies.remove(Constants.SESSION_KEY); 14 | let response = await Requests.post("/api/sign-in", body); 15 | if (response.success) { 16 | if (response.token) { 17 | cookies.set(Constants.SESSION_KEY, response.token); 18 | } 19 | 20 | return window.location.reload(); 21 | } 22 | 23 | return alert(response.error); 24 | }; 25 | 26 | const signOut = async () => { 27 | const jwt = cookies.get(Constants.SESSION_KEY); 28 | if (jwt) { 29 | cookies.remove(Constants.SESSION_KEY); 30 | return window.location.reload(); 31 | } 32 | 33 | return alert("There was no session to sign out of."); 34 | }; 35 | 36 | const deleteViewer = async () => { 37 | let response = await Requests.del("/api/viewer/delete"); 38 | if (response.success) { 39 | cookies.remove(Constants.SESSION_KEY); 40 | return window.location.reload(); 41 | } 42 | 43 | return alert(response.error); 44 | }; 45 | 46 | const connectMetamask = async () => { 47 | if (!window.ethereum) { 48 | alert("Metamask is not installed"); 49 | } 50 | 51 | // NOTE(jim): Returns an array of ethereum addresses. 52 | const response = await window.ethereum.request({ 53 | method: "eth_requestAccounts", 54 | }); 55 | 56 | // NOTE(jim): Add a database record to associate centralized data to an address. 57 | if (response && response.length) { 58 | for await (const address of response) { 59 | await Requests.post("/api/ethereum/create", { address }); 60 | } 61 | } 62 | 63 | return window.location.reload(); 64 | }; 65 | 66 | const connectPhantom = async () => { 67 | let address = null; 68 | try { 69 | const response = await window.solana.connect(); 70 | address = response.publicKey.toString(); 71 | } catch (e) { 72 | console.log(e); 73 | } 74 | 75 | // NOTE(jim): Add a database record to associate centralized data to an address. 76 | if (!Strings.isEmpty(address)) { 77 | await Requests.post("/api/solana/create", { address }); 78 | } 79 | 80 | return window.location.reload(); 81 | }; 82 | 83 | export const execute = async (key: string, body?: any) => { 84 | if (key === "SIGN_IN") return await signIn(body); 85 | if (key === "SIGN_OUT") return await signOut(); 86 | if (key === "VIEWER_DELETE_USER") return await deleteViewer(); 87 | if (key === "VIEWER_CONNECT_METAMASK") return await connectMetamask(); 88 | if (key === "VIEWER_CONNECT_PHANTOM") return await connectPhantom(); 89 | 90 | return alert(`There is no action: ${key}`); 91 | }; 92 | -------------------------------------------------------------------------------- /common/constants.ts: -------------------------------------------------------------------------------- 1 | // NOTE(jim): You should change these values to match your service. 2 | export const SESSION_KEY = "DEMO_WEB_SERVICE_SESSION_KEY"; 3 | export const SESSION_KEY_REGEX = /(?:(?:^|.*;\s*)DEMO_WEB_SERVICE_SESSION_KEY\s*\=\s*([^;]*).*$)|^.*$/; 4 | -------------------------------------------------------------------------------- /common/requests.ts: -------------------------------------------------------------------------------- 1 | import * as Constants from "@common/constants"; 2 | 3 | // TODO(jim): Remove this dependency at some point. 4 | import Cookies from "universal-cookie"; 5 | 6 | const REQUEST_HEADERS = { 7 | Accept: "application/json", 8 | "Content-Type": "application/json", 9 | }; 10 | 11 | const cookies = new Cookies(); 12 | 13 | const getHeaders = () => { 14 | const jwt = cookies.get(Constants.SESSION_KEY); 15 | 16 | if (jwt) { 17 | return { 18 | ...REQUEST_HEADERS, 19 | authorization: `Bearer ${jwt}`, 20 | }; 21 | } 22 | 23 | return REQUEST_HEADERS; 24 | }; 25 | 26 | export async function get(route: string, options = {}) { 27 | try { 28 | const response = await fetch(route, { 29 | method: "GET", 30 | headers: getHeaders(), 31 | }); 32 | 33 | const json = await response.json(); 34 | console.log(route, json); 35 | return json; 36 | } catch (e) { 37 | console.log(e); 38 | return { 39 | error: "REQUEST_FAILED", 40 | }; 41 | } 42 | } 43 | 44 | export async function post(route: string, options = {}) { 45 | try { 46 | const response = await fetch(route, { 47 | method: "POST", 48 | headers: getHeaders(), 49 | body: JSON.stringify(options), 50 | }); 51 | 52 | const json = await response.json(); 53 | console.log(route, json); 54 | return json; 55 | } catch (e) { 56 | console.log(e); 57 | return { 58 | error: "REQUEST_FAILED", 59 | }; 60 | } 61 | } 62 | 63 | export async function del(route: string, options = {}) { 64 | try { 65 | const response = await fetch(route, { 66 | method: "DELETE", 67 | headers: getHeaders(), 68 | body: JSON.stringify(options), 69 | }); 70 | 71 | const json = await response.json(); 72 | console.log(route, json); 73 | return json; 74 | } catch (e) { 75 | console.log(e); 76 | return { 77 | error: "REQUEST_FAILED", 78 | }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /common/server.ts: -------------------------------------------------------------------------------- 1 | import Cors from "@modules/cors"; 2 | 3 | export function initMiddleware(middleware) { 4 | return (req, res) => 5 | new Promise((resolve, reject) => { 6 | middleware(req, res, (result) => { 7 | if (result instanceof Error) { 8 | return reject(result); 9 | } 10 | return resolve(result); 11 | }); 12 | }); 13 | } 14 | 15 | export const cors = initMiddleware( 16 | Cors({ 17 | methods: ["GET", "POST", "OPTIONS"], 18 | }) 19 | ); 20 | -------------------------------------------------------------------------------- /common/strings.ts: -------------------------------------------------------------------------------- 1 | export const isEmpty = (string: any) => { 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 getDomainFromEmail = (email: string): string => { 10 | return email.replace(/.*@/, ""); 11 | }; 12 | 13 | export const capitalizeFirstLetter = (word: string): string => { 14 | return word.charAt(0).toUpperCase() + word.substring(1); 15 | }; 16 | 17 | export const elide = (text, length = 140, emptyState = "...") => { 18 | if (isEmpty(text)) { 19 | return emptyState; 20 | } 21 | 22 | if (text.length < length) { 23 | return text.trim(); 24 | } 25 | 26 | return `${text.substring(0, length)}...`; 27 | }; 28 | 29 | export const toDate = (data) => { 30 | const date = new Date(data); 31 | return `${date.getMonth() + 1}-${date.getDate()}-${date.getFullYear()}`; 32 | }; 33 | 34 | export const toDateISO = (data: string): string => { 35 | const date = new Date(data); 36 | return date.toLocaleDateString("en-US", { 37 | weekday: "long", 38 | day: "numeric", 39 | month: "long", 40 | year: "numeric", 41 | hour12: true, 42 | hour: "numeric", 43 | minute: "2-digit", 44 | second: "2-digit", 45 | }); 46 | }; 47 | 48 | export const bytesToSize = (bytes: number, decimals: number = 2): string => { 49 | if (bytes === 0) return "0 Bytes"; 50 | 51 | const k = 1024; 52 | const dm = decimals < 0 ? 0 : decimals; 53 | const sizes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; 54 | 55 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 56 | 57 | return `${(bytes / Math.pow(k, i)).toFixed(dm)} ${sizes[i]}`; 58 | }; 59 | -------------------------------------------------------------------------------- /common/utilities-authentication.ts: -------------------------------------------------------------------------------- 1 | import * as Constants from "@common/constants"; 2 | import * as Strings from "@common/strings"; 3 | 4 | export const getToken = (req) => { 5 | if (Strings.isEmpty(req.headers.cookie)) { 6 | return null; 7 | } 8 | 9 | return req.headers.cookie.replace(Constants.SESSION_KEY_REGEX, "$1"); 10 | }; 11 | 12 | export const parseAuthHeader = (value) => { 13 | if (typeof value !== "string") { 14 | return null; 15 | } 16 | 17 | var matches = value.match(/(\S+)\s+(\S+)/); 18 | return matches && { scheme: matches[1], value: matches[2] }; 19 | }; 20 | -------------------------------------------------------------------------------- /common/utilities.ts: -------------------------------------------------------------------------------- 1 | declare const window: any; 2 | 3 | const hasOwn = {}.hasOwnProperty; 4 | 5 | export async function getWalletStatus() { 6 | const isMetamaskEnabled = 7 | typeof window.ethereum !== "undefined" && window.ethereum.isMetaMask; 8 | const isPhantomEnabled = 9 | typeof window.solana !== "undefined" && window.solana.isPhantom; 10 | 11 | // NOTE(jim): This allows you to get the public key. 12 | if (isPhantomEnabled && !window.solana.publicKey) { 13 | try { 14 | await window.solana.connect({ onlyIfTrusted: true }); 15 | } catch (e) { 16 | console.log(e); 17 | } 18 | } 19 | 20 | return { isMetamaskEnabled, isPhantomEnabled }; 21 | } 22 | 23 | export function classNames(...args: any[]): string { 24 | var classes = []; 25 | 26 | for (var i = 0; i < arguments.length; i++) { 27 | var arg = arguments[i]; 28 | if (!arg) continue; 29 | 30 | var argType = typeof arg; 31 | 32 | if (argType === "string" || argType === "number") { 33 | classes.push(arg); 34 | } else if (Array.isArray(arg)) { 35 | if (arg.length) { 36 | var inner = classNames.apply(null, arg); 37 | if (inner) { 38 | classes.push(inner); 39 | } 40 | } 41 | } else if (argType === "object") { 42 | if (arg.toString !== Object.prototype.toString) { 43 | classes.push(arg.toString()); 44 | } else { 45 | for (var key in arg) { 46 | if (hasOwn.call(arg, key) && arg[key]) { 47 | classes.push(key); 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | return classes.join(" "); 55 | } 56 | -------------------------------------------------------------------------------- /components/App.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/App.module.scss"; 2 | 3 | import Head from "next/head"; 4 | 5 | import * as React from "react"; 6 | 7 | // NOTE(jim): You'll need an SEO image, 1200px x 675px 8 | export default function App(props) { 9 | return ( 10 | 11 | 12 | {props.title} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 35 | 41 | 42 | 43 | 44 | {props.children} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/Button.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: var(--theme-button); 3 | color: var(--theme-background); 4 | box-sizing: border-box; 5 | text-transform: uppercase; 6 | border-radius: 4px; 7 | outline: 0; 8 | border: 0; 9 | min-height: 40px; 10 | padding: 4px 24px 4px 24px; 11 | display: inline-flex; 12 | align-items: center; 13 | justify-content: center; 14 | font-size: 11px; 15 | letter-spacing: 0.6px; 16 | font-weight: 600; 17 | overflow-wrap: break-word; 18 | user-select: none; 19 | cursor: pointer; 20 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); 21 | transition: 200ms ease all; 22 | transform: scale(1); 23 | text-decoration: none; 24 | 25 | &:hover { 26 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/Button.module.scss"; 2 | 3 | import * as Strings from "@common/strings"; 4 | 5 | function Button(props) { 6 | if (!Strings.isEmpty(props.href)) { 7 | return ( 8 | 9 | {props.children} 10 | 11 | ); 12 | } 13 | 14 | return ( 15 | 18 | ); 19 | } 20 | 21 | export default Button; 22 | -------------------------------------------------------------------------------- /components/Content.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | color: var(--theme-text); 3 | padding: 16px; 4 | background: rgba(0, 0, 0, 0.07); 5 | border-radius: 4px; 6 | overflow-wrap: break-word; 7 | } 8 | -------------------------------------------------------------------------------- /components/Content.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/Content.module.scss"; 2 | 3 | function Content(props) { 4 | return
{props.children}
; 5 | } 6 | 7 | export default Content; 8 | -------------------------------------------------------------------------------- /components/H1.module.scss: -------------------------------------------------------------------------------- 1 | .heading { 2 | font-weight: 600; 3 | font-size: 1rem; 4 | } 5 | -------------------------------------------------------------------------------- /components/H1.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/H1.module.scss"; 2 | 3 | function H1(props) { 4 | return ( 5 |

6 | {props.children} 7 |

8 | ); 9 | } 10 | 11 | export default H1; 12 | -------------------------------------------------------------------------------- /components/H2.module.scss: -------------------------------------------------------------------------------- 1 | .heading { 2 | font-weight: 600; 3 | font-size: 1rem; 4 | margin-bottom: 24px; 5 | } 6 | -------------------------------------------------------------------------------- /components/H2.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/H2.module.scss"; 2 | 3 | function H2(props) { 4 | return ( 5 |

6 | {props.children} 7 |

8 | ); 9 | } 10 | 11 | export default H2; 12 | -------------------------------------------------------------------------------- /components/Header.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: block; 3 | width: 100%; 4 | padding: 48px 40px 16px 40px; 5 | font-weight: 600; 6 | max-width: 1120px; 7 | margin: 0 auto 0 auto; 8 | 9 | @media (max-width: 728px) { 10 | width: 100%; 11 | padding: 48px 24px 16px 24px; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/Header.module.scss"; 2 | 3 | function Header(props) { 4 | return
{props.children}
; 5 | } 6 | 7 | export default Header; 8 | -------------------------------------------------------------------------------- /components/Input.module.scss: -------------------------------------------------------------------------------- 1 | .input { 2 | -webkit-appearance: none; 3 | color: rgba(0, 0, 0, 0.8); 4 | background: rgba(0, 0, 0, 0.1); 5 | box-sizing: border-box; 6 | width: 100%; 7 | height: 48px; 8 | border-radius: 4px; 9 | display: flex; 10 | font-size: 16px; 11 | align-items: center; 12 | justify-content: flex-start; 13 | outline: 0; 14 | border: 0; 15 | box-sizing: border-box; 16 | transition: 200ms ease all; 17 | padding: 0 16px 0 16px; 18 | text-overflow: ellipsis; 19 | white-space: nowrap; 20 | box-shadow: 0px 1px 4 px rgba(0, 0, 0, 0.07); 21 | 22 | &:focus { 23 | outline: 0; 24 | border: 0; 25 | outline: 0; 26 | } 27 | 28 | &::placeholder { 29 | opacity: 1; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /components/Input.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/Input.module.scss"; 2 | 3 | function Input(props) { 4 | return ( 5 | 6 | {props.children} 7 | 8 | ); 9 | } 10 | 11 | export default Input; 12 | -------------------------------------------------------------------------------- /components/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .layout { 2 | display: flex; 3 | align-items: flex-start; 4 | justify-content: space-between; 5 | width: 100%; 6 | max-width: 1120px; 7 | padding: 0 24px 128px 24px; 8 | margin: 0 auto 0 auto; 9 | 10 | @media (max-width: 728px) { 11 | display: block; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/Layout.module.scss"; 2 | 3 | function Layout(props) { 4 | return
{props.children}
; 5 | } 6 | 7 | export default Layout; 8 | -------------------------------------------------------------------------------- /components/LayoutLeft.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 60%; 3 | padding: 16px 8px 16px 16px; 4 | 5 | @media (max-width: 728px) { 6 | width: 100%; 7 | padding: 16px 0px 16px 0px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /components/LayoutLeft.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/LayoutLeft.module.scss"; 2 | 3 | function LayoutLeft(props) { 4 | return
{props.children}
; 5 | } 6 | 7 | export default LayoutLeft; 8 | -------------------------------------------------------------------------------- /components/LayoutRight.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 40%; 3 | padding: 16px; 4 | 5 | @media (max-width: 728px) { 6 | width: 100%; 7 | padding: 16px 0 16px 0; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /components/LayoutRight.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/LayoutRight.module.scss"; 2 | 3 | function LayoutRight(props) { 4 | return
{props.children}
; 5 | } 6 | 7 | export default LayoutRight; 8 | -------------------------------------------------------------------------------- /components/LineItem.module.scss: -------------------------------------------------------------------------------- 1 | .item { 2 | display: block; 3 | width: 100%; 4 | margin-bottom: 16px; 5 | } 6 | -------------------------------------------------------------------------------- /components/LineItem.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/LineItem.module.scss"; 2 | 3 | function LineItem(props) { 4 | return
{props.children}
; 5 | } 6 | 7 | export default LineItem; 8 | -------------------------------------------------------------------------------- /components/P.module.scss: -------------------------------------------------------------------------------- 1 | .paragraph { 2 | line-height: 1.5; 3 | margin-bottom: 24px; 4 | 5 | &:last-child { 6 | margin-bottom: 0px; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /components/P.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/P.module.scss"; 2 | 3 | function P(props) { 4 | return ( 5 |

6 | {props.children} 7 |

8 | ); 9 | } 10 | 11 | export default P; 12 | -------------------------------------------------------------------------------- /components/StatePreview.module.scss: -------------------------------------------------------------------------------- 1 | .preview { 2 | background: rgba(0, 0, 0, 1); 3 | color: rgba(255, 255, 255, 1); 4 | padding: 16px; 5 | border-radius: 4px; 6 | } 7 | 8 | .code { 9 | color: rgba(255, 255, 255, 0.7); 10 | font-size: 12px; 11 | line-height: 12px; 12 | font-family: lucida console, monospace; 13 | white-space: pre-wrap; 14 | overflow-wrap: break-word; 15 | } 16 | -------------------------------------------------------------------------------- /components/StatePreview.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/StatePreview.module.scss"; 2 | 3 | import H2 from "@components/H2"; 4 | import P from "@components/P"; 5 | 6 | function StatePreview(props) { 7 | return ( 8 |
9 |

State preview

10 |

11 | A JSON representation of the properties from the server, and the 12 | properties generated on the client. The benefit of this template is that 13 | no state management library is necessary. Feel free to add one if you 14 | like. 15 |

16 |
{JSON.stringify(props.state, null, 2)}
17 |
18 | ); 19 | } 20 | 21 | export default StatePreview; 22 | -------------------------------------------------------------------------------- /components/Tip.module.scss: -------------------------------------------------------------------------------- 1 | .tip { 2 | background: var(--theme-tip-default); 3 | color: var(--light-background); 4 | padding: 8px 16px 8px 16px; 5 | font-weight: 400; 6 | margin-bottom: 16px; 7 | box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.07); 8 | border-radius: 4px; 9 | overflow-wrap: break-word; 10 | white-space: pre-wrap; 11 | } 12 | -------------------------------------------------------------------------------- /components/Tip.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/Tip.module.scss"; 2 | 3 | function Tip(props) { 4 | return ( 5 | 8 | ); 9 | } 10 | 11 | export default Tip; 12 | -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmylee/www-react-postgres/1ee0ed6efc80e2a90f95d57b88be81e9e645c886/data/.gitkeep -------------------------------------------------------------------------------- /data/environment.ts: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV !== "production") { 2 | require("dotenv").config(); 3 | } 4 | 5 | export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; 6 | export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; 7 | export const GOOGLE_REDIRECT_URIS = process.env.GOOGLE_REDIRECT_URIS; 8 | export const JWT_SECRET = process.env.JWT_SECRET; 9 | export const PASSWORD_ROUNDS = parseInt(process.env.PASSWORD_ROUNDS); 10 | export const SERVICE_PASSWORD_SALT = process.env.SERVICE_PASSWORD_SALT; 11 | -------------------------------------------------------------------------------- /data/node-authentication.ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as NodeData from "@data/node-data"; 3 | import * as Strings from "@common/strings"; 4 | import * as Utilities from "@common/utilities"; 5 | import * as AuthUtilities from "@common/utilities-authentication"; 6 | 7 | import JWT, { decode } from "jsonwebtoken"; 8 | 9 | export const getViewer = async (req, existingToken = undefined) => { 10 | let viewer = null; 11 | 12 | try { 13 | let token = existingToken; 14 | if (!token) { 15 | token = AuthUtilities.getToken(req); 16 | } 17 | 18 | let decode = JWT.verify(token, Env.JWT_SECRET); 19 | viewer = await NodeData.getUserByEmail({ email: decode.email }); 20 | } catch (e) { 21 | viewer = null; 22 | } 23 | 24 | if (!viewer || viewer.error) { 25 | viewer = null; 26 | } 27 | 28 | return { viewer }; 29 | }; 30 | -------------------------------------------------------------------------------- /data/node-data.ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Strings from "@common/strings"; 3 | import * as Utilities from "@common/utilities"; 4 | import * as AuthUtilities from "@common/utilities-authentication"; 5 | 6 | import DB from "@root/db"; 7 | import JWT, { decode } from "jsonwebtoken"; 8 | 9 | const google = require("googleapis").google; 10 | const OAuth2 = google.auth.OAuth2; 11 | 12 | const runQuery = async ({ queryFn, errorFn, label }) => { 13 | let response; 14 | try { 15 | response = await queryFn(); 16 | } catch (e) { 17 | response = await errorFn(e); 18 | } 19 | 20 | console.log("[ database-query ]", { query: label }); 21 | return JSON.parse(JSON.stringify(response)); 22 | }; 23 | 24 | export const deleteUserById = async ({ id }) => { 25 | return await runQuery({ 26 | label: "DELETE_USER_BY_ID", 27 | queryFn: async () => { 28 | const data = await DB.from("users") 29 | .where({ id }) 30 | .del(); 31 | 32 | return 1 === data; 33 | }, 34 | errorFn: async (e) => { 35 | return { 36 | error: "DELETE_USER_BY_ID", 37 | source: e, 38 | }; 39 | }, 40 | }); 41 | }; 42 | 43 | export const deleteUserFromOrganizationByUserId = async ({ 44 | organizationId, 45 | userId, 46 | }) => { 47 | return await runQuery({ 48 | label: "DELETE_USER_FROM_ORGANIZATION_BY_USER_ID", 49 | queryFn: async () => { 50 | const o = await DB.select("*") 51 | .from("organizations") 52 | .where({ id: organizationId }) 53 | .first(); 54 | 55 | if (!o || !o.id) { 56 | return null; 57 | } 58 | 59 | if (o.data && o.data.ids && o.data.ids.length === 1) { 60 | const data = await DB.from("organizations") 61 | .where({ id: organizationId }) 62 | .del(); 63 | 64 | return 1 === data; 65 | } 66 | 67 | const data = await DB.from("organizations") 68 | .where("id", o.id) 69 | .update({ 70 | data: { 71 | ...o.data, 72 | ids: o.data.ids.filter((each) => userId !== each), 73 | }, 74 | }) 75 | .returning("*"); 76 | 77 | const index = data ? data.pop() : null; 78 | return index; 79 | }, 80 | errorFn: async (e) => { 81 | return { 82 | error: "DELETE_USER_FROM_ORGANIZATION_BY_USER_ID", 83 | source: e, 84 | }; 85 | }, 86 | }); 87 | }; 88 | 89 | export const getOrganizationByUserId = async ({ id }) => { 90 | return await runQuery({ 91 | label: "GET_ORGANIZATION_BY_USER_ID", 92 | queryFn: async () => { 93 | const hasUser = (userId) => 94 | DB.raw(`?? @> ?::jsonb`, ["data", JSON.stringify({ ids: [userId] })]); 95 | 96 | const query = await DB.select("*") 97 | .from("organizations") 98 | .where(hasUser(id)) 99 | .first(); 100 | 101 | if (!query || query.error) { 102 | return null; 103 | } 104 | 105 | if (query.id) { 106 | return query; 107 | } 108 | 109 | return null; 110 | }, 111 | errorFn: async (e) => { 112 | return { 113 | error: "GET_ORGANIZATION_BY_USER_ID", 114 | source: e, 115 | }; 116 | }, 117 | }); 118 | }; 119 | 120 | export const getOrganizationByDomain = async ({ domain }) => { 121 | return await runQuery({ 122 | label: "GET_ORGANIZATION_BY_DOMAIN", 123 | queryFn: async () => { 124 | const query = await DB.select("*") 125 | .from("organizations") 126 | .where({ domain }) 127 | .first(); 128 | 129 | if (!query || query.error) { 130 | return null; 131 | } 132 | 133 | if (query.id) { 134 | return query; 135 | } 136 | 137 | return null; 138 | }, 139 | errorFn: async (e) => { 140 | return { 141 | error: "GET_ORGANIZATION_BY_DOMAIN", 142 | source: e, 143 | }; 144 | }, 145 | }); 146 | }; 147 | 148 | export const getUserByEmail = async ({ email }) => { 149 | return await runQuery({ 150 | label: "GET_USER_BY_EMAIL", 151 | queryFn: async () => { 152 | const query = await DB.select("*") 153 | .from("users") 154 | .where({ email }) 155 | .first(); 156 | 157 | if (!query || query.error) { 158 | return null; 159 | } 160 | 161 | if (query.id) { 162 | return query; 163 | } 164 | 165 | return null; 166 | }, 167 | errorFn: async (e) => { 168 | return { 169 | error: "GET_USER_BY_EMAIL", 170 | source: e, 171 | }; 172 | }, 173 | }); 174 | }; 175 | 176 | export const createOrganization = async ({ domain, data = {} }) => { 177 | return await runQuery({ 178 | label: "CREATE_ORGANIZATION", 179 | queryFn: async () => { 180 | const query: any = await DB.insert({ 181 | domain, 182 | data, 183 | }) 184 | .into("organizations") 185 | .returning("*"); 186 | 187 | const index = query ? query.pop() : null; 188 | return index; 189 | }, 190 | errorFn: async (e) => { 191 | return { 192 | error: "CREATE_ORGANIZATION", 193 | source: e, 194 | }; 195 | }, 196 | }); 197 | }; 198 | 199 | export const createUser = async ({ email, password, salt, data = {} }) => { 200 | return await runQuery({ 201 | label: "CREATE_USER", 202 | queryFn: async () => { 203 | const query: any = await DB.insert({ 204 | email, 205 | password, 206 | salt, 207 | data, 208 | }) 209 | .into("users") 210 | .returning("*"); 211 | 212 | const index = query ? query.pop() : null; 213 | return index; 214 | }, 215 | errorFn: async (e) => { 216 | return { 217 | error: "CREATE_USER", 218 | source: e, 219 | }; 220 | }, 221 | }); 222 | }; 223 | 224 | // NOTE(jim): Careful, you could wipe out all of the user custom fields here. 225 | export const updateUserDataByEmail = async ({ email, data = {} }) => { 226 | return await runQuery({ 227 | label: "UPDATE_USER_DATA_BY_EMAIL", 228 | queryFn: async () => { 229 | const query: any = await DB.from("users") 230 | .where("email", email) 231 | .update({ 232 | data, 233 | }); 234 | 235 | const index = query ? query.pop() : null; 236 | return index; 237 | }, 238 | errorFn: async (e) => { 239 | return { 240 | error: "UPDATE_USER_DATA_BY_EMAIL", 241 | source: e, 242 | }; 243 | }, 244 | }); 245 | }; 246 | -------------------------------------------------------------------------------- /data/node-ethereum.ts: -------------------------------------------------------------------------------- 1 | import DB from "@root/db"; 2 | 3 | const runQuery = async ({ queryFn, errorFn, label }) => { 4 | let response; 5 | try { 6 | response = await queryFn(); 7 | } catch (e) { 8 | response = await errorFn(e); 9 | } 10 | 11 | console.log("[ database-query ]", { query: label }); 12 | return JSON.parse(JSON.stringify(response)); 13 | }; 14 | 15 | export const createAddress = async ({ address, data = {} }) => { 16 | return await runQuery({ 17 | label: "CREATE_ETHEREUM_ADDRESS", 18 | queryFn: async () => { 19 | const query: any = await DB.insert({ 20 | address, 21 | data, 22 | }) 23 | .into("ethereum") 24 | .returning("*"); 25 | 26 | const index = query ? query.pop() : null; 27 | return index; 28 | }, 29 | errorFn: async (e) => { 30 | return { 31 | error: "CREATE_ETHEREUM_ADDRESS", 32 | source: e, 33 | }; 34 | }, 35 | }); 36 | }; 37 | 38 | export const getAddress = async ({ address }) => { 39 | return await runQuery({ 40 | label: "GET_ETHEREUM_ADDRESS", 41 | queryFn: async () => { 42 | const query: any = await DB.select("*") 43 | .from("ethereum") 44 | .where({ address }) 45 | .first(); 46 | 47 | if (!query || query.error) { 48 | return null; 49 | } 50 | 51 | if (query.address) { 52 | return query; 53 | } 54 | 55 | return null; 56 | }, 57 | errorFn: async (e) => { 58 | return { 59 | error: "GET_ETHEREUM_ADDRESS", 60 | source: e, 61 | }; 62 | }, 63 | }); 64 | }; 65 | 66 | // NOTE(jim) 67 | // Warning this function has the power to wipe all data 68 | export const updateAddress = async ({ address, data = {} }) => { 69 | return await runQuery({ 70 | label: "UPDATE_ETHEREUM_ADDRESS", 71 | queryFn: async () => { 72 | const query: any = await DB.from("ethereum") 73 | .where("adddress", address) 74 | .update({ 75 | data, 76 | }); 77 | 78 | const index = query ? query.pop() : null; 79 | return index; 80 | }, 81 | errorFn: async (e) => { 82 | return { 83 | error: "UPDATE_ETHEREUM_ADDRESS", 84 | source: e, 85 | }; 86 | }, 87 | }); 88 | }; 89 | -------------------------------------------------------------------------------- /data/node-google.ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | 3 | const google = require("googleapis").google; 4 | const OAuth2 = google.auth.OAuth2; 5 | 6 | export const generateURL = async () => { 7 | const client = new OAuth2( 8 | Env.GOOGLE_CLIENT_ID, 9 | Env.GOOGLE_CLIENT_SECRET, 10 | Env.GOOGLE_REDIRECT_URIS 11 | ); 12 | 13 | const googleURL = client.generateAuthUrl({ 14 | access_type: "offline", 15 | scope: [ 16 | "https://www.googleapis.com/auth/userinfo.email", 17 | "https://www.googleapis.com/auth/userinfo.profile", 18 | "https://www.googleapis.com/auth/user.organization.read", 19 | ], 20 | prompt: "consent", 21 | }); 22 | 23 | return { googleURL }; 24 | }; 25 | -------------------------------------------------------------------------------- /data/node-solana.ts: -------------------------------------------------------------------------------- 1 | import DB from "@root/db"; 2 | 3 | const runQuery = async ({ queryFn, errorFn, label }) => { 4 | let response; 5 | try { 6 | response = await queryFn(); 7 | } catch (e) { 8 | response = await errorFn(e); 9 | } 10 | 11 | console.log("[ database-query ]", { query: label }); 12 | return JSON.parse(JSON.stringify(response)); 13 | }; 14 | 15 | export const createAddress = async ({ address, data = {} }) => { 16 | return await runQuery({ 17 | label: "CREATE_SOLANA_ADDRESS", 18 | queryFn: async () => { 19 | const query: any = await DB.insert({ 20 | address, 21 | data, 22 | }) 23 | .into("solana") 24 | .returning("*"); 25 | 26 | const index = query ? query.pop() : null; 27 | return index; 28 | }, 29 | errorFn: async (e) => { 30 | return { 31 | error: "CREATE_SOLANA_ADDRESS", 32 | source: e, 33 | }; 34 | }, 35 | }); 36 | }; 37 | 38 | export const getAddress = async ({ address }) => { 39 | return await runQuery({ 40 | label: "GET_SOLANA_ADDRESS", 41 | queryFn: async () => { 42 | const query: any = await DB.select("*") 43 | .from("solana") 44 | .where({ address }) 45 | .first(); 46 | 47 | if (!query || query.error) { 48 | return null; 49 | } 50 | 51 | if (query.address) { 52 | return query; 53 | } 54 | 55 | return null; 56 | }, 57 | errorFn: async (e) => { 58 | return { 59 | error: "GET_SOLANA_ADDRESS", 60 | source: e, 61 | }; 62 | }, 63 | }); 64 | }; 65 | 66 | // NOTE(jim) 67 | // Warning this function has the power to wipe all data 68 | export const updateAddress = async ({ address, data = {} }) => { 69 | return await runQuery({ 70 | label: "UPDATE_SOLANA_ADDRESS", 71 | queryFn: async () => { 72 | const query: any = await DB.from("solana") 73 | .where("adddress", address) 74 | .update({ 75 | data, 76 | }); 77 | 78 | const index = query ? query.pop() : null; 79 | return index; 80 | }, 81 | errorFn: async (e) => { 82 | return { 83 | error: "UPDATE_SOLANA_ADDRESS", 84 | source: e, 85 | }; 86 | }, 87 | }); 88 | }; 89 | -------------------------------------------------------------------------------- /db.ts: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV !== "production") { 2 | require("dotenv").config(); 3 | } 4 | 5 | import configs from "@root/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 | export default db; 14 | -------------------------------------------------------------------------------- /global.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div, 4 | span, 5 | applet, 6 | object, 7 | iframe, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6, 14 | p, 15 | blockquote, 16 | pre, 17 | a, 18 | abbr, 19 | acronym, 20 | address, 21 | big, 22 | cite, 23 | code, 24 | del, 25 | dfn, 26 | em, 27 | img, 28 | ins, 29 | kbd, 30 | q, 31 | s, 32 | samp, 33 | small, 34 | strike, 35 | strong, 36 | sub, 37 | sup, 38 | tt, 39 | var, 40 | b, 41 | u, 42 | i, 43 | center, 44 | dl, 45 | dt, 46 | dd, 47 | ol, 48 | ul, 49 | li, 50 | fieldset, 51 | form, 52 | label, 53 | legend, 54 | table, 55 | caption, 56 | tbody, 57 | tfoot, 58 | thead, 59 | tr, 60 | th, 61 | td, 62 | article, 63 | aside, 64 | canvas, 65 | details, 66 | embed, 67 | figure, 68 | figcaption, 69 | footer, 70 | header, 71 | hgroup, 72 | menu, 73 | nav, 74 | output, 75 | ruby, 76 | section, 77 | summary, 78 | time, 79 | mark, 80 | audio, 81 | video { 82 | box-sizing: border-box; 83 | vertical-align: baseline; 84 | margin: 0; 85 | padding: 0; 86 | border: 0; 87 | } 88 | 89 | article, 90 | aside, 91 | details, 92 | figcaption, 93 | figure, 94 | footer, 95 | header, 96 | hgroup, 97 | menu, 98 | nav, 99 | section { 100 | display: block; 101 | } 102 | 103 | html, 104 | body { 105 | --light-background: #fff; 106 | --light-text: #1c1c1c; 107 | --color-primary: #0047ff; 108 | --color-success: #28a745; 109 | --color-warning: #ffbd00; 110 | --color-failure: #ff0000; 111 | 112 | --theme-background: var(--light-background); 113 | --theme-text: var(--light-text); 114 | --theme-button: var(--color-primary); 115 | --theme-tip-default: var(--color-success); 116 | 117 | font-size: 14px; 118 | background: var(--theme-background); 119 | color: var(--theme-text); 120 | font-family: -apple-system, BlinkMacSystemFont, helvetica neue, helvetica, 121 | sans-serif; 122 | scrollbar-width: none; 123 | -ms-overflow-style: -ms-autohiding-scrollbar; 124 | 125 | ::-webkit-scrollbar { 126 | display: none; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /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 | // NOTE(jim): README.md value. 9 | database: 'wwwdb', 10 | // NOTE(jim): README.md value. 11 | user: 'admin', 12 | // NOTE(jim): README.md value. 13 | password: 'oblivion' 14 | } 15 | }, 16 | production: { 17 | client: "pg", 18 | connection: { 19 | // NOTE(jim): You'll need to setup a remote database when you deploy to production. 20 | port: process.env.PRODUCTION_DATABASE_PORT, 21 | host: process.env.PRODUCTION_DATABASE_HOST, 22 | database: process.env.PRODUCTION_DATABASE_NAME, 23 | user: process.env.PRODUCTION_DATABASE_USERNAME, 24 | password: process.env.PRODUCTION_DATABASE_PASSWORD, 25 | // NOTE(jim): SSL is true in production for remote databases. 26 | ssl: true, 27 | }, 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /modules/cors.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | 'use strict'; 4 | 5 | import assign from "@modules/object-assign"; 6 | import vary from "@modules/vary"; 7 | 8 | var defaults = { 9 | origin: '*', 10 | methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', 11 | preflightContinue: false, 12 | optionsSuccessStatus: 204 13 | }; 14 | 15 | function isString(s) { 16 | return typeof s === 'string' || s instanceof String; 17 | } 18 | 19 | function isOriginAllowed(origin, allowedOrigin) { 20 | if (Array.isArray(allowedOrigin)) { 21 | for (var i = 0; i < allowedOrigin.length; ++i) { 22 | if (isOriginAllowed(origin, allowedOrigin[i])) { 23 | return true; 24 | } 25 | } 26 | return false; 27 | } else if (isString(allowedOrigin)) { 28 | return origin === allowedOrigin; 29 | } else if (allowedOrigin instanceof RegExp) { 30 | return allowedOrigin.test(origin); 31 | } else { 32 | return !!allowedOrigin; 33 | } 34 | } 35 | 36 | function configureOrigin(options, req) { 37 | var requestOrigin = req.headers.origin, 38 | headers = [], 39 | isAllowed; 40 | 41 | if (!options.origin || options.origin === '*') { 42 | // allow any origin 43 | headers.push([{ 44 | key: 'Access-Control-Allow-Origin', 45 | value: '*' 46 | }]); 47 | } else if (isString(options.origin)) { 48 | // fixed origin 49 | headers.push([{ 50 | key: 'Access-Control-Allow-Origin', 51 | value: options.origin 52 | }]); 53 | headers.push([{ 54 | key: 'Vary', 55 | value: 'Origin' 56 | }]); 57 | } else { 58 | isAllowed = isOriginAllowed(requestOrigin, options.origin); 59 | // reflect origin 60 | headers.push([{ 61 | key: 'Access-Control-Allow-Origin', 62 | value: isAllowed ? requestOrigin : false 63 | }]); 64 | headers.push([{ 65 | key: 'Vary', 66 | value: 'Origin' 67 | }]); 68 | } 69 | 70 | return headers; 71 | } 72 | 73 | function configureMethods(options) { 74 | var methods = options.methods; 75 | if (methods.join) { 76 | methods = options.methods.join(','); // .methods is an array, so turn it into a string 77 | } 78 | return { 79 | key: 'Access-Control-Allow-Methods', 80 | value: methods 81 | }; 82 | } 83 | 84 | function configureCredentials(options) { 85 | if (options.credentials === true) { 86 | return { 87 | key: 'Access-Control-Allow-Credentials', 88 | value: 'true' 89 | }; 90 | } 91 | return null; 92 | } 93 | 94 | function configureAllowedHeaders(options, req) { 95 | var allowedHeaders = options.allowedHeaders || options.headers; 96 | var headers = []; 97 | 98 | if (!allowedHeaders) { 99 | allowedHeaders = req.headers['access-control-request-headers']; // .headers wasn't specified, so reflect the request headers 100 | headers.push([{ 101 | key: 'Vary', 102 | value: 'Access-Control-Request-Headers' 103 | }]); 104 | } else if (allowedHeaders.join) { 105 | allowedHeaders = allowedHeaders.join(','); // .headers is an array, so turn it into a string 106 | } 107 | if (allowedHeaders && allowedHeaders.length) { 108 | headers.push([{ 109 | key: 'Access-Control-Allow-Headers', 110 | value: allowedHeaders 111 | }]); 112 | } 113 | 114 | return headers; 115 | } 116 | 117 | function configureExposedHeaders(options) { 118 | var headers = options.exposedHeaders; 119 | if (!headers) { 120 | return null; 121 | } else if (headers.join) { 122 | headers = headers.join(','); // .headers is an array, so turn it into a string 123 | } 124 | if (headers && headers.length) { 125 | return { 126 | key: 'Access-Control-Expose-Headers', 127 | value: headers 128 | }; 129 | } 130 | return null; 131 | } 132 | 133 | function configureMaxAge(options) { 134 | var maxAge = (typeof options.maxAge === 'number' || options.maxAge) && options.maxAge.toString() 135 | if (maxAge && maxAge.length) { 136 | return { 137 | key: 'Access-Control-Max-Age', 138 | value: maxAge 139 | }; 140 | } 141 | return null; 142 | } 143 | 144 | function applyHeaders(headers, res) { 145 | for (var i = 0, n = headers.length; i < n; i++) { 146 | var header = headers[i]; 147 | if (header) { 148 | if (Array.isArray(header)) { 149 | applyHeaders(header, res); 150 | } else if (header.key === 'Vary' && header.value) { 151 | vary(res, header.value); 152 | } else if (header.value) { 153 | res.setHeader(header.key, header.value); 154 | } 155 | } 156 | } 157 | } 158 | 159 | function cors(options, req, res, next) { 160 | var headers = [], 161 | method = req.method && req.method.toUpperCase && req.method.toUpperCase(); 162 | 163 | if (method === 'OPTIONS') { 164 | // preflight 165 | headers.push(configureOrigin(options, req)); 166 | headers.push(configureCredentials(options)) 167 | headers.push(configureMethods(options)) 168 | headers.push(configureAllowedHeaders(options, req)); 169 | headers.push(configureMaxAge(options)) 170 | headers.push(configureExposedHeaders(options)) 171 | applyHeaders(headers, res); 172 | 173 | if (options.preflightContinue) { 174 | next(); 175 | } else { 176 | // Safari (and potentially other browsers) need content-length 0, 177 | // for 204 or they just hang waiting for a body 178 | res.statusCode = options.optionsSuccessStatus; 179 | res.setHeader('Content-Length', '0'); 180 | res.end(); 181 | } 182 | } else { 183 | // actual response 184 | headers.push(configureOrigin(options, req)); 185 | headers.push(configureCredentials(options)) 186 | headers.push(configureExposedHeaders(options)) 187 | applyHeaders(headers, res); 188 | next(); 189 | } 190 | } 191 | 192 | function middlewareWrapper(o) { 193 | // if options are static (either via defaults or custom options passed in), wrap in a function 194 | var optionsCallback = null; 195 | if (typeof o === 'function') { 196 | optionsCallback = o; 197 | } else { 198 | optionsCallback = function (req, cb) { 199 | cb(null, o); 200 | }; 201 | } 202 | 203 | return function corsMiddleware(req, res, next) { 204 | optionsCallback(req, function (err, options) { 205 | if (err) { 206 | next(err); 207 | } else { 208 | var corsOptions = assign({}, defaults, options); 209 | var originCallback = null; 210 | if (corsOptions.origin && typeof corsOptions.origin === 'function') { 211 | originCallback = corsOptions.origin; 212 | } else if (corsOptions.origin) { 213 | originCallback = function (origin, cb) { 214 | cb(null, corsOptions.origin); 215 | }; 216 | } 217 | 218 | if (originCallback) { 219 | originCallback(req.headers.origin, function (err2, origin) { 220 | if (err2 || !origin) { 221 | next(err2); 222 | } else { 223 | corsOptions.origin = origin; 224 | cors(corsOptions, req, res, next); 225 | } 226 | }); 227 | } else { 228 | next(); 229 | } 230 | } 231 | }); 232 | }; 233 | } 234 | 235 | // can pass either an options hash, an options delegate, or nothing 236 | module.exports = middlewareWrapper; 237 | 238 | export default middlewareWrapper 239 | -------------------------------------------------------------------------------- /modules/object-assign.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | /* 4 | object-assign 5 | (c) Sindre Sorhus 6 | @license MIT 7 | */ 8 | 9 | 'use strict'; 10 | 11 | /* eslint-disable no-unused-vars */ 12 | var getOwnPropertySymbols = Object.getOwnPropertySymbols; 13 | var hasOwnProperty = Object.prototype.hasOwnProperty; 14 | var propIsEnumerable = Object.prototype.propertyIsEnumerable; 15 | 16 | function toObject(val) { 17 | if (val === null || val === undefined) { 18 | throw new TypeError('Object.assign cannot be called with null or undefined'); 19 | } 20 | 21 | return Object(val); 22 | } 23 | 24 | function shouldUseNative() { 25 | try { 26 | if (!Object.assign) { 27 | return false; 28 | } 29 | 30 | // Detect buggy property enumeration order in older V8 versions. 31 | 32 | // https://bugs.chromium.org/p/v8/issues/detail?id=4118 33 | var test1 = new String('abc'); // eslint-disable-line no-new-wrappers 34 | // @ts-ignore 35 | test1[5] = 'de'; 36 | if (Object.getOwnPropertyNames(test1)[0] === '5') { 37 | return false; 38 | } 39 | 40 | // https://bugs.chromium.org/p/v8/issues/detail?id=3056 41 | var test2 = {}; 42 | for (var i = 0; i < 10; i++) { 43 | test2['_' + String.fromCharCode(i)] = i; 44 | } 45 | var order2 = Object.getOwnPropertyNames(test2).map(function (n) { 46 | return test2[n]; 47 | }); 48 | if (order2.join('') !== '0123456789') { 49 | return false; 50 | } 51 | 52 | // https://bugs.chromium.org/p/v8/issues/detail?id=3056 53 | var test3 = {}; 54 | 'abcdefghijklmnopqrst'.split('').forEach(function (letter) { 55 | test3[letter] = letter; 56 | }); 57 | if (Object.keys(Object.assign({}, test3)).join('') !== 58 | 'abcdefghijklmnopqrst') { 59 | return false; 60 | } 61 | 62 | return true; 63 | } catch (err) { 64 | // We don't expect any of the above to throw, but better to be safe. 65 | return false; 66 | } 67 | } 68 | 69 | const assign = shouldUseNative() ? Object.assign : function (target, source) { 70 | var from; 71 | var to = toObject(target); 72 | var symbols; 73 | 74 | for (var s = 1; s < arguments.length; s++) { 75 | from = Object(arguments[s]); 76 | 77 | for (var key in from) { 78 | if (hasOwnProperty.call(from, key)) { 79 | to[key] = from[key]; 80 | } 81 | } 82 | 83 | if (getOwnPropertySymbols) { 84 | symbols = getOwnPropertySymbols(from); 85 | for (var i = 0; i < symbols.length; i++) { 86 | if (propIsEnumerable.call(from, symbols[i])) { 87 | to[symbols[i]] = from[symbols[i]]; 88 | } 89 | } 90 | } 91 | } 92 | 93 | return to; 94 | }; 95 | 96 | export default assign; -------------------------------------------------------------------------------- /modules/vary.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | /*! 4 | * vary 5 | * Copyright(c) 2014-2017 Douglas Christopher Wilson 6 | * MIT Licensed 7 | */ 8 | 9 | 'use strict' 10 | 11 | /** 12 | * Module exports. 13 | */ 14 | 15 | module.exports = vary 16 | module.exports.append = append 17 | 18 | /** 19 | * RegExp to match field-name in RFC 7230 sec 3.2 20 | * 21 | * field-name = token 22 | * token = 1*tchar 23 | * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" 24 | * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" 25 | * / DIGIT / ALPHA 26 | * ; any VCHAR, except delimiters 27 | */ 28 | 29 | var FIELD_NAME_REGEXP = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ 30 | 31 | /** 32 | * Append a field to a vary header. 33 | * 34 | * @param {String} header 35 | * @param {String|Array} field 36 | * @return {String} 37 | * @public 38 | */ 39 | 40 | function append (header, field) { 41 | if (typeof header !== 'string') { 42 | throw new TypeError('header argument is required') 43 | } 44 | 45 | if (!field) { 46 | throw new TypeError('field argument is required') 47 | } 48 | 49 | // get fields array 50 | var fields = !Array.isArray(field) 51 | ? parse(String(field)) 52 | : field 53 | 54 | // assert on invalid field names 55 | for (var j = 0; j < fields.length; j++) { 56 | if (!FIELD_NAME_REGEXP.test(fields[j])) { 57 | throw new TypeError('field argument contains an invalid header name') 58 | } 59 | } 60 | 61 | // existing, unspecified vary 62 | if (header === '*') { 63 | return header 64 | } 65 | 66 | // enumerate current values 67 | var val = header 68 | var vals = parse(header.toLowerCase()) 69 | 70 | // unspecified vary 71 | if (fields.indexOf('*') !== -1 || vals.indexOf('*') !== -1) { 72 | return '*' 73 | } 74 | 75 | for (var i = 0; i < fields.length; i++) { 76 | var fld = fields[i].toLowerCase() 77 | 78 | // append value (case-preserving) 79 | if (vals.indexOf(fld) === -1) { 80 | vals.push(fld) 81 | val = val 82 | ? val + ', ' + fields[i] 83 | : fields[i] 84 | } 85 | } 86 | 87 | return val 88 | } 89 | 90 | /** 91 | * Parse a vary header into an array. 92 | * 93 | * @param {String} header 94 | * @return {Array} 95 | * @private 96 | */ 97 | 98 | function parse (header) { 99 | var end = 0 100 | var list = [] 101 | var start = 0 102 | 103 | // gather tokens 104 | for (var i = 0, len = header.length; i < len; i++) { 105 | switch (header.charCodeAt(i)) { 106 | case 0x20: /* */ 107 | if (start === end) { 108 | start = end = i + 1 109 | } 110 | break 111 | case 0x2c: /* , */ 112 | list.push(header.substring(start, end)) 113 | start = end = i + 1 114 | break 115 | default: 116 | end = i + 1 117 | break 118 | } 119 | } 120 | 121 | // final token 122 | list.push(header.substring(start, end)) 123 | 124 | return list 125 | } 126 | 127 | /** 128 | * Mark that a request is varied on a header field. 129 | * 130 | * @param {Object} res 131 | * @param {String|Array} field 132 | * @public 133 | */ 134 | 135 | function vary (res, field) { 136 | if (!res || !res.getHeader || !res.setHeader) { 137 | // quack quack 138 | throw new TypeError('res argument is required') 139 | } 140 | 141 | // get existing header 142 | var val = res.getHeader('Vary') || '' 143 | var header = Array.isArray(val) 144 | ? val.join(', ') 145 | : String(val) 146 | 147 | // set new header 148 | if ((val = append(header, field))) { 149 | res.setHeader('Vary', val) 150 | } 151 | } 152 | 153 | export default vary; -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "www-react-postgres", 3 | "license": "MIT", 4 | "version": "0.0.4", 5 | "scripts": { 6 | "dev": "next -p 3005", 7 | "build": "next build", 8 | "start": "NODE_ENV=production next start", 9 | "script": "ts-node -O '{\"module\":\"commonjs\"}' scripts/index.js", 10 | "production-script": "NODE_ENV=production ts-node -O '{\"module\":\"commonjs\"}' scripts/index.js" 11 | }, 12 | "dependencies": { 13 | "bcrypt": "^5.0.1", 14 | "dotenv": "^16.0.1", 15 | "googleapis": "^103.0.0", 16 | "jsonwebtoken": "^8.5.1", 17 | "knex": "^2.1.0", 18 | "next": "12.1.6", 19 | "pg": "^8.7.3", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "sass": "1.52.3", 23 | "universal-cookie": "^4.0.4" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^18.0.0", 27 | "@types/react": "^18.0.14", 28 | "ts-node": "^10.8.1", 29 | "typescript": "^4.7.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@root/global.scss"; 2 | 3 | import * as React from "react"; 4 | 5 | function MyApp({ Component, pageProps }) { 6 | return ; 7 | } 8 | 9 | export default MyApp; 10 | -------------------------------------------------------------------------------- /pages/api/ethereum/[address].ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Ethereum from "@data/node-ethereum"; 3 | import * as Server from "@common/server"; 4 | import * as Strings from "@common/strings"; 5 | 6 | export default async function getEthereumAddress(req, res) { 7 | await Server.cors(req, res); 8 | 9 | const { address } = req.query; 10 | 11 | if (Strings.isEmpty(address)) { 12 | res.json({ error: "There is no Ethereum address to look up locally." }); 13 | } 14 | 15 | const response = await Ethereum.getAddress({ address }); 16 | 17 | res.json({ ...response }); 18 | } 19 | -------------------------------------------------------------------------------- /pages/api/ethereum/create.ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Ethereum from "@data/node-ethereum"; 3 | import * as Server from "@common/server"; 4 | import * as Strings from "@common/strings"; 5 | 6 | export default async function createEthereumAddress(req, res) { 7 | await Server.cors(req, res); 8 | 9 | if (Strings.isEmpty(req.body.address)) { 10 | return res 11 | .status(500) 12 | .send({ error: "An Ethereum address must be provided." }); 13 | } 14 | 15 | const response = await Ethereum.createAddress({ address: req.body.address }); 16 | 17 | res.json({ address: response }); 18 | } 19 | -------------------------------------------------------------------------------- /pages/api/index.ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Server from "@common/server"; 3 | 4 | // NOTE(jim): 5 | // CORS API example. 6 | export default async function apiIndex(req, res) { 7 | await Server.cors(req, res); 8 | 9 | res.json({ success: true }); 10 | } 11 | -------------------------------------------------------------------------------- /pages/api/sign-in.ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Server from "@common/server"; 3 | import * as Strings from "@common/strings"; 4 | import * as AuthUtilities from "@common/utilities-authentication"; 5 | import * as Data from "@data/node-data"; 6 | 7 | import JWT from "jsonwebtoken"; 8 | import BCrypt from "bcrypt"; 9 | 10 | export default async function signIn(req, res) { 11 | await Server.cors(req, res); 12 | 13 | if (Strings.isEmpty(req.body.email)) { 14 | return res 15 | .status(500) 16 | .send({ error: "An e-mail address was not provided." }); 17 | } 18 | 19 | if (Strings.isEmpty(req.body.password)) { 20 | return res.status(500).send({ error: "A password was not provided." }); 21 | } 22 | 23 | let user = await Data.getUserByEmail({ email: req.body.email }); 24 | 25 | if (!user) { 26 | const salt = BCrypt.genSaltSync(Env.PASSWORD_ROUNDS); 27 | const hash = BCrypt.hashSync(req.body.password, salt); 28 | const password = BCrypt.hashSync(hash, Env.SERVICE_PASSWORD_SALT); 29 | 30 | user = await Data.createUser({ 31 | email: req.body.email, 32 | password, 33 | salt, 34 | data: { verified: false }, 35 | }); 36 | } else { 37 | if (user.error) { 38 | return res 39 | .status(500) 40 | .send({ error: "We could not authenticate you (1)." }); 41 | } 42 | 43 | const hash = BCrypt.hashSync(req.body.password, user.salt); 44 | const password = BCrypt.hashSync(hash, Env.SERVICE_PASSWORD_SALT); 45 | 46 | if (password !== user.password) { 47 | return res 48 | .status(500) 49 | .send({ error: "We could not authenticate you (2)." }); 50 | } 51 | } 52 | 53 | const authorization = AuthUtilities.parseAuthHeader( 54 | req.headers.authorization 55 | ); 56 | 57 | if (authorization && !Strings.isEmpty(authorization.value)) { 58 | const verfied = JWT.verify(authorization.value, Env.JWT_SECRET); 59 | 60 | if (user.email === verfied.email) { 61 | return res.status(200).send({ 62 | message: "You are already authenticated. Welcome back!", 63 | success: true, 64 | viewer: user, 65 | }); 66 | } 67 | } 68 | 69 | const token = JWT.sign({ user: user.id, email: user.email }, Env.JWT_SECRET); 70 | 71 | return res.status(200).send({ token, success: true }); 72 | } 73 | -------------------------------------------------------------------------------- /pages/api/solana/[address].ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Solana from "@data/node-solana"; 3 | import * as Server from "@common/server"; 4 | import * as Strings from "@common/strings"; 5 | 6 | export default async function getSolanaAddress(req, res) { 7 | await Server.cors(req, res); 8 | 9 | const { address } = req.query; 10 | 11 | if (Strings.isEmpty(address)) { 12 | res.json({ error: "There is no solana address to look up locally." }); 13 | } 14 | 15 | const response = await Solana.getAddress({ address }); 16 | 17 | res.json({ ...response }); 18 | } 19 | -------------------------------------------------------------------------------- /pages/api/solana/create.ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Solana from "@data/node-solana"; 3 | import * as Server from "@common/server"; 4 | import * as Strings from "@common/strings"; 5 | 6 | export default async function createSolanaAddress(req, res) { 7 | await Server.cors(req, res); 8 | 9 | if (Strings.isEmpty(req.body.address)) { 10 | return res 11 | .status(500) 12 | .send({ error: "A Solana address must be provided." }); 13 | } 14 | 15 | const response = await Solana.createAddress({ address: req.body.address }); 16 | 17 | res.json({ address: response }); 18 | } 19 | -------------------------------------------------------------------------------- /pages/api/viewer/delete.ts: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Data from "@data/node-data"; 3 | import * as Strings from "@common/strings"; 4 | import * as AuthUtilities from "@common/utilities-authentication"; 5 | 6 | import JWT from "jsonwebtoken"; 7 | 8 | export default async function deleteViewer(req, res) { 9 | const authorization = AuthUtilities.parseAuthHeader( 10 | req.headers.authorization 11 | ); 12 | 13 | if (!authorization) { 14 | return res.status(500).send({ error: "viewer/delete error (1)" }); 15 | } 16 | 17 | const v = JWT.verify(authorization.value, Env.JWT_SECRET); 18 | 19 | if (!v || !v.email) { 20 | return res.status(500).send({ error: "viewer/delete error (2)" }); 21 | } 22 | 23 | const user = await Data.getUserByEmail({ email: v.email }); 24 | 25 | if (!user) { 26 | return res.status(500).send({ error: "viewer/delete error (3)" }); 27 | } 28 | 29 | const organization = await Data.getOrganizationByUserId({ id: user.id }); 30 | 31 | if ( 32 | organization && 33 | organization.data && 34 | organization.data.ids && 35 | organization.data.ids.length === 1 36 | ) { 37 | const co = await Data.deleteUserFromOrganizationByUserId({ 38 | organizationId: organization.id, 39 | userId: user.id, 40 | }); 41 | 42 | if (!co) { 43 | return res.status(500).send({ error: "viewer/delete error (4)" }); 44 | } 45 | } 46 | 47 | const d = await Data.deleteUserById({ id: user.id }); 48 | if (!d) { 49 | return res.status(500).send({ error: "viewer/delete error (5)" }); 50 | } 51 | 52 | return res.status(200).send({ success: true }); 53 | } 54 | -------------------------------------------------------------------------------- /pages/google-authentication.tsx: -------------------------------------------------------------------------------- 1 | import * as Env from "@data/environment"; 2 | import * as Data from "@data/node-data"; 3 | import * as Strings from "@common/strings"; 4 | import * as Constants from "@common/constants"; 5 | import * as React from "react"; 6 | 7 | import App from "@components/App"; 8 | 9 | import JWT from "jsonwebtoken"; 10 | import BCrypt from "bcrypt"; 11 | import Cookies from "universal-cookie"; 12 | 13 | const cookies = new Cookies(); 14 | const google = require("googleapis").google; 15 | const OAuth2 = google.auth.OAuth2; 16 | 17 | function GoogleAuthenticationPage(props) { 18 | React.useEffect(() => { 19 | if (!Strings.isEmpty(props.token)) { 20 | cookies.remove(Constants.SESSION_KEY); 21 | cookies.set(Constants.SESSION_KEY, props.token); 22 | return window.location.replace("/"); 23 | } 24 | 25 | window.location.replace("/?error=google"); 26 | }, []); 27 | 28 | return ( 29 | 34 | ); 35 | } 36 | 37 | export async function getServerSideProps(context) { 38 | let client = new OAuth2( 39 | Env.GOOGLE_CLIENT_ID, 40 | Env.GOOGLE_CLIENT_SECRET, 41 | Env.GOOGLE_REDIRECT_URIS 42 | ); 43 | 44 | if (context.query.error) { 45 | return { 46 | redirect: { 47 | permanent: false, 48 | destination: "/?error=google", 49 | }, 50 | }; 51 | } 52 | 53 | // NOTE(jim): A wrapped function in a promise to get a token since I'm not sure 54 | // this method is async/await compatible. 55 | const getGoogleData = async (code: string): Promise => { 56 | return new Promise((resolve) => { 57 | client.getToken(code, async (error, token) => { 58 | if (error) { 59 | resolve({ error: "Failed to get token (1)." }); 60 | } 61 | 62 | return resolve({ 63 | success: true, 64 | token, 65 | }); 66 | }); 67 | }); 68 | }; 69 | 70 | const tokenResponse = await getGoogleData(context.query.code); 71 | 72 | if (tokenResponse.error) { 73 | return { 74 | redirect: { 75 | permanent: false, 76 | destination: "/?error=google", 77 | }, 78 | }; 79 | } 80 | 81 | const jwt = JWT.sign(tokenResponse.token, Env.JWT_SECRET); 82 | client.credentials = JWT.verify(jwt, Env.JWT_SECRET); 83 | 84 | const people = google.people({ 85 | version: "v1", 86 | auth: client, 87 | }); 88 | 89 | const response = await people.people.get({ 90 | resourceName: "people/me", 91 | personFields: "emailAddresses,names,organizations,memberships", 92 | }); 93 | 94 | const email = response.data.emailAddresses[0].value; 95 | const name = response.data.names[0].displayName; 96 | 97 | // NOTE(jim): 98 | // You'll want to e-mail this to the user or something 99 | // Or generate some other password. Do whatever you want. 100 | const password = BCrypt.genSaltSync(Env.PASSWORD_ROUNDS); 101 | 102 | let user = await Data.getUserByEmail({ email }); 103 | if (!user) { 104 | const salt = BCrypt.genSaltSync(Env.PASSWORD_ROUNDS); 105 | const hash = await BCrypt.hashSync(password, salt); 106 | const nextPassword = await BCrypt.hashSync(hash, Env.SERVICE_PASSWORD_SALT); 107 | user = await Data.createUser({ 108 | email, 109 | password: nextPassword, 110 | salt, 111 | data: { name, verified: true, google: true }, 112 | }); 113 | 114 | if (user.error) { 115 | return { 116 | redirect: { 117 | permanent: false, 118 | destination: "/?error=google", 119 | }, 120 | }; 121 | } 122 | 123 | // NOTE(jim): Because the domain comes from google. 124 | // If the organization doesn't exist. create it. 125 | const domain = Strings.getDomainFromEmail(email); 126 | const organization = await Data.getOrganizationByDomain({ domain }); 127 | 128 | if (!organization) { 129 | const companyName = domain.split(".")[0]; 130 | await Data.createOrganization({ 131 | domain, 132 | data: { 133 | name: Strings.capitalizeFirstLetter(companyName), 134 | tier: 0, 135 | ids: [user.id], 136 | admins: [], 137 | }, 138 | }); 139 | } 140 | } 141 | 142 | if (user.error) { 143 | return { 144 | redirect: { 145 | permanent: false, 146 | destination: "/?error=google", 147 | }, 148 | }; 149 | } 150 | 151 | // NOTE(jim): If you are able to authenticate with google... 152 | // the user is now verified and updated. 153 | if (!user.data.google) { 154 | await Data.updateUserDataByEmail({ 155 | email: user.email, 156 | data: { ...user.data, verified: true, google: true }, 157 | }); 158 | } 159 | 160 | const authToken = JWT.sign( 161 | { user: user.id, email: user.email }, 162 | Env.JWT_SECRET 163 | ); 164 | 165 | return { 166 | props: { 167 | token: authToken, 168 | }, 169 | }; 170 | } 171 | 172 | export default GoogleAuthenticationPage; 173 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@components/index.module.scss"; 2 | 3 | import * as React from "react"; 4 | import * as Requests from "@common/requests"; 5 | import * as Strings from "@common/strings"; 6 | import * as Utilities from "@common/utilities"; 7 | import * as NodeAuth from "@data/node-authentication"; 8 | import * as NodeGoogle from "@data/node-google"; 9 | 10 | import SceneHome from "@scenes/SceneHome"; 11 | import App from "@components/App"; 12 | 13 | declare const window: any; 14 | 15 | function IndexPage(props) { 16 | const [state, setState] = React.useState({ 17 | ethereum: null, 18 | solana: null, 19 | isMetamaskEnabled: false, 20 | isPhantomEnabled: false, 21 | }); 22 | 23 | React.useEffect(() => { 24 | const loadWatchers = async () => { 25 | const { isMetamaskEnabled } = await Utilities.getWalletStatus(); 26 | 27 | if (!isMetamaskEnabled) { 28 | return; 29 | } 30 | 31 | // TODO(jim): This is lazy. you can find another way. 32 | window.ethereum.on("accountsChanged", function(accounts) { 33 | window.location.reload(); 34 | }); 35 | }; 36 | 37 | loadWatchers(); 38 | }, []); 39 | 40 | React.useEffect(() => { 41 | const load = async () => { 42 | const { 43 | isMetamaskEnabled, 44 | isPhantomEnabled, 45 | } = await Utilities.getWalletStatus(); 46 | 47 | // NOTE(jim): The associated Ethereum address 48 | let ethereumResponse = null; 49 | if (isMetamaskEnabled) { 50 | if (!Strings.isEmpty(window.ethereum.selectedAddress)) { 51 | const eResponse = await Requests.get( 52 | `/api/ethereum/${window.ethereum.selectedAddress}` 53 | ); 54 | 55 | if (eResponse && eResponse.address) { 56 | ethereumResponse = eResponse; 57 | } 58 | } 59 | } 60 | 61 | // NOTE(jim): The associated Solana address 62 | let solanaResponse = null; 63 | if (isPhantomEnabled && window.solana.publicKey) { 64 | const solanaAddress = window.solana.publicKey.toString(); 65 | if (!Strings.isEmpty(solanaAddress)) { 66 | const sResponse = await Requests.get(`/api/solana/${solanaAddress}`); 67 | 68 | if (sResponse && sResponse.address) { 69 | solanaResponse = sResponse; 70 | } 71 | } 72 | } 73 | 74 | setState({ 75 | ...state, 76 | isMetamaskEnabled, 77 | isPhantomEnabled, 78 | ethereum: ethereumResponse, 79 | solana: solanaResponse, 80 | }); 81 | }; 82 | 83 | load(); 84 | }, []); 85 | 86 | return ( 87 | 92 | 98 | 99 | ); 100 | } 101 | 102 | export async function getServerSideProps(context) { 103 | const { viewer } = await NodeAuth.getViewer(context.req); 104 | const { googleURL } = await NodeGoogle.generateURL(); 105 | 106 | return { 107 | props: { 108 | viewer: viewer, 109 | host: context.req.headers.host, 110 | googleURL, 111 | }, 112 | }; 113 | } 114 | 115 | export default IndexPage; 116 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmylee/www-react-postgres/1ee0ed6efc80e2a90f95d57b88be81e9e645c886/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmylee/www-react-postgres/1ee0ed6efc80e2a90f95d57b88be81e9e645c886/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmylee/www-react-postgres/1ee0ed6efc80e2a90f95d57b88be81e9e645c886/public/favicon.ico -------------------------------------------------------------------------------- /scenes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmylee/www-react-postgres/1ee0ed6efc80e2a90f95d57b88be81e9e645c886/scenes/.gitkeep -------------------------------------------------------------------------------- /scenes/SceneHome.module.scss: -------------------------------------------------------------------------------- 1 | .scene { 2 | } 3 | -------------------------------------------------------------------------------- /scenes/SceneHome.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as Actions from "@common/actions"; 3 | import * as Strings from "@common/strings"; 4 | 5 | import styles from "@scenes/SceneHome.module.scss"; 6 | 7 | import Header from "@components/Header"; 8 | import Layout from "@components/Layout"; 9 | import LayoutLeft from "@components/LayoutLeft"; 10 | import LayoutRight from "@components/LayoutRight"; 11 | import LineItem from "@components/LineItem"; 12 | import StatePreview from "@components/StatePreview"; 13 | import Content from "@components/Content"; 14 | import Input from "@components/Input"; 15 | import Button from "@components/Button"; 16 | import Tip from "@components/Tip"; 17 | 18 | import H1 from "@components/H1"; 19 | import H2 from "@components/H2"; 20 | import P from "@components/P"; 21 | 22 | declare const window: any; 23 | 24 | const handleChange = (e, state, setState) => { 25 | setState({ ...state, [e.target.name]: e.target.value }); 26 | }; 27 | 28 | function SceneHome(props) { 29 | const [state, setState] = React.useState({ email: "", password: "" }); 30 | 31 | let maybeRenderGoogleAuth = 32 | !props.viewer && !Strings.isEmpty(props.googleURL); 33 | if ( 34 | props.viewer && 35 | !Strings.isEmpty(props.googleURL) && 36 | props.viewer.data && 37 | !props.viewer.data.google 38 | ) { 39 | maybeRenderGoogleAuth = true; 40 | } 41 | 42 | return ( 43 |
44 |
45 | 46 |

www-react-postgres 0.1

47 |
48 |
49 | 50 | 51 | {props.viewer && ( 52 | 53 | 54 | 55 |

Sign out

56 | 57 |

58 | This method will delete string that holds the JWT in the 59 | cookie. To authenticate again you will need to sign in. 60 |

61 | 62 | 68 |
69 |
70 | 71 | 72 |

Delete Account

73 | 74 |

75 | This method will delete the user entry from the user table. 76 | If the user is part of an organization this will delete the 77 | user from the organization but will not delete the 78 | organization row even if it has no members. 79 |

80 | 81 | 87 |
88 |
89 |
90 | )} 91 | {!props.viewer && ( 92 | 93 | 94 |

Sign in

95 | 96 |

97 | This is a traditional username and password sign in, if an 98 | account exists it will check if the credentials are correct, 99 | if the account does not exist a new user entry will be added 100 | in your Postgres database. You will need to figure out a way 101 | to verify the e-mail address on your own. 102 |

103 | 104 | handleChange(e, state, setState)} 110 | /> 111 | handleChange(e, state, setState)} 119 | /> 120 | 123 |
124 |
125 | )} 126 | 127 | {maybeRenderGoogleAuth && ( 128 | 129 | 130 | {props.viewer ? ( 131 |

Continue with Google

132 | ) : ( 133 |

Sign in with Google

134 | )} 135 | 136 |

137 | This is a traditional Google sign in flow. The necessary 138 | client ID and client secret are provided by Google. Unlike the 139 | local authentication strategy with a username and password, a 140 | user using this method will have a verified e-mail. 141 |

142 | 143 | {props.viewer && ( 144 |

145 | If a user continues with a Google account that does not 146 | match the local authentication e-mail, it will sign that 147 | user into that account instead. 148 |

149 | )} 150 | 151 | 152 |
153 |
154 | )} 155 | 156 | {props.state.isMetamaskEnabled && !window.ethereum.selectedAddress && ( 157 | 158 | 159 |

Connect Metamask to {props.host}

160 |

161 | The user has Metamask installed in their browser. A user can 162 | now click the button below to connect their Ethereum address 163 | to {props.host}. This action will also create an Ethereum 164 | address entry in the Postgres table. 165 |

166 | 173 |
174 |
175 | )} 176 | 177 | {props.state.isMetamaskEnabled && window.ethereum.selectedAddress && ( 178 | 179 | 180 |

181 | Metamask is connected and an Ethereum address is selected. 182 |

183 |

184 | As the developer you could write some code that associates the 185 | Ethereum address with the authenticated user. However it is 186 | common advice to wait for an actual need. 187 |

188 |

From this point on you can build your DAPP or DAO.

189 |
190 |
191 | )} 192 | 193 | {props.state.isPhantomEnabled && !props.state.solana && ( 194 | 195 | 196 |

Connect Phantom to {props.host}

197 |

198 | The user has Phantom installed in their browser. A user can 199 | now click the button below to connect their Solana address to{" "} 200 | {props.host}. This action will also create an Solana address 201 | entry in the Postgres table. 202 |

203 | 210 |
211 |
212 | )} 213 | 214 | {props.state.isPhantomEnabled && props.state.solana && ( 215 | 216 | 217 |

218 | Phantom is connected and the Solana address is selected. 219 |

220 |

221 | As the developer you could write some code that associates the 222 | Solana address with the authenticated user. However it is 223 | common advice to wait for an actual need. 224 |

225 |
226 |
227 | )} 228 | 229 | {!props.state.isMetamaskEnabled && ( 230 | 231 | 232 |

Install Metamask

233 | 234 |
235 |
236 | )} 237 | 238 | {!props.state.isPhantomEnabled && ( 239 | 240 | 241 |

Install Phantom

242 | 243 |
244 |
245 | )} 246 |
247 | 248 | {props.state.isMetamaskEnabled && window.ethereum.selectedAddress ? ( 249 | Metamask ➝ {window.ethereum.selectedAddress} 250 | ) : null} 251 | 252 | {props.state.isMetamaskEnabled && props.state.ethereum && ( 253 | 254 | Ethereum address ➝ {props.state.ethereum.address} has an entry in 255 | this server's Postgres database. 256 | 257 | )} 258 | 259 | {props.state.isPhantomEnabled && props.state.solana && ( 260 | 261 | Solana address (Phantom public key) ➝ {props.state.solana.address}{" "} 262 | has an entry in this server's Postgres database. 263 | 264 | )} 265 | 266 | {props.viewer && props.viewer.data.verified && ( 267 | {props.viewer.email} is verified. 268 | )} 269 | 270 | {props.viewer && props.viewer.data.google && ( 271 | 272 | {props.viewer.email} has connected Google to their account. 273 | 274 | )} 275 | 276 | {props.viewer && !props.viewer.data.verified && ( 277 | 278 | {props.viewer.email} is not verified. 279 | 280 | )} 281 | 282 | 283 |
284 |
285 | ); 286 | } 287 | 288 | export default SceneHome; 289 | -------------------------------------------------------------------------------- /scripts/database-drop.js: -------------------------------------------------------------------------------- 1 | import configs from "../knexfile"; 2 | import knex from "knex"; 3 | 4 | const name = `database-drop.js`; 5 | const environment = 6 | process.env.NODE_ENV !== "production" ? "development" : "production"; 7 | const envConfig = configs[environment]; 8 | const db = knex(envConfig); 9 | 10 | console.log(`RUNNING: ${name} NODE_ENV=${environment}`); 11 | 12 | // -------------------------- 13 | // SCRIPTS 14 | // -------------------------- 15 | 16 | const dropUserTable = db.schema.dropTable("users"); 17 | const dropOrganizationsTable = db.schema.dropTable("organizations"); 18 | 19 | // -------------------------- 20 | // RUN 21 | // -------------------------- 22 | 23 | Promise.all([dropUserTable, dropOrganizationsTable]); 24 | 25 | console.log(`FINISHED: ${name} NODE_ENV=${environment} (⌘ + C to quit)`); 26 | -------------------------------------------------------------------------------- /scripts/database-seed.js: -------------------------------------------------------------------------------- 1 | import configs from "../knexfile"; 2 | import knex from "knex"; 3 | 4 | const name = `database-seed.js`; 5 | const environment = 6 | process.env.NODE_ENV !== "production" ? "development" : "production"; 7 | const envConfig = configs[environment]; 8 | const db = knex(envConfig); 9 | 10 | console.log(`RUNNING: ${name} NODE_ENV=${environment}`); 11 | 12 | // -------------------------- 13 | // SCRIPTS 14 | // -------------------------- 15 | 16 | const createEthereumAddressTable = db.schema.createTable("ethereum", function( 17 | table 18 | ) { 19 | table 20 | .string("address") 21 | .primary() 22 | .unique() 23 | .notNullable(); 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.jsonb("data").nullable(); 36 | }); 37 | 38 | const createSolanaAddressTable = db.schema.createTable("solana", function( 39 | table 40 | ) { 41 | table 42 | .string("address") 43 | .primary() 44 | .unique() 45 | .notNullable(); 46 | 47 | table 48 | .timestamp("created_at") 49 | .notNullable() 50 | .defaultTo(db.raw("now()")); 51 | 52 | table 53 | .timestamp("updated_at") 54 | .notNullable() 55 | .defaultTo(db.raw("now()")); 56 | 57 | table.jsonb("data").nullable(); 58 | }); 59 | 60 | const createUserTable = db.schema.createTable("users", function(table) { 61 | table 62 | .uuid("id") 63 | .primary() 64 | .unique() 65 | .notNullable() 66 | .defaultTo(db.raw("uuid_generate_v4()")); 67 | 68 | table 69 | .timestamp("created_at") 70 | .notNullable() 71 | .defaultTo(db.raw("now()")); 72 | 73 | table 74 | .timestamp("updated_at") 75 | .notNullable() 76 | .defaultTo(db.raw("now()")); 77 | 78 | table 79 | .string("email") 80 | .unique() 81 | .notNullable(); 82 | 83 | table.string("password").nullable(); 84 | table.string("salt").nullable(); 85 | table.jsonb("data").nullable(); 86 | }); 87 | 88 | const createOrganizationsTable = db.schema.createTable( 89 | "organizations", 90 | function(table) { 91 | table 92 | .uuid("id") 93 | .primary() 94 | .unique() 95 | .notNullable() 96 | .defaultTo(db.raw("uuid_generate_v4()")); 97 | 98 | table 99 | .timestamp("created_at") 100 | .notNullable() 101 | .defaultTo(db.raw("now()")); 102 | 103 | table 104 | .timestamp("updated_at") 105 | .notNullable() 106 | .defaultTo(db.raw("now()")); 107 | 108 | table 109 | .string("domain") 110 | .unique() 111 | .notNullable(); 112 | 113 | table.jsonb("data").nullable(); 114 | } 115 | ); 116 | 117 | // -------------------------- 118 | // RUN 119 | // -------------------------- 120 | 121 | Promise.all([ 122 | createEthereumAddressTable, 123 | createSolanaAddressTable, 124 | createUserTable, 125 | createOrganizationsTable, 126 | ]); 127 | 128 | console.log(`FINISHED: ${name} NODE_ENV=${environment} (⌘ + C to quit)`); 129 | -------------------------------------------------------------------------------- /scripts/database-setup.js: -------------------------------------------------------------------------------- 1 | import configs from "../knexfile"; 2 | import knex from "knex"; 3 | 4 | const name = `database-setup.js`; 5 | const environment = 6 | process.env.NODE_ENV !== "production" ? "development" : "production"; 7 | const envConfig = configs[environment]; 8 | const db = knex(envConfig); 9 | 10 | console.log(`RUNNING: ${name} NODE_ENV=${environment}`); 11 | 12 | Promise.all([db.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')]); 13 | 14 | console.log(`FINISHED: ${name} NODE_ENV=${environment} (⌘ + C to quit)`); 15 | -------------------------------------------------------------------------------- /scripts/example.js: -------------------------------------------------------------------------------- 1 | const NAME = `example.js`; 2 | 3 | console.log(`RUNNING: ${NAME} NODE_ENV=${process.env.NODE_ENV}`); 4 | 5 | console.log( 6 | `FINISHED: ${NAME} NODE_ENV=${process.env.NODE_ENV} (⌘ + C to quit)` 7 | ); 8 | -------------------------------------------------------------------------------- /scripts/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./" + process.argv[2] + ".js"); 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@root/*": ["./*"], 6 | "@data/*": ["./data/*"], 7 | "@common/*": ["./common/*"], 8 | "@scenes/*": ["./scenes/*"], 9 | "@components/*": ["./components/*"], 10 | "@modules/*": ["./modules/*"], 11 | "@pages/*": ["./pages/*"] 12 | }, 13 | "target": "es5", 14 | "lib": ["dom", "dom.iterable", "esnext"], 15 | "allowJs": true, 16 | "skipLibCheck": true, 17 | "strict": false, 18 | "forceConsistentCasingInFileNames": true, 19 | "noEmit": true, 20 | "esModuleInterop": true, 21 | "module": "esnext", 22 | "moduleResolution": "node", 23 | "resolveJsonModule": true, 24 | "isolatedModules": true, 25 | "jsx": "preserve", 26 | "incremental": true 27 | }, 28 | "exclude": ["node_modules", "**/*.spec.ts"], 29 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 30 | } 31 | --------------------------------------------------------------------------------