├── .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 |
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 |
16 | {props.children}
17 |
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 ;
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 | Actions.execute("SIGN_OUT")}
64 | style={{ background: `var(--color-warning)` }}
65 | >
66 | Sign out
67 |
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 | Actions.execute("VIEWER_DELETE_USER")}
83 | style={{ background: `var(--color-failure)` }}
84 | >
85 | Delete {props.viewer.id}
86 |
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 | Actions.execute("SIGN_IN", state)}>
121 | Continue
122 |
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 | Continue to Google
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 |
168 | Actions.execute("VIEWER_CONNECT_METAMASK", state)
169 | }
170 | >
171 | Connect Metamask
172 |
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 |
205 | Actions.execute("VIEWER_CONNECT_PHANTOM", state)
206 | }
207 | >
208 | Connect Phantom
209 |
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 | Visit metamask.io
234 |
235 |
236 | )}
237 |
238 | {!props.state.isPhantomEnabled && (
239 |
240 |
241 | Install Phantom
242 | Visit phantom.app
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 |
--------------------------------------------------------------------------------