Skip to content

Commit 537698a

Browse files
authored
Merge pull request #27 from bc-chaz/multi-user
feat(common): LFG-21 - adds multi user support
2 parents 1b7b6d5 + 1a79147 commit 537698a

File tree

13 files changed

+164
-53
lines changed

13 files changed

+164
-53
lines changed

.env-sample

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ FIRE_PROJECT_ID={firebase project id}
2626
# If using mysql, enter your config here
2727

2828
MYSQL_HOST={mysql host}
29-
MYSQL_DATABASE={mysql domain}
29+
MYSQL_DATABASE={mysql database name}
3030
MYSQL_USERNAME={mysql username}
3131
MYSQL_PASSWORD={mysql password}
32-
MYSQL_PORT={mysql port}
32+
MYSQL_PORT={mysql port *optional*}

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@ To get the app running locally, follow these instructions:
1919
- Get `ngrok_id` from the terminal that's running `ngrok http 3000`.
2020
- e.g. auth callback: `https://12345.ngrok.io/api/auth`
2121
5. Copy .env-sample to `.env`.
22+
- If deploying on Heroku, skip `.env` setup. Instead, enter `env` variables in the Heroku App Dashboard under `Settings -> Config Vars`.
2223
6. [Replace client_id and client_secret in .env](https://devtools.bigcommerce.com/my/apps) (from `View Client ID` in the dev portal).
2324
7. Update AUTH_CALLBACK in `.env` with the `ngrok_id` from step 5.
2425
8. Enter a cookie name, as well as a jwt secret in `.env`.
2526
- The cookie name should be unique
2627
- JWT key should be at least 32 random characters (256 bits) for HS256
2728
9. Specify DB_TYPE in `.env`
2829
- If using Firebase, enter your firebase config keys. See [Firebase quickstart](https://firebase.google.com/docs/firestore/quickstart)
29-
- If using MySQL, enter your mysql database config keys (host, database, user/pass and port).
30+
- 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`.
3031
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.
3132
- `npm run dev`
3233
11. [Install the app and launch.](https://developer.bigcommerce.com/api-docs/apps/quick-start#install-the-app)

lib/auth.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ const bigcommerceSigned = new BigCommerce({
2323
responseType: 'json'
2424
});
2525

26-
export function bigcommerceClient(accessToken: string, storeId: string) {
26+
export function bigcommerceClient(accessToken: string, storeHash: string) {
2727
return new BigCommerce({
2828
clientId: CLIENT_ID,
2929
accessToken,
30-
storeHash: storeId,
30+
storeHash,
3131
responseType: 'json',
3232
apiVersion: 'v3'
3333
});
@@ -46,13 +46,14 @@ export async function setSession(req: NextApiRequest, res: NextApiResponse, sess
4646

4747
db.setUser(session);
4848
db.setStore(session);
49+
db.setStoreUser(session);
4950
}
5051

5152
export async function getSession(req: NextApiRequest) {
5253
const cookies = getCookie(req);
5354
if (cookies) {
5455
const cookieData = decode(cookies);
55-
const accessToken = await db.getStoreToken(cookieData?.storeId);
56+
const accessToken = await db.getStoreToken(cookieData?.storeHash);
5657

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

6667
await db.deleteStore(session);
6768
}
69+
70+
export async function removeUserData(res: NextApiResponse, session: SessionProps) {
71+
removeCookie(res);
72+
73+
await db.deleteUser(session);
74+
}

lib/cookie.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ const MAX_AGE = 60 * 60 * 24; // 24 hours
88

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

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

42-
export function encode(scope: string, storeId: string) {
43-
return jwt.sign({ scope, storeId }, JWT_KEY);
42+
export function encode(scope: string, storeHash: string) {
43+
return jwt.sign({ scope, storeHash }, JWT_KEY);
4444
}
4545

4646
export function decode(encodedCookie: string) {

lib/dbs/firebase.ts

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@ if (!firebase.apps.length) {
2222
const db = firebase.firestore();
2323

2424
// Firestore data management functions
25-
export async function setUser({ context, user }: SessionProps) {
25+
26+
// Use setUser for storing global user data (persists between installs)
27+
export async function setUser({ user }: SessionProps) {
2628
if (!user) return null;
2729

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

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

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

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

4950
await ref.set(data);
5051
}
5152

53+
// User management for multi-user apps
54+
// Use setStoreUser for storing store specific variables
55+
export async function setStoreUser(session: SessionProps) {
56+
const { access_token: accessToken, context, user: { id } } = session;
57+
if (!id) return null;
58+
59+
const storeHash = context?.split('/')[1] || '';
60+
const collection = db.collection('storeUsers');
61+
const ref = collection.doc(String(id));
62+
63+
// Set admin (store owner) if installing/ updating the app
64+
// https://developer.bigcommerce.com/api-docs/apps/guide/users
65+
if (accessToken) {
66+
const oldAdmin = collection.where('isAdmin', '==', true).limit(1);
67+
const oldAdminRes = await oldAdmin.get();
68+
const [oldAdminDoc] = oldAdminRes?.docs ?? [];
69+
70+
// Nothing to update if admin the same
71+
if (oldAdminDoc?.id === String(id)) return null;
72+
73+
// Update admin (if different and previously installed)
74+
if (oldAdminDoc?.exists) {
75+
await oldAdminDoc.ref.update({ isAdmin: false });
76+
}
77+
78+
// Create a new record
79+
await ref.set({ storeHash, isAdmin: true });
80+
} else {
81+
const storeUser = await ref.get();
82+
83+
// Create a new user if it doesn't exist (non-store owners added here for multi-user apps)
84+
if (!storeUser?.exists) {
85+
await ref.set({ storeHash, isAdmin: false });
86+
}
87+
}
88+
}
89+
90+
export async function deleteUser({ user }: SessionProps) {
91+
const storeUsersRef = db.collection('storeUsers').doc(String(user?.id));
92+
93+
await storeUsersRef.delete();
94+
}
95+
5296
export async function getStore() {
5397
const doc = await db.collection('store').limit(1).get();
5498
const [storeDoc] = doc?.docs ?? [];
55-
const storeData: StoreData = { ...storeDoc?.data(), storeId: storeDoc?.id };
99+
const storeData: StoreData = { ...storeDoc?.data(), storeHash: storeDoc?.id };
56100

57-
return storeDoc.exists ? storeData : null;
101+
return storeDoc?.exists ? storeData : null;
58102
}
59103

60-
export async function getStoreToken(storeId: string) {
61-
if (!storeId) return null;
62-
const storeDoc = await db.collection('store').doc(storeId).get();
104+
export async function getStoreToken(storeHash: string) {
105+
if (!storeHash) return null;
106+
const storeDoc = await db.collection('store').doc(storeHash).get();
63107

64-
return storeDoc.exists ? storeDoc.data()?.accessToken : null;
108+
return storeDoc?.exists ? storeDoc.data()?.accessToken : null;
65109
}
66110

67-
export async function deleteStore({ store_hash: storeId }: SessionProps) {
68-
const ref = db.collection('store').doc(storeId);
111+
export async function deleteStore({ store_hash: storeHash }: SessionProps) {
112+
const ref = db.collection('store').doc(storeHash);
69113

70114
await ref.delete();
71115
}

lib/dbs/mysql.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import * as mysql from 'mysql';
22
import { promisify } from 'util';
33
import { SessionProps, StoreData } from '../../types';
44

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

8-
export async function setUser({ context, user }: SessionProps) {
10+
// Use setUser for storing global user data (persists between installs)
11+
export async function setUser({ user }: SessionProps) {
912
if (!user) return null;
1013

1114
const { email, id, username } = user;
12-
const storeId = context?.split('/')[1] || '';
13-
14-
const userData = { email, userId: id, storeId, username };
15+
const userData = { email, userId: id, username };
1516

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

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

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

31+
// Use setStoreUser for storing store specific variables
32+
export async function setStoreUser(session: SessionProps) {
33+
const { access_token: accessToken, context, user: { id } } = session;
34+
if (!id) return null;
35+
36+
const storeHash = context?.split('/')[1] || '';
37+
const [oldAdmin] = await query('SELECT * FROM storeUsers WHERE isAdmin IS TRUE limit 1') ?? [];
38+
39+
// Set admin (store owner) if installing/ updating the app
40+
// https://developer.bigcommerce.com/api-docs/apps/guide/users
41+
if (accessToken) {
42+
// Nothing to update if admin the same
43+
if (oldAdmin?.userId === String(id)) return null;
44+
45+
// Update admin (if different and previously installed)
46+
if (oldAdmin) {
47+
await query('UPDATE storeUsers SET isAdmin=0 WHERE isAdmin IS TRUE');
48+
}
49+
50+
// Create a new record
51+
await query('INSERT INTO storeUsers SET ?', { isAdmin: true, storeHash, userId: id });
52+
} else {
53+
const storeUser = await query('SELECT * FROM storeUsers WHERE userId = ?', String(id));
54+
55+
// Create a new user if it doesn't exist (non-store owners added here for multi-user apps)
56+
if (!storeUser.length) {
57+
await query('INSERT INTO storeUsers SET ?', { isAdmin: false, storeHash, userId: id });
58+
}
59+
}
60+
}
61+
62+
export async function deleteUser({ user }: SessionProps) {
63+
await query('DELETE FROM storeUsers WHERE userId = ?', String(user?.id));
64+
}
65+
3066
export async function getStore() {
31-
const results = await query('SELECT * from stores limit 1');
67+
const results = await query('SELECT * FROM stores limit 1');
3268

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

36-
export async function getStoreToken(storeId: string) {
37-
if (!storeId) return null;
72+
export async function getStoreToken(storeHash: string) {
73+
if (!storeHash) return null;
3874

39-
const results = await query('SELECT accessToken from stores limit 1');
75+
const results = await query('SELECT accessToken FROM stores limit 1');
4076

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

44-
export async function deleteStore({ store_hash: storeId }: SessionProps) {
45-
await query('DELETE FROM stores WHERE storeId = ?', storeId);
80+
export async function deleteStore({ store_hash: storeHash }: SessionProps) {
81+
await query('DELETE FROM stores WHERE storeHash = ?', storeHash);
4682
}

lib/hooks.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ export function useProducts() {
1919
}
2020

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

2524
return {
2625
list: data,

pages/api/products/[pid].ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export default async function products(req: NextApiRequest, res: NextApiResponse
1111
switch (method) {
1212
case 'GET':
1313
try {
14-
const { accessToken, storeId } = await getSession(req);
15-
const bigcommerce = bigcommerceClient(accessToken, storeId);
14+
const { accessToken, storeHash } = await getSession(req);
15+
const bigcommerce = bigcommerceClient(accessToken, storeHash);
1616

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

2929
const { data } = await bigcommerce.put(`/catalog/products/${pid}`, body);
3030
res.status(200).json(data);

pages/api/products/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { bigcommerceClient, getSession } from '../../../lib/auth';
33

44
export default async function products(req: NextApiRequest, res: NextApiResponse) {
55
try {
6-
const { accessToken, storeId } = await getSession(req);
7-
const bigcommerce = bigcommerceClient(accessToken, storeId);
6+
const { accessToken, storeHash } = await getSession(req);
7+
const bigcommerce = bigcommerceClient(accessToken, storeHash);
88

99
const { data } = await bigcommerce.get('/catalog/summary');
1010
res.status(200).json(data);

pages/api/products/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { bigcommerceClient, getSession } from '../../../lib/auth';
33

44
export default async function list(req: NextApiRequest, res: NextApiResponse) {
55
try {
6-
const { accessToken, storeId } = await getSession(req);
7-
const bigcommerce = bigcommerceClient(accessToken, storeId);
6+
const { accessToken, storeHash } = await getSession(req);
7+
const bigcommerce = bigcommerceClient(accessToken, storeHash);
88
// Optional: pass in API params here
99
const params = [
1010
'limit=11',

0 commit comments

Comments
 (0)