This tutorial will show you how to develop a secure e-commerce store using SuperTokens authentication in a React.js App.
We'll use a modern stack that includes React, Hasura GraphQL, and SuperTokens.
The source code for the App we're working on is available to view here.
By learning how to combine all of these features, you should be able to apply what you've learned here to create your ideas. Understanding the fundamental building blocks allows you to take this knowledge with you and use it in any way you see fit in the future.
SuperTokens provides authentication, and Hasura exposes a single GraphQL endpoint that you use on the frontend to send GraphQL queries and access data. Because it is a public API by default, SuperTokens will make it secure or private.
You will integrate SuperTokens with Hasura. Tokens generated from SuperTokens will be sent from the UI side in request headers to Hasura, where they will be validated.
SuperTokens is an open-source AuthO alternative that allows you to set up authentication in less than 30 minutes.
Over the last few months, SuperTokens has grown in popularity and adoption among developers in my network. And many of the developers I've talked to about it like the fact that it's open-source.
When you start a new project, SuperTokens provides user authentication. From there, you can quickly implement additional features in your App.
SuperTokens is an open-source alternative with the following features:
Here are the links to quickly access the source code or learn more about both products.
To get started, first create a new React.js App:
npx create-react-app my-app
cd my-app
npm start
To create a SuperTokens Managed Service, click the blue "Create an App" button, which will take you to an account creation page. Then, by following the instructions, you can select an availability region for your managed service.
You'll see the following UI after creating a SuperTokens Managed Service, which contains a default development environment.
Hasura can be used in two different ways. You can use it locally with Docker or on Hasura Cloud. Hasura Cloud is used throughout the tutorial.
If you're new to Hasura, you'll need to create an account and a project. If you follow this guide, you should be up and running in no time.
You should be in the project dashboard after setting up your account and project.
The first step is to connect the database with Hasura. Next, select the "Connect Database" option as indicated in the image below. This will take you to the database page, where you can connect to an existing database or create one from scratch.
This tutorial will connect the database we created using SuperTokens to managed services.
After connecting the database, you can access it by clicking on "public," as shown in the figure below.
In this blog, I'm connecting Hasura to a SuperTokens cloud database, but in production, SuperTokens doesn't allow the database, so the database should be a separate entity, which is the recommended approach. I'm only using it for this demo app.
Now that you've connected the database, it's time to create the tables.
You will create a few more tables in this step:
user_cart
products
user_wishlist
merchants
orders
categories
Hasura lets you define access control rules at three different levels:
Table level, Action level, and Role level are examples of levels.
Role-level examples are used in this app.
You can find detailed instructions in the documentation link
SuperTokens lets us automatically add request interceptors to save and store tokens for enduring user sessions by initializing them on the frontend.
We'll use the pre-built **EmailPassword ** recipe to access the SuperTokens demo app.
Let's add the following code block to the top of the index.tsx to initialize the Supertokens client on the React app.
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import SuperTokens from 'supertokens-auth-react';
import Session, { SessionAuth } from 'supertokens-auth-react/recipe/session';
import { BrowserRouter } from 'react-router-dom';
import EmailPassword from 'supertokens-auth-react/recipe/emailpassword';
import { getApiDomain, getWebsiteDomain } from './utils/utils';
import App from './App';
import reportWebVitals from './reportWebVitals';
SuperTokens.init({
appInfo: {
appName: 'Shopping App',
apiDomain: getApiDomain(),
websiteDomain: getWebsiteDomain(),
},
recipeList: [
EmailPassword.init({
getRedirectionURL: async (context) => {
if (context.action === 'SUCCESS') {
return '/home';
}
return undefined;
},
emailVerificationFeature: {
mode: 'REQUIRED',
},
}),
Session.init(),
],
});
Our session context can be accessed after wrapping the App() component with the EmailPaswordAuth wrapper.
SuperTokens takes care of many things for you and abstracts them away. When calling supertokens.init, we must specify the override config value to override the default implementation. Each recipe in the recipeList has an override config that may be used to alter its behavior.
supertokens.init({
framework: 'express',
supertokens: {
connectionURI: process.env.API_TOKENS_URL,
apiKey: process.env.API_KEY,
},
appInfo: {
appName: 'SuperTokens Demo App',
apiDomain,
websiteDomain,
},
recipeList: [EmailPassword.init({}), Session.init({
jwt: {
enable: true,
/*
* This is an example of a URL that ngrok generates when
* you expose localhost to the internet
*/
issuer: process.env.API_JWT_URL,
},
})],
});
Learn how to customize SuperTokens APIs here.
The architecture diagram for SuperTokens managed services version 👇
An example of how the three components interact for a sign-in and sign-out flow (using email and password) is shown below
The token issuer URL must be added to Hasura env variables to integrate SuperTokens with Hasura. Because we'll be calling the Hasura endpoint from our local, we'll need to expose it to the internet. To do so, we'll use ng-rock, and we'll also need to enable JWT in SuperTokens.
ngrok is a well-known tunneling tool that allows you to connect a locally running application to the internet. By clicking here, you can get it for free and with all of the functionality you need, as shown in the diagram below.
Follow the documentation, which includes step-by-step instructions, to set up Hasura environment variables.
Set up Hasura environment variables
Hasura's env variables on the cloud are configured with SuperTokens JWT URls, as shown in the diagram below.
recipeList: [EmailPassword.init({}), Session.init({
jwt: {
enable: true,
/*
* This is an example of a URL that ngrok generates when
* you expose localhost to the internet
*/
issuer: process.env.API_JWT_URL,
},
REACT_APP_API_PORT=3002
REACT_APP_API_GRAPHQL_URL=https://supertokens.hasura.app/v1/graphql
API_KEY=SSugiN8EMGZv=fL33=yJbycgI7UmSd
API_TOKENS_URL=https://0def13719ed411ecb83cf5e5275e2536-ap-southeast-1.aws.supertokens.io:3568
API_JWT_URL=http://ec87-223-185-12-185.ngrok.io/auth
We need to share user role-related information with Hasura for role-based permission. This may be done in the SuperTokens by overriding the existing token, as seen in the code spinet below.
override: {
functions(originalImplementation) {
return {
...originalImplementation,
async createNewSession(sessionInput) {
const input = sessionInput;
input.accessTokenPayload = {
...input.accessTokenPayload,
'https://hasura.io/jwt/claims': {
'x-hasura-user-id': input.userId,
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user', 'anonymous', 'admin'],
},
};
return originalImplementation.createNewSession(input);
},
};
},
},
Hasura will validate authorization using the headers listed below.
x-hasura-user-id
x-hasura-default-role
x-hasura-allowed-roles
We're using the apollo/client npm package to access the Hasura GrpahQL endpoint.
Adding apollo/client to our app:
import React from 'react';
import './App.scss';
import { useSessionContext } from 'supertokens-auth-react/recipe/session';
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
} from '@apollo/client';
import AppRoutes from './shared/components/routes/AppRoutes';
function App() {
const { accessTokenPayload } = useSessionContext();
const client = new ApolloClient({
uri: `${process.env.REACT_APP_API_GRAPHQL_URL}`,
cache: new InMemoryCache(),
headers: {
Authorization: `Bearer ${accessTokenPayload?.jwt}`,
'Content-Type': 'application/json',
},
});
return (
<div className="App">
<ApolloProvider client={client}>
<AppRoutes />
</ApolloProvider>
</div>
);
}
export default App;
We are sending a token generated by SuperTokens in Authorization: Bearer $accessTokenPayload?.jwt
"dependencies": {
"@apollo/client": "^3.5.9",
"@emotion/react": "^11.8.1",
"@emotion/styled": "^11.8.1",
"@material-ui/icons": "^4.11.2",
"@mui/icons-material": "^5.4.4",
"@mui/lab": "^5.0.0-alpha.72",
"@mui/material": "^5.4.3",
"@mui/styles": "^5.4.4",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^13.5.0",
"@types/express": "^4.17.13",
"@types/jest": "^27.4.0",
"@types/node": "^16.11.25",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"axios": "^0.26.0",
"body-parser": "^1.19.2",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"graphql": "^16.3.0",
"helmet": "^5.0.2",
"morgan": "^1.10.0",
"nodemon": "^2.0.15",
"npm-run-all": "^4.1.5",
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"react-scripts": "5.0.0",
"sass": "^1.49.8",
"supertokens-auth-react": "^0.18.7",
"supertokens-node": "^9.0.0",
"typescript": "^4.5.5",
"web-vitals": "^2.1.4"
},
The folder structure in Visual Studio Code looks like this:
This component displays a list of all of the products.
import React from 'react';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import {
useQuery,
gql,
} from '@apollo/client';
import Skeleton from '@mui/material/Skeleton';
import Card from '@mui/material/Card';
import ProductItem from '../product-item/ProductItem';
import { Product } from '../models/Product';
import useToast from '../../hooks/useToast';
const PRODUCT_LIST = gql`query{products {id category_id merchant_id name price product_img_url status}user_whishlist {
product_id
}}`;
function ProductList() {
const { loading, error, data } = useQuery(PRODUCT_LIST);
const { addToast } = useToast();
if (error) {
addToast('Unable to load.....');
return null;
}
return (
<Box sx={{ flexGrow: 1, padding: '20px' }}>
<Grid container spacing={6}>
{
!loading ? data?.products?.map((product: Product) => (
<Grid item xs={3}>
<ProductItem
productData={product}
whishlisted={data?.user_whishlist
.some((item: any) => item.product_id === product.id)}
/>
</Grid>
)) : (
<Grid item xs={3}>
<Card style={{ padding: '10px' }}>
<Skeleton variant="rectangular" height={50} style={{ marginBottom: '10px' }} />
<Skeleton variant="rectangular" height={200} style={{ marginBottom: '10px' }} />
<Skeleton variant="rectangular" height={40} width={100} style={{ margin: '0 auto' }} />
</Card>
</Grid>
)
}
</Grid>
</Box>
);
}
export default ProductList;
When a user clicks on any products on the ProductList page, this component displays all of the product's details and specifications.
/* eslint-disable no-unused-vars */
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardMedia from '@mui/material/CardMedia';
import { makeStyles } from '@mui/styles';
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
import {
useQuery,
gql,
useMutation,
} from '@apollo/client';
import CardActions from '@mui/material/CardActions';
import LoadingButton from '@mui/lab/LoadingButton';
import Skeleton from '@mui/material/Skeleton';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
import { useParams, useNavigate } from 'react-router-dom';
import ProductSpecifications from '../product-specifications/ProductSpecifications';
const FETCH_PRODUCT = gql`query getProduct($pid: Int!) {
products(where: {id: {_eq: $pid}}) {
category_id
id
merchant_id
name
price
product_img_url
status
descriptions
}
user_cart(where: {product_id: {_eq: $pid}}) {
product_id
}
}
`;
const ADD_TO_CART = gql`mutation addToCart($pid: Int!, $price: Int!) {
insert_user_cart_one(object: {product_id: $pid, price: $price}) {
product_id
}
}
`;
const useStyles: any = makeStyles(() => ({
productImg: {
height: '416px',
width: '200px',
marginLeft: 'auto',
marginRight: 'auto',
padding: '10px',
},
addtoCartBtn: {
backgroundColor: '#ff9f00',
fontWeight: 'bold',
fontSize: '16px',
},
buyNowBtn: {
backgroundColor: '#fb641b',
fontWeight: 'bold',
fontSize: '16px',
},
textLeft: {
textAlign: 'left',
},
offerHeader: {
fontSize: '16px',
fontWeight: '500',
color: '#212121',
textAlign: 'left',
},
offerList: {
textAlign: 'left',
lineHeight: '1.43',
paddingLeft: '0',
},
specHeader: {
fontSize: '24px',
fontWeight: '500',
lineHeight: '1.14',
textAlign: 'left',
color: '#212121',
},
cardWrapper: {
padding: '20px',
},
currencyTxt: {
fontSize: '28px',
textAlign: 'left',
fontWeight: 'bold',
},
offerImg: {
height: '18px',
width: '18px',
position: 'relative',
top: '6px',
marginRight: '10px',
},
offerListWrapper: {
listStyle: 'none',
},
pb0: {
paddingBottom: '0',
},
currIcon: {
position: 'relative',
top: '5px',
fontWeight: 'bold',
fontSize: '28px',
},
cardActions: {
display: 'flex',
justifyContent: 'center',
},
productCard: {
cursor: 'pointer',
},
}));
export default function ProductDetails() {
const { pid } = useParams();
const { loading, data, error } = useQuery(FETCH_PRODUCT, {
variables: {
pid,
},
});
const [addToCart, {
loading: AddLoader,
data: AddData, error: AddError,
}] = useMutation(ADD_TO_CART);
const product = data?.products[0];
const [addToCartLoader, setAddToCartLoader] = useState(false);
const classes = useStyles();
const [cartBtnTxt, setCartBtnTxt] = useState('ADD TO CART');
const navigate = useNavigate();
useEffect(() => {
setCartBtnTxt(data?.user_cart.length > 0 ? 'GO TO CART' : 'ADD TO CART');
}, [data]);
const addToCartHandler = async () => {
if (data?.user_cart.length > 0) {
navigate('/cart');
} else {
setCartBtnTxt('GOING TO CART');
setAddToCartLoader(true);
await addToCart({
variables: {
pid,
price: product.price,
},
});
navigate('/cart');
}
};
return (
<Box sx={{ padding: '20px' }}>
<Grid container spacing={6}>
<Grid item xs={4}>
<Card className={classes.cardWrapper}>
{!loading ? (
<CardMedia
className={classes.productImg}
component="img"
image={product.product_img_url}
alt="Paella dish"
/>
) : <Skeleton animation="wave" variant="rectangular" height="416px" />}
<CardActions className={classes.cardActions}>
{!loading ? (
<>
<LoadingButton
variant="contained"
disableElevation
size="large"
loading={addToCartLoader}
loadingPosition="start"
className={classes.addtoCartBtn}
startIcon={<AddShoppingCartIcon />}
onClick={addToCartHandler}
>
{cartBtnTxt}
</LoadingButton>
<LoadingButton
variant="contained"
disableElevation
size="large"
className={classes.buyNowBtn}
>
BUY NOW
</LoadingButton>
</>
) : (
<>
<Skeleton animation="wave" variant="rectangular" height="43px" width="190px" />
<Skeleton animation="wave" variant="rectangular" height="43px" width="190px" />
</>
)}
</CardActions>
</Card>
</Grid>
<Grid item xs={8}>
<Card>
{!loading ? <CardHeader className={`${classes.textLeft} ${classes.pb0}`} title={product.name} /> : <Skeleton animation="wave" variant="rectangular" height="43px" />}
<CardContent className={classes.pb0}>
{!loading ? (
<>
<Typography color="text.primary" className={classes.currencyTxt}>
<CurrencyRupeeIcon className={classes.currIcon} />
{product?.price}
</Typography>
{product?.descriptions?.offers?.length > 0 && (
<div className={classes.offers}>
<p className={classes.offerHeader}>Available Offers</p>
<ul className={classes.offerList}>
{
product?.descriptions?.offers.map((item: string) => (
<li className={classes.offerListWrapper}>
<span><img className={classes.offerImg} alt="" src="/images/offer.png" /></span>
{item}
</li>
))
}
</ul>
</div>
) }
<div>
<p className={classes.specHeader}>Specifications</p>
<ProductSpecifications header="General" specs={product?.descriptions?.specifications?.general} />
<ProductSpecifications header="Display Features" specs={product?.descriptions?.specifications?.display} />
</div>
</>
) : <Skeleton animation="wave" variant="rectangular" height="43px" width="190px" />}
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}
This component displays a list of the products you have added to your cart.
/* eslint-disable no-unused-vars */
import React from 'react';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardContent from '@mui/material/CardContent';
import {
useQuery,
gql,
} from '@apollo/client';
import Skeleton from '@mui/material/Skeleton';
import Button from '@mui/material/Button';
import { useNavigate } from 'react-router-dom';
import CartItem from '../cart-item/CartItem';
import PriceDetails from '../price-details/PriceDetails';
// import CardMedia from '@mui/material/CardMedia';
const PRODUCTS_IN_CART = gql`query getProductsInCart {
user_cart {
cartProducts {
category_id
name
price
product_img_url
id
}
price
discount
}
}
`;
export default function CartList() {
const {
data, loading, error, refetch,
} = useQuery(PRODUCTS_IN_CART);
const navigate = useNavigate();
const refereshCart = () => {
refetch();
};
if (!loading && data.user_cart.length === 0) {
return (
<Box>
<Card>
<CardHeader sx={{ textAlign: 'left', paddingLeft: '33px' }} title="My Cart" />
<CardContent>
<img style={{ height: '162px' }} alt="" src="/images/empty.png" />
<p>Your Cart is empty</p>
<Button variant="contained" onClick={() => navigate('/home')}>Shop Now</Button>
</CardContent>
</Card>
</Box>
);
}
return (
<Box sx={{ padding: '20px' }}>
<Grid container spacing={6}>
<Grid item xs={7}>
<Card>
{!loading ? (
<>
<CardHeader sx={{ borderBottom: '1px solid #efefef', textAlign: 'left', paddingLeft: '33px' }} title={`My Cart (${data.user_cart.length})`} />
<CardContent sx={{ padding: '0' }}>
{data.user_cart.map((item: any) => (
<CartItem
refereshCart={refereshCart}
product={item.cartProducts}
/>
))}
</CardContent>
</>
) : <Skeleton animation="wave" variant="rectangular" height="416px" />}
</Card>
</Grid>
<Grid item xs={5}>
<Card>
{!loading ? (
<CardContent sx={{ padding: '0' }}>
<PriceDetails priceDetails={data.user_cart} />
</CardContent>
) : <Skeleton animation="wave" variant="rectangular" height="416px" />}
</Card>
</Grid>
</Grid>
</Box>
);
}
This component displays the price calculation for all of the products that are currently in the shopping cart.
import React from 'react';
import { makeStyles } from '@mui/styles';
const useStyles = makeStyles({
detailsHeader: {
fontSize: '24px',
fontWeight: '500',
textAlign: 'left',
color: '#878787',
borderBottom: '1px solid #efefef',
padding: '16px',
},
prcieWrapper: {
display: 'flex',
},
priceContent: {
width: '50%',
padding: '16px',
textAlign: 'left',
fontSize: '22px',
},
});
export default function PriceDetails({ priceDetails }: { priceDetails: any}) {
const classes = useStyles();
const total = priceDetails.reduce((prev: any, curr: any) => ({
price: prev.price + curr.price,
discount: prev.discount + curr.discount,
}));
return (
<div>
<div className={classes.detailsHeader}>
PRICE DETAILS
</div>
<div className={classes.prcieWrapper}>
<div className={classes.priceContent}>Price</div>
<div className={classes.priceContent}>{total.price}</div>
</div>
<div className={classes.prcieWrapper}>
<div className={classes.priceContent}>Discount</div>
<div className={classes.priceContent}>
-
{total.discount}
</div>
</div>
<div className={classes.prcieWrapper}>
<div className={classes.priceContent}>Delivery Charges</div>
<div className={classes.priceContent}>-</div>
</div>
<div className={classes.prcieWrapper}>
<div className={classes.priceContent}>Total Amount</div>
<div className={classes.priceContent}>
{Number(total.price)
- Number(total.discount)}
</div>
</div>
</div>
);
}
Finally, this is how the app will appear once you sign in.
If you have any problems implementing the workflow after reading this article, don't hesitate to get in touch with me on Twitter or ping your questions to the SuperTokens Discord channel.
That was the end of this blog.
A big thank you to the SuperTokens team for spearheading this excellent open-source authentication project and developing this integration functionality with Hasura.
This project's final code can be found here.
Today, I hope you learned something new and if you did, please like/share it so that others can see it.
Thank you for being a regular reader; you're a big part of why I've been able to share my life/career experiences with you.
Let me know how you plan to use SuperTokens in your next project.
For the most up-to-date information, follow SuperTokens on Twitter.
Also Published here