Skip to content

Commit

Permalink
Merge pull request #27 from bc-chaz/multi-user
Browse files Browse the repository at this point in the history
feat(common): LFG-21 - adds multi user support
  • Loading branch information
bc-chaz committed May 26, 2021
2 parents 1b7b6d5 + 1a79147 commit 537698a
Show file tree
Hide file tree
Showing 13 changed files with 164 additions and 53 deletions.
4 changes: 2 additions & 2 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ FIRE_PROJECT_ID={firebase project id}
# If using mysql, enter your config here

MYSQL_HOST={mysql host}
MYSQL_DATABASE={mysql domain}
MYSQL_DATABASE={mysql database name}
MYSQL_USERNAME={mysql username}
MYSQL_PASSWORD={mysql password}
MYSQL_PORT={mysql port}
MYSQL_PORT={mysql port *optional*}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ To get the app running locally, follow these instructions:
- Get `ngrok_id` from the terminal that's running `ngrok http 3000`.
- e.g. auth callback: `https://12345.ngrok.io/api/auth`
5. Copy .env-sample to `.env`.
- If deploying on Heroku, skip `.env` setup. Instead, enter `env` variables in the Heroku App Dashboard under `Settings -> Config Vars`.
6. [Replace client_id and client_secret in .env](https://devtools.bigcommerce.com/my/apps) (from `View Client ID` in the dev portal).
7. Update AUTH_CALLBACK in `.env` with the `ngrok_id` from step 5.
8. Enter a cookie name, as well as a jwt secret in `.env`.
- The cookie name should be unique
- JWT key should be at least 32 random characters (256 bits) for HS256
9. Specify DB_TYPE in `.env`
- If using Firebase, enter your firebase config keys. See [Firebase quickstart](https://firebase.google.com/docs/firestore/quickstart)
- If using MySQL, enter your mysql database config keys (host, database, user/pass and port).
- If using MySQL, enter your mysql database config keys (host, database, user/pass and optionally port). Note: if using Heroku with ClearDB, the DB should create the necessary `Config Var`, i.e. `CLEARDB_DATABASE_URL`.
10. Start your dev environment in a **separate** terminal from `ngrok`. If `ngrok` restarts, update callbacks in steps 4 and 7 with the new ngrok_id.
- `npm run dev`
11. [Install the app and launch.](https://developer.bigcommerce.com/api-docs/apps/quick-start#install-the-app)
13 changes: 10 additions & 3 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ const bigcommerceSigned = new BigCommerce({
responseType: 'json'
});

export function bigcommerceClient(accessToken: string, storeId: string) {
export function bigcommerceClient(accessToken: string, storeHash: string) {
return new BigCommerce({
clientId: CLIENT_ID,
accessToken,
storeHash: storeId,
storeHash,
responseType: 'json',
apiVersion: 'v3'
});
Expand All @@ -46,13 +46,14 @@ export async function setSession(req: NextApiRequest, res: NextApiResponse, sess

db.setUser(session);
db.setStore(session);
db.setStoreUser(session);
}

export async function getSession(req: NextApiRequest) {
const cookies = getCookie(req);
if (cookies) {
const cookieData = decode(cookies);
const accessToken = await db.getStoreToken(cookieData?.storeId);
const accessToken = await db.getStoreToken(cookieData?.storeHash);

return { ...cookieData, accessToken };
}
Expand All @@ -65,3 +66,9 @@ export async function removeSession(res: NextApiResponse, session: SessionProps)

await db.deleteStore(session);
}

export async function removeUserData(res: NextApiResponse, session: SessionProps) {
removeCookie(res);

await db.deleteUser(session);
}
8 changes: 4 additions & 4 deletions lib/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ const MAX_AGE = 60 * 60 * 24; // 24 hours

export async function setCookie(res: NextApiResponse, session: SessionProps) {
const { context, scope } = session;
const storeId = context?.split('/')[1] || '';
const storeHash = context?.split('/')[1] || '';

const cookie = serialize(COOKIE_NAME, encode(scope, storeId), {
const cookie = serialize(COOKIE_NAME, encode(scope, storeHash), {
expires: new Date(Date.now() + MAX_AGE * 1000),
httpOnly: true,
path: '/',
Expand Down Expand Up @@ -39,8 +39,8 @@ export function removeCookie(res: NextApiResponse) {
res.setHeader('Set-Cookie', cookie);
}

export function encode(scope: string, storeId: string) {
return jwt.sign({ scope, storeId }, JWT_KEY);
export function encode(scope: string, storeHash: string) {
return jwt.sign({ scope, storeHash }, JWT_KEY);
}

export function decode(encodedCookie: string) {
Expand Down
74 changes: 59 additions & 15 deletions lib/dbs/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ if (!firebase.apps.length) {
const db = firebase.firestore();

// Firestore data management functions
export async function setUser({ context, user }: SessionProps) {

// Use setUser for storing global user data (persists between installs)
export async function setUser({ user }: SessionProps) {
if (!user) return null;

const { email, id, username } = user;
const storeId = context?.split('/')[1] || '';
const ref = db.collection('users').doc(String(id));
const data: UserData = { email, storeId };
const data: UserData = { email };

if (username) {
data.username = username;
Expand All @@ -38,34 +39,77 @@ export async function setUser({ context, user }: SessionProps) {
}

export async function setStore(session: SessionProps) {
const { access_token: accessToken, context, scope } = session;
const { access_token: accessToken, context, scope, user: { id } } = session;
// Only set on app install or update
if (!accessToken || !scope) return null;

const storeId = context?.split('/')[1] || '';
const ref = db.collection('store').doc(storeId);
const data = { accessToken, scope };
const storeHash = context?.split('/')[1] || '';
const ref = db.collection('store').doc(storeHash);
const data = { accessToken, adminId: id, scope };

await ref.set(data);
}

// User management for multi-user apps
// Use setStoreUser for storing store specific variables
export async function setStoreUser(session: SessionProps) {
const { access_token: accessToken, context, user: { id } } = session;
if (!id) return null;

const storeHash = context?.split('/')[1] || '';
const collection = db.collection('storeUsers');
const ref = collection.doc(String(id));

// Set admin (store owner) if installing/ updating the app
// https://developer.bigcommerce.com/api-docs/apps/guide/users
if (accessToken) {
const oldAdmin = collection.where('isAdmin', '==', true).limit(1);
const oldAdminRes = await oldAdmin.get();
const [oldAdminDoc] = oldAdminRes?.docs ?? [];

// Nothing to update if admin the same
if (oldAdminDoc?.id === String(id)) return null;

// Update admin (if different and previously installed)
if (oldAdminDoc?.exists) {
await oldAdminDoc.ref.update({ isAdmin: false });
}

// Create a new record
await ref.set({ storeHash, isAdmin: true });
} else {
const storeUser = await ref.get();

// Create a new user if it doesn't exist (non-store owners added here for multi-user apps)
if (!storeUser?.exists) {
await ref.set({ storeHash, isAdmin: false });
}
}
}

export async function deleteUser({ user }: SessionProps) {
const storeUsersRef = db.collection('storeUsers').doc(String(user?.id));

await storeUsersRef.delete();
}

export async function getStore() {
const doc = await db.collection('store').limit(1).get();
const [storeDoc] = doc?.docs ?? [];
const storeData: StoreData = { ...storeDoc?.data(), storeId: storeDoc?.id };
const storeData: StoreData = { ...storeDoc?.data(), storeHash: storeDoc?.id };

return storeDoc.exists ? storeData : null;
return storeDoc?.exists ? storeData : null;
}

export async function getStoreToken(storeId: string) {
if (!storeId) return null;
const storeDoc = await db.collection('store').doc(storeId).get();
export async function getStoreToken(storeHash: string) {
if (!storeHash) return null;
const storeDoc = await db.collection('store').doc(storeHash).get();

return storeDoc.exists ? storeDoc.data()?.accessToken : null;
return storeDoc?.exists ? storeDoc.data()?.accessToken : null;
}

export async function deleteStore({ store_hash: storeId }: SessionProps) {
const ref = db.collection('store').doc(storeId);
export async function deleteStore({ store_hash: storeHash }: SessionProps) {
const ref = db.collection('store').doc(storeHash);

await ref.delete();
}
60 changes: 48 additions & 12 deletions lib/dbs/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ import * as mysql from 'mysql';
import { promisify } from 'util';
import { SessionProps, StoreData } from '../../types';

// For use with Heroku ClearDB
// Other mysql: https://www.npmjs.com/package/mysql#establishing-connections
const connection = mysql.createConnection(process.env.CLEARDB_DATABASE_URL);
const query = promisify(connection.query.bind(connection));

export async function setUser({ context, user }: SessionProps) {
// Use setUser for storing global user data (persists between installs)
export async function setUser({ user }: SessionProps) {
if (!user) return null;

const { email, id, username } = user;
const storeId = context?.split('/')[1] || '';

const userData = { email, userId: id, storeId, username };
const userData = { email, userId: id, username };

await query('REPLACE INTO users SET ?', userData);
}
Expand All @@ -21,26 +22,61 @@ export async function setStore(session: SessionProps) {
// Only set on app install or update
if (!accessToken || !scope) return null;

const storeId = context?.split('/')[1] || '';
const storeHash = context?.split('/')[1] || '';
const storeData: StoreData = { accessToken, scope, storeHash };

const storeData: StoreData = { accessToken, scope, storeId };
await query('REPLACE INTO stores SET ?', storeData);
}

// Use setStoreUser for storing store specific variables
export async function setStoreUser(session: SessionProps) {
const { access_token: accessToken, context, user: { id } } = session;
if (!id) return null;

const storeHash = context?.split('/')[1] || '';
const [oldAdmin] = await query('SELECT * FROM storeUsers WHERE isAdmin IS TRUE limit 1') ?? [];

// Set admin (store owner) if installing/ updating the app
// https://developer.bigcommerce.com/api-docs/apps/guide/users
if (accessToken) {
// Nothing to update if admin the same
if (oldAdmin?.userId === String(id)) return null;

// Update admin (if different and previously installed)
if (oldAdmin) {
await query('UPDATE storeUsers SET isAdmin=0 WHERE isAdmin IS TRUE');
}

// Create a new record
await query('INSERT INTO storeUsers SET ?', { isAdmin: true, storeHash, userId: id });
} else {
const storeUser = await query('SELECT * FROM storeUsers WHERE userId = ?', String(id));

// Create a new user if it doesn't exist (non-store owners added here for multi-user apps)
if (!storeUser.length) {
await query('INSERT INTO storeUsers SET ?', { isAdmin: false, storeHash, userId: id });
}
}
}

export async function deleteUser({ user }: SessionProps) {
await query('DELETE FROM storeUsers WHERE userId = ?', String(user?.id));
}

export async function getStore() {
const results = await query('SELECT * from stores limit 1');
const results = await query('SELECT * FROM stores limit 1');

return results.length ? results[0] : null;
}

export async function getStoreToken(storeId: string) {
if (!storeId) return null;
export async function getStoreToken(storeHash: string) {
if (!storeHash) return null;

const results = await query('SELECT accessToken from stores limit 1');
const results = await query('SELECT accessToken FROM stores limit 1');

return results.length ? results[0].accessToken : null;
}

export async function deleteStore({ store_hash: storeId }: SessionProps) {
await query('DELETE FROM stores WHERE storeId = ?', storeId);
export async function deleteStore({ store_hash: storeHash }: SessionProps) {
await query('DELETE FROM stores WHERE storeHash = ?', storeHash);
}
3 changes: 1 addition & 2 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ export function useProducts() {
}

export function useProductList() {
const options = { revalidateOnMount: false }; // Disable auto validation when switching pages
const { data, error, mutate: mutateList } = useSWR('/api/products/list', fetcher, options);
const { data, error, mutate: mutateList } = useSWR('/api/products/list', fetcher);

return {
list: data,
Expand Down
8 changes: 4 additions & 4 deletions pages/api/products/[pid].ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export default async function products(req: NextApiRequest, res: NextApiResponse
switch (method) {
case 'GET':
try {
const { accessToken, storeId } = await getSession(req);
const bigcommerce = bigcommerceClient(accessToken, storeId);
const { accessToken, storeHash } = await getSession(req);
const bigcommerce = bigcommerceClient(accessToken, storeHash);

const { data } = await bigcommerce.get(`/catalog/products/${pid}`);
res.status(200).json(data);
Expand All @@ -23,8 +23,8 @@ export default async function products(req: NextApiRequest, res: NextApiResponse
break;
case 'PUT':
try {
const { accessToken, storeId } = await getSession(req);
const bigcommerce = bigcommerceClient(accessToken, storeId);
const { accessToken, storeHash } = await getSession(req);
const bigcommerce = bigcommerceClient(accessToken, storeHash);

const { data } = await bigcommerce.put(`/catalog/products/${pid}`, body);
res.status(200).json(data);
Expand Down
4 changes: 2 additions & 2 deletions pages/api/products/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { bigcommerceClient, getSession } from '../../../lib/auth';

export default async function products(req: NextApiRequest, res: NextApiResponse) {
try {
const { accessToken, storeId } = await getSession(req);
const bigcommerce = bigcommerceClient(accessToken, storeId);
const { accessToken, storeHash } = await getSession(req);
const bigcommerce = bigcommerceClient(accessToken, storeHash);

const { data } = await bigcommerce.get('/catalog/summary');
res.status(200).json(data);
Expand Down
4 changes: 2 additions & 2 deletions pages/api/products/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { bigcommerceClient, getSession } from '../../../lib/auth';

export default async function list(req: NextApiRequest, res: NextApiResponse) {
try {
const { accessToken, storeId } = await getSession(req);
const bigcommerce = bigcommerceClient(accessToken, storeId);
const { accessToken, storeHash } = await getSession(req);
const bigcommerce = bigcommerceClient(accessToken, storeHash);
// Optional: pass in API params here
const params = [
'limit=11',
Expand Down
14 changes: 14 additions & 0 deletions pages/api/removeUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getBCVerify, removeUserData } from '../../lib/auth';

export default async function removeUser(req: NextApiRequest, res: NextApiResponse) {
try {
const session = await getBCVerify(req.query);

await removeUserData(res, session);
res.status(200).end();
} catch (error) {
const { message, response } = error;
res.status(response?.status || 500).json(message);
}
}
17 changes: 13 additions & 4 deletions scripts/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,33 @@ const query = util.promisify(connection.query.bind(connection));
const usersCreate = query('CREATE TABLE `users` (\n' +
' `id` int(11) unsigned NOT NULL AUTO_INCREMENT,\n' +
' `userId` int(11) NOT NULL,\n' +
' `storeId` int(11) NOT NULL,\n' +
' `email` text NOT NULL,\n' +
' `username` text,\n' +
' PRIMARY KEY (`id`),\n' +
' UNIQUE KEY `userId` (`userId`,`storeId`)\n' +
' UNIQUE KEY `userId` (`userId`)\n' +
') ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;\n'
);

const storesCreate = query('CREATE TABLE `stores` (\n' +
' `id` int(11) unsigned NOT NULL AUTO_INCREMENT,\n' +
' `storeId` int(11) NOT NULL,\n' +
' `storeHash` varchar(10) NOT NULL,\n' +
' `accessToken` text,\n' +
' `scope` text,\n' +
' PRIMARY KEY (`id`),\n' +
' UNIQUE KEY `storeId` (`storeId`)\n' +
' UNIQUE KEY `storeHash` (`storeHash`)\n' +
') ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;'
);

const storeUsersCreate = query('CREATE TABLE `storeUsers` (\n' +
' `id` int(11) unsigned NOT NULL AUTO_INCREMENT,\n' +
' `userId` int(11) NOT NULL,\n' +
' `storeHash` varchar(10),\n' +
' `isAdmin` boolean,\n' +
' PRIMARY KEY (`id`),\n' +
' UNIQUE KEY `userId` (`userId`,`storeHash`)\n' +
') ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;\n'
);

Promise.all([usersCreate, storesCreate]).then(() => {
connection.end();
});
Loading

0 comments on commit 537698a

Please sign in to comment.