move to webapp
8
.babelrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
"next/babel"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"superjson-next"
|
||||||
|
]
|
||||||
|
}
|
15
.gitignore
vendored
@ -1,2 +1,13 @@
|
|||||||
node_modules/
|
.next/*
|
||||||
.idea/
|
node_modules/*
|
||||||
|
.idea/*
|
||||||
|
build/*
|
||||||
|
.env
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
|
||||||
|
mosaic
|
||||||
|
*.tgz
|
||||||
|
*.zip
|
||||||
|
aOf719eN
|
||||||
|
old
|
||||||
|
11
.travis.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
language: node_js
|
||||||
|
|
||||||
|
node_js:
|
||||||
|
- node
|
||||||
|
- 'lts/*'
|
||||||
|
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
script:
|
||||||
|
- npm run build
|
||||||
|
- npm run test
|
@ -1,8 +0,0 @@
|
|||||||
node_modules
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
.github
|
|
||||||
dist
|
|
||||||
.example.env
|
|
||||||
docker-compose.yml
|
|
||||||
README.md
|
|
@ -1,5 +0,0 @@
|
|||||||
PORT=3000
|
|
||||||
NODE_ENV=development
|
|
||||||
DATABASE_URL=postgres://user:pass@localhost:5432/apidb
|
|
||||||
TWILIO_ACCOUNT_SID=ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
||||||
TWILIO_AUTH_TOKEN=your_auth_token
|
|
25
api/.github/workflows/ci.yml
vendored
@ -1,25 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build the project
|
|
||||||
runs-on: ubuntu-16.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@master
|
|
||||||
- uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: '12.x'
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
- name: Building the project
|
|
||||||
run: npm run build
|
|
||||||
- name: Unit tests
|
|
||||||
run: npm run test
|
|
21
api/.gitignore
vendored
@ -1,21 +0,0 @@
|
|||||||
# API keys and secrets
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Dependency directory
|
|
||||||
node_modules
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# Editors
|
|
||||||
.idea
|
|
||||||
*.iml
|
|
||||||
|
|
||||||
# OS metadata
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Ignore built ts files
|
|
||||||
dist/**/*
|
|
||||||
|
|
||||||
# Logging files
|
|
||||||
*.log
|
|
||||||
*.pyc
|
|
@ -1,13 +0,0 @@
|
|||||||
FROM node:14-alpine
|
|
||||||
|
|
||||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
|
|
||||||
WORKDIR /home/node/app
|
|
||||||
COPY package*.json ./
|
|
||||||
USER node
|
|
||||||
RUN npm install
|
|
||||||
COPY --chown=node:node . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD [ "npm", "run", "start" ]
|
|
560
api/README.md
@ -1,560 +0,0 @@
|
|||||||
# Node - Koa - Typescript Project
|
|
||||||
|
|
||||||
|
|
||||||
[![NPM version](https://img.shields.io/npm/v/node-typescript-koa-rest.svg)](https://www.npmjs.com/package/node-typescript-koa-rest)
|
|
||||||
[![Dependency Status](https://david-dm.org/javieraviles/node-typescript-koa-rest.svg)](https://david-dm.org/javieraviles/node-typescript-koa-rest)
|
|
||||||
|
|
||||||
|
|
||||||
The main purpose of this repository is to build a good project setup and workflow for writing a Node api rest in TypeScript using KOA and an SQL DB.
|
|
||||||
|
|
||||||
Koa is a new web framework designed by the team behind Express, which aims to be a smaller, more expressive, and more robust foundation for web applications and APIs. Through leveraging generators Koa allows you to ditch callbacks and greatly increase error-handling. Koa does not bundle any middleware within core, and provides an elegant suite of methods that make writing servers fast and enjoyable.
|
|
||||||
|
|
||||||
Through Github Actions CI, this boilerplate is deployed [here](https://node-typescript-koa-rest.herokuapp.com/)! You can try to make requests to the different defined endpoints and see how it works. The following Authorization header will have to be set (already signed with the boilerplate's secret) to pass the JWT middleware:
|
|
||||||
|
|
||||||
HEADER (DEMO)
|
|
||||||
```
|
|
||||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJuYW1lIjoiSmF2aWVyIEF2aWxlcyIsImVtYWlsIjoiYXZpbGVzbG9wZXouamF2aWVyQGdtYWlsLmNvbSJ9.7oxEVGy4VEtaDQyLiuoDvzdO0AyrNrJ_s9NU3vko5-k
|
|
||||||
```
|
|
||||||
|
|
||||||
AVAILABLE ENDPOINTS DEMO [SWAGGER DOCS DEMO](https://node-typescript-koa-rest.herokuapp.com/swagger-html)
|
|
||||||
|
|
||||||
When running the project locally with `watch-server`, being `.env` file config the very same as `.example.env` file, the swagger docs will be deployed at: `http:localhost:3000/swagger-html`, and the bearer token for authorization should be as follows:
|
|
||||||
|
|
||||||
HEADER (LOCALHOST BASED ON DEFAULT SECRET KEY 'your-secret-whatever')
|
|
||||||
```
|
|
||||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJuYW1lIjoiSmF2aWVyIEF2aWxlcyIsImVtYWlsIjoiYXZpbGVzbG9wZXouamF2aWVyQGdtYWlsLmNvbSJ9.rgOobROftUYSWphkdNfxoN2cgKiqNXd4Km4oz6Ex4ng
|
|
||||||
```
|
|
||||||
|
|
||||||
| method | resource | description |
|
|
||||||
|:-------------------|:-----------------|:-----------------------------------------------------------------------------------------------|
|
|
||||||
| `GET` | `/` | Simple hello world response |
|
|
||||||
| `GET` | `/users` | returns the collection of users present in the DB |
|
|
||||||
| `GET` | `/users/:id` | returns the specified id user |
|
|
||||||
| `POST` | `/users` | creates a user in the DB (object user to be includued in request's body) |
|
|
||||||
| `PUT` | `/users/:id` | updates an already created user in the DB (object user to be includued in request's body) |
|
|
||||||
| `DELETE` | `/users/:id` | deletes a user from the DB (JWT token user ID must be the same as the user you want to delete) |
|
|
||||||
|
|
||||||
- [Node - Koa - Typescript Project](#node---koa---typescript-project)
|
|
||||||
- [Pre-reqs](#pre-reqs)
|
|
||||||
- [Features:](#features)
|
|
||||||
- [Included middleware:](#included-middleware)
|
|
||||||
- [Getting Started](#getting-started)
|
|
||||||
- [Docker (optional)](#docker-optional)
|
|
||||||
- [Setting up the Database - ORM](#setting-up-the-database---orm)
|
|
||||||
- [Entities validation](#entities-validation)
|
|
||||||
- [Environment variables](#environment-variables)
|
|
||||||
- [Getting TypeScript](#getting-typescript)
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Configuring TypeScript compilation](#configuring-typescript-compilation)
|
|
||||||
- [Running the build](#running-the-build)
|
|
||||||
- [CI: Github Actions](#ci-github-actions)
|
|
||||||
- [ESLint](#eslint)
|
|
||||||
- [ESLint rules](#eslint-rules)
|
|
||||||
- [Running ESLint](#running-eslint)
|
|
||||||
- [Register cron jobs](#register-cron-jobs)
|
|
||||||
- [Integrations and load tests](#integrations-and-load-tests)
|
|
||||||
- [Logging](#logging)
|
|
||||||
- [Authentication - Security](#authentication---security)
|
|
||||||
- [CORS](#cors)
|
|
||||||
- [Helmet](#helmet)
|
|
||||||
- [Dependencies](#dependencies)
|
|
||||||
- [dependencies](#dependencies-1)
|
|
||||||
- [devDependencies](#devdependencies)
|
|
||||||
- [Changelog](#changelog)
|
|
||||||
- [1.7.1](#171)
|
|
||||||
- [1.7.0](#170)
|
|
||||||
- [1.6.1](#161)
|
|
||||||
- [1.6.0](#160)
|
|
||||||
- [1.5.0](#150)
|
|
||||||
- [1.4.2](#142)
|
|
||||||
- [1.4.1](#141)
|
|
||||||
- [1.4.0](#140)
|
|
||||||
- [1.3.0](#130)
|
|
||||||
- [1.2.0](#120)
|
|
||||||
- [1.1.0](#110)
|
|
||||||
|
|
||||||
|
|
||||||
## Pre-reqs
|
|
||||||
To build and run this app locally you will need:
|
|
||||||
- Install [Node.js](https://nodejs.org/en/)
|
|
||||||
|
|
||||||
## Features:
|
|
||||||
* Nodemon - server auto-restarts when code changes
|
|
||||||
* Koa v2
|
|
||||||
* TypeORM (SQL DB) with basic CRUD included
|
|
||||||
* Swagger decorator (auto generated swagger docs)
|
|
||||||
* Class-validator - Decorator based entities validation
|
|
||||||
* Docker-compose ready to go
|
|
||||||
* Postman (newman) integration tests
|
|
||||||
* Locust load tests
|
|
||||||
* Jest unit tests
|
|
||||||
* Github actions - CI for building and testing the project
|
|
||||||
* Cron jobs prepared
|
|
||||||
|
|
||||||
## Included middleware:
|
|
||||||
* @koa/router
|
|
||||||
* koa-bodyparser
|
|
||||||
* Winston Logger
|
|
||||||
* JWT auth koa-jwt
|
|
||||||
* Helmet (security headers)
|
|
||||||
* CORS
|
|
||||||
|
|
||||||
# Getting Started
|
|
||||||
- Clone the repository
|
|
||||||
```
|
|
||||||
git clone --depth=1 https://github.com/javieraviles/node-typescript-koa-rest.git <project_name>
|
|
||||||
```
|
|
||||||
- Install dependencies
|
|
||||||
```
|
|
||||||
cd <project_name>
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
- Run the project directly in TS
|
|
||||||
```
|
|
||||||
npm run watch-server
|
|
||||||
```
|
|
||||||
|
|
||||||
- Build and run the project in JS
|
|
||||||
```
|
|
||||||
npm run build
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
- Run integration or load tests
|
|
||||||
```
|
|
||||||
npm run test:integration:local (newman needed)
|
|
||||||
npm run test:load (locust needed)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Run unit tests
|
|
||||||
```
|
|
||||||
npm run test
|
|
||||||
```
|
|
||||||
|
|
||||||
- Run unit tests with coverage
|
|
||||||
```
|
|
||||||
npm run test:coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
- Run unit tests on Jest watch mode
|
|
||||||
```
|
|
||||||
npm run test:watch
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker (optional)
|
|
||||||
A docker-compose file has been added to the project with a postgreSQL (already setting user, pass and dbname as the ORM config is expecting) and an ADMINER image (easy web db client).
|
|
||||||
|
|
||||||
It is as easy as go to the project folder and execute the command 'docker-compose up' once you have Docker installed, and both the postgreSQL server and the Adminer client will be running in ports 5432 and 8080 respectively with all the config you need to start playing around.
|
|
||||||
|
|
||||||
If you use Docker natively, the host for the server which you will need to include in the ORM configuration file will be localhost, but if you were to run Docker in older Windows versions, you will be using Boot2Docker and probably your virtual machine will use your ip 192.168.99.100 as network adapter (if not, command `docker-machine ip` will tell you). This mean your database host will be the aforementioned ip and in case you want to access the web db client you will also need to go to http://192.168.99.100/8080
|
|
||||||
|
|
||||||
## Setting up the Database - ORM
|
|
||||||
This API is prepared to work with an SQL database, using [TypeORM](https://github.com/typeorm/typeorm). In this case we are using postgreSQL, and that is why in the package.json 'pg' has been included. If you where to use a different SQL database remember to install the correspondent driver.
|
|
||||||
|
|
||||||
The ORM configuration and connection to the database can be specified in the file 'ormconfig.json'. Here is directly in the connection to the database in 'server.ts' file because a environment variable containing databaseUrl is being used to set the connection data. This is prepared for Heroku, which provides a postgres-string-connection as env variable. In local is being mocked with the docker local postgres as can be seen in ".example.env"
|
|
||||||
|
|
||||||
It is importante to notice that, when serving the project directly with *.ts files using ts-node,the configuration for the ORM should specify the *.ts files path, but once the project is built (transpiled) and run as plain js, it will be needed to change it accordingly to find the built js files:
|
|
||||||
|
|
||||||
```
|
|
||||||
"entities": [
|
|
||||||
"dist/entity/**/*.js"
|
|
||||||
],
|
|
||||||
"migrations": [
|
|
||||||
"dist/migration/**/*.js"
|
|
||||||
],
|
|
||||||
"subscribers": [
|
|
||||||
"dist/subscriber/**/*.js"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**NOTE: this is now automatically handled by the NODE_ENV variable too.
|
|
||||||
|
|
||||||
Notice that if NODE_ENV is set to development, the ORM config won't be using SSL to connect to the DB. Otherwise it will.
|
|
||||||
|
|
||||||
And because Heroku uses self-signed certificates, this bit has been added, **please take it out if connecting to a local DB without SSL**.
|
|
||||||
|
|
||||||
```
|
|
||||||
createConnection({
|
|
||||||
...
|
|
||||||
extra: {
|
|
||||||
ssl: {
|
|
||||||
rejectUnauthorized: false // Heroku uses self signed certificates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
You can find an implemented **CRUD of the entity user** in the correspondent controller controller/user.ts and its routes in routes.ts file.
|
|
||||||
|
|
||||||
## Entities validation
|
|
||||||
This project uses the library class-validator, a decorator-based entity validation, which is used directly in the entities files as follows:
|
|
||||||
```
|
|
||||||
export class User {
|
|
||||||
@Length(10, 100) // length of string email must be between 10 and 100 characters
|
|
||||||
@IsEmail() // the string must comply with an standard email format
|
|
||||||
@IsNotEmpty() // the string can't be empty
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Once the decorators have been set in the entity, you can validate from anywhere as follows:
|
|
||||||
```
|
|
||||||
const user = new User();
|
|
||||||
user.email = "avileslopez.javier@gmail"; // should not pass, needs the ending .com to be a valid email
|
|
||||||
|
|
||||||
validate(user).then(errors => { // errors is an array of validation errors
|
|
||||||
if (errors.length > 0) {
|
|
||||||
console.log("validation failed. errors: ", errors); // code will get here, printing an "IsEmail" error
|
|
||||||
} else {
|
|
||||||
console.log("validation succeed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
For further documentation regarding validations see [class-validator docs](https://github.com/typestack/class-validator).
|
|
||||||
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
Create a .env file (or just rename the .example.env) containing all the env variables you want to set, dotenv library will take care of setting them. This project is using three variables at the moment:
|
|
||||||
|
|
||||||
* PORT -> port where the server will be started on, Heroku will set this env variable automatically
|
|
||||||
* NODE_ENV -> environment, development value will set the logger as debug level, also important for CI. In addition will determine if the ORM connects to the DB through SSL or not.
|
|
||||||
* JWT_SECRET -> secret value, JWT tokens should be signed with this value
|
|
||||||
* DATABASE_URL -> DB connection data in connection-string format.
|
|
||||||
|
|
||||||
## Getting TypeScript
|
|
||||||
TypeScript itself is simple to add to any project with `npm`.
|
|
||||||
```
|
|
||||||
npm install -D typescript
|
|
||||||
```
|
|
||||||
If you're using VS Code then you're good to go!
|
|
||||||
VS Code will detect and use the TypeScript version you have installed in your `node_modules` folder.
|
|
||||||
For other editors, make sure you have the corresponding [TypeScript plugin](http://www.typescriptlang.org/index.html#download-links).
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
The most obvious difference in a TypeScript + Node project is the folder structure.
|
|
||||||
TypeScript (`.ts`) files live in your `src` folder and after compilation are output as JavaScript (`.js`) in the `dist` folder.
|
|
||||||
|
|
||||||
The full folder structure of this app is explained below:
|
|
||||||
|
|
||||||
> **Note!** Make sure you have already built the app using `npm run build`
|
|
||||||
|
|
||||||
| Name | Description |
|
|
||||||
| ----------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
||||||
| **dist** | Contains the distributable (or output) from your TypeScript build. This is the code you ship |
|
|
||||||
| **node_modules** | Contains all your npm dependencies |
|
|
||||||
| **src** | Contains your source code that will be compiled to the dist dir |
|
|
||||||
| **src**/server.ts | Entry point to your KOA app |
|
|
||||||
| **.github**/**workflows**/ci.yml | Github actions CI configuration |
|
|
||||||
| **loadtests**/locustfile.py | Locust load tests |
|
|
||||||
| **integrationtests**/node-koa-typescript.postman_collection.json | Postman integration test collection |
|
|
||||||
| .copyStaticAssets.ts | Build script that copies images, fonts, and JS libs to the dist folder |
|
|
||||||
| package.json | File that contains npm dependencies as well as [build scripts](#what-if-a-library-isnt-on-definitelytyped) |
|
|
||||||
| docker-compose.yml | Docker PostgreSQL and Adminer images in case you want to load the db from Docker |
|
|
||||||
| tsconfig.json | Config settings for compiling server code written in TypeScript |
|
|
||||||
| .eslintrc and .eslintignore | Config settings for ESLint code style checking |
|
|
||||||
| .example.env | Env variables file example to be renamed to .env |
|
|
||||||
| Dockerfile and dockerignore | The app is dockerized to be deployed from CI in a more standard way, not needed for dev |
|
|
||||||
|
|
||||||
## Configuring TypeScript compilation
|
|
||||||
TypeScript uses the file `tsconfig.json` to adjust project compile options.
|
|
||||||
Let's dissect this project's `tsconfig.json`, starting with the `compilerOptions` which details how your project is compiled.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "commonjs",
|
|
||||||
"target": "es2017",
|
|
||||||
"lib": ["es6"],
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"strictPropertyInitialization": false,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"sourceMap": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"emitDecoratorMetadata": true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
| `compilerOptions` | Description |
|
|
||||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `"module": "commonjs"` | The **output** module type (in your `.js` files). Node uses commonjs, so that is what we use |
|
|
||||||
| `"target": "es2017"` | The output language level. Node supports ES2017, so we can target that here |
|
|
||||||
| `"lib": ["es6"]` | Needed for TypeORM. |
|
|
||||||
| `"noImplicitAny": true` | Enables a stricter setting which throws errors when something has a default `any` value |
|
|
||||||
| `"moduleResolution": "node"` | TypeScript attempts to mimic Node's module resolution strategy. Read more [here](https://www.typescriptlang.org/docs/handbook/module-resolution.html#node) |
|
|
||||||
| `"sourceMap": true` | We want source maps to be output along side our JavaScript. |
|
|
||||||
| `"outDir": "dist"` | Location to output `.js` files after compilation |
|
|
||||||
| `"baseUrl": "."` | Part of configuring module resolution. |
|
|
||||||
| `paths: {...}` | Part of configuring module resolution. |
|
|
||||||
| `"experimentalDecorators": true` | Needed for TypeORM. Allows use of @Decorators |
|
|
||||||
| `"emitDecoratorMetadata": true` | Needed for TypeORM. Allows use of @Decorators |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The rest of the file define the TypeScript project context.
|
|
||||||
The project context is basically a set of options that determine which files are compiled when the compiler is invoked with a specific `tsconfig.json`.
|
|
||||||
In this case, we use the following to define our project context:
|
|
||||||
```json
|
|
||||||
"include": [
|
|
||||||
"src/**/*"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
`include` takes an array of glob patterns of files to include in the compilation.
|
|
||||||
This project is fairly simple and all of our .ts files are under the `src` folder.
|
|
||||||
For more complex setups, you can include an `exclude` array of glob patterns that removes specific files from the set defined with `include`.
|
|
||||||
There is also a `files` option which takes an array of individual file names which overrides both `include` and `exclude`.
|
|
||||||
|
|
||||||
|
|
||||||
## Running the build
|
|
||||||
All the different build steps are orchestrated via [npm scripts](https://docs.npmjs.com/misc/scripts).
|
|
||||||
Npm scripts basically allow us to call (and chain) terminal commands via npm.
|
|
||||||
This is nice because most JavaScript tools have easy to use command line utilities allowing us to not need grunt or gulp to manage our builds.
|
|
||||||
If you open `package.json`, you will see a `scripts` section with all the different scripts you can call.
|
|
||||||
To call a script, simply run `npm run <script-name>` from the command line.
|
|
||||||
You'll notice that npm scripts can call each other which makes it easy to compose complex builds out of simple individual build scripts.
|
|
||||||
Below is a list of all the scripts this template has available:
|
|
||||||
|
|
||||||
|
|
||||||
| Npm Script | Description |
|
|
||||||
| ------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
||||||
| `start` | Does the same as 'npm run serve'. Can be invoked with `npm start` |
|
|
||||||
| `build` | Full build. Runs ALL build tasks (`build-ts`, `lint`, `copy-static-assets`) |
|
|
||||||
| `serve` | Runs node on `dist/server/server.js` which is the apps entry point |
|
|
||||||
| `watch-server` | Nodemon, process restarts if crashes. Continuously watches `.ts` files and re-compiles to `.js` |
|
|
||||||
| `build-ts` | Compiles all source `.ts` files to `.js` files in the `dist` folder |
|
|
||||||
| `lint` | Runs ESLint check and fix on project files |
|
|
||||||
| `copy-static-assets` | Calls script that copies JS libs, fonts, and images to dist directory |
|
|
||||||
| `test:integration:<env>` | Execute Postman integration tests collection using newman on any env (`local` or `heroku`) |
|
|
||||||
| `test:load` | Execute Locust load tests using a specific configuration |
|
|
||||||
|
|
||||||
# CI: Github Actions
|
|
||||||
Using Github Actions a pipeline is deploying the application in Heroku and running tests against it, checking the application is healthy deployed. The pipeline can be found at `/.github/workflows/test.yml`. This performs the following:
|
|
||||||
- Build the project
|
|
||||||
- Install Node
|
|
||||||
- Install dependencies
|
|
||||||
- Build the project (transpile to JS)
|
|
||||||
- Run unit tests
|
|
||||||
- Deploy to Heroku
|
|
||||||
- Install Docker cli
|
|
||||||
- Build the application container
|
|
||||||
- Install Heroku cli
|
|
||||||
- Login into Heroku
|
|
||||||
- Push Docker image to Heroku
|
|
||||||
- Trigger release in Heroku
|
|
||||||
- Run integration tests
|
|
||||||
- Install Node
|
|
||||||
- Install Newman
|
|
||||||
- Run Postman collection using Newman against deployed app in Heroku
|
|
||||||
- Run load tests
|
|
||||||
- Install Python
|
|
||||||
- Install Locust
|
|
||||||
- Run Locust load tests against deployed app in Heroku
|
|
||||||
|
|
||||||
# ESLint
|
|
||||||
Since TSLint is deprecated now, ESLint feels like the way to go as also supports typescript.
|
|
||||||
ESLint is a static code analysis tool for identifying problematic patterns found in JavaScript/typescript code.
|
|
||||||
|
|
||||||
## ESLint rules
|
|
||||||
Like most linters, ESLint has a wide set of configurable rules as well as support for custom rule sets.
|
|
||||||
All rules are configured through `.eslintrc`.
|
|
||||||
In this project, we are using a fairly basic set of rules with no additional custom rules.
|
|
||||||
|
|
||||||
## Running ESLint
|
|
||||||
Like the rest of our build steps, we use npm scripts to invoke ESLint.
|
|
||||||
To run ESLint you can call the main build script or just the ESLint task.
|
|
||||||
```
|
|
||||||
npm run build // runs full build including ESLint format check
|
|
||||||
npm run lint // runs ESLint check + fix
|
|
||||||
```
|
|
||||||
Notice that ESLint is not a part of the main watch task.
|
|
||||||
It can be annoying for ESLint to clutter the output window while in the middle of writing a function, so I elected to only run it only during the full build.
|
|
||||||
If you are interested in seeing ESLint feedback as soon as possible, I strongly recommend the [ESLint extension in VS Code](https://github.com/Microsoft/vscode-eslint.git).
|
|
||||||
|
|
||||||
# Register cron jobs
|
|
||||||
[Cron](https://github.com/node-cron/node-cron) dependency has been added to the project together with types. A `cron.ts` file has been created where a cron job is created using a cron expression configured in `config.ts` file.
|
|
||||||
|
|
||||||
```
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import { config } from './config';
|
|
||||||
|
|
||||||
const cron = new CronJob(config.cronJobExpression, () => {
|
|
||||||
console.log('Executing cron job once every hour');
|
|
||||||
});
|
|
||||||
|
|
||||||
export { cron };
|
|
||||||
```
|
|
||||||
|
|
||||||
From the `server.ts`, the cron job gets started:
|
|
||||||
|
|
||||||
```
|
|
||||||
import { cron } from './cron';
|
|
||||||
// Register cron job to do any action needed
|
|
||||||
cron.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
# Integrations and load tests
|
|
||||||
Integrations tests are a Postman collection with assertions, which gets executed using Newman from the CI (Github Actions). It can be found at `/integrationtests/node-koa-typescript.postman_collection.json`; it can be opened in Postman and get modified very easily. Feel free to install Newman in your local environment and trigger `npm run test:integration:local` command which will use local environment file (instead of heroku dev one) to trigger your postman collection faster than using postman.
|
|
||||||
|
|
||||||
Load tests are a locust file with assertions, which gets executed from the CI (Github Actions). It can be found at `/loadtests/locustfile.py`; It is written in python and can be executed locally against any host once python and locust are installed on your dev machine.
|
|
||||||
|
|
||||||
**NOTE: at the end of load tests, an endpoint to remove all created test users is called.
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
Winston is designed to be a simple and universal logging library with support for multiple transports.
|
|
||||||
|
|
||||||
A "logger" middleware passing a winstonInstance has been created. Current configuration of the logger can be found in the file "logger.ts". It will log 'error' level to an error.log file and 'debug' or 'info' level (depending on NODE_ENV environment variable, debug if == development) to the console.
|
|
||||||
|
|
||||||
```
|
|
||||||
// Logger middleware -> use winston as logger (logger.ts with config)
|
|
||||||
app.use(logger(winston));
|
|
||||||
```
|
|
||||||
|
|
||||||
# Authentication - Security
|
|
||||||
The idea is to keep the API as clean as possible, therefore the auth will be done from the client using an auth provider such as Auth0. The client making requests to the API should include the JWT in the Authorization header as "Authorization: Bearer <jwt_token>". HS256 will be used as the secret will be known by both your api and your client and will be used to sign the token, so make sure you keep it hidden.
|
|
||||||
|
|
||||||
As can be found in the server.ts file, a JWT middleware has been added, passing the secret from an environment variable. The middleware will validate that every request to the routes below, MUST include a valid JWT signed with the same secret. The middleware will set automatically the payload information in ctx.state.user.
|
|
||||||
|
|
||||||
```
|
|
||||||
// JWT middleware -> below this line, routes are only reached if JWT token is valid, secret as env variable
|
|
||||||
app.use(jwt({ secret: config.jwtSecret }));
|
|
||||||
```
|
|
||||||
Go to the website [https://jwt.io/](https://jwt.io/) to create JWT tokens for testing/debugging purposes. Select algorithm HS256 and include the generated token in the Authorization header to pass through the jwt middleware.
|
|
||||||
|
|
||||||
Custom 401 handling -> if you don't want to expose koa-jwt errors to users:
|
|
||||||
```
|
|
||||||
app.use(function(ctx, next){
|
|
||||||
return next().catch((err) => {
|
|
||||||
if (401 == err.status) {
|
|
||||||
ctx.status = 401;
|
|
||||||
ctx.body = 'Protected resource, use Authorization header to get access\n';
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
If you want to authenticate from the API, and you fancy the idea of an auth provider like Auth0, have a look at [jsonwebtoken — JSON Web Token signing and verification](https://github.com/auth0/node-jsonwebtoken)
|
|
||||||
|
|
||||||
|
|
||||||
## CORS
|
|
||||||
This boilerplate uses @koa/cors, a simple CORS middleware for koa. If you are not sure what this is about, click [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).
|
|
||||||
|
|
||||||
```
|
|
||||||
// Enable CORS with default options
|
|
||||||
app.use(cors());
|
|
||||||
```
|
|
||||||
Have a look at [Official @koa/cors docs](https://github.com/koajs/cors) in case you want to specify 'origin' or 'allowMethods' properties.
|
|
||||||
|
|
||||||
|
|
||||||
## Helmet
|
|
||||||
This boilerplate uses koa-helmet, a wrapper for helmet to work with koa. It provides important security headers to make your app more secure by default.
|
|
||||||
|
|
||||||
Usage is the same as [helmet](https://github.com/helmetjs/helmet). Helmet offers 11 security middleware functions (clickjacking, DNS prefetching, Security Policy...), everything is set by default here.
|
|
||||||
|
|
||||||
```
|
|
||||||
// Enable helmet with default options
|
|
||||||
app.use(helmet());
|
|
||||||
```
|
|
||||||
|
|
||||||
Have a look at [Official koa-helmet docs](https://github.com/venables/koa-helmet) in case you want to customize which security middlewares are enabled.
|
|
||||||
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
Dependencies are managed through `package.json`.
|
|
||||||
In that file you'll find two sections:
|
|
||||||
## dependencies
|
|
||||||
|
|
||||||
| Package | Description |
|
|
||||||
| ------------------------------- | --------------------------------------------------------------------- |
|
|
||||||
| dotenv | Loads environment variables from .env file. |
|
|
||||||
| koa | Node web framework. |
|
|
||||||
| koa-bodyparser | A bodyparser for koa. |
|
|
||||||
| koa-jwt | Middleware to validate JWT tokens. |
|
|
||||||
| @koa/router | Router middleware for koa. |
|
|
||||||
| koa-helmet | Wrapper for helmet, important security headers to make app more secure|
|
|
||||||
| @koa/cors | Cross-Origin Resource Sharing(CORS) for koa |
|
|
||||||
| pg | PostgreSQL driver, needed for the ORM. |
|
|
||||||
| reflect-metadata | Used by typeORM to implement decorators. |
|
|
||||||
| typeorm | A very cool SQL ORM. |
|
|
||||||
| winston | Logging library. |
|
|
||||||
| class-validator | Decorator based entities validation. |
|
|
||||||
| koa-swagger-decorator | using decorator to automatically generate swagger doc for koa-router. |
|
|
||||||
| cron | Register cron jobs in node. |
|
|
||||||
|
|
||||||
## devDependencies
|
|
||||||
|
|
||||||
| Package | Description |
|
|
||||||
| ------------------------------- | --------------------------------------------------------------------- |
|
|
||||||
| @types | Dependencies in this folder are `.d.ts` files used to provide types |
|
|
||||||
| nodemon | Utility that automatically restarts node process when it crashes |
|
|
||||||
| ts-node | Enables directly running TS files. Used to run `copy-static-assets.ts`|
|
|
||||||
| eslint | Linter for Javascript/TypeScript files |
|
|
||||||
| typescript | JavaScript compiler/type checker that boosts JavaScript productivity |
|
|
||||||
| shelljs | Portable Unix shell commands for Node.js |
|
|
||||||
|
|
||||||
To install or update these dependencies you can use `npm install` or `npm update`.
|
|
||||||
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
### 1.8.0
|
|
||||||
- Unit tests included using Jest (Thanks to [@rafapaezbas](https://github.com/rafapaezbas))
|
|
||||||
- Upgrade all dependencies
|
|
||||||
- Upgrade to Node 14
|
|
||||||
### 1.7.1
|
|
||||||
- Upgrading Locust + fixing load tests
|
|
||||||
- Improving Logger
|
|
||||||
|
|
||||||
### 1.7.0
|
|
||||||
- Migrating `TSLint` (deprecated already) to `ESLint`
|
|
||||||
- Node version upgraded from `10.x.x` to `12.0.0` (LTS)
|
|
||||||
- Now CI installs from `package-lock.json` using `npm ci` (Beyond guaranteeing you that you'll only get what is in your lock-file it's also much faster (2x-10x!) than npm install when you don't start with a node_modules).
|
|
||||||
- included integraton test using Newman for local env too
|
|
||||||
- `koa-router` deprecated, using new fork from koa team `@koa/router`
|
|
||||||
- Dependencies updated, some @types removed as more and more libraries include their own types now!
|
|
||||||
- Typescript to latest
|
|
||||||
|
|
||||||
### 1.6.1
|
|
||||||
- Fixing CI
|
|
||||||
- Improving integration tests robustness
|
|
||||||
|
|
||||||
### 1.6.0
|
|
||||||
- CI migrated from Travis to Github actions
|
|
||||||
- cron dependency -> register cron jobs
|
|
||||||
- Node app dockerized -> now is directly pushed as a docker image to Heroku from CI, not using any webhook
|
|
||||||
- Added postman integration tests, executed from Github actions CI using Newman
|
|
||||||
- Added locust load tests, executed from Github actions CI
|
|
||||||
- PRs merged: [47](https://github.com/javieraviles/node-typescript-koa-rest/pull/47), [48](https://github.com/javieraviles/node-typescript-koa-rest/pull/48) and [49](https://github.com/javieraviles/node-typescript-koa-rest/pull/49). Thanks to everybody!
|
|
||||||
|
|
||||||
### 1.5.0
|
|
||||||
- koa-swagger-decorator -> generate [swagger docs](https://node-typescript-koa-rest.herokuapp.com/swagger-html) with decorators in the endpoints
|
|
||||||
- Split routes into protected and unprotected. Hello world + swagger docs are not proteted by jwt
|
|
||||||
- some dependencies have been updated
|
|
||||||
|
|
||||||
### 1.4.2
|
|
||||||
- Fix -> `npm run watch-server` is now working properly live-reloading changes in the code [Issue 39](https://github.com/javieraviles/node-typescript-koa-rest/issues/39).
|
|
||||||
- Fix -> Logging levels were not correctly mapped. Thanks to @atamano for the PR [Pull Request 35](https://github.com/javieraviles/node-typescript-koa-rest/pull/35)
|
|
||||||
- Some code leftovers removed
|
|
||||||
|
|
||||||
### 1.4.1
|
|
||||||
- Fix -> After updating winston to 3.0.0, it was throwing an error when logging errors into file
|
|
||||||
- Fix -> Config in config.ts wasn't implementing IConfig interface
|
|
||||||
|
|
||||||
### 1.4.0
|
|
||||||
- Dotenv lib updated, no changes needed (they are dropping node4 support)
|
|
||||||
- Class-validator lib updated, no chages needed (cool features added like IsPhoneNumber or custom context for decorators)
|
|
||||||
- Winston lib updated to 3.0.0, some amendments needed to format the console log. Removed the @types as Winston now supports Typescript natively!
|
|
||||||
- Some devDependencies updated as well
|
|
||||||
|
|
||||||
### 1.3.0
|
|
||||||
- CORS added
|
|
||||||
- Syntax full REST
|
|
||||||
- Some error handling improvement
|
|
||||||
|
|
||||||
### 1.2.0
|
|
||||||
- Heroku deployment added
|
|
||||||
|
|
||||||
### 1.1.0
|
|
||||||
- Added Helmet for security
|
|
||||||
- Some bad practices await/async fixed
|
|
6852
api/package-lock.json
generated
@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "virtual-phone-number-api",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "",
|
|
||||||
"main": "dist/server.js",
|
|
||||||
"scripts": {
|
|
||||||
"watch-server": "nodemon --ignore src/__tests__/ --watch src -e ts,tsx --exec ts-node src/server.ts",
|
|
||||||
"serve": "node dist/server.js",
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "npm run serve",
|
|
||||||
"test": "jest",
|
|
||||||
"test:coverage": "jest --collect-coverage",
|
|
||||||
"test:watch": "jest --watch"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14",
|
|
||||||
"npm": ">=6"
|
|
||||||
},
|
|
||||||
"author": "Mokhtar Mial",
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/jest": "^26.0.23",
|
|
||||||
"@types/koa": "^2.13.2",
|
|
||||||
"@types/koa-bodyparser": "^4.3.0",
|
|
||||||
"@types/koa-helmet": "^6.0.2",
|
|
||||||
"@types/koa__router": "^8.0.4",
|
|
||||||
"@types/koa__cors": "^3.0.2",
|
|
||||||
"@types/node": "^14.17.1",
|
|
||||||
"jest": "^27.0.1",
|
|
||||||
"nodemon": "^2.0.7",
|
|
||||||
"ts-jest": "^27.0.1",
|
|
||||||
"ts-node": "^10.0.0",
|
|
||||||
"typescript": "^4.3.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@koa/cors": "3.1.0",
|
|
||||||
"@koa/router": "10.0.0",
|
|
||||||
"@lifeomic/twilio-webhook-validator-koa": "^1.2.0",
|
|
||||||
"class-validator": "0.13.1",
|
|
||||||
"dotenv": "10.0.0",
|
|
||||||
"koa": "2.13.1",
|
|
||||||
"koa-bodyparser": "4.3.0",
|
|
||||||
"koa-helmet": "6.1.0",
|
|
||||||
"pg": "8.6.0",
|
|
||||||
"reflect-metadata": "0.1.13",
|
|
||||||
"twilio": "^3.63.0",
|
|
||||||
"typeorm": "0.2.32",
|
|
||||||
"winston": "3.3.3"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"roots": [
|
|
||||||
"<rootDir>"
|
|
||||||
],
|
|
||||||
"testMatch": [
|
|
||||||
"**/__tests__/**/*.+(ts|tsx|js)"
|
|
||||||
],
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(ts|tsx)$": "ts-jest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,60 +0,0 @@
|
|||||||
import SmsController from "../../src/controller/sms";
|
|
||||||
import { Sms } from "../../src/entity/sms";
|
|
||||||
import { getManager } from "typeorm";
|
|
||||||
import { Context } from "koa";
|
|
||||||
import { ValidationError, validate } from "class-validator";
|
|
||||||
|
|
||||||
const sms: Sms = new Sms();
|
|
||||||
sms.id = 0;
|
|
||||||
sms.name = "John";
|
|
||||||
sms.name = "johndoe@gmail.com";
|
|
||||||
|
|
||||||
jest.mock("typeorm", () => {
|
|
||||||
const doNothing = () => {
|
|
||||||
//Empty function that mocks typeorm annotations
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
getManager: jest.fn(),
|
|
||||||
PrimaryGeneratedColumn: doNothing,
|
|
||||||
Column: doNothing,
|
|
||||||
Entity: doNothing,
|
|
||||||
Equal: doNothing,
|
|
||||||
Not: doNothing,
|
|
||||||
Like: doNothing,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
jest.mock("class-validator", () => {
|
|
||||||
const doNothing = () => {
|
|
||||||
//Empty function that mocks typeorm annotations
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
validate: jest.fn(),
|
|
||||||
Length: doNothing,
|
|
||||||
IsEmail: doNothing,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Sms controller", () => {
|
|
||||||
it("getUsers should return status 200 and found users.", async () => {
|
|
||||||
const userRepository = { find: jest.fn().mockReturnValue([sms]) };
|
|
||||||
(getManager as jest.Mock).mockReturnValue({ getRepository: () => userRepository });
|
|
||||||
const context = { status: undefined, body: undefined } as Context;
|
|
||||||
await SmsController.sendSms(context);
|
|
||||||
expect(userRepository.find).toHaveBeenCalledTimes(1);
|
|
||||||
expect(context.status).toBe(200);
|
|
||||||
expect(context.body).toStrictEqual([sms]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createUser should return status 201 if is created.", async () => {
|
|
||||||
const userRepository = { save: jest.fn().mockReturnValue(sms), findOne: () => undefined as Sms };
|
|
||||||
(getManager as jest.Mock).mockReturnValue({ getRepository: () => userRepository });
|
|
||||||
(validate as jest.Mock).mockReturnValue([]);
|
|
||||||
const context = { status: undefined, body: undefined, request: { body: sms } } as unknown as Context;
|
|
||||||
await SmsController.receiveSms(context);
|
|
||||||
expect(userRepository.save).toHaveBeenCalledTimes(1);
|
|
||||||
expect(context.status).toBe(201);
|
|
||||||
expect(context.body).toStrictEqual(sms);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,9 +0,0 @@
|
|||||||
import { Sms } from "../../src/entity/user";
|
|
||||||
|
|
||||||
test("user", () => {
|
|
||||||
const user = new Sms();
|
|
||||||
user.name = "John Doe";
|
|
||||||
user.email = "johndoe@gmail.com";
|
|
||||||
expect(user.name).toBe("John Doe");
|
|
||||||
expect(user.email).toBe("johndoe@gmail.com");
|
|
||||||
});
|
|
@ -1,20 +0,0 @@
|
|||||||
import dotenv from "dotenv";
|
|
||||||
|
|
||||||
dotenv.config({ path: ".env" });
|
|
||||||
|
|
||||||
const isDevMode = process.env.NODE_ENV == "development";
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
port: +(process.env.PORT || 9029),
|
|
||||||
debugLogging: isDevMode,
|
|
||||||
databaseUrl: process.env.DATABASE_URL || "postgres://user:pass@localhost:5432/apidb",
|
|
||||||
dbEntitiesPath: [
|
|
||||||
...isDevMode ? ["src/entity/**/*.ts"] : ["dist/entity/**/*.js"],
|
|
||||||
],
|
|
||||||
twilio: {
|
|
||||||
accountSid: process.env.TWILIO_ACCOUNT_SID!,
|
|
||||||
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
@ -1,31 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
import config from "../config";
|
|
||||||
|
|
||||||
const ENCRYPTION_KEY = computeEncryptionKey(config.twilio.accountSid);
|
|
||||||
const IV_LENGTH = 16;
|
|
||||||
const ALGORITHM = "aes-256-cbc";
|
|
||||||
|
|
||||||
export function encrypt(text: string) {
|
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
||||||
const encrypted = cipher.update(text);
|
|
||||||
const encryptedBuffer = Buffer.concat([encrypted, cipher.final()]);
|
|
||||||
|
|
||||||
return `${iv.toString("hex")}:${encryptedBuffer.toString("hex")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decrypt(encryptedHexText: string) {
|
|
||||||
const [hexIv, hexText] = encryptedHexText.split(":");
|
|
||||||
const iv = Buffer.from(hexIv, "hex");
|
|
||||||
const encryptedText = Buffer.from(hexText, "hex");
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
||||||
const decrypted = decipher.update(encryptedText);
|
|
||||||
const decryptedBuffer = Buffer.concat([decrypted, decipher.final()]);
|
|
||||||
|
|
||||||
return decryptedBuffer.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeEncryptionKey(userIdentifier: string) {
|
|
||||||
return crypto.scryptSync(userIdentifier, crypto.randomBytes(16), 32);
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
import Router from "@koa/router";
|
|
||||||
import { Twilio, twiml } from "twilio";
|
|
||||||
|
|
||||||
import config from "../config";
|
|
||||||
|
|
||||||
const forwardTo = "+33613370787";
|
|
||||||
|
|
||||||
export default class CallController {
|
|
||||||
public static forwardCall: Router.Middleware = async (ctx) => {
|
|
||||||
const voiceResponse = new twiml.VoiceResponse()
|
|
||||||
voiceResponse.dial(forwardTo);
|
|
||||||
|
|
||||||
ctx.status = 200;
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,106 +0,0 @@
|
|||||||
import Router from "@koa/router";
|
|
||||||
import { Twilio } from "twilio";
|
|
||||||
import { getManager } from "typeorm";
|
|
||||||
|
|
||||||
import config from "../config";
|
|
||||||
import { Sms, SmsType } from "../entity/sms";
|
|
||||||
import { decrypt, encrypt } from "./_encryption";
|
|
||||||
|
|
||||||
const client = new Twilio(config.twilio.accountSid, config.twilio.authToken);
|
|
||||||
const phoneNumber = "+33757592025";
|
|
||||||
// const from = "Mokhtar";
|
|
||||||
|
|
||||||
type Recipient = string;
|
|
||||||
export type Conversation = Record<Recipient, Sms[]>;
|
|
||||||
|
|
||||||
export default class SmsController {
|
|
||||||
public static getConversations: Router.Middleware = async (ctx) => {
|
|
||||||
const smsRepository = getManager().getRepository(Sms);
|
|
||||||
const messages = await smsRepository.find({
|
|
||||||
where: [
|
|
||||||
{ from: phoneNumber },
|
|
||||||
{ to: phoneNumber },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const conversations = messages.reduce<Conversation>((acc, message) => {
|
|
||||||
let recipient: string;
|
|
||||||
if (message.type === SmsType.SENT) {
|
|
||||||
recipient = message.to;
|
|
||||||
} else {
|
|
||||||
recipient = message.from;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!acc[recipient]) {
|
|
||||||
acc[recipient] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[recipient].push({
|
|
||||||
...message,
|
|
||||||
content: decrypt(message.content), // TODO: should probably decrypt on the phone
|
|
||||||
});
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
ctx.body = conversations;
|
|
||||||
ctx.status = 200;
|
|
||||||
};
|
|
||||||
|
|
||||||
public static sendSms: Router.Middleware = async (ctx) => {
|
|
||||||
const smsRepository = getManager().getRepository(Sms);
|
|
||||||
const { to, content } = ctx.request.body;
|
|
||||||
await client.messages.create({ body: content, from: phoneNumber, to });
|
|
||||||
const sms = new Sms();
|
|
||||||
sms.type = SmsType.SENT;
|
|
||||||
sms.sentAt = new Date();
|
|
||||||
sms.content = encrypt(content); // TODO: should probably encrypt on the phone
|
|
||||||
sms.to = to;
|
|
||||||
sms.from = phoneNumber;
|
|
||||||
await smsRepository.save(sms);
|
|
||||||
|
|
||||||
ctx.status = 200;
|
|
||||||
};
|
|
||||||
|
|
||||||
public static receiveSms: Router.Middleware = async (ctx) => {
|
|
||||||
const smsRepository = getManager().getRepository(Sms);
|
|
||||||
console.log("ctx.request.body", ctx.request.body);
|
|
||||||
const body: ReceivedSms = ctx.request.body;
|
|
||||||
console.log("body.From", body.From);
|
|
||||||
console.log("body.To", body.To);
|
|
||||||
console.log("body.Body", body.Body);
|
|
||||||
const sms = new Sms();
|
|
||||||
sms.type = SmsType.RECEIVED;
|
|
||||||
sms.sentAt = new Date();
|
|
||||||
sms.content = encrypt(body.Body);
|
|
||||||
sms.to = body.To;
|
|
||||||
sms.from = body.From;
|
|
||||||
await smsRepository.save(sms);
|
|
||||||
|
|
||||||
// TODO: send notification to `body.To` and let him know he received an SMS
|
|
||||||
|
|
||||||
ctx.status = 200;
|
|
||||||
ctx.body = undefined;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReceivedSms = {
|
|
||||||
ToCountry: string;
|
|
||||||
ToState: string;
|
|
||||||
SmsMessageSid: string;
|
|
||||||
NumMedia: string;
|
|
||||||
ToCity: string;
|
|
||||||
FromZip: string;
|
|
||||||
SmsSid: string;
|
|
||||||
FromState: string;
|
|
||||||
SmsStatus: string;
|
|
||||||
FromCity: string;
|
|
||||||
Body: string;
|
|
||||||
FromCountry: string;
|
|
||||||
To: string;
|
|
||||||
ToZip: string;
|
|
||||||
NumSegments: string;
|
|
||||||
MessageSid: string;
|
|
||||||
AccountSid: string;
|
|
||||||
From: string;
|
|
||||||
ApiVersion: string;
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import { Entity, Column, PrimaryGeneratedColumn, Index, CreateDateColumn } from "typeorm";
|
|
||||||
import { Length, IsPhoneNumber } from "class-validator";
|
|
||||||
|
|
||||||
export enum SmsType {
|
|
||||||
SENT = "sent",
|
|
||||||
RECEIVED = "received",
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity()
|
|
||||||
export class Sms {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
@Column("text")
|
|
||||||
@Length(1, 10000)
|
|
||||||
content: string;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column("text")
|
|
||||||
@IsPhoneNumber()
|
|
||||||
from: string;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column("text")
|
|
||||||
@IsPhoneNumber()
|
|
||||||
to: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: "enum",
|
|
||||||
enum: SmsType,
|
|
||||||
})
|
|
||||||
type: SmsType;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
sentAt: Date;
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
import type { Context, Middleware } from "koa";
|
|
||||||
import { transports, format } from "winston";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import config from "./config";
|
|
||||||
|
|
||||||
const logger = (winstonInstance: typeof import("winston")): Middleware => {
|
|
||||||
winstonInstance.configure({
|
|
||||||
level: config.debugLogging ? "debug" : "info",
|
|
||||||
transports: [
|
|
||||||
// - Write all logs error (and below) to `error.log`.
|
|
||||||
new transports.File({ filename: path.resolve(__dirname, "../error.log"), level: "error" }),
|
|
||||||
// - Write to all logs with specified level to console.
|
|
||||||
new transports.Console({
|
|
||||||
format: format.combine(
|
|
||||||
format.colorize(),
|
|
||||||
format.simple(),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return async (ctx: Context, next: () => Promise<any>): Promise<void> => {
|
|
||||||
const start = Date.now();
|
|
||||||
try {
|
|
||||||
await next();
|
|
||||||
} catch (err) {
|
|
||||||
ctx.status = err.status || 500;
|
|
||||||
ctx.body = err.message;
|
|
||||||
}
|
|
||||||
const ms = Date.now() - start;
|
|
||||||
|
|
||||||
let logLevel: string;
|
|
||||||
if (ctx.status >= 500) {
|
|
||||||
logLevel = "error";
|
|
||||||
} else if (ctx.status >= 400) {
|
|
||||||
logLevel = "warn";
|
|
||||||
} else {
|
|
||||||
logLevel = "info";
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = `${ctx.method} ${ctx.originalUrl} ${ctx.status} ${ms}ms`;
|
|
||||||
|
|
||||||
winstonInstance.log(logLevel, msg);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default logger;
|
|
@ -1,15 +0,0 @@
|
|||||||
import Router from "@koa/router";
|
|
||||||
|
|
||||||
import SmsController from "./controller/sms";
|
|
||||||
import CallController from "./controller/call";
|
|
||||||
import { webhookValidator } from "@lifeomic/twilio-webhook-validator-koa";
|
|
||||||
import config from "./config";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
router.get("/conversations", SmsController.getConversations);
|
|
||||||
router.post("/send-sms", SmsController.sendSms);
|
|
||||||
router.post("/receive-sms", webhookValidator({ authToken: config.twilio.authToken, protocol: "https" }), SmsController.receiveSms);
|
|
||||||
router.post("/receive-call", webhookValidator({ authToken: config.twilio.authToken, protocol: "https" }), CallController.forwardCall);
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,46 +0,0 @@
|
|||||||
import Koa from "koa";
|
|
||||||
import bodyParser from "koa-bodyparser";
|
|
||||||
import helmet from "koa-helmet";
|
|
||||||
import cors from "@koa/cors";
|
|
||||||
import winston from "winston";
|
|
||||||
import type { ConnectionOptions } from "typeorm";
|
|
||||||
import { createConnection } from "typeorm";
|
|
||||||
import "reflect-metadata";
|
|
||||||
|
|
||||||
import logger from "./logger";
|
|
||||||
import config from "./config";
|
|
||||||
import router from "./router";
|
|
||||||
|
|
||||||
const connectionOptions: ConnectionOptions = {
|
|
||||||
type: "postgres",
|
|
||||||
url: config.databaseUrl,
|
|
||||||
synchronize: true,
|
|
||||||
logging: false,
|
|
||||||
entities: config.dbEntitiesPath,
|
|
||||||
extra: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// create connection with database
|
|
||||||
// note that its not active database connection
|
|
||||||
// TypeORM creates you connection pull to uses connections from pull on your requests
|
|
||||||
createConnection(connectionOptions).then(async () => {
|
|
||||||
const app = new Koa();
|
|
||||||
|
|
||||||
app.use(helmet.contentSecurityPolicy({
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com"],
|
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com"],
|
|
||||||
fontSrc: ["'self'", "fonts.gstatic.com"],
|
|
||||||
imgSrc: ["'self'", "data:"],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
app.use(cors());
|
|
||||||
app.use(logger(winston));
|
|
||||||
app.use(bodyParser());
|
|
||||||
app.use(router.routes()).use(router.allowedMethods());
|
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
|
||||||
console.log(`Server running on port ${config.port}`);
|
|
||||||
});
|
|
||||||
}).catch((error: string) => console.log("TypeORM connection error: ", error));
|
|
@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2017",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["es6"],
|
|
||||||
"sourceMap": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"strict": true,
|
|
||||||
"strictPropertyInitialization": false,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"*": [
|
|
||||||
"node_modules/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"emitDecoratorMetadata": true,
|
|
||||||
"skipLibCheck": true
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"src/__tests__"
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true,
|
|
||||||
"af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true,
|
|
||||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
|
||||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
|
||||||
}
|
|
13
app/.gitignore
vendored
@ -1,13 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.expo/
|
|
||||||
npm-debug.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
*.orig.*
|
|
||||||
web-build/
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
24
app/App.tsx
@ -1,24 +0,0 @@
|
|||||||
import "react-native-gesture-handler";
|
|
||||||
import { StatusBar } from "expo-status-bar";
|
|
||||||
import React from "react";
|
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
import useCachedResources from "./hooks/useCachedResources";
|
|
||||||
import useColorScheme from "./hooks/useColorScheme";
|
|
||||||
import Navigation from "./navigation";
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const isLoadingComplete = useCachedResources();
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
if (!isLoadingComplete) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<SafeAreaProvider>
|
|
||||||
<Navigation colorScheme={colorScheme} />
|
|
||||||
<StatusBar />
|
|
||||||
</SafeAreaProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
34
app/app.json
@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"name": "virtual-phone",
|
|
||||||
"slug": "virtual-phone",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"icon": "./assets/images/icon.png",
|
|
||||||
"scheme": "myapp",
|
|
||||||
"userInterfaceStyle": "automatic",
|
|
||||||
"splash": {
|
|
||||||
"image": "./assets/images/splash.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#ffffff"
|
|
||||||
},
|
|
||||||
"updates": {
|
|
||||||
"fallbackToCacheTimeout": 0
|
|
||||||
},
|
|
||||||
"assetBundlePatterns": [
|
|
||||||
"**/*"
|
|
||||||
],
|
|
||||||
"ios": {
|
|
||||||
"supportsTablet": true
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
|
||||||
"backgroundColor": "#ffffff"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"favicon": "./assets/images/favicon.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 47 KiB |
@ -1,7 +0,0 @@
|
|||||||
module.exports = function (api) {
|
|
||||||
api.cache(true);
|
|
||||||
return {
|
|
||||||
presets: ["babel-preset-expo"],
|
|
||||||
plugins: ["react-native-reanimated/plugin"],
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,80 +0,0 @@
|
|||||||
import * as WebBrowser from "expo-web-browser";
|
|
||||||
import React from "react";
|
|
||||||
import { StyleSheet, TouchableOpacity } from "react-native";
|
|
||||||
|
|
||||||
import Colors from "../constants/Colors";
|
|
||||||
import { MonoText } from "./StyledText";
|
|
||||||
import { Text, View } from "./Themed";
|
|
||||||
|
|
||||||
export default function EditScreenInfo({ path }: { path: string }) {
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View style={styles.getStartedContainer}>
|
|
||||||
<Text
|
|
||||||
style={styles.getStartedText}
|
|
||||||
lightColor="rgba(0,0,0,0.8)"
|
|
||||||
darkColor="rgba(255,255,255,0.8)">
|
|
||||||
Open up the code for this screen:
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
|
|
||||||
darkColor="rgba(255,255,255,0.05)"
|
|
||||||
lightColor="rgba(0,0,0,0.05)">
|
|
||||||
<MonoText>{path}</MonoText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
style={styles.getStartedText}
|
|
||||||
lightColor="rgba(0,0,0,0.8)"
|
|
||||||
darkColor="rgba(255,255,255,0.8)">
|
|
||||||
Change any of the text, save the file, and your app will automatically update.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.helpContainer}>
|
|
||||||
<TouchableOpacity onPress={handleHelpPress} style={styles.helpLink}>
|
|
||||||
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
|
|
||||||
Tap here if your app doesn't automatically update after making changes
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHelpPress() {
|
|
||||||
WebBrowser.openBrowserAsync(
|
|
||||||
"https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
getStartedContainer: {
|
|
||||||
alignItems: "center",
|
|
||||||
marginHorizontal: 50,
|
|
||||||
},
|
|
||||||
homeScreenFilename: {
|
|
||||||
marginVertical: 7,
|
|
||||||
},
|
|
||||||
codeHighlightContainer: {
|
|
||||||
borderRadius: 3,
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
},
|
|
||||||
getStartedText: {
|
|
||||||
fontSize: 17,
|
|
||||||
lineHeight: 24,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
helpContainer: {
|
|
||||||
marginTop: 15,
|
|
||||||
marginHorizontal: 20,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
helpLink: {
|
|
||||||
paddingVertical: 15,
|
|
||||||
},
|
|
||||||
helpLinkText: {
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,7 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { Text, TextProps } from "./Themed";
|
|
||||||
|
|
||||||
export function MonoText(props: TextProps) {
|
|
||||||
return <Text {...props} style={[props.style, { fontFamily: "space-mono" }]} />;
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about Light and Dark modes:
|
|
||||||
* https://docs.expo.io/guides/color-schemes/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { Text as DefaultText, View as DefaultView } from "react-native";
|
|
||||||
|
|
||||||
import Colors from "../constants/Colors";
|
|
||||||
import useColorScheme from "../hooks/useColorScheme";
|
|
||||||
|
|
||||||
export function useThemeColor(
|
|
||||||
props: { light?: string; dark?: string },
|
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
|
|
||||||
) {
|
|
||||||
const theme = useColorScheme();
|
|
||||||
const colorFromProps = props[theme];
|
|
||||||
|
|
||||||
if (colorFromProps) {
|
|
||||||
return colorFromProps;
|
|
||||||
} else {
|
|
||||||
return Colors[theme][colorName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ThemeProps = {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TextProps = ThemeProps & DefaultText["props"];
|
|
||||||
export type ViewProps = ThemeProps & DefaultView["props"];
|
|
||||||
|
|
||||||
export function Text(props: TextProps) {
|
|
||||||
const { style, lightColor, darkColor, ...otherProps } = props;
|
|
||||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, "text");
|
|
||||||
|
|
||||||
return <DefaultText style={[{ color }, style]} {...otherProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function View(props: ViewProps) {
|
|
||||||
const { style, lightColor, darkColor, ...otherProps } = props;
|
|
||||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, "background");
|
|
||||||
|
|
||||||
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import renderer from "react-test-renderer";
|
|
||||||
|
|
||||||
import { MonoText } from "../StyledText";
|
|
||||||
|
|
||||||
it(`renders correctly`, () => {
|
|
||||||
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
|
|
||||||
|
|
||||||
expect(tree).toMatchSnapshot();
|
|
||||||
});
|
|
@ -1,19 +0,0 @@
|
|||||||
const tintColorLight = "#2f95dc";
|
|
||||||
const tintColorDark = "#fff";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
light: {
|
|
||||||
text: "#000",
|
|
||||||
background: "#fff",
|
|
||||||
tint: tintColorLight,
|
|
||||||
tabIconDefault: "#ccc",
|
|
||||||
tabIconSelected: tintColorLight,
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
text: "#fff",
|
|
||||||
background: "#000",
|
|
||||||
tint: tintColorDark,
|
|
||||||
tabIconDefault: "#ccc",
|
|
||||||
tabIconSelected: tintColorDark,
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,12 +0,0 @@
|
|||||||
import { Dimensions } from "react-native";
|
|
||||||
|
|
||||||
const width = Dimensions.get("window").width;
|
|
||||||
const height = Dimensions.get("window").height;
|
|
||||||
|
|
||||||
export default {
|
|
||||||
window: {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
},
|
|
||||||
isSmallDevice: width < 375,
|
|
||||||
};
|
|
@ -1,33 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as Font from "expo-font";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
export default function useCachedResources() {
|
|
||||||
const [isLoadingComplete, setLoadingComplete] = React.useState(false);
|
|
||||||
|
|
||||||
// Load any resources or data that we need prior to rendering the app
|
|
||||||
React.useEffect(() => {
|
|
||||||
async function loadResourcesAndDataAsync() {
|
|
||||||
try {
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
|
||||||
|
|
||||||
// Load fonts
|
|
||||||
await Font.loadAsync({
|
|
||||||
...Ionicons.font,
|
|
||||||
"space-mono": require("../assets/fonts/SpaceMono-Regular.ttf"),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// We might want to provide this error information to an error reporting service
|
|
||||||
console.warn(e);
|
|
||||||
} finally {
|
|
||||||
setLoadingComplete(true);
|
|
||||||
SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadResourcesAndDataAsync();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return isLoadingComplete;
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
import { ColorSchemeName, useColorScheme as _useColorScheme } from "react-native";
|
|
||||||
|
|
||||||
// The useColorScheme value is always either light or dark, but the built-in
|
|
||||||
// type suggests that it can be null. This will not happen in practice, so this
|
|
||||||
// makes it a bit easier to work with.
|
|
||||||
export default function useColorScheme(): NonNullable<ColorSchemeName> {
|
|
||||||
return _useColorScheme() as NonNullable<ColorSchemeName>;
|
|
||||||
}
|
|
@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about createBottomTabNavigator:
|
|
||||||
* https://reactnavigation.org/docs/bottom-tab-navigator
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
|
||||||
import { createStackNavigator } from "@react-navigation/stack";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import Colors from "../constants/Colors";
|
|
||||||
import useColorScheme from "../hooks/useColorScheme";
|
|
||||||
import TabOneScreen from "../screens/TabOneScreen";
|
|
||||||
import TabTwoScreen from "../screens/TabTwoScreen";
|
|
||||||
import { BottomTabParamList, TabOneParamList, TabTwoParamList } from "../types";
|
|
||||||
|
|
||||||
const BottomTab = createBottomTabNavigator<BottomTabParamList>();
|
|
||||||
|
|
||||||
export default function BottomTabNavigator() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomTab.Navigator
|
|
||||||
initialRouteName="TabOne"
|
|
||||||
tabBarOptions={{ activeTintColor: Colors[colorScheme].tint }}>
|
|
||||||
<BottomTab.Screen
|
|
||||||
name="TabOne"
|
|
||||||
component={TabOneNavigator}
|
|
||||||
options={{
|
|
||||||
tabBarIcon: ({ color }) => <TabBarIcon name="ios-code" color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<BottomTab.Screen
|
|
||||||
name="TabTwo"
|
|
||||||
component={TabTwoNavigator}
|
|
||||||
options={{
|
|
||||||
tabBarIcon: ({ color }) => <TabBarIcon name="ios-code" color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</BottomTab.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// You can explore the built-in icon families and icons on the web at:
|
|
||||||
// https://icons.expo.fyi/
|
|
||||||
function TabBarIcon(props: { name: React.ComponentProps<typeof Ionicons>["name"]; color: string }) {
|
|
||||||
return <Ionicons size={30} style={{ marginBottom: -3 }} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each tab has its own navigation stack, you can read more about this pattern here:
|
|
||||||
// https://reactnavigation.org/docs/tab-based-navigation#a-stack-navigator-for-each-tab
|
|
||||||
const TabOneStack = createStackNavigator<TabOneParamList>();
|
|
||||||
|
|
||||||
function TabOneNavigator() {
|
|
||||||
return (
|
|
||||||
<TabOneStack.Navigator>
|
|
||||||
<TabOneStack.Screen
|
|
||||||
name="TabOneScreen"
|
|
||||||
component={TabOneScreen}
|
|
||||||
options={{ headerTitle: "Tab One Title" }}
|
|
||||||
/>
|
|
||||||
</TabOneStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabTwoStack = createStackNavigator<TabTwoParamList>();
|
|
||||||
|
|
||||||
function TabTwoNavigator() {
|
|
||||||
return (
|
|
||||||
<TabTwoStack.Navigator>
|
|
||||||
<TabTwoStack.Screen
|
|
||||||
name="TabTwoScreen"
|
|
||||||
component={TabTwoScreen}
|
|
||||||
options={{ headerTitle: "Tab Two Title" }}
|
|
||||||
/>
|
|
||||||
</TabTwoStack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about deep linking with React Navigation
|
|
||||||
* https://reactnavigation.org/docs/deep-linking
|
|
||||||
* https://reactnavigation.org/docs/configuring-links
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
prefixes: [Linking.makeUrl("/")],
|
|
||||||
config: {
|
|
||||||
screens: {
|
|
||||||
Root: {
|
|
||||||
screens: {
|
|
||||||
TabOne: {
|
|
||||||
screens: {
|
|
||||||
TabOneScreen: "one",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
TabTwo: {
|
|
||||||
screens: {
|
|
||||||
TabTwoScreen: "two",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NotFound: "*",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* If you are not familiar with React Navigation, check out the "Fundamentals" guide:
|
|
||||||
* https://reactnavigation.org/docs/getting-started
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
|
||||||
import { createStackNavigator } from "@react-navigation/stack";
|
|
||||||
import * as React from "react";
|
|
||||||
import { ColorSchemeName } from "react-native";
|
|
||||||
|
|
||||||
import NotFoundScreen from "../screens/NotFoundScreen";
|
|
||||||
import { RootStackParamList } from "../types";
|
|
||||||
import BottomTabNavigator from "./BottomTabNavigator";
|
|
||||||
import LinkingConfiguration from "./LinkingConfiguration";
|
|
||||||
|
|
||||||
export default function Navigation({ colorScheme }: { colorScheme: ColorSchemeName }) {
|
|
||||||
return (
|
|
||||||
<NavigationContainer
|
|
||||||
linking={LinkingConfiguration}
|
|
||||||
theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
|
||||||
<RootNavigator />
|
|
||||||
</NavigationContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A root stack navigator is often used for displaying modals on top of all other content
|
|
||||||
// Read more here: https://reactnavigation.org/docs/modal
|
|
||||||
const Stack = createStackNavigator<RootStackParamList>();
|
|
||||||
|
|
||||||
function RootNavigator() {
|
|
||||||
return (
|
|
||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
|
||||||
<Stack.Screen name="Root" component={BottomTabNavigator} />
|
|
||||||
<Stack.Screen name="NotFound" component={NotFoundScreen} options={{ title: "Oops!" }} />
|
|
||||||
</Stack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
15458
app/package-lock.json
generated
@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"main": "node_modules/expo/AppEntry.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "expo start",
|
|
||||||
"android": "expo start --android",
|
|
||||||
"ios": "expo start --ios",
|
|
||||||
"web": "expo start --web",
|
|
||||||
"eject": "expo eject",
|
|
||||||
"test": "jest --watchAll"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"preset": "jest-expo"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/vector-icons": "12.0.5",
|
|
||||||
"@react-native-community/masked-view": "0.1.11",
|
|
||||||
"@react-navigation/bottom-tabs": "5.11.11",
|
|
||||||
"@react-navigation/native": "5.9.4",
|
|
||||||
"@react-navigation/stack": "5.14.5",
|
|
||||||
"expo": "41.0.1",
|
|
||||||
"expo-asset": "8.3.2",
|
|
||||||
"expo-constants": "10.1.3",
|
|
||||||
"expo-font": "9.1.0",
|
|
||||||
"expo-linking": "2.2.3",
|
|
||||||
"expo-splash-screen": "0.10.2",
|
|
||||||
"expo-status-bar": "1.0.4",
|
|
||||||
"expo-web-browser": "9.1.0",
|
|
||||||
"react": "17.0.2",
|
|
||||||
"react-dom": "17.0.2",
|
|
||||||
"react-native": "https://github.com/expo/react-native/archive/sdk-41.0.0.tar.gz",
|
|
||||||
"react-native-reanimated": "2.2.0",
|
|
||||||
"react-native-gesture-handler": "1.10.3",
|
|
||||||
"react-native-safe-area-context": "3.2.0",
|
|
||||||
"react-native-screens": "3.3.0",
|
|
||||||
"react-native-web": "0.16.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "7.14.3",
|
|
||||||
"@types/react": "17.0.8",
|
|
||||||
"@types/react-native": "0.64.8",
|
|
||||||
"jest-expo": "41.0.0",
|
|
||||||
"typescript": "4.3.2"
|
|
||||||
},
|
|
||||||
"private": true
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
import { StackScreenProps } from "@react-navigation/stack";
|
|
||||||
import * as React from "react";
|
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
import { RootStackParamList } from "../types";
|
|
||||||
|
|
||||||
export default function NotFoundScreen({
|
|
||||||
navigation,
|
|
||||||
}: StackScreenProps<RootStackParamList, "NotFound">) {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
|
||||||
<TouchableOpacity onPress={() => navigation.replace("Root")} style={styles.link}>
|
|
||||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "bold",
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
marginTop: 15,
|
|
||||||
paddingVertical: 15,
|
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#2e78b7",
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,32 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
import EditScreenInfo from "../components/EditScreenInfo";
|
|
||||||
import { Text, View } from "../components/Themed";
|
|
||||||
|
|
||||||
export default function TabOneScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Tab One</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="/screens/TabOneScreen.tsx" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "bold",
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: "80%",
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,57 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
import EditScreenInfo from "../components/EditScreenInfo";
|
|
||||||
import { Text, View } from "../components/Themed";
|
|
||||||
import type { Conversation } from "../../api/src/controller/sms";
|
|
||||||
|
|
||||||
export default function TabTwoScreen() {
|
|
||||||
const [conversations, setConversations] = React.useState<Conversation>({});
|
|
||||||
const conversationsEntries = Object.entries(conversations);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
fetch("http://192.168.1.40:3000/conversations")
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(conversations => setConversations(conversations))
|
|
||||||
.catch(error => console.error("error", error));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Tab Two</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
{conversationsEntries.map(([recipient, messages], index) => {
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<View>
|
|
||||||
<Text>{recipient}</Text>
|
|
||||||
<Text>{lastMessage.content}</Text>
|
|
||||||
<Text>{new Date(lastMessage.sentAt).toDateString()}</Text>
|
|
||||||
</View>
|
|
||||||
{index + 1 < messages.length && (
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "bold",
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: "80%",
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "expo/tsconfig.base",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about using TypeScript with React Navigation:
|
|
||||||
* https://reactnavigation.org/docs/typescript/
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type RootStackParamList = {
|
|
||||||
Root: undefined;
|
|
||||||
NotFound: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BottomTabParamList = {
|
|
||||||
TabOne: undefined;
|
|
||||||
TabTwo: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TabOneParamList = {
|
|
||||||
TabOneScreen: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TabTwoParamList = {
|
|
||||||
TabTwoScreen: undefined;
|
|
||||||
};
|
|
15
jest.config.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
collectCoverageFrom: [
|
||||||
|
"src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"lib/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
transform: {
|
||||||
|
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
"/node_modules/",
|
||||||
|
"/.next/",
|
||||||
|
],
|
||||||
|
setupFilesAfterEnv: ["./jest/setup.ts"],
|
||||||
|
testEnvironment: "node",
|
||||||
|
};
|
109
jest/helpers.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import type { NextApiHandler } from "next";
|
||||||
|
import type { IncomingMessage, RequestListener, ServerResponse } from "http";
|
||||||
|
import http from "http";
|
||||||
|
import type { __ApiPreviewProps } from "next/dist/next-server/server/api-utils";
|
||||||
|
import { apiResolver } from "next/dist/next-server/server/api-utils";
|
||||||
|
import listen from "test-listen";
|
||||||
|
import fetch from "isomorphic-fetch";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
import CookieStore from "../lib/cookie-store";
|
||||||
|
import Session from "../lib/session";
|
||||||
|
|
||||||
|
type Authentication =
|
||||||
|
| "none"
|
||||||
|
| "auth0"
|
||||||
|
| "google-oauth2"
|
||||||
|
| "facebook"
|
||||||
|
| "twitter";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
method: string;
|
||||||
|
body?: any;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
authentication?: Authentication;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiPreviewProps: __ApiPreviewProps = {
|
||||||
|
previewModeEncryptionKey: crypto.randomBytes(16).toString("hex"),
|
||||||
|
previewModeId: crypto.randomBytes(32).toString("hex"),
|
||||||
|
previewModeSigningKey: crypto.randomBytes(32).toString("hex"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function callApiHandler(handler: NextApiHandler, params: Params) {
|
||||||
|
const {
|
||||||
|
method = "GET",
|
||||||
|
body,
|
||||||
|
headers = {},
|
||||||
|
query = {},
|
||||||
|
authentication = "none",
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const requestHandler: RequestListener = (req, res) => {
|
||||||
|
const propagateError = false;
|
||||||
|
Object.assign(req.headers, headers);
|
||||||
|
|
||||||
|
if (req.url !== "/") {
|
||||||
|
// in these API tests, our http server uses the same handler for all routes, it has no idea about our app's routes
|
||||||
|
// when we're hitting anything else than the / route, it means that we've been redirected
|
||||||
|
const fallbackHandler: NextApiHandler = (req, res) =>
|
||||||
|
res.status(200).end();
|
||||||
|
|
||||||
|
return apiResolver(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
query,
|
||||||
|
fallbackHandler,
|
||||||
|
apiPreviewProps,
|
||||||
|
propagateError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authentication !== "none") {
|
||||||
|
writeSessionToCookie(req, res, authentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiResolver(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
query,
|
||||||
|
handler,
|
||||||
|
apiPreviewProps,
|
||||||
|
propagateError,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer(requestHandler);
|
||||||
|
const url = await listen(server);
|
||||||
|
let fetchOptions: RequestInit = { method, redirect: "manual" };
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
fetchOptions.headers = { "Content-Type": "application/json" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
server.close();
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSessionToCookie(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
authentication: Authentication,
|
||||||
|
) {
|
||||||
|
const cookieStore = new CookieStore();
|
||||||
|
const session: Session = new Session({
|
||||||
|
id: `${authentication}|userId`,
|
||||||
|
email: "test@fss.test",
|
||||||
|
name: "Groot",
|
||||||
|
teamId: "teamId",
|
||||||
|
role: "owner",
|
||||||
|
});
|
||||||
|
cookieStore.save(req, res, session);
|
||||||
|
|
||||||
|
const setCookieHeader = res.getHeader("Set-Cookie") as string[];
|
||||||
|
// write it to request headers to immediately have access to the user's session
|
||||||
|
req.headers.cookie = setCookieHeader.join("");
|
||||||
|
}
|
24
jest/setup.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import "@testing-library/jest-dom/extend-expect";
|
||||||
|
|
||||||
|
jest.mock("next/config", () => () => {
|
||||||
|
// see https://github.com/vercel/next.js/issues/4024
|
||||||
|
const config = require("../next.config");
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverRuntimeConfig: config.serverRuntimeConfig,
|
||||||
|
publicRuntimeConfig: config.publicRuntimeConfig,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../lib/logger", () => ({
|
||||||
|
child: jest.fn().mockReturnValue({
|
||||||
|
log: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function noop() {
|
||||||
|
// exported function to mark the file as a module
|
||||||
|
}
|
1
jest/testing-library.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "@testing-library/react";
|
30
lib/__tests__/session-helpers.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { NextApiHandler } from "next";
|
||||||
|
|
||||||
|
import { withApiAuthRequired } from "../session-helpers";
|
||||||
|
import { callApiHandler } from "../../jest/helpers";
|
||||||
|
|
||||||
|
describe("session-helpers", () => {
|
||||||
|
describe("withApiAuthRequired", () => {
|
||||||
|
const basicHandler: NextApiHandler = (req, res) =>
|
||||||
|
res.status(200).end();
|
||||||
|
|
||||||
|
test("responds 401 to unauthenticated GET", async () => {
|
||||||
|
const withAuthHandler = withApiAuthRequired(basicHandler);
|
||||||
|
const { status } = await callApiHandler(withAuthHandler, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 to authenticated GET", async () => {
|
||||||
|
const withAuthHandler = withApiAuthRequired(basicHandler);
|
||||||
|
const { status } = await callApiHandler(withAuthHandler, {
|
||||||
|
method: "GET",
|
||||||
|
authentication: "auth0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
12
lib/logger.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
const appLogger = pino({
|
||||||
|
level: "debug",
|
||||||
|
base: {
|
||||||
|
env: process.env.NODE_ENV || "NODE_ENV not set",
|
||||||
|
revision: process.env.VERCEL_GITHUB_COMMIT_SHA,
|
||||||
|
},
|
||||||
|
prettyPrint: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default appLogger;
|
184
lib/session-helpers.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import type {
|
||||||
|
GetServerSideProps,
|
||||||
|
GetServerSidePropsContext,
|
||||||
|
GetServerSidePropsResult,
|
||||||
|
NextApiHandler,
|
||||||
|
NextApiRequest,
|
||||||
|
NextApiResponse,
|
||||||
|
} from "next";
|
||||||
|
import type { User } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
import supabase from "../src/supabase/server";
|
||||||
|
import appLogger from "./logger";
|
||||||
|
import { setCookie } from "./utils/cookies";
|
||||||
|
import { findCustomer } from "../src/database/customer";
|
||||||
|
import { findCustomerPhoneNumber } from "../src/database/phone-number";
|
||||||
|
|
||||||
|
const logger = appLogger.child({ module: "session-helpers" });
|
||||||
|
|
||||||
|
type EmptyProps = Record<string, unknown>;
|
||||||
|
|
||||||
|
type SessionProps = {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasProps<Props extends EmptyProps = EmptyProps>(
|
||||||
|
result: GetServerSidePropsResult<Props>,
|
||||||
|
): result is { props: Props } {
|
||||||
|
return result.hasOwnProperty("props");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withPageOnboardingRequired<Props extends EmptyProps = EmptyProps>(
|
||||||
|
getServerSideProps?: GSSPWithSession<Props>,
|
||||||
|
) {
|
||||||
|
return withPageAuthRequired(
|
||||||
|
async function wrappedGetServerSideProps(context, user) {
|
||||||
|
if (context.req.cookies.hasDoneOnboarding !== "true") {
|
||||||
|
try {
|
||||||
|
const customer = await findCustomer(user.id);
|
||||||
|
console.log("customer", customer);
|
||||||
|
if (!customer.accountSid || !customer.authToken) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: "/welcome/step-two",
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/*if (!customer.paddleCustomerId || !customer.paddleSubscriptionId) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: "/welcome/step-one",
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}*/
|
||||||
|
try {
|
||||||
|
await findCustomerPhoneNumber(user.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: "/welcome/step-three",
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie({
|
||||||
|
req: context.req,
|
||||||
|
res: context.res,
|
||||||
|
name: "hasDoneOnboarding",
|
||||||
|
value: "true",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getServerSideProps) {
|
||||||
|
return {
|
||||||
|
props: {} as Props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return getServerSideProps(context, user);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GSSPWithSession<Props> = (
|
||||||
|
context: GetServerSidePropsContext,
|
||||||
|
user: User,
|
||||||
|
) => GetServerSidePropsResult<Props> | Promise<GetServerSidePropsResult<Props>>;
|
||||||
|
|
||||||
|
export function withPageAuthRequired<Props extends EmptyProps = EmptyProps>(
|
||||||
|
getServerSideProps?: GSSPWithSession<Props>,
|
||||||
|
): GetServerSideProps<Omit<Props, "user"> & SessionProps> {
|
||||||
|
return async function wrappedGetServerSideProps(context) {
|
||||||
|
const redirectTo = `/auth/sign-in?redirectTo=${context.resolvedUrl}`;
|
||||||
|
const userResponse = await supabase.auth.api.getUserByCookie(context.req);
|
||||||
|
const user = userResponse.user!;
|
||||||
|
if (userResponse.error) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: redirectTo,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getServerSideProps) {
|
||||||
|
return {
|
||||||
|
props: { user } as Props & SessionProps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getServerSidePropsResult = await getServerSideProps(
|
||||||
|
context,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
if (!hasProps(getServerSidePropsResult)) {
|
||||||
|
return getServerSidePropsResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...getServerSidePropsResult.props,
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiHandlerWithAuth<T> = (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<T>,
|
||||||
|
user: User,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
export function withApiAuthRequired<T = any>(
|
||||||
|
handler: ApiHandlerWithAuth<T>,
|
||||||
|
): NextApiHandler {
|
||||||
|
return async function wrappedApiHandler(req, res) {
|
||||||
|
const userResponse = await supabase.auth.api.getUserByCookie(req);
|
||||||
|
if (userResponse.error) {
|
||||||
|
logger.error(userResponse.error.message);
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(req, res, userResponse.user!);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withPageAuthNotRequired<Props extends EmptyProps = EmptyProps>(
|
||||||
|
getServerSideProps?: GetServerSideProps<Props>,
|
||||||
|
): GetServerSideProps<Props> {
|
||||||
|
return async function wrappedGetServerSideProps(context) {
|
||||||
|
let redirectTo: string;
|
||||||
|
if (Array.isArray(context.query.redirectTo)) {
|
||||||
|
redirectTo = context.query.redirectTo[0];
|
||||||
|
} else {
|
||||||
|
redirectTo = context.query.redirectTo ?? "/messages";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await supabase.auth.api.getUserByCookie(context.req);
|
||||||
|
console.log("user", user);
|
||||||
|
if (user !== null) {
|
||||||
|
console.log("redirect");
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: redirectTo,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("no redirect");
|
||||||
|
if (getServerSideProps) {
|
||||||
|
return getServerSideProps(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { props: {} as Props };
|
||||||
|
};
|
||||||
|
}
|
79
lib/utils/cookies.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
|
import type { CookieSerializeOptions } from "cookie";
|
||||||
|
import nookies from "nookies";
|
||||||
|
|
||||||
|
const defaultOptions: CookieSerializeOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCookies(req?: BaseParams["req"]) {
|
||||||
|
const context = buildContext({ req });
|
||||||
|
|
||||||
|
return nookies.get(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetCookieParams = BaseParams & {
|
||||||
|
value: string;
|
||||||
|
options?: CookieSerializeOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setCookie(params: SetCookieParams) {
|
||||||
|
const { req, res, name, value } = params;
|
||||||
|
const context = buildContext({ res });
|
||||||
|
const options: CookieSerializeOptions = {
|
||||||
|
...defaultOptions,
|
||||||
|
...params.options,
|
||||||
|
secure: isSecureEnvironment(req),
|
||||||
|
};
|
||||||
|
|
||||||
|
return nookies.set(context, name, value, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DestroyCookieParams = BaseParams & {
|
||||||
|
options?: CookieSerializeOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function destroyCookie(params: DestroyCookieParams) {
|
||||||
|
const { res, name } = params;
|
||||||
|
const context = buildContext({ res });
|
||||||
|
const options = Object.assign({}, defaultOptions, params.options);
|
||||||
|
|
||||||
|
return nookies.destroy(context, name, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSecureEnvironment(req: IncomingMessage | null | undefined): boolean {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req || !req.headers || !req.headers.host) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host =
|
||||||
|
(req.headers.host.indexOf(":") > -1 &&
|
||||||
|
req.headers.host.split(":")[0]) ||
|
||||||
|
req.headers.host;
|
||||||
|
|
||||||
|
return !["localhost", "127.0.0.1"].includes(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseParams = {
|
||||||
|
req?: IncomingMessage | null;
|
||||||
|
res?: ServerResponse | null;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildContext({ req, res }: Pick<BaseParams, "req" | "res">) {
|
||||||
|
if (req !== null && typeof req !== "undefined") {
|
||||||
|
return { req };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res !== null && typeof res !== "undefined") {
|
||||||
|
return { res };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
7
lib/utils/hkdf.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import hkdf from "futoin-hkdf";
|
||||||
|
|
||||||
|
const BYTE_LENGTH = 32;
|
||||||
|
|
||||||
|
export function encryption(secret: string) {
|
||||||
|
return hkdf(secret, BYTE_LENGTH, { info: "JWE CEK", hash: "SHA-256" });
|
||||||
|
}
|
3
next-env.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/types/global" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
104
next.config.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
|
||||||
|
|
||||||
|
const contentSecurityPolicy = `
|
||||||
|
child-src 'none';
|
||||||
|
connect-src *;
|
||||||
|
default-src 'self';
|
||||||
|
font-src 'self';
|
||||||
|
frame-ancestors 'none';
|
||||||
|
img-src 'self' data:;
|
||||||
|
media-src 'none';
|
||||||
|
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||||||
|
style-src 'self' 'unsafe-inline';
|
||||||
|
`;
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
/*{
|
||||||
|
key: "Content-Security-Policy",
|
||||||
|
value: contentSecurityPolicy.replace(/\n/g, ""),
|
||||||
|
},*/
|
||||||
|
{
|
||||||
|
key: "Referrer-Policy",
|
||||||
|
value: "origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Content-Type-Options",
|
||||||
|
value: "nosniff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-DNS-Prefetch-Control",
|
||||||
|
value: "on",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Strict-Transport-Security",
|
||||||
|
value: "max-age=31536000; includeSubDomains; preload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Permissions-Policy",
|
||||||
|
value: "interest-cohort=()",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/(.*).woff2",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Cache-Control",
|
||||||
|
value:
|
||||||
|
"public, immutable, max-age=31536000",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
serverRuntimeConfig: {
|
||||||
|
paddle: {
|
||||||
|
apiKey: process.env.PADDLE_API_KEY,
|
||||||
|
publicKey: process.env.PADDLE_PUBLIC_KEY,
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
secret: process.env.SESSION_COOKIE_SECRET,
|
||||||
|
},
|
||||||
|
auth0: {
|
||||||
|
clientSecret: process.env.AUTH0_CLIENT_SECRET,
|
||||||
|
managementClientId: process.env.AUTH0_MANAGEMENT_CLIENT_ID,
|
||||||
|
managementClientSecret: process.env.AUTH0_MANAGEMENT_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
awsSes: {
|
||||||
|
awsRegion: process.env.AWS_SES_REGION,
|
||||||
|
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET,
|
||||||
|
fromEmail: process.env.AWS_SES_FROM_EMAIL,
|
||||||
|
},
|
||||||
|
mailChimp: {
|
||||||
|
apiKey: process.env.MAILCHIMP_API_KEY,
|
||||||
|
audienceId: process.env.MAILCHIMP_AUDIENCE_ID,
|
||||||
|
},
|
||||||
|
supabase: {
|
||||||
|
roleKey: process.env.SUPABASE_ROLE_KEY,
|
||||||
|
},
|
||||||
|
masterEncryptionKey: process.env.MASTER_ENCRYPTION_KEY,
|
||||||
|
},
|
||||||
|
publicRuntimeConfig: {
|
||||||
|
paddle: {
|
||||||
|
vendorId: process.env.PADDLE_VENDOR_ID,
|
||||||
|
},
|
||||||
|
auth0: {
|
||||||
|
domain: process.env.AUTH0_DOMAIN,
|
||||||
|
redirectUri: process.env.AUTH0_REDIRECT_URI,
|
||||||
|
clientId: process.env.AUTH0_CLIENT_ID,
|
||||||
|
},
|
||||||
|
supabase: {
|
||||||
|
url: process.env.SUPABASE_URL,
|
||||||
|
anonKey: process.env.SUPABASE_ANON_KEY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
11912
package-lock.json
generated
Normal file
87
package.json
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "my-fullstack-serverless-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"dev": "next",
|
||||||
|
"test": "jest --coverage",
|
||||||
|
"test:watch": "jest --watchAll",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@devoxa/paddle-sdk": "0.2.1",
|
||||||
|
"@fortawesome/fontawesome-pro": "file:./fontawesome/fortawesome-fontawesome-pro-5.15.3.tgz",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^1.2.35",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^5.15.3",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^5.15.3",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^5.15.3",
|
||||||
|
"@fortawesome/pro-duotone-svg-icons": "file:./fontawesome/fortawesome-pro-duotone-svg-icons-5.15.3.tgz",
|
||||||
|
"@fortawesome/pro-light-svg-icons": "file:./fontawesome/fortawesome-pro-light-svg-icons-5.15.3.tgz",
|
||||||
|
"@fortawesome/pro-regular-svg-icons": "file:./fontawesome/fortawesome-pro-regular-svg-icons-5.15.3.tgz",
|
||||||
|
"@fortawesome/pro-solid-svg-icons": "file:./fontawesome/fortawesome-pro-solid-svg-icons-5.15.3.tgz",
|
||||||
|
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||||
|
"@headlessui/react": "1.3.0",
|
||||||
|
"@heroicons/react": "1.0.1",
|
||||||
|
"@supabase/supabase-js": "^1.18.0",
|
||||||
|
"aws-sdk": "2.934.0",
|
||||||
|
"axios": "0.21.1",
|
||||||
|
"babel-plugin-superjson-next": "0.3.0",
|
||||||
|
"clsx": "1.1.1",
|
||||||
|
"dotenv": "10.0.0",
|
||||||
|
"es6-promisify": "6.1.1",
|
||||||
|
"firebase-admin": "9.10.0",
|
||||||
|
"futoin-hkdf": "1.3.3",
|
||||||
|
"joi": "17.4.0",
|
||||||
|
"jose": "2.0.5",
|
||||||
|
"jotai": "^1.1.2",
|
||||||
|
"jsonwebtoken": "8.5.1",
|
||||||
|
"next": "11.0.1",
|
||||||
|
"nookies": "2.5.2",
|
||||||
|
"on-headers": "1.0.2",
|
||||||
|
"openid-client": "4.7.4",
|
||||||
|
"pino": "6.11.3",
|
||||||
|
"pino-pretty": "5.1.0",
|
||||||
|
"quirrel": "1.6.2",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-dom": "17.0.2",
|
||||||
|
"react-gui": "^0.0.0-de24df473",
|
||||||
|
"react-hook-form": "7.9.0",
|
||||||
|
"react-query": "3.17.2",
|
||||||
|
"superjson": "1.7.4",
|
||||||
|
"twilio": "3.66.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/forms": "0.3.3",
|
||||||
|
"@tailwindcss/typography": "0.4.1",
|
||||||
|
"@testing-library/dom": "8.0.0",
|
||||||
|
"@testing-library/jest-dom": "5.14.1",
|
||||||
|
"@testing-library/react": "12.0.0",
|
||||||
|
"@testing-library/user-event": "13.1.9",
|
||||||
|
"@types/css-font-loading-module": "0.0.4",
|
||||||
|
"@types/es6-promisify": "6.0.0",
|
||||||
|
"@types/isomorphic-fetch": "0.0.35",
|
||||||
|
"@types/jest": "26.0.23",
|
||||||
|
"@types/jsonwebtoken": "8.5.2",
|
||||||
|
"@types/node": "15.12.4",
|
||||||
|
"@types/on-headers": "1.0.0",
|
||||||
|
"@types/pino": "6.3.8",
|
||||||
|
"@types/react": "17.0.11",
|
||||||
|
"@types/react-dom": "17.0.8",
|
||||||
|
"@types/set-cookie-parser": "2.4.0",
|
||||||
|
"@types/test-listen": "1.1.0",
|
||||||
|
"autoprefixer": "10.2.6",
|
||||||
|
"babel-jest": "27.0.5",
|
||||||
|
"eslint": "7.29.0",
|
||||||
|
"eslint-config-next": "11.0.1",
|
||||||
|
"isomorphic-fetch": "3.0.0",
|
||||||
|
"jest": "27.0.5",
|
||||||
|
"msw": "0.30.0",
|
||||||
|
"postcss": "8.3.5",
|
||||||
|
"react-refresh": "0.10.0",
|
||||||
|
"set-cookie-parser": "2.4.8",
|
||||||
|
"tailwindcss": "2.2.4",
|
||||||
|
"test-listen": "1.1.0",
|
||||||
|
"typescript": "4.3.4"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"tailwindcss": {},
|
||||||
|
"autoprefixer": {},
|
||||||
|
},
|
||||||
|
};
|
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
BIN
public/static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/static/fonts/inter/Inter-italic.var.woff2
Normal file
BIN
public/static/fonts/inter/Inter-roman.var.woff2
Normal file
5714
public/static/illustrations/data-analytics.svg
Normal file
After Width: | Height: | Size: 631 KiB |
386
public/static/illustrations/learn-coding.svg
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 287 236" style="enable-background:new 0 0 287 236;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#35174D;}
|
||||||
|
.st1{fill:#18C5FF;}
|
||||||
|
.st2{fill:#FDAC9D;}
|
||||||
|
.st3{fill:#37164D;}
|
||||||
|
.st4{fill:#35164B;}
|
||||||
|
.st5{fill:#3A3A47;}
|
||||||
|
.st6{fill:#FFBB4E;}
|
||||||
|
.st7{fill:#E5E6ED;}
|
||||||
|
.st8{fill:#BB7FEF;}
|
||||||
|
.st9{fill:#11BDED;}
|
||||||
|
.st10{fill:#F39695;}
|
||||||
|
.st11{fill:#1C1A23;}
|
||||||
|
.st12{fill:#62687E;}
|
||||||
|
.st13{fill:#D9DEF1;}
|
||||||
|
.st14{fill:#33184B;}
|
||||||
|
.st15{fill:#FFFFFF;}
|
||||||
|
.st16{fill:#FFAA97;}
|
||||||
|
.st17{fill:#F7684E;}
|
||||||
|
.st18{fill:#FBA795;}
|
||||||
|
.st19{fill:#DD6B66;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M109.21,23.25c-3.02,4.36,1.59,6.95,1.59,6.95s-3.48,5.36,3.04,7.38c0,0-1.3,8.83,5.07,11l38.8-2.9
|
||||||
|
c0,0,5.21-30.84-9.56-36.49c-14.77-5.65-32.87,8.11-34.46,12.89C113.7,22.09,110.93,20.77,109.21,23.25z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M275.69,168.18c-1.67,17.27-37.05,16.85-42.35,16.85c-5.29,0-8.14-2.49-8.14-2.49l1.6,10.57l2.66,17.55
|
||||||
|
h-36.45c0,0-103.93-93.97-103.93-108.24c0-14.26,7.62-19.6,13.69-20.19c6.06-0.59,35.51-8.14,35.96-10.51
|
||||||
|
c0.44-2.37-0.76-8.06-0.76-8.06c-17.59,1.62-19.15-15.61-19.15-15.61c-3.88-7.28-4.2-18.69,2.92-15.21
|
||||||
|
c7.12,3.48,16.91-0.24,18.36-2.27c1.46-2.02,4.78-3.15,5.18-2.67c0.41,0.49,0,4.61,0,4.61l3.4,0.57c0,0,1.13,5.26,1.21,6.88
|
||||||
|
c0.08,1.62,1.3,1.94,1.86,1.94c0.57,0,1.38-7.69,1.38-7.69s6.71-1.13,6.71,5.75s-5.82,7.36-5.82,7.36
|
||||||
|
c1.2,12.41,6.21,18.04,11.22,20.62c0,0,0,0,0,0.01c5.23,2.7,10.48,2.07,11.4,2.36c1.81,0.56,19.79-0.49,19.79-0.49
|
||||||
|
c20.2-1.53,30.09,15.61,31.9,17.83c1.81,2.23,11.84,14.35,27.44,32.46C271.38,138.22,277.37,150.9,275.69,168.18z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st2" d="M153.97,81.09c-12.39,1.42-15.24-9.37-15.24-9.37c0.44-2.37-0.76-8.06-0.76-8.06
|
||||||
|
c-17.59,1.62-19.15-15.61-19.15-15.61c-3.88-7.28-4.2-18.69,2.92-15.21c7.12,3.48,16.91-0.24,18.36-2.27
|
||||||
|
c1.46-2.02,4.78-3.15,5.18-2.67c0.41,0.49,0,4.61,0,4.61l3.4,0.57c0,0,1.13,5.26,1.21,6.88c0.08,1.62,1.3,1.94,1.86,1.94
|
||||||
|
c0.57,0,1.38-7.69,1.38-7.69s6.71-1.13,6.71,5.75s-5.82,7.36-5.82,7.36c1.2,12.41,6.21,18.04,11.22,20.62c0,0,0,0,0,0.01
|
||||||
|
C165.28,68.36,166.14,79.7,153.97,81.09z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st3" d="M226.8,193.11c0,0-13.45-1.53-19.4-3.68c-5.94-2.15-17.33-3.99-17.33-3.99v-17.52l35.56,12.27l-0.25,3.52
|
||||||
|
L226.8,193.11z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M224.09,153.72c-1.21-0.82-1.23-2.65-0.07-3.55c0.59-0.46,1.33-0.83,2.23-0.96
|
||||||
|
c2.67-0.38,12.83-0.92,15.9-1.08c0.6-0.03,1.04,0.44,1.62,0.57c2.18,0.51,5.4,5.3,5.57,9.97c0.23,5.97,0.95,24.9-1.78,25.69
|
||||||
|
c-2.39,0.69-15.46,1.59-20.02-0.42c-0.83-0.36-1.36-1.18-1.36-2.08c0.02-4.62,0.12-22.79,0.06-24.43
|
||||||
|
C226.2,156.26,225.41,154.63,224.09,153.72z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st4" d="M226.24,149.72c-0.13,0-0.26-0.05-0.36-0.15l-12.96-13.41c-0.19-0.2-0.19-0.52,0.01-0.71
|
||||||
|
c0.2-0.19,0.52-0.19,0.71,0.01l12.96,13.41c0.19,0.2,0.19,0.52-0.01,0.71C226.49,149.67,226.36,149.72,226.24,149.72z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="30.58" y="101.63" class="st5" width="165.35" height="109.03"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="30.58" y="101.63" class="st6" width="165.35" height="8.59"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M99.42,134.22H79.57c-0.55,0-1-0.45-1-1s0.45-1,1-1h19.85c0.55,0,1,0.45,1,1S99.97,134.22,99.42,134.22z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M104.66,140.61H84.82c-0.55,0-1-0.45-1-1s0.45-1,1-1h19.85c0.55,0,1,0.45,1,1S105.22,140.61,104.66,140.61z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M95.85,167.6H83.14c-0.55,0-1-0.45-1-1s0.45-1,1-1h12.71c0.55,0,1,0.45,1,1S96.41,167.6,95.85,167.6z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M108.19,174.98H83.14c-0.55,0-1-0.45-1-1s0.45-1,1-1h25.05c0.55,0,1,0.45,1,1S108.74,174.98,108.19,174.98z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M126.62,182.36H88.98c-0.55,0-1-0.45-1-1s0.45-1,1-1h37.64c0.55,0,1,0.45,1,1S127.17,182.36,126.62,182.36z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M115.26,189.74H93.23c-0.55,0-1-0.45-1-1s0.45-1,1-1h22.03c0.55,0,1,0.45,1,1S115.81,189.74,115.26,189.74z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M110.43,198.07H88.4c-0.55,0-1-0.45-1-1s0.45-1,1-1h22.03c0.55,0,1,0.45,1,1S110.99,198.07,110.43,198.07z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M154.21,198.07h-22.03c-0.55,0-1-0.45-1-1s0.45-1,1-1h22.03c0.55,0,1,0.45,1,1S154.76,198.07,154.21,198.07z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st8" d="M143.19,189.74h-22.03c-0.55,0-1-0.45-1-1s0.45-1,1-1h22.03c0.55,0,1,0.45,1,1S143.74,189.74,143.19,189.74z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st8" d="M147.57,126.72h-22.03c-0.55,0-1-0.45-1-1s0.45-1,1-1h22.03c0.55,0,1,0.45,1,1S148.12,126.72,147.57,126.72z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st9" d="M140.68,182.36h-8.25c-0.55,0-1-0.45-1-1s0.45-1,1-1h8.25c0.55,0,1,0.45,1,1S141.24,182.36,140.68,182.36z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st9" d="M124.93,198.07h-8.25c-0.55,0-1-0.45-1-1s0.45-1,1-1h8.25c0.55,0,1,0.45,1,1S125.48,198.07,124.93,198.07z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M160.27,174.98h-45.01c-0.55,0-1-0.45-1-1s0.45-1,1-1h45.01c0.55,0,1,0.45,1,1S160.82,174.98,160.27,174.98z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M114.66,147.19H94.82c-0.55,0-1-0.45-1-1s0.45-1,1-1h19.85c0.55,0,1,0.45,1,1S115.22,147.19,114.66,147.19z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M173.46,126.72h-19.85c-0.55,0-1-0.45-1-1s0.45-1,1-1h19.85c0.55,0,1,0.45,1,1S174.02,126.72,173.46,126.72z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M108.19,153.91H94.82c-0.55,0-1-0.45-1-1s0.45-1,1-1h13.37c0.55,0,1,0.45,1,1S108.74,153.91,108.19,153.91z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M137.77,160.21H94.82c-0.55,0-1-0.45-1-1s0.45-1,1-1h42.96c0.55,0,1,0.45,1,1S138.33,160.21,137.77,160.21z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M155.19,134.22h-31.72c-0.55,0-1-0.45-1-1s0.45-1,1-1h31.72c0.55,0,1,0.45,1,1S155.74,134.22,155.19,134.22z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st9" d="M188.13,134.22h-27.86c-0.55,0-1-0.45-1-1s0.45-1,1-1h27.86c0.55,0,1,0.45,1,1S188.68,134.22,188.13,134.22z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st9" d="M133.2,204.39h-27.86c-0.55,0-1-0.45-1-1s0.45-1,1-1h27.86c0.55,0,1,0.45,1,1S133.75,204.39,133.2,204.39z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M117.92,134.32h-11.75c-0.55,0-1-0.45-1-1s0.45-1,1-1h11.75c0.55,0,1,0.45,1,1S118.48,134.32,117.92,134.32z
|
||||||
|
"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M132.55,147.19H120.8c-0.55,0-1-0.45-1-1s0.45-1,1-1h11.75c0.55,0,1,0.45,1,1S133.1,147.19,132.55,147.19z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M147.53,153.91h-34.13c-0.55,0-1-0.45-1-1s0.45-1,1-1h34.13c0.55,0,1,0.45,1,1S148.08,153.91,147.53,153.91z
|
||||||
|
"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M183.99,153.91h-15.67c-0.55,0-1-0.45-1-1s0.45-1,1-1h15.67c0.55,0,1,0.45,1,1S184.54,153.91,183.99,153.91z
|
||||||
|
"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st6" d="M163.54,153.91h-12.29c-0.55,0-1-0.45-1-1s0.45-1,1-1h12.29c0.55,0,1,0.45,1,1S164.09,153.91,163.54,153.91z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st6" d="M100.89,204.39H88.59c-0.55,0-1-0.45-1-1s0.45-1,1-1h12.29c0.55,0,1,0.45,1,1S101.44,204.39,100.89,204.39z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st6" d="M134.92,120.22h-12.29c-0.55,0-1-0.45-1-1s0.45-1,1-1h12.29c0.55,0,1,0.45,1,1S135.48,120.22,134.92,120.22z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<rect x="30.58" y="101.62" class="st11" width="40.73" height="109.03"/>
|
||||||
|
<rect x="30.58" y="137.98" width="40.73" height="8.21"/>
|
||||||
|
<rect x="34.77" y="130.14" class="st12" width="4.82" height="4.82"/>
|
||||||
|
<rect x="39.39" y="139.61" class="st12" width="4.82" height="4.82"/>
|
||||||
|
<rect x="46.46" y="148.09" class="st12" width="4.82" height="4.82"/>
|
||||||
|
<rect x="46.46" y="156.14" class="st12" width="4.82" height="4.82"/>
|
||||||
|
<rect x="46.46" y="164.19" class="st12" width="4.82" height="4.82"/>
|
||||||
|
<rect x="46.46" y="172.24" class="st12" width="4.82" height="4.82"/>
|
||||||
|
<rect x="46.46" y="180.29" class="st12" width="4.82" height="4.82"/>
|
||||||
|
<g>
|
||||||
|
<path class="st13" d="M127.78,77.05v48.44H25.81V77.05c0-4,3.24-7.24,7.24-7.24h87.49C124.54,69.81,127.78,73.05,127.78,77.05z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st14" d="M127.78,77.05v10.33H25.81V77.05c0-4,3.24-7.24,7.24-7.24h87.49C124.54,69.81,127.78,73.05,127.78,77.05z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M67.65,94.2H44.63c-0.4,0-0.72,0.32-0.72,0.72v23.02c0,0.4,0.32,0.72,0.72,0.72h23.02
|
||||||
|
c0.4,0,0.72-0.32,0.72-0.72V94.91C68.37,94.52,68.05,94.2,67.65,94.2z M66.94,95.63v4.35H45.35v-4.35H66.94z M45.35,117.21v-15.81
|
||||||
|
h21.59v15.81H45.35z"/>
|
||||||
|
<path class="st15" d="M50.41,110h7.16c0.4,0,0.72-0.32,0.72-0.72c0-0.4-0.32-0.72-0.72-0.72h-7.16c-0.4,0-0.72,0.32-0.72,0.72
|
||||||
|
C49.7,109.68,50.02,110,50.41,110z"/>
|
||||||
|
<path class="st15" d="M50.41,112.87h5.73c0.4,0,0.72-0.32,0.72-0.72s-0.32-0.72-0.72-0.72h-5.73c-0.4,0-0.72,0.32-0.72,0.72
|
||||||
|
S50.02,112.87,50.41,112.87z"/>
|
||||||
|
<path class="st15" d="M57.58,114.3h-7.16c-0.4,0-0.72,0.32-0.72,0.72s0.32,0.72,0.72,0.72h7.16c0.4,0,0.72-0.32,0.72-0.72
|
||||||
|
S57.97,114.3,57.58,114.3z"/>
|
||||||
|
<path class="st15" d="M52.77,106.93c0.28,0.28,0.73,0.28,1.01,0c0.28-0.28,0.28-0.73,0-1.01l-0.93-0.93l0.93-0.93
|
||||||
|
c0.28-0.28,0.28-0.73,0-1.01c-0.28-0.28-0.73-0.28-1.01,0l-1.43,1.43c-0.28,0.28-0.28,0.73,0,1.01L52.77,106.93z"/>
|
||||||
|
<path class="st15" d="M59.43,104.99l-0.92,0.92c-0.28,0.28-0.28,0.73,0,1.01c0.28,0.28,0.73,0.28,1.01,0l1.43-1.43
|
||||||
|
c0.28-0.28,0.28-0.73,0-1.01l-1.44-1.44c-0.28-0.28-0.73-0.28-1.01,0c-0.28,0.28-0.28,0.73,0,1.01L59.43,104.99z"/>
|
||||||
|
<path class="st15" d="M55.11,107.06c0.35,0.18,0.78,0.03,0.96-0.32l1.43-2.87c0.18-0.35,0.03-0.78-0.32-0.96
|
||||||
|
c-0.35-0.18-0.78-0.03-0.96,0.32l-1.43,2.87C54.61,106.45,54.75,106.88,55.11,107.06L55.11,107.06z"/>
|
||||||
|
<path class="st15" d="M61.87,108.57h-1.43c-0.4,0-0.72,0.32-0.72,0.72c0,0.4,0.32,0.72,0.72,0.72h1.43c0.4,0,0.72-0.32,0.72-0.72
|
||||||
|
C62.59,108.89,62.27,108.57,61.87,108.57z"/>
|
||||||
|
<path class="st15" d="M61.87,111.44h-2.87c-0.4,0-0.72,0.32-0.72,0.72s0.32,0.72,0.72,0.72h2.87c0.4,0,0.72-0.32,0.72-0.72
|
||||||
|
S62.27,111.44,61.87,111.44z"/>
|
||||||
|
<path class="st15" d="M61.87,114.3h-1.43c-0.4,0-0.72,0.32-0.72,0.72s0.32,0.72,0.72,0.72h1.43c0.4,0,0.72-0.32,0.72-0.72
|
||||||
|
S62.27,114.3,61.87,114.3z"/>
|
||||||
|
<circle class="st15" cx="64.74" cy="97.82" r="0.72"/>
|
||||||
|
<circle class="st15" cx="61.87" cy="97.82" r="0.72"/>
|
||||||
|
<circle class="st15" cx="59.01" cy="97.82" r="0.72"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st16" d="M188.77,115c-0.83-0.07-1.25-1.04-0.74-1.7c0.98-1.27,2.85-2.7,6.04-1.92c0,0,5.99-1.46,6.97,5.35
|
||||||
|
c0.97,6.8,2.11,9.23,2.27,16.85c0.16,7.61-3.4,19.92,11.82,19.6l7.94,0.01c0,0,3.08-0.33,3.24,6.15
|
||||||
|
c0.14,5.56,0.64,18.86,0.77,22.53c0.02,0.54-0.37,1.01-0.91,1.09c-3.79,0.53-17.93,2.44-22.54,2.3
|
||||||
|
c-5.35-0.16-18.95-4.7-18.14-20.73c0.81-16.04,2.11-34.18,0.97-37.9c-1.11-3.66-2.23-6.85,1.43-8.19
|
||||||
|
c0.14-0.05,0.28-0.07,0.43-0.06c0.89,0.06,4.05,0.74,4.46,7.12c0.49,7.45,1.78,18.47,2.75,17.66c0.97-0.81,0.16-18.47-0.65-20.73
|
||||||
|
C194.12,120.26,196.06,115.59,188.77,115z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st17" d="M189.02,189.53v32.3c0,4.2-3.41,7.61-7.61,7.61h-32.3c-4.2,0-7.6-3.41-7.6-7.61v-32.3c0-4.2,3.4-7.6,7.6-7.6
|
||||||
|
h32.3C185.61,181.93,189.02,185.33,189.02,189.53z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st6" d="M189.02,189.53v11.6c-8.29-2.85-14.4,0.48-17.61,4.55c-3.21,4.07-7.24,12.2-15.56,8.99
|
||||||
|
c-8.31-3.2-14.34,1.31-14.34,1.31v-26.45c0-4.2,3.4-7.6,7.6-7.6h32.3C185.61,181.93,189.02,185.33,189.02,189.53z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st15" cx="155.85" cy="198.95" r="8.79"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M119.14,84.6H48.24c-3.22,0-5.83-2.61-5.83-5.83v0c0-3.22,2.61-5.83,5.83-5.83h70.89
|
||||||
|
c3.22,0,5.83,2.61,5.83,5.83v0C124.96,82,122.35,84.6,119.14,84.6z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M34.77,84.6L34.77,84.6c-3.22,0-5.83-2.61-5.83-5.83v0c0-3.22,2.61-5.83,5.83-5.83h0
|
||||||
|
c3.22,0,5.83,2.61,5.83,5.83v0C40.6,82,37.99,84.6,34.77,84.6z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M119.27,102.37h-39.7c-0.41,0-0.75-0.34-0.75-0.75s0.34-0.75,0.75-0.75h39.7c0.41,0,0.75,0.34,0.75,0.75
|
||||||
|
S119.68,102.37,119.27,102.37z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M105.67,97.99H79.32c-0.27,0-0.5-0.34-0.5-0.75s0.22-0.75,0.5-0.75h26.35c0.27,0,0.5,0.34,0.5,0.75
|
||||||
|
S105.94,97.99,105.67,97.99z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M89.99,106.42H79.57c-0.41,0-0.75-0.34-0.75-0.75s0.34-0.75,0.75-0.75h10.43c0.41,0,0.75,0.34,0.75,0.75
|
||||||
|
S90.41,106.42,89.99,106.42z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M99.06,117.96H80.18c-0.75,0-1.36-0.34-1.36-0.75s0.61-0.75,1.36-0.75h18.88c0.75,0,1.36,0.34,1.36,0.75
|
||||||
|
S99.81,117.96,99.06,117.96z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M119.27,106.42h-26.2c-0.41,0-0.75-0.34-0.75-0.75s0.34-0.75,0.75-0.75h26.2c0.41,0,0.75,0.34,0.75,0.75
|
||||||
|
S119.68,106.42,119.27,106.42z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M119.27,110.97h-39.7c-0.41,0-0.75-0.34-0.75-0.75s0.34-0.75,0.75-0.75h39.7c0.41,0,0.75,0.34,0.75,0.75
|
||||||
|
S119.68,110.97,119.27,110.97z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M101.49,114.39H79.24c-0.23,0-0.42-0.34-0.42-0.75s0.19-0.75,0.42-0.75h22.25c0.23,0,0.42,0.34,0.42,0.75
|
||||||
|
S101.72,114.39,101.49,114.39z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M119.5,114.39h-15.83c-0.17,0-0.3-0.34-0.3-0.75s0.13-0.75,0.3-0.75h15.83c0.17,0,0.3,0.34,0.3,0.75
|
||||||
|
S119.66,114.39,119.5,114.39z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st18" d="M18.9,67.56c-5.53-0.11-7.37,3.81-7.37,13.65s0,15.53,0,15.53s-3.23,8.6,3.69,10.9c0,0-1.84,6.14,3.69,9.06
|
||||||
|
c0,0-5.21,8.62,11.68,16.61v-7.83h-4.77c0,0,4.76-3.25,12.13-4.94c7.37-1.69,8.29-7.83,5.68-9.52c-2.61-1.69-10.9-0.46-10.9-0.46
|
||||||
|
s12.33-5.84,12.23-11.98s-7.16-4.76-7.16-4.76s3.38-5.07,1.69-8.14s-10.9-3.23-16.13,0.61l0.46-10.6
|
||||||
|
C23.81,75.7,23.22,67.65,18.9,67.56z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="42.42" y="131.88" class="st12" width="13.72" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="47.04" y="141.35" class="st12" width="9.7" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="54.1" y="149.83" class="st12" width="10.5" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="54.1" y="157.88" class="st12" width="10.5" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="54.1" y="165.93" class="st12" width="10.5" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="54.1" y="173.98" class="st12" width="10.5" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="54.1" y="182.03" class="st12" width="10.5" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="39.39" y="189.61" class="st12" width="4.82" height="4.82"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="47.04" y="191.35" class="st12" width="13.72" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="39.39" y="197.07" class="st12" width="4.82" height="4.82"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="47.04" y="198.81" class="st12" width="13.72" height="1.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M12.37,97.04c-0.09,0-0.18-0.03-0.26-0.09c-0.2-0.15-0.24-0.45-0.1-0.66c0.21-0.31,5.29-7.56,10.72-9.96
|
||||||
|
c0.23-0.1,0.49,0.01,0.58,0.25c0.09,0.24-0.01,0.51-0.24,0.61c-5.2,2.29-10.29,9.57-10.34,9.64
|
||||||
|
C12.65,96.97,12.51,97.04,12.37,97.04z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M15.21,108.15c-0.12,0-0.25-0.05-0.35-0.14c-0.2-0.19-0.21-0.51-0.02-0.71
|
||||||
|
c0.53-0.56,13.15-13.67,22.92-13.98c0.28-0.04,0.51,0.21,0.52,0.48c0.01,0.28-0.21,0.51-0.48,0.52
|
||||||
|
c-9.36,0.3-22.1,13.53-22.23,13.67C15.47,108.1,15.34,108.15,15.21,108.15z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M18.9,117.21c-0.13,0-0.26-0.05-0.36-0.15c-0.19-0.2-0.19-0.51,0.01-0.71c0.2-0.19,4.98-4.73,14.09-6.28
|
||||||
|
c0.27-0.05,0.53,0.14,0.58,0.41c0.05,0.27-0.14,0.53-0.41,0.58c-8.78,1.49-13.51,5.96-13.56,6.01
|
||||||
|
C19.15,117.16,19.02,117.21,18.9,117.21z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M133.96,41.75c-0.16,0-0.31-0.04-0.46-0.11c-0.48-0.25-0.68-0.84-0.44-1.33c0.09-0.18,2.26-4.33,9.42-1.68
|
||||||
|
c0.52,0.19,0.78,0.77,0.59,1.28c-0.19,0.52-0.76,0.78-1.29,0.59c-5.39-2-6.88,0.6-6.94,0.71
|
||||||
|
C134.67,41.56,134.32,41.75,133.96,41.75z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M126.62,42.83c-0.31,0-0.62-0.15-0.82-0.42c-2.47-3.52-5.7-1.95-6.06-1.76c-0.48,0.26-1.09,0.08-1.35-0.41
|
||||||
|
c-0.26-0.48-0.08-1.09,0.4-1.35c1.75-0.95,5.8-1.68,8.64,2.37c0.32,0.45,0.21,1.08-0.24,1.39
|
||||||
|
C127.02,42.77,126.82,42.83,126.62,42.83z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M133.18,55.52c-0.01,0-0.02,0-0.03,0c-0.47-0.03-2.83-0.22-3.46-1.37c-0.16-0.29-0.28-0.77,0.07-1.39
|
||||||
|
c0.9-1.61,0.77-7.36-1.23-8.56c-0.24-0.14-0.31-0.45-0.17-0.69c0.14-0.24,0.45-0.31,0.69-0.17c2.6,1.56,2.73,7.86,1.59,9.91
|
||||||
|
c-0.15,0.27-0.09,0.38-0.06,0.43c0.27,0.49,1.74,0.8,2.64,0.85c0.28,0.02,0.49,0.25,0.47,0.53
|
||||||
|
C133.66,55.32,133.44,55.52,133.18,55.52z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M133.82,58.7c-1.26,0-2.19-0.3-2.21-0.3c-0.26-0.09-0.41-0.37-0.32-0.63c0.09-0.26,0.37-0.41,0.63-0.32
|
||||||
|
c0.16,0.05,3.99,1.25,6.07-1.8c0.16-0.23,0.47-0.28,0.7-0.13c0.23,0.16,0.29,0.47,0.13,0.7C137.42,58.25,135.38,58.7,133.82,58.7z
|
||||||
|
"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st4" d="M125.96,50.12c-0.69,0-1.3-0.72-1.48-1.76c-0.19-1.12,0.23-2.06,0.98-2.19c0.4-0.07,0.81,0.12,1.13,0.51
|
||||||
|
c0.25,0.32,0.44,0.75,0.52,1.23c0.19,1.12-0.23,2.06-0.98,2.19C126.07,50.11,126.01,50.12,125.96,50.12z M125.62,47.16
|
||||||
|
c-0.07,0.04-0.26,0.42-0.15,1.03c0.1,0.61,0.4,0.91,0.5,0.92c0.07-0.04,0.26-0.42,0.15-1.03
|
||||||
|
C126.01,47.47,125.71,47.17,125.62,47.16z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st4" d="M138.57,48.95c-0.69,0-1.3-0.72-1.48-1.76c-0.19-1.12,0.23-2.06,0.98-2.19c0.4-0.06,0.81,0.12,1.13,0.51
|
||||||
|
c0.25,0.32,0.44,0.75,0.52,1.23c0.19,1.12-0.23,2.06-0.98,2.19C138.68,48.94,138.62,48.95,138.57,48.95z M138.23,45.99
|
||||||
|
c-0.07,0.04-0.26,0.42-0.15,1.03c0.1,0.61,0.4,0.91,0.5,0.92c0.07-0.04,0.26-0.42,0.15-1.03C138.62,46.3,138.32,46,138.23,45.99z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M139.1,55.12c-4.09,0-7.43-3.33-7.43-7.43c0-4.09,3.33-7.43,7.43-7.43s7.43,3.33,7.43,7.43
|
||||||
|
C146.53,51.78,143.2,55.12,139.1,55.12z M139.1,41.26c-3.54,0-6.43,2.88-6.43,6.43s2.88,6.43,6.43,6.43s6.43-2.88,6.43-6.43
|
||||||
|
S142.65,41.26,139.1,41.26z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M121.34,56.18c-4.09,0-7.43-3.33-7.43-7.43c0-4.09,3.33-7.43,7.43-7.43c4.09,0,7.42,3.33,7.42,7.43
|
||||||
|
C128.76,52.85,125.43,56.18,121.34,56.18z M121.34,42.33c-3.54,0-6.43,2.88-6.43,6.43s2.88,6.43,6.43,6.43s6.42-2.88,6.42-6.43
|
||||||
|
S124.88,42.33,121.34,42.33z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
|
||||||
|
<rect x="128.44" y="47.08" transform="matrix(0.9979 -0.0644 0.0644 0.9979 -2.7961 8.4833)" class="st15" width="3.33" height="1"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
|
||||||
|
<rect x="142.7" y="39.03" transform="matrix(0.5865 -0.81 0.81 0.5865 29.7084 137.2522)" class="st15" width="13.15" height="1"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M155.89,44.27c-0.1,0-0.2-0.03-0.29-0.09c-0.23-0.16-0.28-0.47-0.13-0.7c0.98-1.41,1.34-2.69,1.08-3.81
|
||||||
|
c-0.33-1.43-1.6-2.14-1.61-2.14c-0.24-0.13-0.33-0.44-0.2-0.68c0.13-0.24,0.43-0.33,0.68-0.2c0.07,0.04,1.66,0.92,2.1,2.78
|
||||||
|
c0.34,1.41-0.08,2.97-1.23,4.63C156.21,44.19,156.05,44.27,155.89,44.27z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M154.76,41.26c0,0-0.01,0-0.01,0c-0.28-0.01-0.49-0.24-0.49-0.51c0-0.15,0.06-1.44,0.89-1.99
|
||||||
|
c0.3-0.2,0.82-0.37,1.57-0.07c0.26,0.1,0.38,0.39,0.28,0.65c-0.1,0.26-0.4,0.38-0.65,0.28c-0.28-0.11-0.5-0.12-0.65-0.02
|
||||||
|
c-0.32,0.21-0.43,0.89-0.44,1.18C155.25,41.05,155.03,41.26,154.76,41.26z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 18 KiB |
463
public/static/illustrations/support-team.svg
Normal file
@ -0,0 +1,463 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 460.92 293.79" style="enable-background:new 0 0 460.92 293.79;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#D1CFD0;}
|
||||||
|
.st1{fill:#D62800;}
|
||||||
|
.st2{fill:#F97F23;}
|
||||||
|
.st3{fill:#0F6D66;}
|
||||||
|
.st4{fill:#3F8CFC;}
|
||||||
|
.st5{fill:#54D6E5;}
|
||||||
|
.st6{fill:#F5AB9D;}
|
||||||
|
.st7{fill:#FFFFFF;}
|
||||||
|
.st8{fill:#252525;}
|
||||||
|
.st9{fill:#F4A79C;}
|
||||||
|
.st10{fill:#E58177;}
|
||||||
|
.st11{fill:#092547;}
|
||||||
|
.st12{fill:#C537E2;}
|
||||||
|
.st13{fill:#886D69;}
|
||||||
|
.st14{fill:#0074FF;}
|
||||||
|
.st15{fill:#B7824E;}
|
||||||
|
.st16{fill:#A06B3C;}
|
||||||
|
.st17{fill:#242B29;}
|
||||||
|
.st18{fill:#5AD3E5;}
|
||||||
|
.st19{fill:#844618;}
|
||||||
|
.st20{fill:#022E42;}
|
||||||
|
.st21{fill:#F4A99D;}
|
||||||
|
.st22{fill:#C45853;}
|
||||||
|
.st23{fill:#C9827B;}
|
||||||
|
.st24{fill:#185FED;}
|
||||||
|
.st25{fill:#F4A89C;}
|
||||||
|
.st26{fill:#0B3C89;}
|
||||||
|
</style>
|
||||||
|
<g id="Layer_1">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_25">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_31">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_26">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_27">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_30">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_28">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_29">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_23">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_24">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_33">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_20">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_32">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_22">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_21">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_16">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_18">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_17">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_11">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_12">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_19">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_13">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_14">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_15">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_9">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_2">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_6">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_7">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_3">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_8">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_4">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_5">
|
||||||
|
</g>
|
||||||
|
<g id="Layer_10">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<rect x="59.51" y="93.61" class="st0" width="299.88" height="1.73"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st1" cx="79.7" cy="83.76" r="2.68"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st2" cx="91.83" cy="83.76" r="2.68"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st3" cx="103.52" cy="83.76" r="2.68"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M389.35,282.21H70.1c-6.32,0-11.46-5.14-11.46-11.46v-36.43h32.13c3.94,0,7.14-3.2,7.14-7.14v-66.83
|
||||||
|
c0-3.94-3.2-7.14-7.14-7.14H58.64v-70.9c0-6.32,5.14-11.46,11.46-11.46h319.25c6.32,0,11.46,5.14,11.46,11.46v5.29h-33.15
|
||||||
|
c-3.93,0-7.14,3.2-7.14,7.14v66.83c0,3.94,3.2,7.14,7.14,7.14h33.15v102.04C400.81,277.07,395.67,282.21,389.35,282.21z
|
||||||
|
M60.37,236.04v34.71c0,5.37,4.37,9.73,9.73,9.73h319.25c5.37,0,9.73-4.37,9.73-9.73V170.43h-31.42c-4.89,0-8.86-3.98-8.86-8.86
|
||||||
|
V94.74c0-4.89,3.98-8.86,8.86-8.86h31.42v-3.57c0-5.37-4.37-9.73-9.73-9.73H70.1c-5.37,0-9.73,4.37-9.73,9.73v69.18h30.41
|
||||||
|
c4.89,0,8.86,3.98,8.86,8.86v66.83c0,4.89-3.98,8.86-8.86,8.86H60.37z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M440.41,170.43h-72.75c-4.89,0-8.86-3.98-8.86-8.86V94.74c0-4.89,3.98-8.86,8.86-8.86h72.75
|
||||||
|
c4.89,0,8.86,3.98,8.86,8.86v66.83C449.27,166.46,445.29,170.43,440.41,170.43z M367.66,87.6c-3.93,0-7.14,3.2-7.14,7.14v66.83
|
||||||
|
c0,3.94,3.2,7.14,7.14,7.14h72.75c3.94,0,7.14-3.2,7.14-7.14V94.74c0-3.94-3.2-7.14-7.14-7.14H367.66z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M90.77,236.04H18.03c-4.89,0-8.86-3.98-8.86-8.86v-66.83c0-4.89,3.98-8.86,8.86-8.86h72.75
|
||||||
|
c4.89,0,8.86,3.98,8.86,8.86v66.83C99.64,232.07,95.66,236.04,90.77,236.04z M18.03,153.21c-3.94,0-7.14,3.2-7.14,7.14v66.83
|
||||||
|
c0,3.94,3.2,7.14,7.14,7.14h72.75c3.94,0,7.14-3.2,7.14-7.14v-66.83c0-3.94-3.2-7.14-7.14-7.14H18.03z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st4" d="M71.42,217.13H39.03c-4.57,0-8.27-3.7-8.27-8.27v-30.18c0-4.57,3.7-8.27,8.27-8.27h32.39
|
||||||
|
c4.57,0,8.27,3.7,8.27,8.27v30.18C79.69,213.42,75.99,217.13,71.42,217.13z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st2" d="M420.23,148.34h-32.39c-4.57,0-8.27-3.7-8.27-8.27v-25.74c0-4.57,3.7-8.27,8.27-8.27h32.39
|
||||||
|
c4.57,0,8.27,3.7,8.27,8.27v25.74C428.5,144.64,424.8,148.34,420.23,148.34z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st5" d="M177.38,51.22h-26.3c-4.57,0-8.27-3.7-8.27-8.27V18.22c0-4.57,3.7-8.27,8.27-8.27h26.3
|
||||||
|
c4.57,0,8.27,3.7,8.27,8.27v24.72C185.65,47.51,181.95,51.22,177.38,51.22z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st6" d="M348.6,29.78H280.7c-1.34,0-2.42,1.08-2.42,2.42v15.94c0,1.34,1.08,2.42,2.42,2.42h6.15l-1,5.38l5.99-5.38
|
||||||
|
h56.76c1.34,0,2.42-1.08,2.42-2.42V32.2C351.01,30.86,349.93,29.78,348.6,29.78z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M412.6,135.82h-17.14c-1.66,0-3.01-1.35-3.01-3.01v-12.86c0-1.66,1.35-3.01,3.01-3.01h17.14
|
||||||
|
c1.66,0,3.01,1.35,3.01,3.01v12.86C415.61,134.47,414.26,135.82,412.6,135.82z M395.46,118.68c-0.71,0-1.28,0.57-1.28,1.28v12.86
|
||||||
|
c0,0.71,0.57,1.28,1.28,1.28h17.14c0.71,0,1.28-0.57,1.28-1.28v-12.86c0-0.71-0.57-1.28-1.28-1.28H395.46z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M404.03,128.32c-0.17,0-0.35-0.05-0.49-0.16l-10.71-7.5c-0.39-0.27-0.49-0.81-0.21-1.2
|
||||||
|
c0.27-0.39,0.81-0.49,1.2-0.21l10.22,7.15l10.22-7.15c0.39-0.27,0.93-0.18,1.2,0.21c0.27,0.39,0.18,0.93-0.21,1.2l-10.71,7.5
|
||||||
|
C404.38,128.27,404.2,128.32,404.03,128.32z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M171.08,40.05c-0.07,0-0.15,0-0.22-0.01c-2.79-0.3-5.5-1.27-7.84-2.79c-2.17-1.38-4.05-3.26-5.44-5.44
|
||||||
|
c-1.52-2.35-2.48-5.07-2.78-7.85c-0.06-0.7,0.15-1.37,0.59-1.9c0.44-0.53,1.07-0.86,1.75-0.92c0.07-0.01,0.15-0.01,0.22-0.01h2.6
|
||||||
|
c0.01,0,0.02,0,0.02,0c1.28,0,2.38,0.95,2.56,2.23c0.1,0.77,0.29,1.52,0.56,2.24c0.35,0.94,0.13,2.01-0.58,2.73l-0.62,0.62
|
||||||
|
c1.01,1.59,2.37,2.94,3.96,3.96l0.62-0.62c0.72-0.71,1.79-0.94,2.74-0.59c0.72,0.27,1.47,0.46,2.23,0.56
|
||||||
|
c1.31,0.18,2.26,1.31,2.23,2.62v2.58c0.01,1.42-1.15,2.59-2.58,2.6C171.09,40.05,171.09,40.05,171.08,40.05z M159.98,22.85
|
||||||
|
C159.98,22.85,159.98,22.85,159.98,22.85h-2.61c-0.55,0.05-0.9,0.47-0.86,0.94c0.27,2.51,1.14,4.96,2.51,7.09
|
||||||
|
c1.25,1.97,2.95,3.67,4.92,4.92c2.12,1.38,4.57,2.25,7.08,2.52c0.54,0,0.93-0.39,0.93-0.86v-2.59c0-0.01,0-0.01,0-0.02
|
||||||
|
c0.01-0.44-0.31-0.81-0.74-0.88c-0.88-0.12-1.76-0.34-2.61-0.65c-0.32-0.12-0.67-0.04-0.91,0.19l-1.09,1.09
|
||||||
|
c-0.27,0.27-0.7,0.33-1.04,0.14c-2.29-1.3-4.2-3.21-5.5-5.5c-0.19-0.34-0.13-0.76,0.14-1.04l1.1-1.1
|
||||||
|
c0.23-0.24,0.31-0.59,0.19-0.91c-0.31-0.84-0.53-1.72-0.65-2.62C160.78,23.17,160.41,22.85,159.98,22.85z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M338.19,37.44h-50.38c-0.48,0-0.86-0.39-0.86-0.86s0.39-0.86,0.86-0.86h50.38c0.48,0,0.86,0.39,0.86,0.86
|
||||||
|
S338.66,37.44,338.19,37.44z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M316.23,43.72h-28.42c-0.48,0-0.86-0.39-0.86-0.86s0.39-0.86,0.86-0.86h28.42c0.48,0,0.86,0.39,0.86,0.86
|
||||||
|
S316.71,43.72,316.23,43.72z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M67.91,198.17c0.48-1.38,0.75-2.86,0.75-4.4c0-1.54-0.26-3.02-0.75-4.4c-0.01-0.04-0.03-0.08-0.04-0.12
|
||||||
|
c-1.86-5.16-6.81-8.86-12.6-8.86s-10.74,3.7-12.6,8.86c-0.02,0.04-0.03,0.08-0.04,0.12c-0.48,1.38-0.75,2.86-0.75,4.4
|
||||||
|
c0,1.54,0.26,3.02,0.75,4.4c0.01,0.04,0.03,0.08,0.04,0.12c1.86,5.16,6.81,8.86,12.6,8.86s10.74-3.7,12.6-8.86
|
||||||
|
C67.88,198.25,67.9,198.21,67.91,198.17z M55.26,205.23c-0.51,0-1.33-0.92-2.02-2.98c-0.33-0.99-0.6-2.14-0.79-3.38h5.62
|
||||||
|
c-0.19,1.24-0.46,2.39-0.79,3.38C56.6,204.31,55.77,205.23,55.26,205.23z M52.22,196.95c-0.09-1.02-0.14-2.09-0.14-3.18
|
||||||
|
c0-1.09,0.05-2.16,0.14-3.18h6.09c0.09,1.02,0.14,2.09,0.14,3.18c0,1.09-0.05,2.16-0.14,3.18H52.22z M43.79,193.76
|
||||||
|
c0-1.1,0.16-2.17,0.45-3.18h6.05c-0.09,1.04-0.14,2.11-0.14,3.18s0.05,2.14,0.14,3.18h-6.05
|
||||||
|
C43.95,195.94,43.79,194.87,43.79,193.76z M55.26,182.29c0.51,0,1.33,0.92,2.02,2.98c0.33,0.99,0.6,2.14,0.79,3.38h-5.62
|
||||||
|
c0.19-1.24,0.46-2.39,0.79-3.38C53.93,183.22,54.75,182.29,55.26,182.29z M60.23,190.58h6.05c0.29,1.01,0.45,2.08,0.45,3.18
|
||||||
|
s-0.16,2.17-0.45,3.18h-6.05c0.09-1.04,0.14-2.11,0.14-3.18S60.32,191.62,60.23,190.58z M65.53,188.66h-5.52
|
||||||
|
c-0.34-2.35-0.93-4.47-1.75-5.97C61.45,183.56,64.09,185.76,65.53,188.66z M52.27,182.69c-0.82,1.5-1.41,3.61-1.75,5.97h-5.52
|
||||||
|
C46.44,185.76,49.08,183.56,52.27,182.69z M44.99,198.87h5.52c0.34,2.35,0.93,4.47,1.75,5.97
|
||||||
|
C49.08,203.97,46.44,201.77,44.99,198.87z M58.26,204.83c0.82-1.5,1.41-3.61,1.75-5.97h5.52
|
||||||
|
C64.09,201.77,61.45,203.97,58.26,204.83z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st8" d="M184.49,280.96c0,0,2.65-54.62-3.84-71.64c-6.49-17.01-47.98-0.93-47.98-0.93s-8.97,32.54-4.6,72.57h23.72
|
||||||
|
l4.16-43.07h2.23l3.7,43.07H184.49z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st9" d="M129.18,189.26c0,0-1.2,4.98-2.65,10.94c-1.67,6.88-3.68,15.07-4.58,18.4c-1.68,6.22-4.88,30.11-4.88,30.11
|
||||||
|
c3.87,5.05,3.87,9.76,3.42,11.11c-0.46,1.35-1.91-0.67-1.91-2.02c0-1.35-1.68-4.2-2.63-2.87c-0.95,1.35-0.9,5.38,1.29,7.58
|
||||||
|
c2.19,2.18,2.36,5.04,2.36,5.04c-14.8-5.04-7.7-18.67-7.7-18.67c-3.33-6.89,1.81-42.56,2.99-49.96
|
||||||
|
c0.51-3.23,1.09-7.96,1.57-12.26c0.64-5.59,1.12-10.48,1.12-10.48L129.18,189.26z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M129.18,189.26c0,0-1.2,4.98-2.65,10.94l-10.08-13.54c0.64-5.59,1.12-10.48,1.12-10.48L129.18,189.26z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st11" d="M151.66,79.84c-17.37,2.95-17.33,16.26-17.04,20.88c0.29,4.62-32.49,25.67,5.59,38.41h25.92
|
||||||
|
c0,0,27.21-4.44,15.04-29.64c-2.58-3.69-8.74-11.75-9.59-14.54S165.98,77.41,151.66,79.84z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st9" d="M183.42,150.13l-3.84,59.58l-46.92-1.33l-16.47-30.91c0,0-1.26-20.87,2.01-25.85
|
||||||
|
c1.61-2.47,8.26-6.01,14.57-8.96c6.45-3.02,12.56-5.43,12.56-5.43l-0.41-13.13c-4.75-2.34-5.72-9.59-5.72-9.59
|
||||||
|
c-6.69-1.36-7.67-6.48-5.72-8.95c1.27-1.61,4.64,1.29,4.97,1.55c-0.02-0.03-0.04-0.05-0.08-0.09c-0.85-0.85-0.78-2.67-0.13-4.09
|
||||||
|
c2.86-3.18,3.7-11.03,3.7-11.03c4.6,5.57,23.68,6.16,23.68,6.16c0,2.99,3.5,5.51,3.5,5.51s-0.24,2.11-0.42,3.22
|
||||||
|
c0.28-0.54,0.86-1.11,1.14-1.53c0.39-0.59,5.77-1.95,3.89,3.69c-1.88,5.64-5.9,5.13-5.9,5.13c-1.56,5.84-6.49,9.54-6.49,9.54
|
||||||
|
l-0.2,12.4c5.63,2.09,10.04,4.44,13.4,6.63C180.79,146.69,183.42,150.13,183.42,150.13z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st12" d="M183.42,150.13l-3.84,59.58l-46.92-1.33l-16.47-30.91c0,0-1.26-20.87,2.01-25.85
|
||||||
|
c1.61-2.47,8.26-6.01,14.57-8.96c0,0,18.11,12.4,41.78,0C180.79,146.69,183.42,150.13,183.42,150.13z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M153.27,125.85c-2.67-0.04-5.57-0.46-8-1.69c0.16,0.08-0.09-0.15,0,0c0.41,0.74,1.59,1.97,2.15,2.61
|
||||||
|
c4.32,4.94,11.53,2.75,13.63-3.07c-0.11,0.29-1.49,0.72-1.79,0.85c-0.69,0.3-1.39,0.56-2.11,0.78
|
||||||
|
C155.87,125.71,154.57,125.88,153.27,125.85z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st11" d="M141.75,103.06c-0.12,0-0.23-0.03-0.34-0.1c-0.3-0.19-0.4-0.58-0.21-0.88c0.09-0.15,2.27-3.59,8.56-2.84
|
||||||
|
c0.35,0.04,0.61,0.36,0.57,0.72c-0.04,0.35-0.36,0.61-0.72,0.57c-5.42-0.65-7.23,2.11-7.3,2.23
|
||||||
|
C142.18,102.95,141.97,103.06,141.75,103.06z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st11" d="M163.75,102.39c-0.19,0-0.39-0.09-0.51-0.25c-2-2.6-7.18-1.73-7.23-1.72c-0.35,0.06-0.69-0.17-0.75-0.53
|
||||||
|
c-0.06-0.35,0.17-0.69,0.52-0.75c0.24-0.04,6-1.02,8.48,2.2c0.22,0.28,0.17,0.69-0.12,0.91
|
||||||
|
C164.03,102.34,163.89,102.39,163.75,102.39z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M153.34,112.09c-0.67,0-1.26-0.21-1.64-0.63c-0.52-0.57-0.54-1.41-0.06-2.32c0.95-1.79,0.36-4.54,0.35-4.56
|
||||||
|
c-0.05-0.23,0.09-0.46,0.33-0.51c0.23-0.05,0.46,0.09,0.51,0.33c0.03,0.12,0.67,3.08-0.43,5.16c-0.21,0.4-0.4,0.96-0.06,1.33
|
||||||
|
c0.34,0.36,1.19,0.51,2.17,0.09c0.22-0.1,0.47,0.01,0.57,0.23c0.09,0.22-0.01,0.47-0.23,0.57
|
||||||
|
C154.33,111.98,153.81,112.09,153.34,112.09z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M147.06,113.75c1.93,0.46,6.72,1.3,11.56-0.22c0.03-0.01,0.05,0.01,0.04,0.04c-0.26,0.5-1.57,4.47-5.62,4.47
|
||||||
|
c-3.1,0-5.55-2.55-6.37-3.71C146.47,114.05,146.73,113.67,147.06,113.75z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M169.38,110.4c-0.05,0-0.11-0.01-0.16-0.03c-0.22-0.09-0.33-0.34-0.24-0.56c0.14-0.35,1.45-3.42,3.41-3.48
|
||||||
|
c0.22,0,0.44,0.18,0.44,0.42c0.01,0.24-0.18,0.44-0.42,0.44c-1.19,0.04-2.31,2.13-2.64,2.94
|
||||||
|
C169.71,110.3,169.55,110.4,169.38,110.4z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st10" d="M138.17,110.4c-0.16,0-0.32-0.09-0.39-0.25c-0.95-2.06-3.01-1.68-3.1-1.66c-0.23,0.05-0.46-0.1-0.51-0.34
|
||||||
|
c-0.05-0.23,0.1-0.46,0.34-0.51c0.97-0.2,3.07-0.01,4.06,2.14c0.1,0.22,0.01,0.47-0.21,0.57
|
||||||
|
C138.29,110.39,138.23,110.4,138.17,110.4z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st11" cx="146.56" cy="105.65" r="1.54"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st11" cx="159.51" cy="105.65" r="1.54"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st13" d="M323.39,205.55l1.1,2.26c0,0,3.77,11.42,4.72,23.52c0.94,12.11,0.79,50.01,0.79,50.01h-23.9l-2.83-40.58
|
||||||
|
h-3.3l-3.62,40.58h-10.69l6.37-73.53h32.46"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st14" d="M370.22,157.8l-10.29,19.17c-0.61,1.13-0.17,2.54,0.98,3.13l6.96,3.54c1.1,0.56,2.45,0.15,3.05-0.94
|
||||||
|
l10.59-19.28c0.64-1.16,0.17-2.61-1.03-3.17l-7.26-3.44C372.12,156.29,370.8,156.72,370.22,157.8z"/>
|
||||||
|
<path d="M368.92,184.76c-0.48,0-0.98-0.11-1.44-0.35l-6.96-3.54c-0.76-0.39-1.32-1.05-1.58-1.87c-0.26-0.82-0.17-1.68,0.23-2.43
|
||||||
|
l10.29-19.17v0c0.8-1.48,2.61-2.08,4.13-1.36l7.26,3.44c0.78,0.37,1.39,1.06,1.65,1.88s0.18,1.73-0.24,2.49l-10.59,19.28
|
||||||
|
C371.1,184.17,370.03,184.76,368.92,184.76z M372.24,157.45c-0.51,0-1,0.27-1.26,0.75v0l-10.29,19.17
|
||||||
|
c-0.18,0.34-0.22,0.73-0.1,1.1c0.12,0.37,0.37,0.67,0.72,0.85l6.96,3.54c0.69,0.35,1.53,0.09,1.9-0.59l10.59-19.28
|
||||||
|
c0.19-0.35,0.23-0.75,0.11-1.13c-0.12-0.38-0.39-0.68-0.75-0.85l-7.26-3.44C372.66,157.5,372.45,157.45,372.24,157.45z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M368.12,184.7c0,0-2.79,3.27-7.39,2.77c0,0-8.72,17.85-11.23,21.95c-2.5,4.11-6.71,9.92-10.92,9.22
|
||||||
|
c-4.21-0.71-6.22-6.53-7.92-13.09c-1.71-6.56-5.01-22.6-5.01-22.6s0.2-6.32,3.21-6.82c3-0.5,12.23,0.3,12.23,0.3l2.05,11.68
|
||||||
|
l0.96,5.45c0,0,10.73-10.82,10.73-12.23s0.2-15.14,13.43-10.12c0,0,1.3,1.7-0.8,2.2c-2.11,0.5-4.3-2.68-6.61,1.32
|
||||||
|
c-1.21,2.11-1.72,3.44-1.97,4.06c-0.12,0.3,0.56,0.96,0.92,1.17L368.12,184.7z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st16" d="M344.09,193.57c0,0-6.13,9.6-6.13,7.59c0-1.39,3.18-8.64,5.17-13.04L344.09,193.57z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st17" cx="317.35" cy="80.19" r="11.33"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<ellipse class="st17" cx="302.91" cy="101.11" rx="18.38" ry="19.09"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st15" d="M344.7,177.72l-16.3,2.22c0,0-1.36,8.1-1.82,10.82c-0.45,2.73-2.44,15.23-2.08,17.05h-32.46l-21.12-54.22
|
||||||
|
c0,0,8.45-10.83,21.37-15.58c0.12-0.05,0.24-0.09,0.36-0.14c0,0,2.68-0.33,2.84-2.25c0.17-1.92-0.25-10.11-0.25-10.11
|
||||||
|
s-5.35-3.76-6.6-9.36c0,0-4.51,0.41-6.02-5.1c-1.49-5.51,3.66-4.25,4.82-1.98c-0.04-0.29-0.38-2.61,0.44-3.61
|
||||||
|
c0.88-1.06,3.94-5.38,3.94-6.45c0,0,15.44-0.56,21.26-4.87c-0.07,0.2-1.35,3.37-2.61,3.75c0,0,2.25,0.25,3.75-2.5
|
||||||
|
c0,0,0.19,4.5,1.63,6.22c1.43,1.73,3,4.66,1.75,7.48c0,0,1.31,0.5,2.06-2.19c0,0,2.69-1.44,3.44,1.37
|
||||||
|
c0.75,2.82-2.69,7.2-6.14,7.95c0,0-1.25,6.95-6.2,9.76c0,0-0.75,9.7,0.5,10.52c0.28,0.18,1.48,0.72,3.25,1.6
|
||||||
|
c6.14,3.03,19.1,10.11,24.07,20.27C344.99,171.46,344.7,177.72,344.7,177.72z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st18" d="M344.7,177.72l-16.3,2.22c0,0-1.36,8.1-1.82,10.82c-0.45,2.73-2.44,15.23-2.08,17.05h-32.46l-21.12-54.22
|
||||||
|
c0,0,8.45-10.83,21.37-15.58c1.18,1.27,3.26,3.31,4.92,3.96c2.49,0.99,5.01,2.1,5.78,5.3c0,0,0.76-4.19,4.82-5.17
|
||||||
|
c3.1-0.75,5.56-2.58,6.71-4c6.14,3.03,19.1,10.11,24.07,20.27C344.99,171.46,344.7,177.72,344.7,177.72z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st16" d="M302.91,127.85c-2.67-0.14-5.3-1.04-7.68-2.35c0.15,0.08,0.34,0.78,0.42,0.93
|
||||||
|
c0.38,0.76,0.85,1.48,1.38,2.13c4.14,5.09,11.42,3.15,13.73-2.59c-0.12,0.29-1.51,0.67-1.82,0.79c-0.7,0.27-1.41,0.52-2.14,0.7
|
||||||
|
C305.52,127.8,304.21,127.92,302.91,127.85z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M303.39,114.54c-0.47,0-0.95-0.02-1.37-0.08c-0.24-0.03-0.45-0.17-0.59-0.39c-0.7-1.05,0.14-4.51,0.32-5.2
|
||||||
|
c0.06-0.23,0.3-0.37,0.53-0.31c0.23,0.06,0.37,0.29,0.31,0.53c-0.54,2.1-0.74,4.25-0.42,4.52c1.09,0.15,2.87-0.01,2.89-0.01
|
||||||
|
c0.22-0.02,0.45,0.15,0.47,0.39c0.02,0.24-0.15,0.45-0.39,0.47C305.08,114.47,304.28,114.54,303.39,114.54z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M297.82,116.05h10.32c0,0-2.02,3.94-5.33,3.69C299.5,119.49,297.82,116.05,297.82,116.05z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st17" d="M291.3,103.87c-0.16,0-0.31-0.04-0.46-0.13c-0.4-0.25-0.53-0.78-0.28-1.18c0.03-0.05,2.91-4.58,8.99-1.4
|
||||||
|
c0.42,0.22,0.58,0.74,0.36,1.16c-0.22,0.42-0.74,0.59-1.16,0.36c-4.62-2.42-6.64,0.65-6.73,0.78
|
||||||
|
C291.87,103.73,291.59,103.87,291.3,103.87z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st17" d="M314.13,103.29c-0.26,0-0.52-0.12-0.69-0.35c-2.1-2.8-6.76-1.25-6.81-1.24c-0.45,0.15-0.94-0.09-1.09-0.54
|
||||||
|
c-0.15-0.45,0.09-0.94,0.54-1.1c0.24-0.08,5.89-1.97,8.75,1.83c0.29,0.38,0.21,0.92-0.17,1.21
|
||||||
|
C314.49,103.23,314.31,103.29,314.13,103.29z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st17" cx="295.73" cy="107.52" r="1.46"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st17" cx="309.27" cy="107.52" r="1.46"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M287.12,113.14c-0.19,0-0.37-0.13-0.42-0.32c-0.65-2.52-2.34-2.28-2.36-2.27
|
||||||
|
c-0.24,0.04-0.46-0.12-0.49-0.36c-0.04-0.24,0.12-0.46,0.36-0.49c0.03-0.01,2.49-0.36,3.33,2.91c0.06,0.23-0.08,0.47-0.31,0.53
|
||||||
|
C287.19,113.13,287.16,113.14,287.12,113.14z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st19" d="M317.8,113.14c-0.07,0-0.14-0.02-0.21-0.05c-0.21-0.12-0.28-0.38-0.17-0.59c0.22-0.39,2.16-3.77,4.09-3.21
|
||||||
|
c0.23,0.07,0.36,0.3,0.3,0.53c-0.06,0.23-0.3,0.36-0.53,0.3c-0.96-0.28-2.41,1.57-3.1,2.81
|
||||||
|
C318.1,113.06,317.96,113.14,317.8,113.14z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M294.87,114.92c-3.6,0-6.53-2.93-6.53-6.53s2.93-6.53,6.53-6.53c3.6,0,6.53,2.93,6.53,6.53
|
||||||
|
S298.47,114.92,294.87,114.92z M294.87,102.72c-3.13,0-5.67,2.54-5.67,5.67s2.54,5.67,5.67,5.67s5.67-2.54,5.67-5.67
|
||||||
|
S298,102.72,294.87,102.72z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M310.24,114.59c-3.6,0-6.53-2.93-6.53-6.53c0-3.6,2.93-6.53,6.53-6.53c3.6,0,6.53,2.93,6.53,6.53
|
||||||
|
C316.77,111.66,313.84,114.59,310.24,114.59z M310.24,102.38c-3.13,0-5.67,2.54-5.67,5.67c0,3.13,2.54,5.67,5.67,5.67
|
||||||
|
c3.13,0,5.67-2.54,5.67-5.67C315.91,104.93,313.37,102.38,310.24,102.38z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="300.97" y="107.96" class="st7" width="3.16" height="0.86"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="190.38" y="261.69" class="st20" width="77.35" height="19.65"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st17" d="M216.04,67.06c0,0,5.66-11.02,16.99-7.04c11.33,3.98,17.3,8.42,15.31,20.97c-1.99,12.55-2.91,15-2.91,15
|
||||||
|
l-38.73-1.07c0,0-0.46-8.27-1.22-10.41S199.5,65.23,216.04,67.06z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st21" d="M94.8,137.29L94.8,137.29c0.46-0.34,1.12-0.25,1.47,0.21l6.28,8.13c0.27,0.34,0.76,0.41,1.1,0.14l0,0
|
||||||
|
c0.29-0.23,0.38-0.62,0.22-0.95l-5.12-10.68c-0.3-0.68-0.04-1.48,0.61-1.84l0,0c0.68-0.38,1.53-0.15,1.93,0.51l5.91,9.77
|
||||||
|
c0.25,0.42,0.79,0.58,1.23,0.37l0.06-0.03c0.44-0.21,0.65-0.7,0.51-1.16l-4.09-12.94c-0.21-0.67,0.11-1.39,0.76-1.67h0
|
||||||
|
c0.68-0.3,1.48,0,1.8,0.68l5.57,12.01c0.25,0.53,0.9,0.72,1.39,0.4l0,0c0.3-0.2,0.47-0.54,0.43-0.9l-1.2-11.3
|
||||||
|
c-0.08-0.76,0.52-1.42,1.28-1.42l0,0c0.6,0,1.12,0.41,1.25,0.99c0.84,3.64,3.74,16.16,4.3,18.65c0.66,2.92,2.43-6.89,5.9-7.82
|
||||||
|
c4.98-1.33-1.56,10.92-1.75,12.43c-0.19,1.51-1.98,8.21-3.3,10.76l29.35,34.07l8.4-10.85l24.33,18.31
|
||||||
|
c0,0-18.31,23.74-26.22,26.06c-7.91,2.33-13.03-1.4-18.46-10.24c-5.43-8.84-28.55-51.66-28.55-51.66s-10.24-9-10.86-19.55
|
||||||
|
l-4.84-9.12C94.24,138.18,94.37,137.6,94.8,137.29z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st22" d="M111.9,158.13c-0.23,0-0.42-0.18-0.43-0.42c0-0.1-0.26-9.79,8.96-11.11c0.23-0.04,0.45,0.13,0.49,0.37
|
||||||
|
c0.03,0.24-0.13,0.45-0.37,0.49c-8.44,1.21-8.23,9.85-8.22,10.22c0.01,0.24-0.18,0.44-0.42,0.45
|
||||||
|
C111.91,158.12,111.9,158.13,111.9,158.13z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st9" d="M298.15,202.81l-24.91,4.61l-1.85,56.66l-82.87,0.74l-1.29-62.75l-3.68,4.06c0,0-24.18-14.58-27.32-23.26
|
||||||
|
c0,0,13.11-36.17,49.46-45.59c0,0,7.75-1.1,7.75-4.43c0-0.53,0.01-1.37,0.03-2.42c0.09-5.51,0.35-16.59,0.35-16.59
|
||||||
|
s-4.12-6.01-4.5-9.6c0,0-3.97,2.84-7-1.14c-3.04-3.98-2.3-10.86,4.39-8.18c0,0,1.48,1.08,1.38-1.38
|
||||||
|
c-0.09-2.46-2.63-16.5,6.01-21.83c0,0,6.39,9.28,19.39,8.25c13-1.04,12.37,8.77,11.45,12.69c-0.93,3.92,0.98,1.51,0.98,1.51
|
||||||
|
s3.75-1.87,5.71,0.73c1.97,2.6,1.35,11.57-7.73,9.61c0,0-1.03,7.12-3.81,10c0,0-0.03,0.54-0.07,1.46
|
||||||
|
c-0.07,1.56-0.17,4.2-0.23,6.98c-0.09,4.87-0.04,10.15,0.61,10.85c0.41,0.44,3.79,1.73,8.51,3.81
|
||||||
|
c9.6,4.24,24.72,11.74,31.52,22.01C290.58,174.94,296.49,189.7,298.15,202.81z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st23" d="M240.09,114.51c0,0-0.03,0.54-0.07,1.46c0,0-13.42,23.33-24.3,0.02
|
||||||
|
C216.13,116.49,226.51,129.09,240.09,114.51z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st24" d="M298.15,202.81l-24.91,4.61l-1.85,56.66l-82.87,0.74l-1.29-62.75l-3.68,4.06c0,0-24.18-14.58-27.32-23.26
|
||||||
|
c0,0,13.11-36.17,49.46-45.59l18.34,12.96c1.91,1.35,4.44,1.36,6.36,0.05l18.53-12.69c9.6,4.24,24.72,11.74,31.52,22.01
|
||||||
|
C290.58,174.94,296.49,189.7,298.15,202.81z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<polygon class="st25" points="269.37,207.81 270.92,281.34 288.61,281.34 293.01,203.76 "/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st17" d="M211,88.27c-0.13,0-0.26-0.03-0.38-0.09c-0.43-0.21-0.6-0.73-0.39-1.15c0.03-0.07,3.53-6.94,12.09-3.94
|
||||||
|
c0.45,0.16,0.69,0.65,0.53,1.1c-0.16,0.45-0.65,0.68-1.1,0.53c-7.09-2.49-9.86,2.85-9.97,3.07
|
||||||
|
C211.62,88.1,211.32,88.27,211,88.27z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st17" d="M240.69,88.27c-0.31,0-0.61-0.17-0.76-0.46c-2.61-4.95-9.2-2.87-9.48-2.78c-0.45,0.15-0.94-0.1-1.09-0.55
|
||||||
|
c-0.15-0.45,0.1-0.94,0.55-1.09c2.84-0.93,8.91-1.38,11.55,3.61c0.22,0.42,0.06,0.94-0.36,1.17
|
||||||
|
C240.96,88.24,240.82,88.27,240.69,88.27z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st22" d="M226.95,107.59c-4,0-6.93-2.82-6.97-2.86c-0.17-0.17-0.17-0.44-0.01-0.61c0.17-0.17,0.44-0.17,0.61-0.01
|
||||||
|
c0.25,0.24,6.08,5.82,12.39-0.01c0.17-0.16,0.45-0.15,0.61,0.02c0.16,0.18,0.15,0.45-0.02,0.61
|
||||||
|
C231.24,106.87,228.96,107.59,226.95,107.59z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st22" d="M226.91,102.95c-0.52,0-0.94-0.15-1.27-0.46c-1.83-1.69,0.47-7.53,0.74-8.19c0.09-0.22,0.34-0.33,0.56-0.24
|
||||||
|
c0.22,0.09,0.33,0.34,0.24,0.56c-0.93,2.28-2,6.26-0.95,7.23c0.54,0.5,1.71,0.09,2.59-0.35c0.21-0.11,0.47-0.02,0.58,0.19
|
||||||
|
c0.11,0.21,0.02,0.47-0.19,0.58C228.3,102.73,227.54,102.95,226.91,102.95z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st22" d="M244.74,101.15c-0.06,0-0.13-0.01-0.19-0.05c-0.21-0.11-0.3-0.37-0.19-0.58c0.9-1.8,3.41-4.86,6.48-3.59
|
||||||
|
c0.22,0.09,0.32,0.34,0.23,0.56c-0.09,0.22-0.34,0.32-0.56,0.23c-3.09-1.28-5.36,3.14-5.38,3.18
|
||||||
|
C245.05,101.06,244.9,101.15,244.74,101.15z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st22" d="M208.93,102.17c-0.21,0-0.39-0.15-0.43-0.36c-0.52-3.19-5.31-4.32-5.36-4.33
|
||||||
|
c-0.23-0.05-0.38-0.28-0.32-0.52c0.05-0.23,0.28-0.38,0.52-0.33c0.22,0.05,5.41,1.27,6.02,5.03c0.04,0.24-0.12,0.46-0.36,0.5
|
||||||
|
C208.98,102.17,208.95,102.17,208.93,102.17z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st17" cx="218.46" cy="93.09" r="1.85"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle class="st17" cx="234.18" cy="93.09" r="1.85"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M216.61,102.73c-4.73,0-8.58-3.85-8.58-8.58c0-4.73,3.85-8.58,8.58-8.58s8.58,3.85,8.58,8.58
|
||||||
|
C225.19,98.87,221.34,102.73,216.61,102.73z M216.61,86.85c-4.02,0-7.29,3.27-7.29,7.29c0,4.02,3.27,7.29,7.29,7.29
|
||||||
|
s7.29-3.27,7.29-7.29C223.9,90.12,220.63,86.85,216.61,86.85z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st7" d="M236.03,102.73c-4.73,0-8.58-3.85-8.58-8.58c0-4.73,3.85-8.58,8.58-8.58c4.73,0,8.58,3.85,8.58,8.58
|
||||||
|
C244.62,98.87,240.77,102.73,236.03,102.73z M236.03,86.85c-4.02,0-7.29,3.27-7.29,7.29c0,4.02,3.27,7.29,7.29,7.29
|
||||||
|
c4.02,0,7.29-3.27,7.29-7.29C243.32,90.12,240.05,86.85,236.03,86.85z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="224.54" y="93.49" class="st7" width="3.55" height="1.29"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st26" d="M245.33,192.46c-3.42,0-6.61-1.36-8.98-3.82c-2.37-2.47-3.6-5.71-3.47-9.13l0.47-12.11h24.44V180
|
||||||
|
C257.79,186.87,252.2,192.46,245.33,192.46z M235.01,169.13l-0.41,10.45c-0.12,2.94,0.95,5.74,2.99,7.86
|
||||||
|
c2.04,2.12,4.79,3.29,7.74,3.29c5.92,0,10.74-4.82,10.74-10.74v-10.87H235.01z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
|
||||||
|
<rect x="251.58" y="188.4" transform="matrix(0.0415 -0.9991 0.9991 0.0415 69.8319 451.3111)" class="st26" width="37.12" height="1.73"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
|
||||||
|
<rect x="185.28" y="169.72" transform="matrix(0.9978 -0.0666 0.0666 0.9978 -11.9705 12.8123)" class="st26" width="1.73" height="32.39"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 23 KiB |
31
public/static/logo.svg
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<svg height="1000" width="1000" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M200 940c-38-5-66-41-61-78 1-10 4-18 6-20l49-79 129-211 83-135V197l-7-4a71 71 0 01-34-93c9-19 25-33 46-39l8-2h163l7 2a72 72 0 0152 69c0 27-15 51-39 63l-7 3v7c0 9-2 14-7 19l-7 4-4 1v30l3 1c5 1 9 5 12 10l2 3v145l83 136a390690 390690 0 01178 290c4 4 8 23 6 33-2 26-17 48-40 59-16 7 14 6-317 6H200z"
|
||||||
|
fill="#d3f5fd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M198 939c-5-1-16-4-22-8-11-5-23-18-29-29s-8-23-8-36c1-11 3-19 6-23l132-214 130-212V197l-9-5c-8-3-11-6-18-13a66 66 0 01-19-33c-4-17-2-32 6-48 4-8 6-11 13-18 10-10 20-16 33-19 8-2 9-2 87-2s79 0 87 2c13 3 23 9 33 19 7 7 9 10 13 18a73 73 0 01-1 64c-5 12-17 24-29 30l-8 4v5c0 15-8 25-21 25-10 1-17-6-21-18-2-6-1-30 1-35 2-8 9-13 19-14 9-1 14-4 19-10 8-8 10-18 6-29-1-4-3-7-7-11-9-9-5-9-91-9s-82 0-91 9c-9 8-11 18-7 30 1 6 8 14 14 17s18 4 36 5c12 0 15 0 19 2 15 8 15 28 0 36-5 3-6 3-14 3h-9v224l-2 4-59 96C164 891 180 864 180 869c0 9 3 15 9 21 10 10-24 9 311 9s301 1 311-9c6-6 9-12 9-21a10269652 10269652 0 01-267-443l-1-76c1-81 0-79 8-86 9-9 18-9 28-1 6 6 8 12 6 25l-1 69v60l130 212 132 214c3 4 5 12 6 23 1 21-6 38-21 53-9 9-16 14-29 18l-7 2-302 1-304-1zm75-74c-6-1-8-2-13-7-8-7-12-20-9-30l46-81a1518722 1518722 0 0092-158l13 4c21 9 40 13 62 15 25 2 38 0 68-13 28-12 31-12 54-12l21 1a4826399 4826399 0 01142 244c2 6 1 15-2 22-3 6-10 12-17 15-4 1-14 2-228 2-191 0-224 0-229-2z"
|
||||||
|
fill="#d9b7fb"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M198 939c-5-1-16-4-22-8-11-5-23-18-29-29s-8-23-8-36c1-11 3-19 6-23l132-214 130-212V197l-9-5c-8-3-11-6-18-13a66 66 0 01-19-33c-4-17-2-32 6-48 4-8 6-11 13-18 10-10 20-16 33-19 8-2 9-2 87-2s79 0 87 2c13 3 23 9 33 19 7 7 9 10 13 18a73 73 0 01-1 64c-5 12-17 24-29 30l-8 4v5c0 15-8 25-21 25-10 1-17-6-21-18-2-6-1-30 1-35 2-8 9-13 19-14 9-1 14-4 19-10 8-8 10-18 6-29-1-4-3-7-7-11-9-9-5-9-91-9s-82 0-91 9c-9 8-11 18-7 30 1 6 8 14 14 17s18 4 36 5c12 0 15 0 19 2 15 8 15 28 0 36-5 3-6 3-14 3h-9v224l-2 4-59 96C164 891 180 864 180 869c0 9 3 15 9 21 10 10-24 9 311 9s301 1 311-9c6-6 9-12 9-21a10269652 10269652 0 01-267-443l-1-76c1-81 0-79 8-86 9-9 18-9 28-1 6 6 8 12 6 25l-1 69v60l130 212 132 214c3 4 5 12 6 23 1 21-6 38-21 53-9 9-16 14-29 18l-7 2-302 1-304-1zm72-76c-2 0-6-3-9-6-3-2-5-4-6-3v-1-4l-3-8c-2-8 0-14 8-28 4-7 7-12 6-13l1-1a88 88 0 0015-26 356 356 0 0030-52c1 1 11-17 10-18l1-1a88 88 0 0015-26c1 1 11-17 10-18l1-1c1 1 4-5 3-6l1-1 18-29c14-24 18-30 20-30l11 4c30 12 68 18 93 14 12-2 18-4 41-14 24-10 26-10 49-10h21c2 1 6 7 21 33a251 251 0 0021 33c-1 1 2 7 3 6l1 1c-1 1 9 19 10 18l1 1c-1 1 13 26 14 25l1 1c-1 1 9 19 10 18l1 1a356 356 0 0030 52c-1 1 13 26 14 25l1 1c-1 1 2 6 6 13 8 14 10 20 8 28l-3 8v5c-1-1-3 1-6 3-9 9 13 8-239 8-198 0-226 0-230-2zm371-69c10-7 12-20 3-29s-22-8-29 2c-4 5-5 12-3 18 1 4 6 9 11 11 4 3 13 2 18-2zm-91-1c19-9 28-30 21-49-5-15-21-27-37-27-10 0-23 6-30 15-18 21-9 52 17 62 8 3 22 2 29-1zm23-126c5-2 9-7 10-11 0-5-3-12-7-15s-12-2-16 1c-9 6-8 21 3 25h10z"
|
||||||
|
fill="#b8d0d5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M196 938c-25-6-45-24-53-48-5-15-3-40 3-48a743734 743734 0 01260-424c2-3 2-14 2-112l-1-110-7-4c-18-8-31-24-37-43-2-6-2-10-2-20 0-11 0-14 2-20 7-22 24-38 46-46l8-2h167l7 2c22 8 39 24 46 46 2 6 2 9 2 20 0 10 0 14-2 20-6 19-19 35-36 43-7 3-8 4-8 13 0 10-6 17-15 20-8 2-17-2-21-10-2-5-3-7-3-21v-20c2-7 11-14 19-14 7 0 13-3 19-9 7-6 9-12 9-21 0-10-2-16-9-22-9-10-4-9-92-9s-83-1-92 9c-7 6-9 12-9 22 0 8 2 14 8 20 8 9 14 11 43 12 13 0 18 1 21 2 10 6 13 18 7 28-4 6-10 8-21 9l-10 1-1 113v112l-2 4-134 219-131 213v7c0 9 2 15 9 21 10 10-25 9 312 9s302 1 312-9c7-6 9-12 9-21v-7L690 650 556 431l-2-4v-72l1-78c1-7 4-13 10-16 8-4 20-2 25 6l2 4v72c0 66 0 72 2 75 0 2 28 47 60 100a743734 743734 0 01200 324c6 8 8 33 3 48-7 22-23 38-45 46l-8 2-301 1-307-1zm74-75c-6-2-13-9-16-15-4-8-3-17 1-25a30982 30982 0 01136-232l12 4c29 12 67 18 92 14 12-2 18-4 41-14 24-10 26-10 50-10l21 1 42 70a744743 744743 0 0099 174c2 9-1 20-9 27-9 9 13 8-239 8-198 0-226 0-230-2zm371-69c10-7 12-20 3-29s-22-8-29 2c-4 5-5 12-3 18 1 4 6 9 11 11 4 3 13 2 18-2zm-91-1c19-9 28-30 21-49-5-15-21-27-37-27-10 0-23 6-30 15-18 21-9 52 17 62 8 3 22 2 29-1zm23-126c5-2 9-7 10-11 0-5-3-12-7-15s-12-2-16 1c-9 6-8 21 3 25h10z"
|
||||||
|
fill="#c288fc"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M196 938c-25-6-45-24-53-48-5-15-3-40 3-48a743734 743734 0 01260-424c2-3 2-14 2-112l-1-110-7-4c-18-8-31-24-37-43-2-6-2-10-2-20 0-11 0-14 2-20 7-22 24-38 46-46l8-2h167l7 2c22 8 39 24 46 46 2 6 2 9 2 20 0 10 0 14-2 20-6 19-19 35-36 43-7 3-8 4-8 13 0 10-6 17-15 20-8 2-17-2-21-10-2-5-3-7-3-21v-20c2-7 11-14 19-14 7 0 13-3 19-9 7-6 9-12 9-21 0-10-2-16-9-22-9-10-4-9-92-9s-83-1-92 9c-7 6-9 12-9 22 0 8 2 14 8 20 8 9 14 11 43 12 13 0 18 1 21 2 10 6 13 18 7 28-4 6-10 8-21 9l-10 1-1 113v112l-2 4-134 219-131 213v7c0 9 2 15 9 21 10 10-25 9 312 9s302 1 312-9c7-6 9-12 9-21v-7L690 650 556 431l-2-4v-72l1-78c1-7 4-13 10-16 8-4 20-2 25 6l2 4v72c0 66 0 72 2 75 0 2 28 47 60 100a743734 743734 0 01200 324c6 8 8 33 3 48-7 22-23 38-45 46l-8 2-301 1-307-1z"
|
||||||
|
fill="#969a9b"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M196 936c-4 0-12-3-17-6-8-3-11-6-18-12-6-7-9-10-12-18-6-12-9-23-8-34 1-10 3-20 6-22l132-215 130-212V306l-1-111-10-5c-12-6-23-17-29-30a65 65 0 010-61c8-17 23-30 43-36l88-1 89 1a70 70 0 0150 63c1 11-2 22-8 34-6 13-16 23-29 30l-9 5-1 8c0 8-2 12-6 16-7 7-19 7-25-1-5-5-6-12-6-28 0-13 0-15 2-19 3-6 8-9 17-10 12-1 20-7 25-17 3-6 3-7 3-14 0-8 0-9-3-15-4-7-8-11-16-15l-5-2H423l-6 2c-8 4-12 8-16 15-3 6-3 7-3 15 0 7 0 8 3 14 5 10 13 15 24 17l25 2c16 0 18 1 21 3 10 7 12 21 3 29-5 3-7 4-18 5l-10 1-1 113v112l-4 7-133 218-130 212v7c0 13 6 22 17 28l6 3h598l5-2c7-4 11-8 15-15 3-5 3-7 3-14v-7L691 649 557 430l-3-5 1-75c0-69 1-75 2-78 3-6 5-8 9-10 8-4 17-2 22 5l3 4v146l130 212 132 215c3 2 5 12 6 22 1 11-2 22-8 34-3 8-6 11-12 18-7 6-10 9-18 12-17 9 11 8-322 8-257 0-297 0-303-2z"
|
||||||
|
fill="#7e8081"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M194 935c-32-8-54-38-52-70 1-9 3-18 5-20a25792 25792 0 00262-426l1-114V195l-9-4c-15-8-25-18-32-32-16-33-3-72 30-89 14-8 9-7 101-7l89 1c27 9 47 33 49 61 2 27-14 54-39 65l-7 5-1 9c0 8-3 13-9 17-10 6-22-1-25-13l-1-20c0-14 1-15 3-18 4-5 8-7 16-8 11-2 20-8 25-18 2-3 2-6 3-13 0-8 0-9-3-15-3-8-11-15-18-18l-82-1-82 1c-7 3-15 10-18 18-3 6-3 7-3 15 1 10 3 16 10 22 8 8 16 10 43 11 18 1 21 1 25 7 5 6 5 15 0 21-5 5-8 6-19 7l-11 1-1 114-1 114-263 429c-3 6-3 6-3 14 1 13 7 22 18 28l5 3h601l5-3c10-6 16-15 17-28 0-8 0-8-3-14L557 428l-1-78c0-57 1-75 2-77 1-4 6-9 9-11h14c3 2 9 8 9 10v145l8 13a90695 90695 0 00255 415c2 2 4 11 5 20 2 27-14 54-39 66-16 7 10 6-320 6-278 0-298 0-305-2z"
|
||||||
|
fill="#7a7a7a"
|
||||||
|
/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 6.5 KiB |
330
src/__tests__/pages/__snapshots__/index.tsx.snap
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`/ landing page snapshot 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<section
|
||||||
|
class="bg-white"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="bg-primary-700 bg-opacity-5"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="max-w-screen-lg mx-auto px-3 py-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap justify-between items-center"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative h-8 w-8"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
|
||||||
|
>
|
||||||
|
<noscript />
|
||||||
|
<img
|
||||||
|
alt="app logo"
|
||||||
|
decoding="async"
|
||||||
|
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||||
|
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<nav
|
||||||
|
class="flex items-center justify-end flex-1 w-0"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="whitespace-nowrap text-base font-medium text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out"
|
||||||
|
href="/auth/sign-in"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="ml-8 whitespace-nowrap inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out"
|
||||||
|
href="/auth/sign-up"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main
|
||||||
|
class="max-w-screen-lg mx-auto px-3 pt-16 pb-24 text-center"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
class="text-5xl tracking-tight font-extrabold text-gray-900"
|
||||||
|
>
|
||||||
|
Welcome to your
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
class="text-primary-600"
|
||||||
|
>
|
||||||
|
serverless
|
||||||
|
</span>
|
||||||
|
web app
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
class="mt-3 text-lg text-gray-800 sm:mt-5 sm:max-w-xl sm:mx-auto"
|
||||||
|
>
|
||||||
|
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet fugiat veniam occaecat fugiat aliqua.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="mt-12 space-y-3 sm:space-y-0 sm:space-x-3 sm:flex sm:flex-row-reverse sm:justify-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-md shadow"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out md:py-4 md:text-lg"
|
||||||
|
href="/auth/sign-up"
|
||||||
|
>
|
||||||
|
Create an account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out md:py-4 md:text-lg"
|
||||||
|
href="/auth/sign-in"
|
||||||
|
>
|
||||||
|
I'm already a user
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
<div
|
||||||
|
class="py-20"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-screen-lg mx-auto space-y-32 px-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-center"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
|
||||||
|
>
|
||||||
|
A better way to bootstrap your app
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="mt-4 max-w-2xl text-lg leading-7 text-gray-600 lg:mx-auto"
|
||||||
|
>
|
||||||
|
Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam voluptatum cupiditate veritatis in accusamus quisquam.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse items-center justify-between md:flex-row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-1 text-center"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
|
||||||
|
>
|
||||||
|
Feature #1
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="mt-6 text-lg leading-9 text-gray-800"
|
||||||
|
>
|
||||||
|
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative w-96 h-60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
|
||||||
|
>
|
||||||
|
<noscript />
|
||||||
|
<img
|
||||||
|
alt="Feature Feature #1 illustration"
|
||||||
|
decoding="async"
|
||||||
|
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||||
|
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse items-center justify-between md:flex-row-reverse"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-1 text-center"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
|
||||||
|
>
|
||||||
|
Feature #2
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="mt-6 text-lg leading-9 text-gray-800"
|
||||||
|
>
|
||||||
|
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative w-96 h-60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
|
||||||
|
>
|
||||||
|
<noscript />
|
||||||
|
<img
|
||||||
|
alt="Feature Feature #2 illustration"
|
||||||
|
decoding="async"
|
||||||
|
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||||
|
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse items-center justify-between md:flex-row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-1 text-center"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
|
||||||
|
>
|
||||||
|
Feature #3
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="mt-6 text-lg leading-9 text-gray-800"
|
||||||
|
>
|
||||||
|
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative w-96 h-60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
|
||||||
|
>
|
||||||
|
<noscript />
|
||||||
|
<img
|
||||||
|
alt="Feature Feature #3 illustration"
|
||||||
|
decoding="async"
|
||||||
|
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||||
|
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-primary-700 bg-opacity-5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-screen-lg mx-auto px-3 py-16 xl:flex xl:items-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="xl:w-0 xl:flex-1"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
class="text-3xl font-extrabold tracking-tight"
|
||||||
|
>
|
||||||
|
Want to know when we launch?
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
class="mt-3 max-w-3xl text-lg leading-6 text-gray-600"
|
||||||
|
>
|
||||||
|
Lorem ipsum, dolor sit amet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-8 sm:w-full sm:max-w-md xl:mt-0 xl:ml-8"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="sm:flex"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autocomplete=""
|
||||||
|
class="w-full border-gray-300 px-5 py-3 placeholder-gray-500 rounded-md"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email address"
|
||||||
|
required=""
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="mt-3 w-full flex items-center justify-center px-5 py-3 border border-transparent shadow text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out sm:mt-0 sm:ml-3 sm:w-auto sm:flex-shrink-0"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
💌 Notify me
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="max-w-screen-xl mx-auto py-12 px-4 sm:px-6 md:flex md:items-center md:justify-between lg:px-8"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex justify-center md:order-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="ml-6 text-gray-500 hover:text-gray-600"
|
||||||
|
href="https://twitter.com/m5r_m"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="sr-only"
|
||||||
|
>
|
||||||
|
Twitter
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="ml-6 text-gray-500 hover:text-gray-600"
|
||||||
|
href="https://github.com/m5r"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="sr-only"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-8 md:mt-0 md:order-1"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-center text-base leading-6 text-gray-600"
|
||||||
|
>
|
||||||
|
© 2021
|
||||||
|
<a
|
||||||
|
href="https://www.capsulecorp.dev"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Capsule Corp.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
171
src/__tests__/pages/account/settings/index.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/router", () => ({
|
||||||
|
useRouter: jest.fn(),
|
||||||
|
withRouter: (element: ComponentType) => element,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { ComponentType, FunctionComponent } from "react";
|
||||||
|
import { rest } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { QueryClient, QueryClientProvider } from "react-query";
|
||||||
|
import { render, waitFor, screen } from "../../../../../jest/testing-library";
|
||||||
|
|
||||||
|
import type { SerializedSession } from "../../../../../lib/session";
|
||||||
|
import SettingsPage from "../../../../pages/account/settings";
|
||||||
|
import { SessionProvider } from "../../../../session-context";
|
||||||
|
import { SidebarProvider } from "../../../../components/layout/sidebar";
|
||||||
|
|
||||||
|
const consoleError = console.error;
|
||||||
|
|
||||||
|
describe("/account/settings", () => {
|
||||||
|
type RequestBody = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = jest.fn();
|
||||||
|
const mockedUseRouter = useRouter as jest.Mock<
|
||||||
|
Partial<ReturnType<typeof useRouter>>
|
||||||
|
>;
|
||||||
|
const mockedPush = jest.fn();
|
||||||
|
mockedUseRouter.mockImplementation(() => ({
|
||||||
|
push: mockedPush,
|
||||||
|
pathname: "/account/settings",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session: SerializedSession = {
|
||||||
|
user: {
|
||||||
|
id: "auth0|1234567",
|
||||||
|
email: "test@fss.dev",
|
||||||
|
name: "test",
|
||||||
|
role: "owner",
|
||||||
|
teamId: "98765",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const server = setupServer(
|
||||||
|
rest.get("/api/user/session", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200), ctx.json(session));
|
||||||
|
}),
|
||||||
|
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const wrapper: FunctionComponent = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SidebarProvider>
|
||||||
|
<SessionProvider session={session}>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
</SidebarProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedPush.mockClear();
|
||||||
|
mockedUseRouter.mockClear();
|
||||||
|
queryClient.clear();
|
||||||
|
});
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
console.error = consoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update email only", async () => {
|
||||||
|
render(<SettingsPage session={session} />, { wrapper });
|
||||||
|
|
||||||
|
userEvent.type(
|
||||||
|
screen.getByLabelText("Email address"),
|
||||||
|
"test2@fss.dev{enter}",
|
||||||
|
);
|
||||||
|
await waitFor(() => screen.getByText("Your changes have been saved."));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mismatching passwords", async () => {
|
||||||
|
render(<SettingsPage session={session} />, { wrapper });
|
||||||
|
|
||||||
|
userEvent.type(screen.getByLabelText("New password"), "new password");
|
||||||
|
userEvent.type(
|
||||||
|
screen.getByLabelText("Confirm new password"),
|
||||||
|
"does not match{enter}",
|
||||||
|
);
|
||||||
|
await waitFor(() => screen.getByText("New passwords don't match"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid email format", async () => {
|
||||||
|
server.use(
|
||||||
|
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
|
||||||
|
return res(
|
||||||
|
ctx.status(400),
|
||||||
|
ctx.json({
|
||||||
|
statusCode: 400,
|
||||||
|
errorMessage: "Body is malformed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<SettingsPage session={session} />, { wrapper });
|
||||||
|
|
||||||
|
userEvent.type(
|
||||||
|
screen.getByLabelText("Email address"),
|
||||||
|
"malformed@email{enter}",
|
||||||
|
);
|
||||||
|
await waitFor(() => screen.getByText("Body is malformed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirect to sign in page on 401 unauthorized", async () => {
|
||||||
|
server.use(
|
||||||
|
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(401));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<SettingsPage session={session} />, { wrapper });
|
||||||
|
|
||||||
|
userEvent.type(
|
||||||
|
screen.getByLabelText("Email address"),
|
||||||
|
"unauthorized@fss.dev{enter}",
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(mockedPush).toBeCalledTimes(1));
|
||||||
|
await waitFor(() => expect(mockedPush).toBeCalledWith("/auth/sign-in"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirect to sign in page if user is unauthenticated", async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get("/api/user/session", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(401));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper: FunctionComponent = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SidebarProvider>
|
||||||
|
<SessionProvider session={null}>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
</SidebarProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SettingsPage session={session} />, { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedPush).toBeCalledWith(
|
||||||
|
"/auth/sign-in?redirectTo=/account/settings",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
323
src/__tests__/pages/account/settings/team.tsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/router", () => ({
|
||||||
|
useRouter: jest.fn(),
|
||||||
|
withRouter: (element: ComponentType) => element,
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../database/users", () => ({ findUsersByTeam: jest.fn() }));
|
||||||
|
jest.mock("../../../../database/teams", () => ({ findTeam: jest.fn() }));
|
||||||
|
|
||||||
|
import type { ComponentType, FunctionComponent } from "react";
|
||||||
|
import { rest } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { QueryClient, QueryClientProvider } from "react-query";
|
||||||
|
import { render, waitFor, screen } from "../../../../../jest/testing-library";
|
||||||
|
import { act, waitForElementToBeRemoved } from "@testing-library/react";
|
||||||
|
|
||||||
|
import type { SerializedSession } from "../../../../../lib/session";
|
||||||
|
import Session from "../../../../../lib/session";
|
||||||
|
import type { TeamMembers } from "../../../../pages/api/team/members";
|
||||||
|
import TeamPage, {
|
||||||
|
getServerSideProps,
|
||||||
|
} from "../../../../pages/account/settings/team";
|
||||||
|
import type { User } from "../../../../database/users";
|
||||||
|
import { findUsersByTeam } from "../../../../database/users";
|
||||||
|
import { findTeam } from "../../../../database/teams";
|
||||||
|
import { sessionCache } from "../../../../../lib/session-helpers";
|
||||||
|
import { SessionProvider } from "../../../../session-context";
|
||||||
|
import { SidebarProvider } from "../../../../components/layout/sidebar";
|
||||||
|
|
||||||
|
describe("/account/settings/team", () => {
|
||||||
|
const mockedPush = jest.fn();
|
||||||
|
const mockedUseRouter = useRouter as jest.Mock<
|
||||||
|
Partial<ReturnType<typeof useRouter>>
|
||||||
|
>;
|
||||||
|
const mockedFindTeam = findTeam as jest.Mock<ReturnType<typeof findTeam>>;
|
||||||
|
const mockedFindUsersByTeam = findUsersByTeam as jest.Mock<
|
||||||
|
ReturnType<typeof findUsersByTeam>
|
||||||
|
>;
|
||||||
|
window.IntersectionObserver = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => ({
|
||||||
|
observe: () => null,
|
||||||
|
disconnect: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session: SerializedSession = {
|
||||||
|
user: {
|
||||||
|
id: "auth0|1234567",
|
||||||
|
email: "test@fss.dev",
|
||||||
|
name: "test",
|
||||||
|
role: "owner",
|
||||||
|
teamId: "98765",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createdAt = new Date();
|
||||||
|
const teamMembers = [
|
||||||
|
{
|
||||||
|
...session.user,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const team = {
|
||||||
|
id: "98765",
|
||||||
|
subscriptionId: null,
|
||||||
|
teamMembersLimit: 2,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
};
|
||||||
|
const teamMembersResponse: TeamMembers = {
|
||||||
|
teamMembers,
|
||||||
|
teamMembersLimit: team.teamMembersLimit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = setupServer(
|
||||||
|
rest.get("/api/user/session", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200), ctx.json(session));
|
||||||
|
}),
|
||||||
|
rest.get("/api/team/members", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200), ctx.json(teamMembersResponse));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockedFindUsersByTeam.mockResolvedValue(teamMembers);
|
||||||
|
mockedFindTeam.mockResolvedValue(team);
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const wrapper: FunctionComponent = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SidebarProvider>
|
||||||
|
<SessionProvider session={session}>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
</SidebarProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedPush.mockClear();
|
||||||
|
mockedUseRouter.mockClear();
|
||||||
|
mockedFindTeam.mockClear();
|
||||||
|
mockedFindUsersByTeam.mockClear();
|
||||||
|
mockedUseRouter.mockImplementation(() => ({
|
||||||
|
push: mockedPush,
|
||||||
|
pathname: "/account/settings",
|
||||||
|
}));
|
||||||
|
queryClient.clear();
|
||||||
|
});
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
test("list team members and display team limit", async () => {
|
||||||
|
const teamMembersLimit = team.teamMembersLimit;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TeamPage
|
||||||
|
session={session}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
teamMembersLimit={teamMembersLimit}
|
||||||
|
/>,
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
screen.getByText(
|
||||||
|
(_, node) =>
|
||||||
|
node?.textContent ===
|
||||||
|
"Your team has 1 out of 2 team members.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invite someone", async () => {
|
||||||
|
const inviteMemberHandler = jest.fn();
|
||||||
|
server.use(
|
||||||
|
rest.post("/api/team/invite-member", (req, res, ctx) => {
|
||||||
|
inviteMemberHandler();
|
||||||
|
return res(ctx.status(200));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamMembersLimit = team.teamMembersLimit;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<TeamPage
|
||||||
|
session={session}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
teamMembersLimit={teamMembersLimit}
|
||||||
|
/>,
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.click(screen.getByText("Invite member"));
|
||||||
|
await waitFor(() =>
|
||||||
|
screen.getByText("Invite a member to your team"),
|
||||||
|
);
|
||||||
|
userEvent.type(
|
||||||
|
screen.getByLabelText("Email address"),
|
||||||
|
"recipient@fss.dev{enter}",
|
||||||
|
);
|
||||||
|
await waitForElementToBeRemoved(
|
||||||
|
screen.getByLabelText("Email address"),
|
||||||
|
);
|
||||||
|
expect(inviteMemberHandler).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("team member management", () => {
|
||||||
|
const createdAt = new Date();
|
||||||
|
const invitedUser: User = {
|
||||||
|
id: "auth0|112233",
|
||||||
|
email: "recipient@fss.dev",
|
||||||
|
name: "recipient",
|
||||||
|
teamId: session.user.teamId,
|
||||||
|
role: "member",
|
||||||
|
pendingInvitation: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
};
|
||||||
|
const teamMembers = [
|
||||||
|
{
|
||||||
|
...session.user,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
},
|
||||||
|
invitedUser,
|
||||||
|
];
|
||||||
|
const team = {
|
||||||
|
id: "98765",
|
||||||
|
subscriptionId: null,
|
||||||
|
teamMembersLimit: 2,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
};
|
||||||
|
const teamMembersResponse: TeamMembers = {
|
||||||
|
teamMembers,
|
||||||
|
teamMembersLimit: team.teamMembersLimit,
|
||||||
|
};
|
||||||
|
const teamMembersLimit = team.teamMembersLimit;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get("/api/team/members", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200), ctx.json(teamMembersResponse));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
test("re-send invitation", async () => {
|
||||||
|
const resendInvitationHandler = jest.fn();
|
||||||
|
server.use(
|
||||||
|
rest.post("/api/team/resend-invitation", (req, res, ctx) => {
|
||||||
|
resendInvitationHandler();
|
||||||
|
return res.once(ctx.status(200));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TeamPage
|
||||||
|
session={session}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
teamMembersLimit={teamMembersLimit}
|
||||||
|
/>,
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.click(
|
||||||
|
screen.getByTestId(`manage-team-member-${invitedUser.id}`),
|
||||||
|
);
|
||||||
|
userEvent.click(screen.getByText("Re-send invitation"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(resendInvitationHandler).toBeCalledTimes(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancel invitation", async () => {
|
||||||
|
const cancelInvitationHandler = jest.fn();
|
||||||
|
server.use(
|
||||||
|
rest.post("/api/team/remove-member", (req, res, ctx) => {
|
||||||
|
cancelInvitationHandler();
|
||||||
|
return res.once(ctx.status(200));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TeamPage
|
||||||
|
session={session}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
teamMembersLimit={teamMembersLimit}
|
||||||
|
/>,
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
|
||||||
|
// await waitFor(() => screen.getByText((_, node) => node?.textContent === "Your team has 2 out of 2 team members."));
|
||||||
|
userEvent.click(
|
||||||
|
screen.getByTestId(`manage-team-member-${invitedUser.id}`),
|
||||||
|
);
|
||||||
|
userEvent.click(screen.getByText("Cancel invitation"));
|
||||||
|
userEvent.click(screen.getByText("Remove from my team"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(cancelInvitationHandler).toBeCalledTimes(1),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
screen.getByText(
|
||||||
|
(_, node) =>
|
||||||
|
node?.textContent ===
|
||||||
|
"Your team has 1 out of 2 team members.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getServerSideProps", () => {
|
||||||
|
const context: any = {
|
||||||
|
req: {},
|
||||||
|
res: {},
|
||||||
|
resolvedUrl: "/account/settings/team",
|
||||||
|
};
|
||||||
|
sessionCache.set(context.req, context.req, new Session(session.user));
|
||||||
|
|
||||||
|
test("return team members and team limit", async () => {
|
||||||
|
const serverSideProps = await getServerSideProps(context);
|
||||||
|
// @ts-ignore
|
||||||
|
delete serverSideProps.props._superjson;
|
||||||
|
expect(serverSideProps).toStrictEqual({
|
||||||
|
props: {
|
||||||
|
session: {
|
||||||
|
accessToken: null,
|
||||||
|
accessTokenExpiresAt: null,
|
||||||
|
accessTokenScope: null,
|
||||||
|
idToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: {
|
||||||
|
email: "test@fss.dev",
|
||||||
|
id: "auth0|1234567",
|
||||||
|
name: "test",
|
||||||
|
role: "owner",
|
||||||
|
teamId: "98765",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teamMembers: [
|
||||||
|
{
|
||||||
|
createdAt: createdAt.toISOString(),
|
||||||
|
email: "test@fss.dev",
|
||||||
|
id: "auth0|1234567",
|
||||||
|
name: "test",
|
||||||
|
role: "owner",
|
||||||
|
teamId: "98765",
|
||||||
|
updatedAt: createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
teamMembersLimit: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
221
src/__tests__/pages/api/auth/sign-in.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
jest.mock("../../../../pages/api/user/_auth0", () => ({
|
||||||
|
getAppMetadata: jest.fn(),
|
||||||
|
setAppMetadata: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../database/users", () => ({
|
||||||
|
findUser: jest.fn(),
|
||||||
|
createUser: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../pages/api/_send-email", () => ({
|
||||||
|
sendEmail: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../database/teams", () => ({ createTeam: jest.fn() }));
|
||||||
|
|
||||||
|
import { parse } from "set-cookie-parser";
|
||||||
|
|
||||||
|
import { callApiHandler } from "../../../../../jest/helpers";
|
||||||
|
import signInHandler from "../../../../pages/api/auth/sign-in";
|
||||||
|
import { sessionName } from "../../../../../lib/cookie-store";
|
||||||
|
import { sendEmail } from "../../../../pages/api/_send-email";
|
||||||
|
import { findUser, createUser } from "../../../../database/users";
|
||||||
|
import { createTeam } from "../../../../database/teams";
|
||||||
|
import { getAppMetadata } from "../../../../pages/api/user/_auth0";
|
||||||
|
|
||||||
|
describe("/api/auth/sign-in", () => {
|
||||||
|
const mockedSendEmail = sendEmail as jest.Mock<
|
||||||
|
ReturnType<typeof sendEmail>
|
||||||
|
>;
|
||||||
|
const mockedGetAppMetadata = getAppMetadata as jest.Mock<
|
||||||
|
ReturnType<typeof getAppMetadata>
|
||||||
|
>;
|
||||||
|
const mockedFindUser = findUser as jest.Mock<ReturnType<typeof findUser>>;
|
||||||
|
const mockedCreateUser = createUser as jest.Mock<
|
||||||
|
ReturnType<typeof createUser>
|
||||||
|
>;
|
||||||
|
const mockedCreateTeam = createTeam as jest.Mock<
|
||||||
|
ReturnType<typeof createTeam>
|
||||||
|
>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedFindUser.mockClear();
|
||||||
|
mockedCreateUser.mockClear();
|
||||||
|
mockedGetAppMetadata.mockClear();
|
||||||
|
mockedSendEmail.mockClear();
|
||||||
|
mockedCreateTeam.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 405 to GET", async () => {
|
||||||
|
const response = await callApiHandler(signInHandler, { method: "GET" });
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 400 to POST with malformed body", async () => {
|
||||||
|
const response = await callApiHandler(signInHandler, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 to POST with body from email login", async () => {
|
||||||
|
mockedFindUser.mockResolvedValue({
|
||||||
|
id: "auth0|1234567",
|
||||||
|
teamId: "98765",
|
||||||
|
role: "owner",
|
||||||
|
email: "test@fss.dev",
|
||||||
|
name: "Groot",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
mockedGetAppMetadata.mockResolvedValue({ teamId: "98765" });
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
accessToken:
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDEyMzQ1NjciLCJhdWQiOlsiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS9hcGkvdjIvIiwiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS91c2VyaW5mbyJdLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTM4OTY0NSwiYXpwIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJlYWQ6Y3VycmVudF91c2VyIHVwZGF0ZTpjdXJyZW50X3VzZXJfbWV0YWRhdGEgZGVsZXRlOmN1cnJlbnRfdXNlcl9tZXRhZGF0YSBjcmVhdGU6Y3VycmVudF91c2VyX21ldGFkYXRhIGNyZWF0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIGRlbGV0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIHVwZGF0ZTpjdXJyZW50X3VzZXJfaWRlbnRpdGllcyBvZmZsaW5lX2FjY2VzcyIsImd0eSI6InBhc3N3b3JkIn0",
|
||||||
|
idToken:
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuaWNrbmFtZSI6InRlc3QiLCJuYW1lIjoiR3Jvb3QiLCJwaWN0dXJlIjoiaHR0cHM6Ly9zLmdyYXZhdGFyLmNvbS9hdmF0YXIvYTNiNWU5MjkzYWE1MjE1MTUxZTdjOWVhM2FlZjE4MGQ/cz00ODAmcj1wZyZkPWh0dHBzJTNBJTJGJTJGY2RuLmF1dGgwLmNvbSUyRmF2YXRhcnMlMkZnci5wbmciLCJ1cGRhdGVkX2F0IjoiMjAyMS0wNC0yNFQyMjoyNzoyNS43ODlaIiwiZW1haWwiOiJ0ZXN0QGZzcy5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3IiwiYXVkIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTMzOTI0NX0",
|
||||||
|
scope:
|
||||||
|
"openid profile email read:current_user update:current_user_metadata delete:current_user_metadata create:current_user_metadata create:current_user_device_credentials delete:current_user_device_credentials update:current_user_identities offline_access",
|
||||||
|
tokenType: "Bearer",
|
||||||
|
refreshToken:
|
||||||
|
"v1.Mb2-7pHz02BMS63hMwHhjFCq5KPy0L29ZENzKIr-KaIFuSxhqDvLTac-ZLwrbQR6KOYRq21d5R5QLvZfeKZMCGM",
|
||||||
|
expiresIn: 86400,
|
||||||
|
};
|
||||||
|
const response = await callApiHandler(signInHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const setCookieHeader = response.headers.get("set-cookie")!;
|
||||||
|
const parsedCookies = parse(setCookieHeader);
|
||||||
|
const cookieHasSession = parsedCookies.some((cookie) =>
|
||||||
|
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
|
||||||
|
);
|
||||||
|
expect(cookieHasSession).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 to POST with body from 3rd party provider login", async () => {
|
||||||
|
mockedFindUser.mockResolvedValue({
|
||||||
|
id: "google-oauth2|103423079071922868186",
|
||||||
|
teamId: "98765",
|
||||||
|
role: "owner",
|
||||||
|
email: "fss.user@gmail.com",
|
||||||
|
name: "FSS User",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
mockedGetAppMetadata.mockResolvedValue({ teamId: "98765" });
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
accessToken:
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTAzNDIzMDc5MDcxOTIyODY4MTg2IiwiYXVkIjpbImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vYXBpL3YyLyIsImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyMzYwNjgsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MifQ",
|
||||||
|
idToken:
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJnaXZlbl9uYW1lIjoiRlNTIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwibmlja25hbWUiOiJmc3MudXNlciIsIm5hbWUiOiJGU1MgVXNlciIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vLXgycjhsd0ptUGpNL0FBQUFBQUFBQUFJL0FBQUFBQUFBQUFBL0FNWnV1Y25xLWFocW4tR2VyTHhwakEzODZDbi1kUEtyWkEvczk2LWMvcGhvdG8uanBnIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoiMjAyMS0wMi0xM1QxNTowNzo0OC4zNDlaIiwiZW1haWwiOiJmc3MudXNlckBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMzQyMzA3OTA3MTkyMjg2ODE4NiIsImF1ZCI6ImF1ZGllbmNlIiwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyNjQ4NjgsImF0X2hhc2giOiJiQnFSWVlNUWJzUW5od1R5dGR0SmZBIiwibm9uY2UiOiJDeXJuVm1PU3Q0b0pwWkFDaTQwaXU1YUxON3JGM0JrayJ9.G-mNH6NegAJvaX77nijdrBAXJtNbwzyzLSFLvZOuRMojTxHaecwQyPw4oyj98fVx4K7Wvv7XuyTRcP54DsAiyXwaFCyCdU_X0aE058gmXxmD89udd2yWnz24DgjrNmR2EPqcXRZ5eqNH4_XtfhQAtUWhGpvBbmuLfrMphJLfzWn8rMJP185ahTosjrKl8Hun4nRb3IGYQcfOZzDv8JTki8p38tnVIxZA5QBXNDSxNYaoc2u6QsAd8srQ2aScotPuNG82YAECdQ6ySc-ODGtMQsCr3CwqHVhqUD2nyQtuZ1iiMKCcBKHGCVcuMvibKjKrAV-rFAiYccZ3b-AsmB_u6w",
|
||||||
|
idTokenPayload: {
|
||||||
|
given_name: "FSS",
|
||||||
|
family_name: "User",
|
||||||
|
nickname: "fss.user",
|
||||||
|
name: "FSS User",
|
||||||
|
picture:
|
||||||
|
"https://lh3.googleusercontent.com/-x2r8lwJmPjM/AAAAAAAAAAI/AAAAAAAAAAA/AMZuucnq-ahqn-GerLxpjA386Cn-dPKrZA/s96-c/photo.jpg",
|
||||||
|
locale: "en",
|
||||||
|
updated_at: "2021-02-13T15:07:48.349Z",
|
||||||
|
email: "fss.user@gmail.com",
|
||||||
|
email_verified: true,
|
||||||
|
iss: "https://mokhtar.eu.auth0.com/",
|
||||||
|
sub: "google-oauth2|103423079071922868186",
|
||||||
|
aud: "audience",
|
||||||
|
iat: 1613228868,
|
||||||
|
exp: 1613264868,
|
||||||
|
at_hash: "bBqRYYMQbsQnhwTytdtJfA",
|
||||||
|
nonce: "CyrnVmOSt4oJpZACi40iu5aLN7rF3Bkk",
|
||||||
|
},
|
||||||
|
appState: null,
|
||||||
|
refreshToken: null,
|
||||||
|
state: "xKre8N8V5iq4s4e6GPYvwpRc00WtIn7u",
|
||||||
|
expiresIn: 7200,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
scope: "openid profile email offline_access",
|
||||||
|
};
|
||||||
|
const response = await callApiHandler(signInHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const setCookieHeader = response.headers.get("set-cookie")!;
|
||||||
|
const parsedCookies = parse(setCookieHeader);
|
||||||
|
const cookieHasSession = parsedCookies.some((cookie) =>
|
||||||
|
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
|
||||||
|
);
|
||||||
|
expect(cookieHasSession).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 to POST with body from 3rd party provider login for the first time", async () => {
|
||||||
|
mockedFindUser.mockResolvedValue(undefined);
|
||||||
|
mockedCreateTeam.mockResolvedValue({
|
||||||
|
id: "98765",
|
||||||
|
subscriptionId: null,
|
||||||
|
teamMembersLimit: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
mockedCreateUser.mockResolvedValue({
|
||||||
|
id: "google-oauth2|103423079071922868186",
|
||||||
|
teamId: "98765",
|
||||||
|
role: "owner",
|
||||||
|
email: "fss.user@gmail.com",
|
||||||
|
name: "FSS User",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
accessToken:
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTAzNDIzMDc5MDcxOTIyODY4MTg2IiwiYXVkIjpbImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vYXBpL3YyLyIsImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyMzYwNjgsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MifQ",
|
||||||
|
idToken:
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJnaXZlbl9uYW1lIjoiRlNTIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwibmlja25hbWUiOiJmc3MudXNlciIsIm5hbWUiOiJGU1MgVXNlciIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vLXgycjhsd0ptUGpNL0FBQUFBQUFBQUFJL0FBQUFBQUFBQUFBL0FNWnV1Y25xLWFocW4tR2VyTHhwakEzODZDbi1kUEtyWkEvczk2LWMvcGhvdG8uanBnIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoiMjAyMS0wMi0xM1QxNTowNzo0OC4zNDlaIiwiZW1haWwiOiJmc3MudXNlckBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMzQyMzA3OTA3MTkyMjg2ODE4NiIsImF1ZCI6ImF1ZGllbmNlIiwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyNjQ4NjgsImF0X2hhc2giOiJiQnFSWVlNUWJzUW5od1R5dGR0SmZBIiwibm9uY2UiOiJDeXJuVm1PU3Q0b0pwWkFDaTQwaXU1YUxON3JGM0JrayJ9.G-mNH6NegAJvaX77nijdrBAXJtNbwzyzLSFLvZOuRMojTxHaecwQyPw4oyj98fVx4K7Wvv7XuyTRcP54DsAiyXwaFCyCdU_X0aE058gmXxmD89udd2yWnz24DgjrNmR2EPqcXRZ5eqNH4_XtfhQAtUWhGpvBbmuLfrMphJLfzWn8rMJP185ahTosjrKl8Hun4nRb3IGYQcfOZzDv8JTki8p38tnVIxZA5QBXNDSxNYaoc2u6QsAd8srQ2aScotPuNG82YAECdQ6ySc-ODGtMQsCr3CwqHVhqUD2nyQtuZ1iiMKCcBKHGCVcuMvibKjKrAV-rFAiYccZ3b-AsmB_u6w",
|
||||||
|
idTokenPayload: {
|
||||||
|
given_name: "FSS",
|
||||||
|
family_name: "User",
|
||||||
|
nickname: "fss.user",
|
||||||
|
name: "FSS User",
|
||||||
|
picture:
|
||||||
|
"https://lh3.googleusercontent.com/-x2r8lwJmPjM/AAAAAAAAAAI/AAAAAAAAAAA/AMZuucnq-ahqn-GerLxpjA386Cn-dPKrZA/s96-c/photo.jpg",
|
||||||
|
locale: "en",
|
||||||
|
updated_at: "2021-02-13T15:07:48.349Z",
|
||||||
|
email: "fss.user@gmail.com",
|
||||||
|
email_verified: true,
|
||||||
|
iss: "https://mokhtar.eu.auth0.com/",
|
||||||
|
sub: "google-oauth2|103423079071922868186",
|
||||||
|
aud: "audience",
|
||||||
|
iat: 1613228868,
|
||||||
|
exp: 1613264868,
|
||||||
|
at_hash: "bBqRYYMQbsQnhwTytdtJfA",
|
||||||
|
nonce: "CyrnVmOSt4oJpZACi40iu5aLN7rF3Bkk",
|
||||||
|
},
|
||||||
|
appState: null,
|
||||||
|
refreshToken: null,
|
||||||
|
state: "xKre8N8V5iq4s4e6GPYvwpRc00WtIn7u",
|
||||||
|
expiresIn: 7200,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
scope: "openid profile email offline_access",
|
||||||
|
};
|
||||||
|
const response = await callApiHandler(signInHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockedSendEmail).toBeCalledTimes(1);
|
||||||
|
expect(mockedSendEmail.mock.calls[0][0].recipients[0]).toBe(
|
||||||
|
"fss.user@gmail.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCookieHeader = response.headers.get("set-cookie")!;
|
||||||
|
const parsedCookies = parse(setCookieHeader);
|
||||||
|
const cookieHasSession = parsedCookies.some((cookie) =>
|
||||||
|
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
|
||||||
|
);
|
||||||
|
expect(cookieHasSession).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
95
src/__tests__/pages/api/auth/sign-up.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
jest.mock("../../../../pages/api/user/_auth0", () => ({
|
||||||
|
setAppMetadata: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../pages/api/_send-email", () => ({
|
||||||
|
sendEmail: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../database/users", () => ({ createUser: jest.fn() }));
|
||||||
|
jest.mock("../../../../database/teams", () => ({ createTeam: jest.fn() }));
|
||||||
|
|
||||||
|
import { parse } from "set-cookie-parser";
|
||||||
|
|
||||||
|
import { callApiHandler } from "../../../../../jest/helpers";
|
||||||
|
import signUpHandler from "../../../../pages/api/auth/sign-up";
|
||||||
|
import { sessionName } from "../../../../../lib/cookie-store";
|
||||||
|
import { sendEmail } from "../../../../pages/api/_send-email";
|
||||||
|
import { createUser } from "../../../../database/users";
|
||||||
|
import { createTeam } from "../../../../database/teams";
|
||||||
|
|
||||||
|
describe("/api/auth/sign-up", () => {
|
||||||
|
const mockedSendEmail = sendEmail as jest.Mock<
|
||||||
|
ReturnType<typeof sendEmail>
|
||||||
|
>;
|
||||||
|
const mockedCreateUser = createUser as jest.Mock<
|
||||||
|
ReturnType<typeof createUser>
|
||||||
|
>;
|
||||||
|
const mockedCreateTeam = createTeam as jest.Mock<
|
||||||
|
ReturnType<typeof createTeam>
|
||||||
|
>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedSendEmail.mockClear();
|
||||||
|
mockedCreateUser.mockClear();
|
||||||
|
mockedCreateTeam.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 405 to GET", async () => {
|
||||||
|
const response = await callApiHandler(signUpHandler, { method: "GET" });
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 400 to POST with malformed body", async () => {
|
||||||
|
const response = await callApiHandler(signUpHandler, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 to POST with body from email login", async () => {
|
||||||
|
mockedCreateUser.mockResolvedValue({
|
||||||
|
id: "auth0|1234567",
|
||||||
|
teamId: "98765",
|
||||||
|
role: "owner",
|
||||||
|
email: "test@fss.dev",
|
||||||
|
name: "Groot",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
mockedCreateTeam.mockResolvedValue({
|
||||||
|
id: "98765",
|
||||||
|
subscriptionId: null,
|
||||||
|
teamMembersLimit: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
accessToken:
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDEyMzQ1NjciLCJhdWQiOlsiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS9hcGkvdjIvIiwiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS91c2VyaW5mbyJdLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTM4OTY0NSwiYXpwIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJlYWQ6Y3VycmVudF91c2VyIHVwZGF0ZTpjdXJyZW50X3VzZXJfbWV0YWRhdGEgZGVsZXRlOmN1cnJlbnRfdXNlcl9tZXRhZGF0YSBjcmVhdGU6Y3VycmVudF91c2VyX21ldGFkYXRhIGNyZWF0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIGRlbGV0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIHVwZGF0ZTpjdXJyZW50X3VzZXJfaWRlbnRpdGllcyBvZmZsaW5lX2FjY2VzcyIsImd0eSI6InBhc3N3b3JkIn0",
|
||||||
|
idToken:
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuaWNrbmFtZSI6InRlc3QiLCJuYW1lIjoiR3Jvb3QiLCJwaWN0dXJlIjoiaHR0cHM6Ly9zLmdyYXZhdGFyLmNvbS9hdmF0YXIvYTNiNWU5MjkzYWE1MjE1MTUxZTdjOWVhM2FlZjE4MGQ/cz00ODAmcj1wZyZkPWh0dHBzJTNBJTJGJTJGY2RuLmF1dGgwLmNvbSUyRmF2YXRhcnMlMkZnci5wbmciLCJ1cGRhdGVkX2F0IjoiMjAyMS0wNC0yNFQyMjoyNzoyNS43ODlaIiwiZW1haWwiOiJ0ZXN0QGZzcy5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3IiwiYXVkIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTMzOTI0NX0",
|
||||||
|
scope:
|
||||||
|
"openid profile email read:current_user update:current_user_metadata delete:current_user_metadata create:current_user_metadata create:current_user_device_credentials delete:current_user_device_credentials update:current_user_identities offline_access",
|
||||||
|
tokenType: "Bearer",
|
||||||
|
refreshToken:
|
||||||
|
"v1.Mb2-7pHz02BMS63hMwHhjFCq5KPy0L29ZENzKIr-KaIFuSxhqDvLTac-ZLwrbQR6KOYRq21d5R5QLvZfeKZMCGM",
|
||||||
|
expiresIn: 86400,
|
||||||
|
};
|
||||||
|
const response = await callApiHandler(signUpHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockedSendEmail).toBeCalledTimes(1);
|
||||||
|
expect(mockedSendEmail.mock.calls[0][0].recipients[0]).toBe(
|
||||||
|
"test@fss.dev",
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCookieHeader = response.headers.get("set-cookie")!;
|
||||||
|
const parsedCookies = parse(setCookieHeader);
|
||||||
|
const cookieHasSession = parsedCookies.some((cookie) =>
|
||||||
|
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
|
||||||
|
);
|
||||||
|
expect(cookieHasSession).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
164
src/__tests__/pages/api/subscription/_subscription-created.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
jest.mock("../../../../database/teams", () => ({
|
||||||
|
findTeam: jest.fn(),
|
||||||
|
updateTeam: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../database/subscriptions", () => ({
|
||||||
|
...jest.requireActual("../../../../database/subscriptions"),
|
||||||
|
createSubscription: jest.fn(),
|
||||||
|
findTeamSubscription: jest.fn(),
|
||||||
|
updateSubscription: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../pages/api/_send-email", () => ({
|
||||||
|
sendEmail: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../subscription/plans", () => ({
|
||||||
|
PAID_PLANS: {
|
||||||
|
"229": { teamMembersLimit: 2 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { subscriptionCreatedHandler } from "../../../../pages/api/subscription/_subscription-created";
|
||||||
|
import { callApiHandler } from "../../../../../jest/helpers";
|
||||||
|
import { findTeam, updateTeam } from "../../../../database/teams";
|
||||||
|
import {
|
||||||
|
createSubscription,
|
||||||
|
findUserSubscription,
|
||||||
|
updateSubscription,
|
||||||
|
} from "../../../../database/subscriptions";
|
||||||
|
import { sendEmail } from "../../../../pages/api/_send-email";
|
||||||
|
|
||||||
|
describe("subscription_created webhook event", () => {
|
||||||
|
const mockedSendEmail = sendEmail as jest.Mock<
|
||||||
|
ReturnType<typeof sendEmail>
|
||||||
|
>;
|
||||||
|
const mockedFindTeam = findTeam as jest.Mock<ReturnType<typeof findTeam>>;
|
||||||
|
const mockedUpdateTeam = updateTeam as jest.Mock<
|
||||||
|
ReturnType<typeof updateTeam>
|
||||||
|
>;
|
||||||
|
const mockedCreateSubscription = createSubscription as jest.Mock<
|
||||||
|
ReturnType<typeof createSubscription>
|
||||||
|
>;
|
||||||
|
const mockedFindTeamSubscription = findUserSubscription as jest.Mock<
|
||||||
|
ReturnType<typeof findUserSubscription>
|
||||||
|
>;
|
||||||
|
const mockedUpdateSubscription = updateSubscription as jest.Mock<
|
||||||
|
ReturnType<typeof updateSubscription>
|
||||||
|
>;
|
||||||
|
|
||||||
|
mockedSendEmail.mockResolvedValue();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedSendEmail.mockClear();
|
||||||
|
mockedFindTeam.mockClear();
|
||||||
|
mockedUpdateTeam.mockClear();
|
||||||
|
mockedCreateSubscription.mockClear();
|
||||||
|
mockedFindTeamSubscription.mockClear();
|
||||||
|
mockedUpdateSubscription.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 400 to malformed event", async () => {
|
||||||
|
const { status } = await callApiHandler(subscriptionCreatedHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
expect(status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 404 to valid event with unknown team", async () => {
|
||||||
|
const teamId = "123";
|
||||||
|
const subscriptionId = "222";
|
||||||
|
const planId = "229";
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
alert_id: 1789225139,
|
||||||
|
alert_name: "subscription_created",
|
||||||
|
cancel_url:
|
||||||
|
"https://checkout.paddle.com/subscription/cancel?user=4&subscription=9&hash=098bc6b2f641b4f7595fead9f566682f8c512eb0",
|
||||||
|
checkout_id: "4-d4d49ef5de45892-d6b186adb1",
|
||||||
|
currency: "GBP",
|
||||||
|
email: "reichert.arnaldo@example.net",
|
||||||
|
event_time: "2021-05-07 13:50:58",
|
||||||
|
linked_subscriptions: "6, 8, 7",
|
||||||
|
marketing_consent: undefined,
|
||||||
|
next_bill_date: "2021-06-02",
|
||||||
|
passthrough: `{"teamId":"${teamId}"}`,
|
||||||
|
quantity: 16,
|
||||||
|
source: "Activation",
|
||||||
|
status: "active",
|
||||||
|
subscription_id: subscriptionId,
|
||||||
|
subscription_plan_id: planId,
|
||||||
|
unit_price: "unit_price",
|
||||||
|
update_url:
|
||||||
|
"https://checkout.paddle.com/subscription/update?user=6&subscription=5&hash=018ca7a6b63aaf4c68b7405735084788a3cdd5c6",
|
||||||
|
user_id: "9",
|
||||||
|
p_signature:
|
||||||
|
"Pi/tWLioiCwtTa5HU7N29H1AEDXhfH6+YiBGzu4jxqmXOHZXWVQz0sFMkh4z3Ykp79WgChanGm6kysHk96eGGgM5cg7Y6TCXYFnwHhQdNkkQTPpNrDGbKXdJxj7JJNqa0JxTamMRIXi0o6Azdr2rOgvm+6jQ/FULtZxyqUJSlnm9UrC/QKwPpajtIMUvZy4uSUZnGQl5ynisoyazfFMN3YJ5TMDm0K5Yxx6RC0b+G5AItub900s3jjr41VYhm7svwE/jUCeeNoKT/CIrvBDgWTrqdQYVscTtiSkss9DguDA8yWx2jmzR+fobIxunH3EZ5j7dPFu8WgYtfxeeaaKyChXdl0ubjw2Jwq9PfXjClZnQj6zcEi947329oXN42/lD9FCDbiDkzIiOvOH+RNc3pbPTFfWekcHsc4GEfs2u0ahQ8SbEsLNkki+zF2kaUZrP3qGALnUeHqdSfqivwlEzrb8Qu0Kj6VZfA4zMyAGwgIi2UOFTbXpdck1VJAc0+nafGom9gqTtmqRHwaroKGNKJ7t7AIgjcHZ8I8cgM5Q+OB1i7/JF8aA/WMe4jTdprxeda1XYHCHop+lmwFcSbCc95ZTeD+A0XyGB824eBNU4VTeWfvGhrFNU94qKZXWSq29fl04XaI3hKS1fGbERJ3dz5DUyEU9KpBjSQ+h2MKdbCNw=",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedFindTeam.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const { status } = await callApiHandler(subscriptionCreatedHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body: event,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(404);
|
||||||
|
expect(mockedCreateSubscription).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 to valid event", async () => {
|
||||||
|
const teamId = "123";
|
||||||
|
const subscriptionId = "222";
|
||||||
|
const planId = "229";
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
alert_id: 1789225139,
|
||||||
|
alert_name: "subscription_created",
|
||||||
|
cancel_url:
|
||||||
|
"https://checkout.paddle.com/subscription/cancel?user=4&subscription=9&hash=098bc6b2f641b4f7595fead9f566682f8c512eb0",
|
||||||
|
checkout_id: "4-d4d49ef5de45892-d6b186adb1",
|
||||||
|
currency: "GBP",
|
||||||
|
email: "reichert.arnaldo@example.net",
|
||||||
|
event_time: "2021-05-07 13:50:58",
|
||||||
|
linked_subscriptions: "6, 8, 7",
|
||||||
|
marketing_consent: undefined,
|
||||||
|
next_bill_date: "2021-06-02",
|
||||||
|
passthrough: `{"teamId":"${teamId}"}`,
|
||||||
|
quantity: 16,
|
||||||
|
source: "Activation",
|
||||||
|
status: "active",
|
||||||
|
subscription_id: subscriptionId,
|
||||||
|
subscription_plan_id: planId,
|
||||||
|
unit_price: "unit_price",
|
||||||
|
update_url:
|
||||||
|
"https://checkout.paddle.com/subscription/update?user=6&subscription=5&hash=018ca7a6b63aaf4c68b7405735084788a3cdd5c6",
|
||||||
|
user_id: "9",
|
||||||
|
p_signature:
|
||||||
|
"Pi/tWLioiCwtTa5HU7N29H1AEDXhfH6+YiBGzu4jxqmXOHZXWVQz0sFMkh4z3Ykp79WgChanGm6kysHk96eGGgM5cg7Y6TCXYFnwHhQdNkkQTPpNrDGbKXdJxj7JJNqa0JxTamMRIXi0o6Azdr2rOgvm+6jQ/FULtZxyqUJSlnm9UrC/QKwPpajtIMUvZy4uSUZnGQl5ynisoyazfFMN3YJ5TMDm0K5Yxx6RC0b+G5AItub900s3jjr41VYhm7svwE/jUCeeNoKT/CIrvBDgWTrqdQYVscTtiSkss9DguDA8yWx2jmzR+fobIxunH3EZ5j7dPFu8WgYtfxeeaaKyChXdl0ubjw2Jwq9PfXjClZnQj6zcEi947329oXN42/lD9FCDbiDkzIiOvOH+RNc3pbPTFfWekcHsc4GEfs2u0ahQ8SbEsLNkki+zF2kaUZrP3qGALnUeHqdSfqivwlEzrb8Qu0Kj6VZfA4zMyAGwgIi2UOFTbXpdck1VJAc0+nafGom9gqTtmqRHwaroKGNKJ7t7AIgjcHZ8I8cgM5Q+OB1i7/JF8aA/WMe4jTdprxeda1XYHCHop+lmwFcSbCc95ZTeD+A0XyGB824eBNU4VTeWfvGhrFNU94qKZXWSq29fl04XaI3hKS1fGbERJ3dz5DUyEU9KpBjSQ+h2MKdbCNw=",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedFindTeam.mockResolvedValueOnce({
|
||||||
|
id: teamId,
|
||||||
|
subscriptionId: null,
|
||||||
|
teamMembersLimit: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status } = await callApiHandler(subscriptionCreatedHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body: event,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(mockedCreateSubscription).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedUpdateTeam).toHaveBeenCalledWith({
|
||||||
|
id: teamId,
|
||||||
|
subscriptionId,
|
||||||
|
teamMembersLimit: 2,
|
||||||
|
});
|
||||||
|
expect(mockedSendEmail.mock.calls[0][0].recipients).toStrictEqual([
|
||||||
|
event.email,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
111
src/__tests__/pages/api/subscription/webhook.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
jest.mock(
|
||||||
|
"../../../../pages/api/subscription/_subscription-payment-succeeded",
|
||||||
|
() => ({
|
||||||
|
subscriptionPaymentSucceededHandler: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
import type { NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { subscriptionPaymentSucceededHandler } from "../../../../pages/api/subscription/_subscription-payment-succeeded";
|
||||||
|
import { callApiHandler } from "../../../../../jest/helpers";
|
||||||
|
import webhookHandler from "../../../../pages/api/subscription/webhook";
|
||||||
|
|
||||||
|
describe("/api/subscription/webhook", () => {
|
||||||
|
const mockedSubscriptionPaymentSucceededHandler = subscriptionPaymentSucceededHandler as jest.Mock<
|
||||||
|
ReturnType<typeof subscriptionPaymentSucceededHandler>
|
||||||
|
>;
|
||||||
|
mockedSubscriptionPaymentSucceededHandler.mockImplementation(
|
||||||
|
async (_, res: NextApiResponse) => res.status(200).end(),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedSubscriptionPaymentSucceededHandler.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 405 to GET", async () => {
|
||||||
|
const { status } = await callApiHandler(webhookHandler, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
expect(status).toBe(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 500 to POST with invalid webhook event", async () => {
|
||||||
|
const response = await callApiHandler(webhookHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 400 to POST with unsupported webhook event", async () => {
|
||||||
|
const response = await callApiHandler(webhookHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body: payoutPaid,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 to POST with supported and valid webhook event", async () => {
|
||||||
|
const response = await callApiHandler(webhookHandler, {
|
||||||
|
method: "POST",
|
||||||
|
body: subscriptionPaymentSucceeded,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockedSubscriptionPaymentSucceededHandler).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const payoutPaid = {
|
||||||
|
alert_id: 833499511,
|
||||||
|
alert_name: "transfer_paid",
|
||||||
|
amount: 648.8,
|
||||||
|
currency: "USD",
|
||||||
|
event_time: "2021-05-07 00:29:50",
|
||||||
|
payout_id: 6,
|
||||||
|
status: "paid",
|
||||||
|
p_signature:
|
||||||
|
"p5AwTrjZPgczHkU8CHiUc7VH1mn8FLH+s+JUaNqrlY7xhaD+KG2Aq6njnwH4Q+xGN51pwpFZDpjBI6EZIsYlP/Rs3GWObJU7I2xOpvLXIrvjMDeIgNVL2s+BWeqqzylFYGsH1uKHQIFa5fm/JiUEErHecoNyk3GcwP7j2qeiHra64i+mjhzKsprUd4NUlhxD7nEpfRpM7aMuMii7WE/EGBBW12bxiJCRcrm0yuSrDLTZCbiOnK6ddPqsYrSPjWJjSOFXblQK+erOTuvOZuRaf5eiZodbiOyeGsgZ/AhfqXiWt0bOpbuqgMkofUJSgz5AV3y3HgqxhhsrXCTRgdexr/6Cx7+k1mm2AWMhuTn3DU3+2eDkiNIeP52hPtjx6h/Kxbb7/OoxYB9rfDT42m553nPbWxdSGw6Zz5h2oWOH0goFAFMi9CSXS+HilXpmKWc2KjIFYyu8Yu+3lZ2KAMWPwDEc8liQsWZVSo/R4SXcd3t5p+k3uhFwRkwIoeF7If25MQADEBK1s84p5tZTgo4EPkqEwRYZdRiTBZ+xzrrEOvsAA192hEXcjWRnFlqYeMITY/j2rf/ZTlXXbLw1Bcje1vr27z3Qe64GP4m4Whrh37N0kOkSElMXnCMx8fj3WgyMyHZhKGE96t+sfuA1NJy/dGl968uJIz1XVWh9F+6fcGo=",
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscriptionPaymentSucceeded = {
|
||||||
|
alert_id: 1667920177,
|
||||||
|
alert_name: "subscription_payment_succeeded",
|
||||||
|
balance_currency: "USD",
|
||||||
|
balance_earnings: 791.71,
|
||||||
|
balance_fee: 774.49,
|
||||||
|
balance_gross: 102.03,
|
||||||
|
balance_tax: 282.55,
|
||||||
|
checkout_id: "4-599cbbe6fe49dc0-4c628740d6",
|
||||||
|
country: "DE",
|
||||||
|
coupon: "Coupon 5",
|
||||||
|
currency: "USD",
|
||||||
|
customer_name: "customer_name",
|
||||||
|
earnings: 253.39,
|
||||||
|
email: "baron.daugherty@example.org",
|
||||||
|
event_time: "2021-05-07 00:18:15",
|
||||||
|
fee: 0.11,
|
||||||
|
initial_payment: true,
|
||||||
|
instalments: 6,
|
||||||
|
marketing_consent: 1,
|
||||||
|
next_bill_date: "2021-05-23",
|
||||||
|
next_payment_amount: "next_payment_amount",
|
||||||
|
order_id: 7,
|
||||||
|
passthrough: "Example String",
|
||||||
|
payment_method: "card",
|
||||||
|
payment_tax: 0.69,
|
||||||
|
plan_name: "Example String",
|
||||||
|
quantity: 62,
|
||||||
|
receipt_url: "https://my.paddle.com/receipt/1/a18b96518813baa-a470ddf641",
|
||||||
|
sale_gross: 556.08,
|
||||||
|
status: "trialing",
|
||||||
|
subscription_id: 3,
|
||||||
|
subscription_payment_id: 4,
|
||||||
|
subscription_plan_id: 9,
|
||||||
|
unit_price: "unit_price",
|
||||||
|
user_id: 5,
|
||||||
|
p_signature:
|
||||||
|
"eucBVrNR/4KySSm+sSGwcBcaCXXZFEyTi4OY0nCxAEeGAc3QaBpGI8r+Ma3J4i7XmKOSYxalDx2nuXB2igqomg9YPQirmcgFOECX8NFDLvZeu3/V7SYuEeGHLmjZFyOSK8htwGVheTzQiFGbGq8ALPD1vgb0CME2iulfLC7kiRGut8enpLWUGSXlzXP0AVvxWkS7MyT0EQEE+b62EDEavyds2YaS7/tWQVoKBuHeWm7JqjdbEg4b+ht7ev9ns2RgyGNxsRs3+w9rpL8uAIzib7m24aWqqfBoB2kMhJvM6csfgqDZ6gF3nOG2PE1VJzD4G2Y0RJsZPC3BboQmE//RIS1UdyxKEwGHi8cDPIJIIzn31xx42uJulyX69w0JihBnTfasuEXy9gZKB96XCsMmks9nBQZAi+ZNteBfT7unToXLMwHn0mPDTUj+NpEWjTdIUCL6JM4Ewk3cDTs9tleo0TAXxikk06YnjJbGxL7mEwofB31rFlUyzmkKtf935TMGGe4cbhBdGcLaImithNyo48mWQvTg8F2yvIa6vZ3rmbGL6oNe3GT8q7r+HBLdatv5uDoomboZqh7dsNEmpv6VwJtmeNEoQs8//VD/MCcLFPaKCZp8QmYBwvYXdVunxSwwCF6rwEm77U8Jo/2Ua7giCQj+ekkgJ7uE4ubo10lB5bE=",
|
||||||
|
};
|
90
src/__tests__/pages/api/team/invite-member.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
jest.mock("../../../../pages/api/_send-email", () => ({
|
||||||
|
sendEmail: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../database/users", () => ({
|
||||||
|
createInvitedUser: jest.fn(),
|
||||||
|
findUserByEmail: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../../pages/api/user/_auth0", () => ({
|
||||||
|
createAuth0User: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import inviteMemberHandler from "../../../../pages/api/team/invite-member";
|
||||||
|
import { callApiHandler } from "../../../../../jest/helpers";
|
||||||
|
import { sendEmail } from "../../../../pages/api/_send-email";
|
||||||
|
import { createInvitedUser, findUserByEmail } from "../../../../database/users";
|
||||||
|
import { createAuth0User } from "../../../../pages/api/user/_auth0";
|
||||||
|
|
||||||
|
describe("/api/team/invite-member", () => {
|
||||||
|
const mockedSendEmail = sendEmail as jest.Mock<
|
||||||
|
ReturnType<typeof sendEmail>
|
||||||
|
>;
|
||||||
|
const mockedCreateInvitedUser = createInvitedUser as jest.Mock<
|
||||||
|
ReturnType<typeof createInvitedUser>
|
||||||
|
>;
|
||||||
|
const mockedFindUserByEmail = findUserByEmail as jest.Mock<
|
||||||
|
ReturnType<typeof findUserByEmail>
|
||||||
|
>;
|
||||||
|
const mockedCreateAuth0User = createAuth0User as jest.Mock<
|
||||||
|
ReturnType<typeof createAuth0User>
|
||||||
|
>;
|
||||||
|
|
||||||
|
mockedSendEmail.mockResolvedValue();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedSendEmail.mockClear();
|
||||||
|
mockedCreateInvitedUser.mockClear();
|
||||||
|
mockedFindUserByEmail.mockClear();
|
||||||
|
mockedCreateAuth0User.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 405 to GET", async () => {
|
||||||
|
const { status } = await callApiHandler(inviteMemberHandler, {
|
||||||
|
method: "GET",
|
||||||
|
authentication: "auth0",
|
||||||
|
});
|
||||||
|
expect(status).toBe(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 400 to POST with malformed body", async () => {
|
||||||
|
const { status } = await callApiHandler(inviteMemberHandler, {
|
||||||
|
method: "POST",
|
||||||
|
authentication: "auth0",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
expect(status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 500 to POST with valid body but already taken email address", async () => {
|
||||||
|
const inviteeEmail = "test@fss.dev";
|
||||||
|
mockedFindUserByEmail.mockResolvedValueOnce({
|
||||||
|
email: inviteeEmail,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const body = { inviteeEmail };
|
||||||
|
const { status } = await callApiHandler(inviteMemberHandler, {
|
||||||
|
method: "POST",
|
||||||
|
authentication: "auth0",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 to POST with valid body", async () => {
|
||||||
|
const inviteeUserId = "2";
|
||||||
|
const inviteeEmail = "test@fss.dev";
|
||||||
|
mockedCreateAuth0User.mockResolvedValueOnce({ user_id: inviteeUserId });
|
||||||
|
|
||||||
|
const body = { inviteeEmail };
|
||||||
|
const { status } = await callApiHandler(inviteMemberHandler, {
|
||||||
|
method: "POST",
|
||||||
|
authentication: "auth0",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(mockedSendEmail.mock.calls[0][0].recipients).toStrictEqual([
|
||||||
|
inviteeEmail,
|
||||||
|
]);
|
||||||
|
expect(mockedCreateInvitedUser).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
21
src/__tests__/pages/api/user/session.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { callApiHandler } from "../../../../../jest/helpers";
|
||||||
|
import sessionHandler from "../../../../pages/api/user/session";
|
||||||
|
|
||||||
|
describe("/api/user/session", () => {
|
||||||
|
test("responds 405 to POST", async () => {
|
||||||
|
const { status } = await callApiHandler(sessionHandler, {
|
||||||
|
method: "POST",
|
||||||
|
authentication: "auth0",
|
||||||
|
});
|
||||||
|
expect(status).toBe(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 200 with session to GET", async () => {
|
||||||
|
const response = await callApiHandler(sessionHandler, {
|
||||||
|
method: "GET",
|
||||||
|
authentication: "auth0",
|
||||||
|
});
|
||||||
|
const session = await response.json();
|
||||||
|
expect(session.user).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
112
src/__tests__/pages/api/user/update-user.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
jest.mock("auth0", () => ({
|
||||||
|
ManagementClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("openid-client", () => ({
|
||||||
|
Issuer: {
|
||||||
|
discover: jest.fn().mockImplementation(() => ({
|
||||||
|
Client: jest.fn().mockImplementation(() => ({
|
||||||
|
refresh: jest.fn().mockImplementation(() => ({
|
||||||
|
claims: jest.fn().mockResolvedValue({}),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../../database/users", () => ({
|
||||||
|
findUser: jest.fn(),
|
||||||
|
updateUser: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ManagementClient } from "auth0";
|
||||||
|
|
||||||
|
import { callApiHandler } from "../../../../../jest/helpers";
|
||||||
|
import updateUserHandler from "../../../../pages/api/user/update-user";
|
||||||
|
import { findUser, updateUser } from "../../../../database/users";
|
||||||
|
|
||||||
|
describe("/api/user/update-user", () => {
|
||||||
|
const mockedManagementClient = ManagementClient as ReturnType<
|
||||||
|
typeof jest.fn
|
||||||
|
>;
|
||||||
|
const mockedUpdateAuth0User = jest.fn();
|
||||||
|
mockedManagementClient.mockImplementation(() => ({
|
||||||
|
updateUser: mockedUpdateAuth0User,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedFindUser = findUser as ReturnType<typeof jest.fn>;
|
||||||
|
const mockedUpdateUser = updateUser as ReturnType<typeof jest.fn>;
|
||||||
|
mockedFindUser.mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: "auth0|1234567",
|
||||||
|
email: "test@fss.dev",
|
||||||
|
name: "Groot",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedUpdateAuth0User.mockClear();
|
||||||
|
mockedFindUser.mockClear();
|
||||||
|
mockedUpdateUser.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 401 to unauthenticated request", async () => {
|
||||||
|
const response = await callApiHandler(updateUserHandler, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 405 to authenticated GET", async () => {
|
||||||
|
const response = await callApiHandler(updateUserHandler, {
|
||||||
|
method: "GET",
|
||||||
|
authentication: "auth0",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 400 to authenticated POST with malformed body", async () => {
|
||||||
|
const body = { name: "", email: "", password: "" };
|
||||||
|
const response = await callApiHandler(updateUserHandler, {
|
||||||
|
method: "POST",
|
||||||
|
authentication: "auth0",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates user password and responds 200 to authenticated POST", async () => {
|
||||||
|
const body = { name: "", email: "", password: "dddddd" };
|
||||||
|
const response = await callApiHandler(updateUserHandler, {
|
||||||
|
method: "POST",
|
||||||
|
authentication: "auth0",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockedUpdateAuth0User).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates both user password & email and responds 200 to authenticated POST", async () => {
|
||||||
|
const body = { name: "", email: "test@fss.xyz", password: "dddddd" };
|
||||||
|
const response = await callApiHandler(updateUserHandler, {
|
||||||
|
method: "POST",
|
||||||
|
authentication: "auth0",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockedUpdateAuth0User).toBeCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responds 403 to authenticated POST when updating email for a 3rd party-authenticated user", async () => {
|
||||||
|
const body = { name: "", email: "test@fss.xyz", password: "dddddd" };
|
||||||
|
const response = await callApiHandler(updateUserHandler, {
|
||||||
|
method: "POST",
|
||||||
|
authentication: "google-oauth2",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(mockedUpdateAuth0User).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
51
src/__tests__/pages/auth/sign-in.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/router", () => ({
|
||||||
|
useRouter: jest.fn().mockImplementation(() => ({ query: {} })),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../hooks/use-auth");
|
||||||
|
|
||||||
|
import { rest } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { render, screen, waitFor } from "../../../../jest/testing-library";
|
||||||
|
|
||||||
|
import useAuth from "../../../hooks/use-auth";
|
||||||
|
|
||||||
|
import SignInPage from "../../../pages/auth/sign-in";
|
||||||
|
|
||||||
|
describe("/auth/sign-in", () => {
|
||||||
|
type RequestBody = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedUseAuth = useAuth as ReturnType<typeof jest.fn>;
|
||||||
|
const mockedSignIn = jest.fn();
|
||||||
|
mockedUseAuth.mockImplementation(() => ({
|
||||||
|
signIn: mockedSignIn,
|
||||||
|
socialProviders: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const server = setupServer(
|
||||||
|
rest.post<RequestBody>("/api/auth/sign-in", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => mockedUseAuth.mockClear());
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
test("sign in with email", async () => {
|
||||||
|
render(<SignInPage />);
|
||||||
|
|
||||||
|
userEvent.type(screen.getByLabelText("Email address"), "test@fss.dev");
|
||||||
|
userEvent.type(screen.getByLabelText(/^Password/)!, "password{enter}");
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedSignIn).toBeCalledTimes(1));
|
||||||
|
});
|
||||||
|
});
|
46
src/__tests__/pages/index.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { rest } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { render, screen } from "../../../jest/testing-library";
|
||||||
|
import { waitFor } from "@testing-library/dom";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import Index from "../../pages";
|
||||||
|
|
||||||
|
describe("/", () => {
|
||||||
|
test("landing page snapshot", () => {
|
||||||
|
const { asFragment } = render(<Index />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscribe to newsletter", () => {
|
||||||
|
const server = setupServer(
|
||||||
|
rest.post("/api/newsletter/subscribe", (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
test("should display successful message after subscribing", async () => {
|
||||||
|
render(<Index />);
|
||||||
|
|
||||||
|
userEvent.type(
|
||||||
|
screen.getByPlaceholderText("Email address"),
|
||||||
|
"test@fss.dev{enter}",
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Thanks! We'll let you know when we launch",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
131
src/__tests__/pages/team/invitation.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/router", () => ({
|
||||||
|
useRouter: jest.fn().mockImplementation(() => ({ query: {} })),
|
||||||
|
}));
|
||||||
|
jest.mock("../../../hooks/use-auth");
|
||||||
|
jest.mock("../../../database/users", () => ({
|
||||||
|
findTeamOwner: jest.fn(),
|
||||||
|
findUser: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { rest } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { render, screen, waitFor } from "../../../../jest/testing-library";
|
||||||
|
|
||||||
|
import InvitationPage, {
|
||||||
|
getServerSideProps,
|
||||||
|
} from "../../../pages/team/invitation";
|
||||||
|
import useAuth from "../../../hooks/use-auth";
|
||||||
|
import { findTeamOwner, findUser } from "../../../database/users";
|
||||||
|
import { generateSignInToken } from "../../../pages/api/team/_invite";
|
||||||
|
|
||||||
|
describe("/team/invitation", () => {
|
||||||
|
type RequestBody = {
|
||||||
|
token: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedFindTeamOwner = findTeamOwner as jest.Mock<
|
||||||
|
ReturnType<typeof findTeamOwner>
|
||||||
|
>;
|
||||||
|
const mockedFindUser = findUser as jest.Mock<ReturnType<typeof findUser>>;
|
||||||
|
const mockedUseAuth = useAuth as ReturnType<typeof jest.fn>;
|
||||||
|
const mockedSignIn = jest.fn();
|
||||||
|
mockedUseAuth.mockImplementation(() => ({
|
||||||
|
signIn: mockedSignIn,
|
||||||
|
socialProviders: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const server = setupServer(
|
||||||
|
rest.post<RequestBody>(
|
||||||
|
"/api/team/accept-invitation",
|
||||||
|
(req, res, ctx) => {
|
||||||
|
return res(ctx.status(200));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedUseAuth.mockClear();
|
||||||
|
mockedFindTeamOwner.mockClear();
|
||||||
|
mockedFindUser.mockClear();
|
||||||
|
});
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
const inviteeEmail = "test@fss.dev";
|
||||||
|
const teamId = "123";
|
||||||
|
const teamOwner: any = {
|
||||||
|
name: "Groot",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("accept invitation", async () => {
|
||||||
|
render(
|
||||||
|
<InvitationPage
|
||||||
|
email={inviteeEmail}
|
||||||
|
teamId={teamId}
|
||||||
|
teamOwner={teamOwner}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.type(screen.getByLabelText("Name"), "John Doe");
|
||||||
|
userEvent.type(screen.getByLabelText(/^Password/)!, "password{enter}");
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedSignIn).toBeCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getServerSideProps", () => {
|
||||||
|
const baseContext: any = {
|
||||||
|
req: {},
|
||||||
|
res: {},
|
||||||
|
resolvedUrl: "/team/invitation",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("decode token and return props", async () => {
|
||||||
|
const userId = "111";
|
||||||
|
const invitedUser: any = {
|
||||||
|
id: userId,
|
||||||
|
email: inviteeEmail,
|
||||||
|
teamId,
|
||||||
|
pendingInvitation: true,
|
||||||
|
};
|
||||||
|
mockedFindTeamOwner.mockResolvedValueOnce(teamOwner);
|
||||||
|
mockedFindUser.mockResolvedValueOnce(invitedUser);
|
||||||
|
const token = await generateSignInToken({ teamId, userId });
|
||||||
|
const context = {
|
||||||
|
...baseContext,
|
||||||
|
query: { token },
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverSideProps = await getServerSideProps(context);
|
||||||
|
expect(serverSideProps).toStrictEqual({
|
||||||
|
props: {
|
||||||
|
email: inviteeEmail,
|
||||||
|
teamId,
|
||||||
|
teamOwner,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirect to sign in page if token is invalid", async () => {
|
||||||
|
const context = {
|
||||||
|
...baseContext,
|
||||||
|
query: { token: "" },
|
||||||
|
};
|
||||||
|
const serverSideProps = await getServerSideProps(context);
|
||||||
|
expect(serverSideProps).toStrictEqual({
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: "/auth/sign-in?error=invalid-invitation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
115
src/components/alert.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
type AlertVariant = "error" | "success" | "info" | "warning";
|
||||||
|
|
||||||
|
type AlertVariantProps = {
|
||||||
|
backgroundColor: string;
|
||||||
|
icon: ReactElement;
|
||||||
|
titleTextColor: string;
|
||||||
|
messageTextColor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
variant: AlertVariant;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALERT_VARIANTS: Record<AlertVariant, AlertVariantProps> = {
|
||||||
|
error: {
|
||||||
|
backgroundColor: "bg-red-50",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-red-400"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
titleTextColor: "text-red-800",
|
||||||
|
messageTextColor: "text-red-700",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
backgroundColor: "bg-green-50",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-green-400"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
titleTextColor: "text-green-800",
|
||||||
|
messageTextColor: "text-green-700",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
backgroundColor: "bg-primary-50",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-primary-400"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
titleTextColor: "text-primary-800",
|
||||||
|
messageTextColor: "text-primary-700",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
backgroundColor: "bg-yellow-50",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-yellow-400"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
titleTextColor: "text-yellow-800",
|
||||||
|
messageTextColor: "text-yellow-700",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Alert({ title, message, variant }: Props) {
|
||||||
|
const variantProperties = ALERT_VARIANTS[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">{variantProperties.icon}</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3
|
||||||
|
className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
224
src/components/auth/auth-page.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import Alert from "../alert";
|
||||||
|
|
||||||
|
import useAuth from "../../hooks/use-auth";
|
||||||
|
|
||||||
|
import appLogger from "../../../lib/logger";
|
||||||
|
import Logo from "../logo";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
authType: "signIn" | "signUp";
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = appLogger.child({ module: "AuthPage" });
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AuthPage({ authType }: Props) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
const auth = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const { register, handleSubmit } = useForm<Form>();
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const texts = TEXTS[authType];
|
||||||
|
let redirectTo: string;
|
||||||
|
if (Array.isArray(router.query.redirectTo)) {
|
||||||
|
redirectTo = router.query.redirectTo[0];
|
||||||
|
} else {
|
||||||
|
redirectTo = router.query.redirectTo ?? "/messages";
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async ({ email, password, name }) => {
|
||||||
|
setErrorMessage("");
|
||||||
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const params = { email, password, name, redirectTo };
|
||||||
|
try {
|
||||||
|
if (authType === "signIn") {
|
||||||
|
await auth.signIn(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === "signUp") {
|
||||||
|
await auth.signUp(params);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
console.log("error", error);
|
||||||
|
setErrorMessage(
|
||||||
|
error.isAxiosError ?
|
||||||
|
error.response.data.errorMessage :
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<Logo className="mx-auto h-8 w-8" />
|
||||||
|
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||||
|
{texts.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm leading-5 text-gray-600">
|
||||||
|
{texts.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<Alert
|
||||||
|
title="Oops, there was an issue"
|
||||||
|
message={errorMessage}
|
||||||
|
variant="error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<div className="py-8 px-4">
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
{authType === "signUp" ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="block text-sm font-medium leading-5 text-gray-700"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 rounded-md shadow-sm">
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
tabIndex={1}
|
||||||
|
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||||
|
{...register("name")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium leading-5 text-gray-700"
|
||||||
|
>
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 rounded-md shadow-sm">
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
tabIndex={1}
|
||||||
|
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||||
|
{...register("email")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
|
||||||
|
>
|
||||||
|
<div>Password</div>
|
||||||
|
|
||||||
|
{authType === "signIn" ? (
|
||||||
|
<div>
|
||||||
|
<Link href="/auth/forgot-password">
|
||||||
|
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 rounded-md shadow-sm">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
tabIndex={2}
|
||||||
|
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||||
|
{...register("password")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<span className="block w-full rounded-md shadow-sm">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={clsx(
|
||||||
|
"w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
|
||||||
|
{
|
||||||
|
"bg-primary-400 cursor-not-allowed": isSubmitting,
|
||||||
|
"bg-primary-600 hover:bg-primary-700": !isSubmitting,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? "Loading..."
|
||||||
|
: texts.actionButton}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthPage;
|
||||||
|
|
||||||
|
type Texts = {
|
||||||
|
title: string;
|
||||||
|
subtitle: ReactNode;
|
||||||
|
actionButton: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXTS: Record<Props["authType"], Texts> = {
|
||||||
|
signUp: {
|
||||||
|
title: "Create your account",
|
||||||
|
subtitle: (
|
||||||
|
<Link href="/auth/sign-in">
|
||||||
|
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
|
||||||
|
Already have an account?
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
actionButton: "Sign up",
|
||||||
|
},
|
||||||
|
signIn: {
|
||||||
|
title: "Welcome back!",
|
||||||
|
subtitle: (
|
||||||
|
<>
|
||||||
|
Need an account?
|
||||||
|
<Link href="/auth/sign-up">
|
||||||
|
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
|
||||||
|
Create yours for free
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
actionButton: "Sign in",
|
||||||
|
},
|
||||||
|
};
|
15
src/components/avatar.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Avatar: FunctionComponent<Props> = ({ name }) => (
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-8 flex-none rounded-full bg-gray-400 group-hover:opacity-75">
|
||||||
|
<span className="text-sm leading-none text-white uppercase">
|
||||||
|
{name.substr(0, 2)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Avatar;
|
261
src/components/billing/billing-plans.tsx
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { RadioGroup } from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import Toggle from "../toggle";
|
||||||
|
import Modal, { ModalTitle } from "../modal";
|
||||||
|
|
||||||
|
import useSubscription from "../../hooks/use-subscription";
|
||||||
|
import useUser from "../../hooks/use-user";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Plan,
|
||||||
|
PlanId,
|
||||||
|
PlanName,
|
||||||
|
} from "../../subscription/plans";
|
||||||
|
import { FREE, PLANS } from "../../subscription/plans";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
activePlanId?: PlanId;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
selectedPlanName: PlanName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
|
||||||
|
const { userProfile } = useUser();
|
||||||
|
const { subscribe, changePlan } = useSubscription();
|
||||||
|
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const activePlan = useMemo(() => {
|
||||||
|
const activePlan = PLANS[activePlanId];
|
||||||
|
if (!activePlan) {
|
||||||
|
return FREE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activePlan;
|
||||||
|
}, [activePlanId]);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
unregister,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = useForm<Form>({
|
||||||
|
defaultValues: getDefaultValues(activePlan),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
register("selectedPlanName");
|
||||||
|
|
||||||
|
const { selectedPlanName } = getDefaultValues(activePlan);
|
||||||
|
setValue("selectedPlanName", selectedPlanName);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregister("selectedPlanName");
|
||||||
|
};
|
||||||
|
}, [register, unregister, activePlan, setValue]);
|
||||||
|
|
||||||
|
const plans = PLANS;
|
||||||
|
const selectedPlanName = watch("selectedPlanName");
|
||||||
|
const selectedPlan = useMemo(() => plans[selectedPlanName] ?? FREE, [
|
||||||
|
plans,
|
||||||
|
selectedPlanName,
|
||||||
|
]);
|
||||||
|
const isActivePlanSelected = activePlan.id === selectedPlan.id;
|
||||||
|
const isSubmitDisabled = isSubmitting || isActivePlanSelected;
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(() => setIsConfirmationModalOpen(true));
|
||||||
|
const closeModal = () => setIsConfirmationModalOpen(false);
|
||||||
|
const onConfirm = async () => {
|
||||||
|
if (isSubmitDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = userProfile!.email!;
|
||||||
|
const userId = userProfile!.id;
|
||||||
|
const selectedPlanId = selectedPlan.id;
|
||||||
|
|
||||||
|
const isMovingToPaidPlan =
|
||||||
|
activePlan.id === "free" && selectedPlanId !== "free";
|
||||||
|
if (isMovingToPaidPlan) {
|
||||||
|
await subscribe({ email, userId, planId: selectedPlanId });
|
||||||
|
} else {
|
||||||
|
await changePlan({ planId: selectedPlanId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="shadow sm:rounded-md sm:overflow-hidden">
|
||||||
|
<div className="bg-white py-6 px-4 space-y-6 sm:p-6">
|
||||||
|
<fieldset>
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedPlan.name}
|
||||||
|
onChange={(planName) => setValue("selectedPlanName", planName)}
|
||||||
|
className="relative bg-white rounded-md -space-y-px"
|
||||||
|
>
|
||||||
|
{Object.entries(plans).map(
|
||||||
|
([planId, plan], index, plansEntries) => {
|
||||||
|
const isChecked = selectedPlan.id === planId;
|
||||||
|
console.log("selectedPlan.name", selectedPlan.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioGroup.Option
|
||||||
|
key={planId}
|
||||||
|
value={planId}
|
||||||
|
as="label"
|
||||||
|
className={clsx(
|
||||||
|
"relative border p-4 flex flex-col cursor-pointer md:pl-4 md:pr-6 md:grid md:grid-cols-3",
|
||||||
|
{
|
||||||
|
"rounded-tl-md rounded-tr-md": index === 0,
|
||||||
|
"rounded-bl-md rounded-br-md": index === plansEntries.length - 1,
|
||||||
|
"bg-primary-50 border-primary-200 z-10": isChecked,
|
||||||
|
"border-gray-200": !isChecked,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<input
|
||||||
|
className="h-4 w-4 text-primary-500 border-gray-300"
|
||||||
|
type="radio"
|
||||||
|
value={planId}
|
||||||
|
checked={isChecked}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<span className="ml-3 font-medium text-gray-900">
|
||||||
|
{plan.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="ml-6 pl-1 text-sm md:ml-0 md:pl-0">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"font-medium",
|
||||||
|
{
|
||||||
|
"text-primary-900": isChecked,
|
||||||
|
"text-gray-900": !isChecked,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.price === "free" ? (
|
||||||
|
"Free "
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
${plan.price} /
|
||||||
|
mo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
"ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-right",
|
||||||
|
{
|
||||||
|
"text-primary-700": isChecked,
|
||||||
|
"text-gray-500": !isChecked,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.description}
|
||||||
|
</p>
|
||||||
|
</RadioGroup.Option>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</RadioGroup>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={clsx(
|
||||||
|
"transition-colors duration-150 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-900",
|
||||||
|
{
|
||||||
|
"bg-primary-400 cursor-not-allowed": isActivePlanSelected,
|
||||||
|
"bg-primary-600 hover:bg-primary-700": !isActivePlanSelected,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
disabled={isSubmitDisabled}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
initialFocus={modalCancelButtonRef}
|
||||||
|
isOpen={isConfirmationModalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
>
|
||||||
|
<div className="md:flex md:items-start">
|
||||||
|
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||||
|
<ModalTitle>
|
||||||
|
Move to {selectedPlan.name} plan
|
||||||
|
</ModalTitle>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Are you sure you want to move to{" "}
|
||||||
|
{selectedPlan.name} plan?{" "}
|
||||||
|
</p>
|
||||||
|
{activePlan.name === "Team" &&
|
||||||
|
selectedPlan.name !== "Team" ? (
|
||||||
|
<p className="mt-2 text-sm font-medium text-gray-500">
|
||||||
|
Attention: moving to a smaller plan will
|
||||||
|
cause to remove extraneous team members to
|
||||||
|
fit the new plan's allowance!
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsx(
|
||||||
|
"transition-colors duration-150 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:ml-3 md:w-auto md:text-sm",
|
||||||
|
{
|
||||||
|
"bg-primary-400 cursor-not-allowed": isSubmitDisabled,
|
||||||
|
"bg-primary-600 hover:bg-primary-700": !isSubmitDisabled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Move to {selectedPlan.name} plan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref={modalCancelButtonRef}
|
||||||
|
type="button"
|
||||||
|
className={clsx(
|
||||||
|
"transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm",
|
||||||
|
{
|
||||||
|
"bg-gray-50 cursor-not-allowed": isSubmitDisabled,
|
||||||
|
"hover:bg-gray-50": !isSubmitDisabled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
onClick={closeModal}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultValues = (activePlan: Plan) => ({
|
||||||
|
selectedPlanName: activePlan.name.toLowerCase(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BillingPlans;
|
58
src/components/button.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type {
|
||||||
|
ButtonHTMLAttributes,
|
||||||
|
FunctionComponent,
|
||||||
|
MouseEventHandler,
|
||||||
|
} from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
variant: Variant;
|
||||||
|
onClick?: MouseEventHandler;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
type: ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Button: FunctionComponent<Props> = ({
|
||||||
|
children,
|
||||||
|
type,
|
||||||
|
variant,
|
||||||
|
onClick,
|
||||||
|
isDisabled,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
type={type}
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-white focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
[VARIANTS_STYLES[variant].base]: !isDisabled,
|
||||||
|
[VARIANTS_STYLES[variant].disabled]: isDisabled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
|
|
||||||
|
type Variant = "error" | "default";
|
||||||
|
|
||||||
|
type VariantStyle = {
|
||||||
|
base: string;
|
||||||
|
disabled: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VARIANTS_STYLES: Record<Variant, VariantStyle> = {
|
||||||
|
error: {
|
||||||
|
base: "bg-red-600 hover:bg-red-700 focus:ring-red-500",
|
||||||
|
disabled: "bg-red-400 cursor-not-allowed focus:ring-red-500",
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
base: "bg-primary-600 hover:bg-primary-700 focus:ring-primary-500",
|
||||||
|
disabled: "bg-primary-400 cursor-not-allowed focus:ring-primary-500",
|
||||||
|
},
|
||||||
|
};
|
9
src/components/divider.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export default function Divider() {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
5
src/components/icons.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
};
|
84
src/components/layout/footer.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import {
|
||||||
|
faPhoneAlt as fasPhone,
|
||||||
|
faTh as fasTh,
|
||||||
|
faComments as fasComments,
|
||||||
|
faCog as fasCog,
|
||||||
|
} from "@fortawesome/pro-solid-svg-icons";
|
||||||
|
import {
|
||||||
|
faPhoneAlt as farPhone,
|
||||||
|
faTh as farTh,
|
||||||
|
faComments as farComments,
|
||||||
|
faCog as farCog,
|
||||||
|
} from "@fortawesome/pro-regular-svg-icons";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className="grid grid-cols-4"
|
||||||
|
style={{ flex: "0 0 50px" }}
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
label="Calls"
|
||||||
|
path="/calls"
|
||||||
|
icons={{
|
||||||
|
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasPhone} />,
|
||||||
|
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farPhone} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="Keypad"
|
||||||
|
path="/keypad"
|
||||||
|
icons={{
|
||||||
|
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasTh} />,
|
||||||
|
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farTh} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="Messages"
|
||||||
|
path="/messages"
|
||||||
|
icons={{
|
||||||
|
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasComments} />,
|
||||||
|
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farComments} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="Settings"
|
||||||
|
path="/settings"
|
||||||
|
icons={{
|
||||||
|
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasCog} />,
|
||||||
|
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farCog} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavLinkProps = {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icons: {
|
||||||
|
active: ReactNode;
|
||||||
|
inactive: ReactNode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ path, label, icons }: NavLinkProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isActiveRoute = router.pathname.startsWith(path);
|
||||||
|
const icon = isActiveRoute ? icons.active : icons.inactive;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-around h-full">
|
||||||
|
<Link href={path}>
|
||||||
|
<a className="flex flex-col items-center">
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs">{label}</span>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
90
src/components/layout/header.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MenuIcon } from "@heroicons/react/solid";
|
||||||
|
|
||||||
|
import Avatar from "../avatar";
|
||||||
|
|
||||||
|
import useUser from "../../hooks/use-user";
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const { userProfile } = useUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{ boxShadow: "0 5px 10px -7px rgba(0, 0, 0, 0.0785)" }}
|
||||||
|
className="z-30 py-4 bg-white"
|
||||||
|
>
|
||||||
|
<div className="container flex items-center justify-between h-full px-6 mx-auto text-primary-600">
|
||||||
|
<button
|
||||||
|
className="p-1 mr-5 -ml-1 rounded-md lg:hidden focus:outline-none focus:shadow-outline-primary"
|
||||||
|
onClick={() => void 0}
|
||||||
|
aria-label="Menu"
|
||||||
|
>
|
||||||
|
<MenuIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className="flex ml-auto items-center flex-shrink-0 space-x-6">
|
||||||
|
<li className="relative">
|
||||||
|
<Menu>
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Menu.Button
|
||||||
|
className="rounded-full focus:shadow-outline-primary focus:outline-none"
|
||||||
|
aria-label="Account"
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={userProfile?.email ?? "FSS"}
|
||||||
|
/>
|
||||||
|
</Menu.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
show={open}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
className="absolute outline-none right-0 px-1 py-1 divide-y divide-gray-100 z-30 mt-2 origin-top-right text-gray-600 bg-white border border-gray-100 rounded-md shadow-md min-w-max-content"
|
||||||
|
static
|
||||||
|
>
|
||||||
|
<MenuItem href="/account/settings">
|
||||||
|
<span>Settings</span>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem href="/api/auth/sign-out">
|
||||||
|
<span>Log out</span>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuItemProps = {
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuItem: FunctionComponent<MenuItemProps> = ({ children, href }) => (
|
||||||
|
<Menu.Item>
|
||||||
|
{() => (
|
||||||
|
<Link href={href}>
|
||||||
|
<a
|
||||||
|
className="inline-flex space-x-2 items-center cursor-pointer w-full px-4 py-2 text-sm font-medium transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
99
src/components/layout/index.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import type { ErrorInfo, FunctionComponent } from "react";
|
||||||
|
import { Component } from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import type { WithRouterProps } from "next/dist/client/with-router";
|
||||||
|
import { withRouter } from "next/router";
|
||||||
|
|
||||||
|
import appLogger from "../../../lib/logger";
|
||||||
|
|
||||||
|
import Footer from "./footer";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
pageTitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = appLogger.child({ module: "Layout" });
|
||||||
|
|
||||||
|
const Layout: FunctionComponent<Props> = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
pageTitle = title,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pageTitle ? (
|
||||||
|
<Head>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
</Head>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="h-full w-full overflow-hidden fixed bg-gray-50">
|
||||||
|
<div className="flex flex-col w-full h-full">
|
||||||
|
<div className="flex flex-col flex-1 w-full overflow-y-auto">
|
||||||
|
<main className="flex-1 my-0 h-full">
|
||||||
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorBoundaryState =
|
||||||
|
| {
|
||||||
|
isError: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
isError: true;
|
||||||
|
errorMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ErrorBoundary = withRouter(
|
||||||
|
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
|
||||||
|
public readonly state = {
|
||||||
|
isError: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
errorMessage: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
logger.error(error, errorInfo.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.isError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||||
|
Oops, something went wrong.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-lg leading-5 text-gray-600">
|
||||||
|
Would you like to{" "}
|
||||||
|
<button
|
||||||
|
className="inline-flex space-x-2 items-center text-left"
|
||||||
|
onClick={this.props.router.reload}
|
||||||
|
>
|
||||||
|
<span className="transition-colors duration-150 border-b border-primary-200 hover:border-primary-500">
|
||||||
|
reload the page
|
||||||
|
</span>
|
||||||
|
</button>{" "}
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Layout;
|
23
src/components/loading.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export default function Loading({ className = "" }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`animate-spin h-5 w-5 text-primary-600 ${className}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
15
src/components/logo.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Logo: FunctionComponent<Props> = ({ className }) => (
|
||||||
|
<div className={clsx("relative", className)}>
|
||||||
|
<Image src="/static/logo.svg" layout="fill" alt="app logo" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Logo;
|
15
src/components/long-press-handler.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import usePress from "react-gui/use-press";
|
||||||
|
|
||||||
|
const LongPressHandler: FunctionComponent = ({ children }) => {
|
||||||
|
const onLongPress = (event: any) => console.log("event", event);
|
||||||
|
const ref = usePress({ onLongPress });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} onContextMenu={e => e.preventDefault()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LongPressHandler;
|
71
src/components/modal.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { FunctionComponent, MutableRefObject, ReactNode } from "react";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { Transition, Dialog } from "@headlessui/react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialFocus?: MutableRefObject<HTMLElement | null> | undefined;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Modal: FunctionComponent<Props> = ({
|
||||||
|
children,
|
||||||
|
initialFocus,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
|
<Dialog
|
||||||
|
className="fixed z-30 inset-0 overflow-y-auto"
|
||||||
|
initialFocus={initialFocus}
|
||||||
|
onClose={onClose}
|
||||||
|
open={isOpen}
|
||||||
|
static
|
||||||
|
>
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center md:block md:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
{/* This element is to trick the browser into centering the modal contents. */}
|
||||||
|
<span className="hidden md:inline-block md:align-middle md:h-screen">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 md:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 md:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
|
||||||
|
>
|
||||||
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all md:my-8 md:align-middle md:max-w-lg md:w-full md:p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModalTitle: FunctionComponent = ({ children }) => (
|
||||||
|
<Dialog.Title
|
||||||
|
as="h3"
|
||||||
|
className="text-lg leading-6 font-medium text-gray-900"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Dialog.Title>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Modal;
|
33
src/components/outside-alerter.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode, RefObject } from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
type Handler = (event: MouseEvent) => void;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
handler: Handler;
|
||||||
|
};
|
||||||
|
|
||||||
|
function OutsideAlerter({ children, handler }: Props) {
|
||||||
|
const wrapperRef = useRef(null);
|
||||||
|
useOutsideAlerter(wrapperRef, handler);
|
||||||
|
|
||||||
|
return <div ref={wrapperRef}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useOutsideAlerter(ref: RefObject<HTMLElement>, handler: Handler) {
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [ref, handler]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OutsideAlerter;
|