initial serverless fullstack setup
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
.DS_Store
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
*.log
|
||||
.serverless
|
||||
v8-compile-cache-*
|
||||
jest/*
|
||||
coverage
|
||||
testProjects/*/package-lock.json
|
||||
testProjects/*/yarn.lock
|
||||
.serverlessUnzipped
|
||||
node_modules
|
||||
.vscode/
|
||||
.eslintcache
|
||||
dist
|
||||
.idea
|
||||
build/
|
||||
.env*
|
||||
.cache*
|
||||
.serverless
|
||||
.serverless_nextjs
|
||||
.serverless_plugins
|
||||
env.js
|
||||
tmp
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
test
|
||||
112
README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
[](https://www.serverless-fullstack-app.com)
|
||||
|
||||
A complete, serverless, full-stack application built on AWS Lambda, AWS HTTP API, Express.js, React and DynamoDB.
|
||||
|
||||
#### Live Demo: [https://www.serverless-fullstack-app.com](https://www.serverless-fullstack-app.com)
|
||||
|
||||
## Quick Start
|
||||
|
||||
Install the latest version of the Serverless Framework:
|
||||
|
||||
|
||||
```
|
||||
npm i -g serverless
|
||||
```
|
||||
|
||||
Then, initialize the `fullstack-app` template:
|
||||
|
||||
```
|
||||
serverless init fullstack-app
|
||||
cd fullstack-app
|
||||
```
|
||||
|
||||
Then, add your AWS credentials in the `.env` file in the root directory, like this:
|
||||
|
||||
```text
|
||||
AWS_ACCESS_KEY_ID=JAFJ89109JASFKLJASF
|
||||
AWS_SECRET_ACCESS_KEY=AJ91J9A0SFA0S9FSKAFLASJFLJ
|
||||
|
||||
# This signs you JWT tokens used for auth. Enter a random string in here that's ~40 characters in length.
|
||||
tokenSecret=yourSecretKey
|
||||
|
||||
# Only add this if you want a custom domain. Purchase it on AWS Route53 in your target AWS account first.
|
||||
domain=serverless-fullstack-app.com
|
||||
```
|
||||
|
||||
In the root folder of the project, run `serverless deploy`
|
||||
|
||||
Lastly, you will need to add your API domain manually to your React application in `./site/src/config.js`, so that you interact with your serverless Express.js back-end. You can find the your API url by going into `./api` and running `serverless info` and copying the `url:` value. It should look something like this `https://9jfalnal19.execute-api.us-east-1.amazonaws.com` or it will look like the custom domain you have set.
|
||||
|
||||
**Note:** Upon the first deployment of your website, it will take a 2-3 minutes for the Cloudfront (CDN) URL to work. Until then, you can access it via the `bucketUrl`.
|
||||
|
||||
After initial deployment, we recommend deploying only the parts you are changing, not the entire thing together (why risk deploying your database with a code change?). To do this, `cd` into a part of the application and run `serverless deploy`.
|
||||
|
||||
When working on the `./api` we highly recommend using `serverless dev`. This command watches your code, auto-deploys it, and streams `console.log()` statements and errors directly to your CLI in real-time!
|
||||
|
||||
If you want to add custom domains to your landing pages and API, either hardcode them in your `serverless.yml` or reference them as environment variables in `serverless.yml`, like this:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
domain: ${env:domain}
|
||||
```
|
||||
|
||||
```text
|
||||
domain=serverless-fullstack-app.com
|
||||
```
|
||||
|
||||
Support for stages is built in.
|
||||
|
||||
You can deploy everything or individual components to different stages via the `--stage` flag, like this:
|
||||
|
||||
`serverless deploy --stage prod`
|
||||
|
||||
Or, you can hardcode the stage in `serverless.yml` (not recommended):
|
||||
|
||||
```yaml
|
||||
app: fullstack
|
||||
component: express@0.0.20
|
||||
name: fullstack-api
|
||||
stage: prod # Put the stage in here
|
||||
```
|
||||
|
||||
Lastly, you can add separate environment variables for each stage using `.env` files with the stage name in them:
|
||||
|
||||
```bash
|
||||
.env # Any stage
|
||||
.env.dev # "dev" stage only
|
||||
.env.prod # "prod" stage only
|
||||
```
|
||||
|
||||
Then simply reference those environment variables using Serverless Variables in your YAML:
|
||||
|
||||
```yaml
|
||||
app: fullstack
|
||||
component: express@0.0.20
|
||||
name: fullstack-api
|
||||
|
||||
inputs:
|
||||
domain: api.${env:domain}
|
||||
```
|
||||
|
||||
And deploy!
|
||||
|
||||
`serverless deploy --stage prod`
|
||||
|
||||
Enjoy! This is a work in progress and we will continue to add funcitonality to this.
|
||||
|
||||
## Other Resources
|
||||
|
||||
For more details on each part of this fullstack application, check out these resources:
|
||||
|
||||
* [Serverless Components](https://github.com/serverless/components)
|
||||
* [Serverless Express](https://github.com/serverless-components/express)
|
||||
* [Serverless Website](https://github.com/serverless-components/website)
|
||||
* [Serverless AWS DynamoDB](https://github.com/serverless-components/aws-dynamodb)
|
||||
* [Serverless AWS IAM Role](https://github.com/serverless-components/aws-iam-role)
|
||||
|
||||
## Guides
|
||||
|
||||
### How To Debug CORS Errors
|
||||
|
||||
If you are running into CORS errors, see our guide on debugging them [within the Express Component's repo](https://github.com/serverless-components/express/blob/master/README.md#how-to-debug-cors-errors)
|
||||
81
api/app.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const express = require('express')
|
||||
const app = express()
|
||||
const passport = require('passport')
|
||||
const {
|
||||
users
|
||||
} = require('./controllers')
|
||||
|
||||
/**
|
||||
* Configure Passport
|
||||
*/
|
||||
|
||||
try { require('./config/passport')(passport) }
|
||||
catch (error) { console.log(error) }
|
||||
|
||||
/**
|
||||
* Configure Express.js Middleware
|
||||
*/
|
||||
|
||||
// Enable CORS
|
||||
app.use(function (req, res, next) {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header('Access-Control-Allow-Methods', '*')
|
||||
res.header('Access-Control-Allow-Headers', '*')
|
||||
res.header('x-powered-by', 'serverless-express')
|
||||
next()
|
||||
})
|
||||
|
||||
// Initialize Passport and restore authentication state, if any, from the session
|
||||
app.use(passport.initialize())
|
||||
app.use(passport.session())
|
||||
|
||||
// Enable JSON use
|
||||
app.use(express.json())
|
||||
|
||||
// Since Express doesn't support error handling of promises out of the box,
|
||||
// this handler enables that
|
||||
const asyncHandler = fn => (req, res, next) => {
|
||||
return Promise
|
||||
.resolve(fn(req, res, next))
|
||||
.catch(next);
|
||||
};
|
||||
|
||||
/**
|
||||
* Routes - Public
|
||||
*/
|
||||
|
||||
app.options(`*`, (req, res) => {
|
||||
res.status(200).send()
|
||||
})
|
||||
|
||||
app.post(`/users/register`, asyncHandler(users.register))
|
||||
|
||||
app.post(`/users/login`, asyncHandler(users.login))
|
||||
|
||||
app.get(`/test/`, (req, res) => {
|
||||
res.status(200).send('Request received')
|
||||
})
|
||||
|
||||
/**
|
||||
* Routes - Protected
|
||||
*/
|
||||
|
||||
app.post(`/user`, passport.authenticate('jwt', { session: false }), asyncHandler(users.get))
|
||||
|
||||
/**
|
||||
* Routes - Catch-All
|
||||
*/
|
||||
|
||||
app.get(`/*`, (req, res) => {
|
||||
res.status(404).send('Route not found')
|
||||
})
|
||||
|
||||
/**
|
||||
* Error Handler
|
||||
*/
|
||||
app.use(function (err, req, res, next) {
|
||||
console.error(err)
|
||||
res.status(500).json({ error: `Internal Serverless Error - "${err.message}"` })
|
||||
})
|
||||
|
||||
module.exports = app
|
||||
27
api/config/passport.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Config: Passport.js
|
||||
*/
|
||||
|
||||
const StrategyJWT = require('passport-jwt').Strategy
|
||||
const ExtractJWT = require('passport-jwt').ExtractJwt
|
||||
const { users } = require('../models')
|
||||
const { comparePassword } = require('../utils')
|
||||
|
||||
module.exports = (passport) => {
|
||||
|
||||
const options = {}
|
||||
options.jwtFromRequest = ExtractJWT.fromAuthHeaderAsBearerToken()
|
||||
options.secretOrKey = process.env.tokenSecret || 'secret_j91jasf0j1asfkl' // Change this to only use your own secret token
|
||||
|
||||
passport.use(new StrategyJWT(options, async (jwtPayload, done) => {
|
||||
let user
|
||||
try { user = await users.getById(jwtPayload.id) }
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
return done(error, null)
|
||||
}
|
||||
|
||||
if (!user) { return done(null, false) }
|
||||
return done(null, user)
|
||||
}))
|
||||
}
|
||||
5
api/controllers/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const users = require('./users')
|
||||
|
||||
module.exports = {
|
||||
users,
|
||||
}
|
||||
81
api/controllers/users.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Controllers: Users
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { users } = require('../models')
|
||||
const { comparePassword } = require('../utils')
|
||||
|
||||
/**
|
||||
* Save
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
* @param {*} next
|
||||
*/
|
||||
const register = async (req, res, next) => {
|
||||
|
||||
try {
|
||||
await users.register(req.body)
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error.message })
|
||||
}
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await users.getByEmail(req.body.email)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return next(error, null)
|
||||
}
|
||||
|
||||
const token = jwt.sign(user, process.env.tokenSecret, {
|
||||
expiresIn: 604800 // 1 week
|
||||
})
|
||||
|
||||
res.json({ message: 'Authentication successful', token })
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a user in
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
* @param {*} next
|
||||
*/
|
||||
const login = async (req, res, next) => {
|
||||
|
||||
let user
|
||||
try { user = await users.getByEmail(req.body.email) }
|
||||
catch (error) { return done(error, null) }
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).send({ error: 'Authentication failed. User not found.' })
|
||||
}
|
||||
|
||||
const isCorrect = comparePassword(req.body.password, user.password)
|
||||
if (!isCorrect) {
|
||||
return res.status(401).send({ error: 'Authentication failed. Wrong password.' })
|
||||
}
|
||||
|
||||
const token = jwt.sign(user, process.env.tokenSecret, {
|
||||
expiresIn: 604800 // 1 week
|
||||
})
|
||||
|
||||
res.json({ message: 'Authentication successful', token })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
* @param {*} next
|
||||
*/
|
||||
const get = async (req, res, next) => {
|
||||
const user = users.convertToPublicFormat(req.user)
|
||||
res.json({ user })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
login,
|
||||
get,
|
||||
}
|
||||
5
api/models/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const users = require('./users')
|
||||
|
||||
module.exports = {
|
||||
users,
|
||||
}
|
||||
136
api/models/users.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Model: Users
|
||||
*/
|
||||
|
||||
const AWS = require('aws-sdk')
|
||||
const shortid = require('shortid')
|
||||
const utils = require('../utils')
|
||||
|
||||
const dynamodb = new AWS.DynamoDB.DocumentClient({
|
||||
region: process.env.AWS_REGION
|
||||
})
|
||||
|
||||
/**
|
||||
* Register user
|
||||
* @param {string} user.email User email
|
||||
* @param {string} user.password User password
|
||||
*/
|
||||
const register = async(user = {}) => {
|
||||
|
||||
// Validate
|
||||
if (!user.email) {
|
||||
throw new Error(`"email" is required`)
|
||||
}
|
||||
if (!user.password) {
|
||||
throw new Error(`"password" is required`)
|
||||
}
|
||||
if (!utils.validateEmailAddress(user.email)) {
|
||||
throw new Error(`"${user.email}" is not a valid email address`)
|
||||
}
|
||||
|
||||
// Check if user is already registered
|
||||
const existingUser = await getByEmail(user.email)
|
||||
if (existingUser) {
|
||||
throw new Error(`A user with email "${user.email}" is already registered`)
|
||||
}
|
||||
|
||||
user.password = utils.hashPassword(user.password)
|
||||
|
||||
// Save
|
||||
const params = {
|
||||
TableName: process.env.db,
|
||||
Item: {
|
||||
hk: user.email,
|
||||
sk: 'user',
|
||||
sk2: shortid.generate(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
password: user.password,
|
||||
}
|
||||
}
|
||||
|
||||
await dynamodb.put(params).promise()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by email address
|
||||
* @param {string} email
|
||||
*/
|
||||
|
||||
const getByEmail = async(email) => {
|
||||
|
||||
// Validate
|
||||
if (!email) {
|
||||
throw new Error(`"email" is required`)
|
||||
}
|
||||
if (!utils.validateEmailAddress(email)) {
|
||||
throw new Error(`"${email}" is not a valid email address`)
|
||||
}
|
||||
|
||||
// Query
|
||||
const params = {
|
||||
TableName: process.env.db,
|
||||
KeyConditionExpression: 'hk = :hk',
|
||||
ExpressionAttributeValues: { ':hk': email }
|
||||
}
|
||||
|
||||
let user = await dynamodb.query(params).promise()
|
||||
|
||||
user = user.Items && user.Items[0] ? user.Items[0] : null
|
||||
if (user) {
|
||||
user.id = user.sk2
|
||||
user.email = user.hk
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by id
|
||||
* @param {string} id
|
||||
*/
|
||||
|
||||
const getById = async(id) => {
|
||||
|
||||
// Validate
|
||||
if (!id) {
|
||||
throw new Error(`"id" is required`)
|
||||
}
|
||||
|
||||
// Query
|
||||
const params = {
|
||||
TableName: process.env.db,
|
||||
IndexName: process.env.dbIndex1,
|
||||
KeyConditionExpression: 'sk2 = :sk2 and sk = :sk',
|
||||
ExpressionAttributeValues: { ':sk2': id, ':sk': 'user' }
|
||||
}
|
||||
let user = await dynamodb.query(params).promise()
|
||||
|
||||
user = user.Items && user.Items[0] ? user.Items[0] : null
|
||||
if (user) {
|
||||
user.id = user.sk2
|
||||
user.email = user.hk
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert user record to public format
|
||||
* This hides the keys used for the dynamodb's single table design and returns human-readable properties.
|
||||
* @param {*} user
|
||||
*/
|
||||
const convertToPublicFormat = (user = {}) => {
|
||||
user.email = user.hk || null
|
||||
user.id = user.sk2 || null
|
||||
if (user.hk) delete user.hk
|
||||
if (user.sk) delete user.sk
|
||||
if (user.sk2) delete user.sk2
|
||||
if (user.password) delete user.password
|
||||
return user
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
getByEmail,
|
||||
getById,
|
||||
convertToPublicFormat,
|
||||
}
|
||||
20
api/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "serverless-fullstack-app-api",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "app.js",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"express": "^4.17.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"passport": "^0.4.1",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"shortid": "^2.2.15"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
18
api/serverless.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
component: express
|
||||
name: api
|
||||
|
||||
inputs:
|
||||
# Express application source code.
|
||||
src: ./
|
||||
# Permissions required for the AWS Lambda function to interact with other resources
|
||||
roleName: ${output:permissions.name}
|
||||
# Enable this when you want to set a custom domain.
|
||||
# domain: api.${env:domain}
|
||||
# Environment variables
|
||||
env:
|
||||
# AWS DynamoDB Table name. Needed for the code to access it.
|
||||
db: ${output:database.name}
|
||||
# AWS DynamoDB Table Index name. Needed for the code to access it.
|
||||
dbIndex1: ${output:database.indexes.gsi1.name}
|
||||
# A secret token to sign the JWT tokens with.
|
||||
tokenSecret: secret_1234 # Change to secret via environment variable: ${env:tokenSecret}
|
||||
35
api/utils/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Utils
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcryptjs')
|
||||
|
||||
/**
|
||||
* Validate email address
|
||||
*/
|
||||
const validateEmailAddress = (email) => {
|
||||
var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
return re.test(String(email).toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password
|
||||
* @param {*} user
|
||||
*/
|
||||
const hashPassword = (password) => {
|
||||
const salt = bcrypt.genSaltSync(10)
|
||||
return bcrypt.hashSync(password, salt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password
|
||||
*/
|
||||
const comparePassword = (candidatePassword, trustedPassword) => {
|
||||
return bcrypt.compareSync(candidatePassword, trustedPassword)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
validateEmailAddress
|
||||
}
|
||||
30
database/serverless.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
component: aws-dynamodb
|
||||
name: database
|
||||
|
||||
inputs:
|
||||
name: ${name}-${stage}
|
||||
region: us-east-1
|
||||
# Don't delete the Database Table if "serverless remove" is run
|
||||
deletionPolicy: retain
|
||||
# Simple, single-table design
|
||||
attributeDefinitions:
|
||||
- AttributeName: hk
|
||||
AttributeType: S
|
||||
- AttributeName: sk
|
||||
AttributeType: S
|
||||
- AttributeName: sk2
|
||||
AttributeType: S
|
||||
keySchema:
|
||||
- AttributeName: hk
|
||||
KeyType: HASH
|
||||
- AttributeName: sk
|
||||
KeyType: RANGE
|
||||
globalSecondaryIndexes:
|
||||
- IndexName: gsi1
|
||||
KeySchema:
|
||||
- AttributeName: sk2
|
||||
KeyType: HASH
|
||||
- AttributeName: sk
|
||||
KeyType: RANGE
|
||||
Projection:
|
||||
ProjectionType: ALL
|
||||
28
permissions/serverless.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
component: aws-iam-role
|
||||
name: permissions
|
||||
|
||||
inputs:
|
||||
name: ${name}-${stage}
|
||||
region: us-east-1
|
||||
service: lambda.amazonaws.com
|
||||
policy:
|
||||
# AWS Lambda function containing Express Logs and Assume Role access
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- sts:AssumeRole
|
||||
- logs:CreateLogGroup
|
||||
- logs:CreateLogStream
|
||||
- logs:PutLogEvents
|
||||
Resource: "*"
|
||||
# AWS DynamoDB Table access
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- dynamodb:DescribeTable
|
||||
- dynamodb:Query
|
||||
- dynamodb:GetItem
|
||||
- dynamodb:PutItem
|
||||
- dynamodb:UpdateItem
|
||||
- dynamodb:DeleteItem
|
||||
Resource:
|
||||
- ${output:database.arn}
|
||||
- ${output:database.arn}/index/*
|
||||
2
serverless.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
app: forget-me-not
|
||||
org: benjaminramey
|
||||
68
site/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br />
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br />
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
|
||||
|
||||
### `yarn build` fails to minify
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
|
||||
37
site/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "serverless-fullstack-app-website",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-scripts": "3.4.3",
|
||||
"js-cookie": "^2.2.1",
|
||||
"moment": "^2.24.0",
|
||||
"react-router-dom": "^5.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
site/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
site/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
site/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 568 B |
BIN
site/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
site/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
site/public/fullstack-app-artwork.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
site/public/fullstack-app-title.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
27
site/public/index.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="%PUBLIC_URL%/favicon.ico"
|
||||
/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Titillium+Web:wght@300;400;600;700&display=swap" rel="stylesheet"> <meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A ready-to-use serverless fullstack application template. Built with AWS Lambda, Express.js, React, AWS HTTP API, AWS DynamoDB and more!"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Serverless Fullstack Application</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
25
site/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "Serverless Fullstack Application",
|
||||
"name": "Serverless Fullstack Application",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "android-chrome-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "android-chrome-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
site/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
11
site/serverless.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
component: website
|
||||
name: site
|
||||
|
||||
inputs:
|
||||
# React application. "hook" runs before deployment to build the source code. "dist" is the built artifact directory which is uploaded.
|
||||
src:
|
||||
src: ./
|
||||
hook: npm run build
|
||||
dist: build
|
||||
# Enable this when you want to set a custom domain.
|
||||
# domain: ${env:domain}
|
||||
58
site/src/App.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { Component } from 'react'
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Switch,
|
||||
Route,
|
||||
} from 'react-router-dom'
|
||||
import Home from './pages/Home/Home'
|
||||
import Auth from './pages/Auth/Auth'
|
||||
import Dashboard from './pages/Dashboard/Dashboard'
|
||||
import { getSession } from './utils'
|
||||
|
||||
export default class App extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
// console.log(getSession())
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Router>
|
||||
<Switch>
|
||||
|
||||
<Route path='/register'>
|
||||
<Auth />
|
||||
</Route>
|
||||
|
||||
<Route path='/login'>
|
||||
<Auth />
|
||||
</Route>
|
||||
|
||||
<PrivateRoute
|
||||
exact
|
||||
path='/'
|
||||
component={Dashboard}
|
||||
/>
|
||||
|
||||
</Switch>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A component to protect routes.
|
||||
* Shows Auth page if the user is not authenticated
|
||||
*/
|
||||
const PrivateRoute = ({ component, ...options }) => {
|
||||
|
||||
const session = getSession()
|
||||
|
||||
const finalComponent = session ? Dashboard : Home
|
||||
return <Route {...options} component={finalComponent} />
|
||||
}
|
||||
333
site/src/App.module.css
Normal file
@@ -0,0 +1,333 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.containerInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-self: center;
|
||||
max-width: 700px;
|
||||
padding: 50px 15px 30px 15px;
|
||||
}
|
||||
|
||||
.containerLoading {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
padding: 160px 0 0 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroArtwork {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroArtwork img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
max-height: 100%;
|
||||
max-width: 500px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroTitle img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
max-height: 100%;
|
||||
max-width: 500px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
margin: 10px 0 32px 0px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin: 18px 0;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: #FD5750;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin: 32px 0;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
text-transform: lowercase;
|
||||
color: #78f542;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.containerRegister {}
|
||||
|
||||
.containerSignIn {}
|
||||
|
||||
.socialButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
margin: 15px auto 0px auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttonGithub {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto 10px auto;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.buttonGithub:hover {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.buttonGithub img {
|
||||
max-height: 28px;
|
||||
margin-right: 13px;
|
||||
margin-top: 1.5px;
|
||||
}
|
||||
|
||||
.buttonGoogle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.buttonGoogle:hover {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.buttonGoogle img {
|
||||
max-height: 25px;
|
||||
margin-right: 11px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.formType {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 0px auto 0px auto;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
font-size: 22px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
text-transform: lowercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.formTypeRegister {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
padding: 5px 40px 5px 0;
|
||||
justify-content: flex-end;
|
||||
border-right: 2px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.formTypeRegister:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formTypeSignIn {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
padding: 5px 0 5px 40px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.formTypeSignIn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formTypeActive {
|
||||
color: rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
margin: 10px auto 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
|
||||
.formLabel {
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 10px 0;
|
||||
user-select: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.formInput {
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
padding: 10px 0px 22px 0px;
|
||||
border-bottom: 1.5px solid rgba(255,255,255,0.4);
|
||||
border-top: 1.5px solid transparent;
|
||||
border-right: 1.5px solid transparent;
|
||||
border-left: 1.5px solid transparent;
|
||||
transition: all 0.6s ease;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
caret-color: white;
|
||||
}
|
||||
|
||||
.formInput::placeholder {
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.formInput:focus {
|
||||
border-bottom: 1.5px solid rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 10px 0 10px 0;
|
||||
color: #FD5750;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.formButton {
|
||||
margin: 16px auto 0 auto!important;
|
||||
}
|
||||
|
||||
.formButton:hover {
|
||||
cursor: pointer;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.formButton:active {
|
||||
cursor: pointer;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.forgotPassword {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
margin: 20px auto 5px auto;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
font-size: 19px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.forgotPassword:hover {
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
.githubLink {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
width: 100%;
|
||||
margin: 26px 0 40px 0;
|
||||
}
|
||||
|
||||
.githubLink a {
|
||||
font-size: 20px;
|
||||
color: #FD5750;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.githubLink:hover a {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
18
site/src/config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Global Config
|
||||
*/
|
||||
|
||||
const config = {}
|
||||
|
||||
// Domains
|
||||
config.domains = {}
|
||||
|
||||
/**
|
||||
* API Domain
|
||||
* Add the domain from your serverless express.js back-end here.
|
||||
* This will enable your front-end to communicate with your back-end.
|
||||
* (e.g. 'https://api.mydomain.com' or 'https://091jafsl10.execute-api.us-east-1.amazonaws.com')
|
||||
*/
|
||||
config.domains.api = null
|
||||
|
||||
export default config
|
||||
32
site/src/fragments/Loading/Loading.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { Component } from 'react'
|
||||
import styles from './Loading.module.css'
|
||||
|
||||
export default class Loading extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
async componentDidMount() {}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={`${this.props.className}`}>
|
||||
<div className={`${styles.container}`}>
|
||||
|
||||
<img
|
||||
draggable={false}
|
||||
alt={`Loading`}
|
||||
src={
|
||||
'https://s3.amazonaws.com/dashboard.serverless.com/images/icon-serverless-framework.png'
|
||||
}
|
||||
/>
|
||||
|
||||
<p>loading...</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
47
site/src/fragments/Loading/Loading.module.css
Normal file
@@ -0,0 +1,47 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: auto;
|
||||
height: 100px;
|
||||
opacity: 0.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container img {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
margin: 0 0 20px 0;
|
||||
animation-duration: 0.4s;
|
||||
animation-name: pulse;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
animation-timing-function: ease;
|
||||
filter: none;
|
||||
-webkit-filter: grayscale(100%);
|
||||
-moz-filter: grayscale(100%);
|
||||
-ms-filter: grayscale(100%);
|
||||
-o-filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.container p {
|
||||
font-size: 22px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
1
site/src/fragments/Loading/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Loading'
|
||||
123
site/src/index.css
Normal file
@@ -0,0 +1,123 @@
|
||||
html, body, #root {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background: #000;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
font-family: 'Titillium Web', sans-serif;
|
||||
font-weight: 500;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
div, h1, h2, h3, h4, h5, h6, p, span, input, button, li {
|
||||
font-family: 'Titillium Web', sans-serif;
|
||||
font-size: 24px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links
|
||||
*/
|
||||
|
||||
a, .link {
|
||||
font-family: 'Titillium Web', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
color: rgba(255,255,255,0.6);
|
||||
text-decoration: none;
|
||||
transition: all 0.14s ease;
|
||||
}
|
||||
|
||||
a:hover, .link:hover {
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
a:active, .link:active {
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buttons
|
||||
*/
|
||||
|
||||
.buttonPrimaryLarge {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
height: auto;
|
||||
margin: 20px 0 0 0;
|
||||
padding: 24px 24px 28px 24px;
|
||||
background: #fd5750;
|
||||
color: #fff;
|
||||
font-family: 'Titillium Web', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 0px;
|
||||
transition: all 0.14s ease;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.buttonPrimaryLarge:hover {
|
||||
cursor: pointer;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.buttonPrimaryLarge:active {
|
||||
cursor: pointer;
|
||||
transform: scale(1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
|
||||
.animateFadeIn {
|
||||
animation: fadeIn 0.45s;
|
||||
}
|
||||
|
||||
.animateScaleIn {
|
||||
animation: scaleIn 0.35s;
|
||||
}
|
||||
|
||||
.animateFlicker {
|
||||
animation: flicker 2.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
15
site/src/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
/**
|
||||
* Render App
|
||||
*/
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
249
site/src/pages/Auth/Auth.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { Component } from 'react'
|
||||
import {
|
||||
Link,
|
||||
withRouter,
|
||||
} from 'react-router-dom'
|
||||
import Loading from '../../fragments/Loading'
|
||||
import styles from './Auth.module.css'
|
||||
import {
|
||||
userRegister,
|
||||
userLogin,
|
||||
userGet,
|
||||
saveSession,
|
||||
} from '../../utils'
|
||||
|
||||
class Auth extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
const pathName = window.location.pathname.replace('/', '')
|
||||
|
||||
this.state = {}
|
||||
this.state.state = pathName
|
||||
this.state.loading = true
|
||||
this.state.error = null
|
||||
this.state.formEmail = ''
|
||||
this.state.formPassword = ''
|
||||
|
||||
// Bindings
|
||||
this.handleFormInput = this.handleFormInput.bind(this)
|
||||
this.handleFormSubmit = this.handleFormSubmit.bind(this)
|
||||
this.handleFormTypeChange = this.handleFormTypeChange.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component did mount
|
||||
*/
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
loading: false
|
||||
})
|
||||
|
||||
// Clear query params
|
||||
const url = document.location.href
|
||||
window.history.pushState({}, '', url.split('?')[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a form change
|
||||
*/
|
||||
handleFormTypeChange(type) {
|
||||
this.setState({ state: type },
|
||||
() => {
|
||||
this.props.history.push(`/${type}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle text changes within form fields
|
||||
*/
|
||||
handleFormInput(field, value) {
|
||||
value = value.trim()
|
||||
|
||||
const nextState = {}
|
||||
nextState[field] = value
|
||||
|
||||
this.setState(Object.assign(this.state, nextState))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles form submission
|
||||
* @param {object} evt
|
||||
*/
|
||||
async handleFormSubmit(evt) {
|
||||
evt.preventDefault()
|
||||
|
||||
this.setState({ loading: true })
|
||||
|
||||
// Validate email
|
||||
if (!this.state.formEmail) {
|
||||
return this.setState({
|
||||
loading: false,
|
||||
formError: 'email is required'
|
||||
})
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (!this.state.formPassword) {
|
||||
return this.setState({
|
||||
loading: false,
|
||||
formError: 'password is required'
|
||||
})
|
||||
}
|
||||
|
||||
let token
|
||||
try {
|
||||
if (this.state.state === 'register') {
|
||||
token = await userRegister(this.state.formEmail, this.state.formPassword)
|
||||
} else {
|
||||
token = await userLogin(this.state.formEmail, this.state.formPassword)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
if (error.message) {
|
||||
this.setState({
|
||||
formError: error.message,
|
||||
loading: false
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
formError: 'Sorry, something unknown went wrong. Please try again.',
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch user record and set session in cookie
|
||||
let user = await userGet(token.token)
|
||||
user = user.user
|
||||
saveSession(user.id, user.email, token.token)
|
||||
|
||||
window.location.replace('/')
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} animateFadeIn`}>
|
||||
<div className={styles.containerInner}>
|
||||
|
||||
{ /* Logo */}
|
||||
|
||||
<Link to='/' className={`${styles.logo}`}>
|
||||
<img
|
||||
draggable='false'
|
||||
src={'./fullstack-app-title.png'}
|
||||
alt='serverless-fullstack-application'
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{ /* Loading */}
|
||||
|
||||
{this.state.loading && (
|
||||
<div>
|
||||
{< Loading className={styles.containerLoading} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ /* Registration Form */}
|
||||
|
||||
{!this.state.loading && (
|
||||
<div className={styles.formType}>
|
||||
<div
|
||||
className={
|
||||
`${styles.formTypeRegister}
|
||||
${this.state.state === 'register' ? styles.formTypeActive : ''}`}
|
||||
onClick={(e) => { this.handleFormTypeChange('register') }}>
|
||||
Register
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
`${styles.formTypeSignIn}
|
||||
${this.state.state === 'login' ? styles.formTypeActive : ''}`}
|
||||
onClick={(e) => { this.handleFormTypeChange('login') }}>
|
||||
Sign-In
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.state === 'register' && !this.state.loading && (
|
||||
<div className={styles.containerRegister}>
|
||||
|
||||
<form className={styles.form} onSubmit={this.handleFormSubmit}>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}>email</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='yours@example.com'
|
||||
className={styles.formInput}
|
||||
value={this.state.formEmail}
|
||||
onChange={(e) => { this.handleFormInput('formEmail', e.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}>password</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='your password'
|
||||
className={styles.formInput}
|
||||
value={this.state.formPassword}
|
||||
onChange={(e) => { this.handleFormInput('formPassword', e.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{this.state.formError && (
|
||||
<div className={styles.formError}>{this.state.formError}</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
className={`buttonPrimaryLarge ${styles.formButton}`}
|
||||
type='submit'
|
||||
value='Register'
|
||||
/>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.state === 'login' && !this.state.loading && (
|
||||
<div className={styles.containerSignIn}>
|
||||
|
||||
<form className={styles.form} onSubmit={this.handleFormSubmit}>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}>email</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='yours@example.com'
|
||||
className={styles.formInput}
|
||||
value={this.state.formEmail}
|
||||
onChange={(e) => { this.handleFormInput('formEmail', e.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}>password</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='your password'
|
||||
className={styles.formInput}
|
||||
value={this.state.formPassword}
|
||||
onChange={(e) => { this.handleFormInput('formPassword', e.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{this.state.formError && (
|
||||
<div className={styles.formError}>{this.state.formError}</div>
|
||||
)}
|
||||
|
||||
<input className={`buttonPrimaryLarge ${styles.formButton}`} type='submit' value='Sign In' />
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Auth)
|
||||
231
site/src/pages/Auth/Auth.module.css
Normal file
@@ -0,0 +1,231 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.containerInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-self: center;
|
||||
max-width: 700px;
|
||||
padding: 50px 15px 30px 15px;
|
||||
}
|
||||
|
||||
.containerLoading {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
padding: 160px 0 0 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
max-height: 100%;
|
||||
max-width: 400px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin: 18px 0;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: #FD5750;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin: 32px 0;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
text-transform: lowercase;
|
||||
color: #78f542;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.containerRegister {}
|
||||
|
||||
.containerSignIn {}
|
||||
|
||||
.formType {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 26px auto 0px auto;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
font-size: 22px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
text-transform: lowercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.formTypeRegister {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
padding: 5px 40px 5px 0;
|
||||
justify-content: flex-end;
|
||||
border-right: 2px solid rgba(255,255,255,0.15);
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.formTypeRegister:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formTypeSignIn {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
padding: 5px 0 5px 40px;
|
||||
text-align: left;
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.formTypeSignIn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formTypeActive {
|
||||
color: rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
margin: 10px auto 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
|
||||
.formLabel {
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 10px 0;
|
||||
user-select: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.formInput {
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
padding: 10px 0px 22px 0px;
|
||||
border-bottom: 1.5px solid rgba(255,255,255,0.4);
|
||||
border-top: 1.5px solid transparent;
|
||||
border-right: 1.5px solid transparent;
|
||||
border-left: 1.5px solid transparent;
|
||||
transition: all 0.6s ease;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
caret-color: white;
|
||||
}
|
||||
|
||||
.formInput::placeholder {
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.formInput:focus {
|
||||
border-bottom: 1.5px solid rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 10px 0 10px 0;
|
||||
color: #FD5750;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.formButton {
|
||||
margin: 16px auto 0 auto!important;
|
||||
}
|
||||
|
||||
.formButton:hover {
|
||||
cursor: pointer;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.formButton:active {
|
||||
cursor: pointer;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.forgotPassword {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
margin: 20px auto 5px auto;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
font-size: 19px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.forgotPassword:hover {
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
.githubLink {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
width: 100%;
|
||||
margin: 26px 0 40px 0;
|
||||
}
|
||||
|
||||
.githubLink a {
|
||||
font-size: 20px;
|
||||
color: #FD5750;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.githubLink:hover a {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
82
site/src/pages/Dashboard/Dashboard.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { Component } from 'react'
|
||||
import {
|
||||
withRouter
|
||||
} from 'react-router-dom'
|
||||
import styles from './Dashboard.module.css'
|
||||
import {
|
||||
getSession,
|
||||
deleteSession
|
||||
} from '../../utils'
|
||||
|
||||
class Dashboard extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
|
||||
// Bindings
|
||||
this.logout = this.logout.bind(this)
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
|
||||
const userSession = getSession()
|
||||
|
||||
this.setState({
|
||||
session: userSession,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Log user out by clearing cookie and redirecting
|
||||
*/
|
||||
logout() {
|
||||
deleteSession()
|
||||
this.props.history.push(`/`)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} animateFadeIn`}>
|
||||
<div className={styles.containerInner}>
|
||||
|
||||
{ /* Navigation */ }
|
||||
|
||||
<div className={styles.navigationContainer}>
|
||||
<div
|
||||
className={`link`}>
|
||||
{ this.state.session ? this.state.session.userEmail : '' }
|
||||
</div>
|
||||
<div
|
||||
className={`link`}
|
||||
onClick={this.logout}>
|
||||
logout
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Content */ }
|
||||
|
||||
<div className={`${styles.contentContainer}`}>
|
||||
|
||||
<div className={`${styles.artwork} animateFlicker`}>
|
||||
<img
|
||||
draggable='false'
|
||||
src={'./fullstack-app-artwork.png'}
|
||||
alt='serverless-fullstack-application'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.welcomeMessage}`}>
|
||||
Welcome to your serverless fullstack dashboard...
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Dashboard)
|
||||
92
site/src/pages/Dashboard/Dashboard.module.css
Normal file
@@ -0,0 +1,92 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.containerInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 0 30px 0;
|
||||
}
|
||||
|
||||
.navigationContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #000;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
padding: 25px 45px 0 45px;
|
||||
}
|
||||
|
||||
.navigationContainer div {
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 7% 15px 45px 15px;
|
||||
}
|
||||
|
||||
.welcomeMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.artwork {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.artwork img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
max-height: 100%;
|
||||
max-width: 500px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Special
|
||||
*/
|
||||
|
||||
::-moz-selection {
|
||||
background: #fd5750;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #fd5750;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
}
|
||||
64
site/src/pages/Home/Home.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { Component } from 'react'
|
||||
import {
|
||||
Link,
|
||||
withRouter
|
||||
} from 'react-router-dom'
|
||||
import styles from './Home.module.css'
|
||||
|
||||
class Home extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
async componentDidMount() { }
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} animateFadeIn`}>
|
||||
<div className={styles.containerInner}>
|
||||
|
||||
{ /* Hero Artwork */}
|
||||
|
||||
<div className={`${styles.heroArtwork} animateFlicker`}>
|
||||
<img
|
||||
draggable='false'
|
||||
src={'./fullstack-app-artwork.png'}
|
||||
alt='serverless-fullstack-application'
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.heroTitle}`}>
|
||||
<img
|
||||
draggable='false'
|
||||
src={'./fullstack-app-title.png'}
|
||||
alt='serverless-fullstack-application'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ /* Hero Description */}
|
||||
|
||||
<div className={`${styles.heroDescription}`}>
|
||||
A serverless full-stack application built with AWS Lambda, AWS HTTP API, Express.js, React & AWS DynamoDB.
|
||||
</div>
|
||||
|
||||
{ /* Call To Action */}
|
||||
|
||||
<div className={`${styles.containerCta}`}>
|
||||
|
||||
<Link to='/register'>
|
||||
<button className={`buttonPrimaryLarge`}>
|
||||
Register
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<Link to='/login' className={`${styles.linkSignIn}`}>sign-in</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Home)
|
||||
105
site/src/pages/Home/Home.module.css
Normal file
@@ -0,0 +1,105 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.containerInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-self: center;
|
||||
max-width: 700px;
|
||||
padding: 50px 15px 30px 15px;
|
||||
}
|
||||
|
||||
.heroArtwork {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroArtwork img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
max-height: 100%;
|
||||
max-width: 500px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroTitle img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
max-height: 100%;
|
||||
max-width: 500px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
line-height: 38px;
|
||||
margin: 32px 0px 52px 0;
|
||||
}
|
||||
|
||||
.containerCta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.containerCta button {
|
||||
margin: 0px auto 20px auto;
|
||||
}
|
||||
|
||||
.containerCta .linkSignIn {
|
||||
padding: 10px;
|
||||
color: #fd5750;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.containerCta .linkSignIn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Special
|
||||
*/
|
||||
|
||||
::-moz-selection {
|
||||
background: #fd5750;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #fd5750;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
}
|
||||
71
site/src/utils/api.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Utils: Back-end
|
||||
*/
|
||||
|
||||
import config from '../config'
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
export const userRegister = async (email, password) => {
|
||||
return await requestApi('/users/register', 'POST', { email, password })
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a new user
|
||||
*/
|
||||
export const userLogin = async (email, password) => {
|
||||
return await requestApi('/users/login', 'POST', { email, password })
|
||||
}
|
||||
|
||||
/**
|
||||
* userGet
|
||||
*/
|
||||
export const userGet = async (token) => {
|
||||
return await requestApi('/user', 'POST', null, {
|
||||
Authorization: `Bearer ${token}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* API request to call the backend
|
||||
*/
|
||||
export const requestApi = async (
|
||||
path = '',
|
||||
method = 'GET',
|
||||
data = null,
|
||||
headers = {}) => {
|
||||
|
||||
// Check if API URL has been set
|
||||
if (!config?.domains?.api) {
|
||||
throw new Error(`Error: Missing API Domain – Please add the API domain from your serverless Express.js back-end to this front-end application. You can do this in the "site" folder, in the "./config.js" file. Instructions are listed there and in the documentation.`)
|
||||
}
|
||||
|
||||
// Prepare URL
|
||||
if (!path.startsWith('/')) {
|
||||
path = `/${path}`
|
||||
}
|
||||
const url = `${config.domains.api}${path}`
|
||||
|
||||
// Set headers
|
||||
headers = Object.assign(
|
||||
{ 'Content-Type': 'application/json' },
|
||||
headers
|
||||
)
|
||||
|
||||
// Default options are marked with *
|
||||
const response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
headers,
|
||||
body: data ? JSON.stringify(data) : null
|
||||
})
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
76
site/src/utils/helpers.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
/**
|
||||
* Format Org and Username correctly for the Serverless Platform backend
|
||||
*/
|
||||
export const formatOrgAndUsername = (name = '') => {
|
||||
name = name.toString().toLowerCase().replace(/[^a-z\d-]+/gi, '-')
|
||||
// Remove multiple instances of hyphens
|
||||
name = name.replace(/-{2,}/g, '-')
|
||||
if (name.length > 40) {
|
||||
name = name.substring(0, 40)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse query parameters in a URL
|
||||
* @param {*} searchString
|
||||
*/
|
||||
export const parseQueryParams = (searchString = null) => {
|
||||
if (!searchString) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Clone string
|
||||
let clonedParams = (' ' + searchString).slice(1)
|
||||
|
||||
return clonedParams
|
||||
.substr(1)
|
||||
.split('&')
|
||||
.filter((el) => el.length)
|
||||
.map((el) => el.split('='))
|
||||
.reduce(
|
||||
(accumulator, currentValue) =>
|
||||
Object.assign(accumulator, {
|
||||
[decodeURIComponent(currentValue.shift())]: decodeURIComponent(currentValue.pop())
|
||||
}),
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hash fragment parameters in a URL
|
||||
*/
|
||||
export const parseHashFragment = (hashString) => {
|
||||
const hashData = {}
|
||||
let hash = decodeURI(hashString)
|
||||
hash = hash.split('&')
|
||||
hash.forEach((val) => {
|
||||
val = val.replace('#', '')
|
||||
hashData[val.split('=')[0]] = val.split('=')[1]
|
||||
})
|
||||
return hashData
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session in browser cookie
|
||||
*/
|
||||
export const saveSession = (userId, userEmail, userToken) => {
|
||||
Cookies.set('serverless', { userId, userEmail, userToken })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session in browser cookie
|
||||
*/
|
||||
export const getSession = () => {
|
||||
const data = Cookies.get('serverless')
|
||||
return data ? JSON.parse(data) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session in browser cookie
|
||||
*/
|
||||
export const deleteSession = () => {
|
||||
Cookies.remove('serverless')
|
||||
}
|
||||
2
site/src/utils/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './helpers'
|
||||
export * from './api'
|
||||