Repository: alex996/graphql-chat Branch: master Commit: 977ca47c7d31 Files: 94 Total size: 76.7 KB Directory structure: gitextract_ht9wwd7y/ ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode/ │ └── settings.json ├── LICENSE ├── docker-compose.dev.yml ├── docker-compose.yml ├── lerna.json ├── mongo-init.sh ├── package.json ├── packages/ │ ├── api/ │ │ ├── .dockerignore │ │ ├── Dockerfile │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── app.ts │ │ │ ├── auth.ts │ │ │ ├── config.ts │ │ │ ├── directives/ │ │ │ │ ├── auth.ts │ │ │ │ ├── guest.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── models/ │ │ │ │ ├── chat.ts │ │ │ │ ├── index.ts │ │ │ │ ├── message.ts │ │ │ │ └── user.ts │ │ │ ├── pubsub.ts │ │ │ ├── resolvers/ │ │ │ │ ├── __fixtures__/ │ │ │ │ │ └── index.ts │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── chat.test.ts │ │ │ │ │ └── user.test.ts │ │ │ │ ├── chat.ts │ │ │ │ ├── index.ts │ │ │ │ ├── message.ts │ │ │ │ └── user.ts │ │ │ ├── typeDefs/ │ │ │ │ ├── chat.ts │ │ │ │ ├── index.ts │ │ │ │ ├── message.ts │ │ │ │ ├── root.ts │ │ │ │ └── user.ts │ │ │ ├── types/ │ │ │ │ ├── chat.d.ts │ │ │ │ ├── express.d.ts │ │ │ │ ├── index.d.ts │ │ │ │ ├── message.d.ts │ │ │ │ └── user.d.ts │ │ │ ├── utils/ │ │ │ │ ├── graphql.ts │ │ │ │ └── index.ts │ │ │ └── validators/ │ │ │ ├── chat.ts │ │ │ ├── index.ts │ │ │ ├── joi.ts │ │ │ ├── message.ts │ │ │ ├── user.ts │ │ │ └── utils.ts │ │ ├── test/ │ │ │ ├── global.d.ts │ │ │ └── setup.ts │ │ ├── tsconfig.json │ │ └── tsconfig.prod.json │ └── web/ │ ├── .dockerignore │ ├── Dockerfile │ ├── index.html │ ├── package.json │ ├── proxy.conf │ ├── readme.md │ ├── src/ │ │ ├── App.tsx │ │ ├── apollo.ts │ │ ├── auth.ts │ │ ├── components/ │ │ │ ├── AdapterLink.tsx │ │ │ ├── CallToAction.tsx │ │ │ ├── ProtectedRoute.tsx │ │ │ └── index.ts │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ └── useInput.ts │ │ ├── icons/ │ │ │ └── index.ts │ │ ├── index.tsx │ │ └── views/ │ │ ├── Home.tsx │ │ ├── Welcome.tsx │ │ ├── auth/ │ │ │ ├── Login.tsx │ │ │ ├── PaperBox.tsx │ │ │ ├── Register.tsx │ │ │ └── index.ts │ │ ├── errors/ │ │ │ ├── 404.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ └── layouts/ │ │ ├── NavBar.tsx │ │ ├── SideBar.tsx │ │ └── index.ts │ ├── tsconfig.json │ └── webpack.config.js ├── proxy.conf ├── queries.gql ├── readme.md └── reference.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: .eslintignore ================================================ node_modules dist data ================================================ FILE: .eslintrc.json ================================================ { "env": { "browser": true, "es6": true, "node": true }, "parser": "@typescript-eslint/parser", "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", "plugin:jest/recommended", "prettier", "plugin:prettier/recommended", "prettier/@typescript-eslint", "prettier/react" ], "plugins": ["react-hooks"], "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly" }, "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": 2018, "sourceType": "module" }, "settings": { "react": { "version": "latest" } }, "rules": { "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "off", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" } } ================================================ FILE: .gitignore ================================================ node_modules *.log dist .env data ================================================ FILE: .prettierignore ================================================ node_modules dist data tsconfig.json ================================================ FILE: .prettierrc ================================================ { "semi": false, "singleQuote": true, "jsxSingleQuote": true } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "[javascript]": { "editor.formatOnSave": false }, "[javascriptreact]": { "editor.formatOnSave": false }, "[typescript]": { "editor.formatOnSave": false }, "[typescriptreact]": { "editor.formatOnSave": false }, "eslint.autoFixOnSave": true, "eslint.validate": [ "javascript", "javascriptreact", { "language": "typescript", "autoFix": true }, { "language": "typescriptreact", "autoFix": true } ] } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) Alex Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: docker-compose.dev.yml ================================================ version: '3' services: db: image: mongo container_name: chat-db ports: - '27017:27017' user: $UID environment: - MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_PASSWORD=secret - MONGO_INITDB_DATABASE=chat - DB_USERNAME=admin - DB_PASSWORD=secret volumes: - ./mongo-init.sh:/docker-entrypoint-initdb.d/mongo-init.sh - ./data:/data/db cache: image: redis:alpine container_name: chat-cache ports: - '6379:6379' command: ['--requirepass "secret"'] ================================================ FILE: docker-compose.yml ================================================ version: "3" services: api: build: packages/api image: chat-api container_name: chat-api restart: unless-stopped expose: - "3000" environment: - DB_USERNAME - DB_PASSWORD - DB_HOST=db - DB_NAME=$MONGO_INITDB_DATABASE - SESS_SECRET - REDIS_HOST=cache - REDIS_PASSWORD depends_on: - db - cache web: build: packages/web image: chat-web container_name: chat-web expose: - "80" depends_on: - api db: image: mongo container_name: chat-db user: $UID expose: - "27017" environment: - MONGO_INITDB_ROOT_USERNAME - MONGO_INITDB_ROOT_PASSWORD - MONGO_INITDB_DATABASE - DB_USERNAME - DB_PASSWORD volumes: - ./mongo-init.sh:/docker-entrypoint-initdb.d/mongo-init.sh - ./data:/data/db cache: image: redis:alpine container_name: chat-cache expose: - "6379" environment: - REDIS_PASSWORD command: ['--requirepass "$REDIS_PASSWORD"'] proxy: image: nginx:alpine container_name: chat-proxy volumes: - ./proxy.conf:/etc/nginx/conf.d/default.conf ports: - 8080:80 depends_on: - web ================================================ FILE: lerna.json ================================================ { "packages": [ "packages/*" ], "version": "independent" } ================================================ FILE: mongo-init.sh ================================================ echo "Creating $DB_USERNAME user on $MONGO_INITDB_DATABASE database" mongo ${MONGO_INITDB_DATABASE} \ -u ${MONGO_INITDB_ROOT_USERNAME} \ -p ${MONGO_INITDB_ROOT_PASSWORD} \ --authenticationDatabase admin \ --eval "db.createUser({user: '$DB_USERNAME', pwd: '$DB_PASSWORD', roles:['readWrite']});" ================================================ FILE: package.json ================================================ { "name": "root", "private": true, "license": "MIT", "scripts": { "up": "mkdir -p data && docker-compose -f docker-compose.dev.yml up -d", "postup": "wait-on data && npm run dev", "dev": "lerna run --parallel dev", "stop": "docker-compose -f docker-compose.dev.yml stop", "down": "docker-compose -f docker-compose.dev.yml down && rm -rf data/*", "commit": "git-cz", "lint": "eslint '**/*.{js,ts,tsx}'", "lint:fix": "npm run lint -- --fix", "test": "lerna run --parallel test" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^2.9.0", "@typescript-eslint/parser": "^2.9.0", "commitizen": "^4.0.3", "cz-conventional-changelog": "^3.0.2", "eslint": "^6.7.2", "eslint-config-prettier": "^6.7.0", "eslint-plugin-import": "^2.18.2", "eslint-plugin-jest": "^23.1.0", "eslint-plugin-node": "^10.0.0", "eslint-plugin-prettier": "^3.1.1", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-react": "^7.17.0", "eslint-plugin-react-hooks": "^2.3.0", "lerna": "^3.19.0", "prettier": "^1.19.1", "typescript": "^3.7.2", "wait-on": "^3.3.0" }, "config": { "commitizen": { "path": "cz-conventional-changelog" } } } ================================================ FILE: packages/api/.dockerignore ================================================ dist node_modules .dockerignore Dockerfile *.log readme.md ================================================ FILE: packages/api/Dockerfile ================================================ FROM node:12-alpine AS builder WORKDIR /usr/src/app COPY package*.json ./ RUN npm install --only=development COPY . . RUN npm run build FROM node:12-alpine WORKDIR /usr/src/app COPY package*.json ./ RUN npm install --only=production COPY --from=builder /usr/src/app/dist ./ USER node ENV NODE_ENV=production CMD ["node", "-r", "source-map-support/register", "index.js"] ================================================ FILE: packages/api/jest.config.js ================================================ module.exports = { preset: 'ts-jest', testEnvironment: 'node', setupFilesAfterEnv: ['./test/setup.ts'] } ================================================ FILE: packages/api/package.json ================================================ { "name": "@graphql-chat/api", "version": "1.0.0", "private": true, "main": "dist/index.js", "description": "Real-time GraphQL chat API", "author": "Alex", "license": "MIT", "scripts": { "predev": "rimraf dist", "dev": "ts-node-dev --transpileOnly --no-notify src", "prebuild": "rimraf dist", "build": "tsc -p tsconfig.prod.json", "test": "jest" }, "homepage": "https://github.com/alex996/graphql-chat#readme", "repository": { "type": "git", "url": "git+https://github.com/alex996/graphql-chat.git" }, "bugs": { "url": "https://github.com/alex996/graphql-chat/issues" }, "dependencies": { "@hapi/joi": "^16.1.8", "apollo-server-express": "^2.9.12", "bcryptjs": "^2.4.3", "connect-redis": "^4.0.3", "express": "^4.17.1", "express-session": "^1.17.0", "graphql": "^14.5.8", "graphql-fields": "^2.0.3", "ioredis": "^4.14.1", "mongoose": "^5.7.13", "source-map-support": "^0.5.15" }, "devDependencies": { "@types/bcryptjs": "^2.4.2", "@types/connect-redis": "^0.0.13", "@types/express": "^4.17.2", "@types/express-session": "^1.15.16", "@types/graphql": "^14.5.0", "@types/graphql-fields": "^1.3.2", "@types/hapi__joi": "^16.0.2", "@types/ioredis": "^4.0.20", "@types/jest": "^24.0.23", "@types/mongodb": "^3.3.11", "@types/mongoose": "^5.5.32", "@types/node": "^12.12.14", "@types/supertest": "^2.0.8", "graphql-redis-subscriptions": "^2.1.1", "jest": "^24.9.0", "mongodb-memory-server": "^6.0.1", "rimraf": "^3.0.0", "supertest": "^4.0.2", "ts-jest": "^24.2.0", "ts-node-dev": "^1.0.0-pre.43", "typescript": "^3.7.2" } } ================================================ FILE: packages/api/readme.md ================================================ # @graphql-chat/api Real-time GraphQL chat API. ================================================ FILE: packages/api/src/app.ts ================================================ import express from 'express' import session from 'express-session' import { ApolloServer } from 'apollo-server-express' import typeDefs from './typeDefs' import resolvers from './resolvers' import schemaDirectives from './directives' import { SESS_OPTIONS, APOLLO_OPTIONS } from './config' import { Request, Response } from './types' import { ensureSignedIn } from './auth' const createApp = (store?: session.Store) => { const app = express() const sessionHandler = session({ store, ...SESS_OPTIONS }) app.use(sessionHandler) const server = new ApolloServer({ ...APOLLO_OPTIONS, typeDefs, resolvers, schemaDirectives, context: ({ req, res, connection }) => connection ? connection.context : { req, res }, subscriptions: { onConnect: async (connectionParams, webSocket, { request }) => { const req = await new Promise(resolve => { sessionHandler(request as Request, {} as Response, () => { // Directives are ignored in WS; need to auth explicitly ensureSignedIn(request as Request) resolve(request) }) }) return { req } } } }) server.applyMiddleware({ app, cors: false }) return { app, server } } export default createApp ================================================ FILE: packages/api/src/auth.ts ================================================ import { AuthenticationError } from 'apollo-server-express' import { User } from './models' import { SESS_NAME } from './config' import { Request, Response, UserDocument } from './types' export const attemptSignIn = async ( { email, password }: { email: string; password: string }, fields: string ): Promise => { const user = await User.findOne({ email }).select(`${fields} password`) if (!user || !(await user.matchesPassword(password))) { throw new AuthenticationError( 'Incorrect email or password. Please try again.' ) } return user } const signedIn = (req: Request): boolean => req.session.userId export const ensureSignedIn = (req: Request): void => { if (!signedIn(req)) { throw new AuthenticationError('You must be signed in.') } } export const ensureSignedOut = (req: Request): void => { if (signedIn(req)) { throw new AuthenticationError('You are already signed in.') } } export const signOut = (req: Request, res: Response): Promise => new Promise((resolve, reject) => { req.session.destroy(err => { if (err) reject(err) res.clearCookie(SESS_NAME) resolve(true) }) }) ================================================ FILE: packages/api/src/config.ts ================================================ const ONE_DAY = 1000 * 60 * 60 * 24 export const { HTTP_PORT = 3000, NODE_ENV = 'development', DB_USERNAME = 'admin', DB_PASSWORD = 'secret', DB_HOST = 'localhost', DB_PORT = 27017, DB_NAME = 'chat', SESS_NAME = 'sid', SESS_SECRET = 'ssh!secret!', SESS_LIFETIME = ONE_DAY, REDIS_HOST = 'localhost', REDIS_PORT = 6379, REDIS_PASSWORD = 'secret' } = process.env export const IN_PROD = NODE_ENV === 'production' // Password URL encoded to escape special characters export const DB_URI = `mongodb://${DB_USERNAME}:${encodeURIComponent( DB_PASSWORD )}@${DB_HOST}:${DB_PORT}/${DB_NAME}` export const DB_OPTIONS = { useNewUrlParser: true, useUnifiedTopology: true } export const REDIS_OPTIONS = { host: REDIS_HOST, port: +REDIS_PORT, password: REDIS_PASSWORD // TODO: retry_strategy } export const SESS_OPTIONS = { name: SESS_NAME, secret: SESS_SECRET, resave: true, rolling: true, saveUninitialized: false, cookie: { maxAge: +SESS_LIFETIME, sameSite: true, secure: IN_PROD } } export const APOLLO_OPTIONS = { playground: IN_PROD ? false : { settings: { 'request.credentials': 'include' } } } ================================================ FILE: packages/api/src/directives/auth.ts ================================================ import { SchemaDirectiveVisitor } from 'apollo-server-express' import { GraphQLField, defaultFieldResolver } from 'graphql' import { ensureSignedIn } from '../auth' class AuthDirective extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const { resolve = defaultFieldResolver } = field field.resolve = function(...args) { const [, , context] = args ensureSignedIn(context.req) return resolve.apply(this, args) } } } export default AuthDirective ================================================ FILE: packages/api/src/directives/guest.ts ================================================ import { SchemaDirectiveVisitor } from 'apollo-server-express' import { GraphQLField, defaultFieldResolver } from 'graphql' import { ensureSignedOut } from '../auth' class GuestDirective extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const { resolve = defaultFieldResolver } = field field.resolve = function(...args) { const [, , context] = args ensureSignedOut(context.req) return resolve.apply(this, args) } } } export default GuestDirective ================================================ FILE: packages/api/src/directives/index.ts ================================================ import AuthDirective from './auth' import GuestDirective from './guest' export default { auth: AuthDirective, guest: GuestDirective } ================================================ FILE: packages/api/src/index.ts ================================================ import mongoose from 'mongoose' import connectRedis from 'connect-redis' import session from 'express-session' import Redis from 'ioredis' import http from 'http' import createApp from './app' import { DB_URI, DB_OPTIONS, REDIS_OPTIONS, HTTP_PORT } from './config' // ;(async () => { try { await mongoose.connect(DB_URI, DB_OPTIONS) const RedisStore = connectRedis(session) const store = new RedisStore({ client: new Redis(REDIS_OPTIONS) }) const { app, server } = createApp(store) const httpServer = http.createServer(app) server.installSubscriptionHandlers(httpServer) httpServer.listen(HTTP_PORT, () => { console.log(`http://localhost:${HTTP_PORT}${server.graphqlPath}`) console.log(`ws://localhost:${HTTP_PORT}${server.subscriptionsPath}`) }) } catch (e) { console.error(e) } })() ================================================ FILE: packages/api/src/models/chat.ts ================================================ /* eslint-disable @typescript-eslint/no-use-before-define */ import { model, Schema } from 'mongoose' import { ChatDocument } from '../types' import { User } from './' const { ObjectId } = Schema.Types const chatSchema = new Schema( { title: String, users: { type: [ { type: ObjectId, ref: 'User' } ], validate: [ { // TODO: both run in parallel, make sequential validator: async (userIds: string[]): Promise => (await User.where('_id') .in(userIds) .countDocuments()) === userIds.length, message: 'One or more User IDs are invalid.' }, { validator: async (userIds: string[]): Promise => !(await Chat.exists({ users: userIds })), message: 'Chat with given User IDs already exists.' } ] }, lastMessage: { type: ObjectId, ref: 'Message' } }, { timestamps: true } ) const Chat = model('Chat', chatSchema) export default Chat ================================================ FILE: packages/api/src/models/index.ts ================================================ export { default as Chat } from './chat' export { default as User } from './user' export { default as Message } from './message' ================================================ FILE: packages/api/src/models/message.ts ================================================ import mongoose, { Schema } from 'mongoose' import { MessageDocument } from '../types' const { ObjectId } = Schema.Types const messageSchema = new Schema( { body: String, sender: { type: ObjectId, ref: 'User' }, chat: { type: ObjectId, ref: 'Chat' } }, { timestamps: true } ) export default mongoose.model('Message', messageSchema) ================================================ FILE: packages/api/src/models/user.ts ================================================ /* eslint-disable @typescript-eslint/no-use-before-define */ import { model, Schema } from 'mongoose' import { hash, compare } from 'bcryptjs' import { UserDocument, UserModel } from '../types' const userSchema = new Schema( { username: { type: String, validate: [ async (username: string): Promise => !(await User.exists({ username })), 'Username is already taken.' ] }, email: { type: String, validate: [ async (email: string): Promise => !(await User.exists({ email })), 'Email is already taken.' ] }, name: String, password: String, chats: [ { type: Schema.Types.ObjectId, ref: 'Chat' } ] }, { timestamps: true } ) userSchema.pre('save', async function(this: UserDocument) { if (this.isModified('password')) { this.password = await User.hash(this.password) } }) userSchema.statics.hash = (password: string): Promise => hash(password, 10) userSchema.methods.matchesPassword = function( this: UserDocument, password: string ): Promise { return compare(password, this.password) } const User = model('User', userSchema) export default User ================================================ FILE: packages/api/src/pubsub.ts ================================================ import { RedisPubSub } from 'graphql-redis-subscriptions' import Redis from 'ioredis' import { REDIS_OPTIONS } from './config' const pubsub = new RedisPubSub({ publisher: new Redis(REDIS_OPTIONS), subscriber: new Redis(REDIS_OPTIONS) }) export default pubsub ================================================ FILE: packages/api/src/resolvers/__fixtures__/index.ts ================================================ // TODO: Consider using faker export const alex = { name: 'Alex', username: 'alex', email: 'alex@gmail.com', password: 'Secret12' } export const max = { name: 'Max', username: 'max', email: 'max@gmail.com', password: 'Password12' } export const users = ['Mark', 'Jane', 'Rick'].map(name => ({ name: name, username: name, email: `${name}@example.com`, password: 'Password12' })) ================================================ FILE: packages/api/src/resolvers/__tests__/chat.test.ts ================================================ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { User, Chat, Message } from '../../models' import { UserDocument } from '../../types' import { alex, max } from '../__fixtures__' const { name, email, password } = alex let a: UserDocument, m: UserDocument, cookie: string beforeEach(async () => { a = await User.create(alex) m = await User.create(max) ;({ header: { 'set-cookie': [cookie] } } = await global.signIn(email, password)) }) afterEach(async () => { await User.deleteMany({}) await Chat.deleteMany({}) }) describe('Mutation', () => { describe('startChat', () => { it('should create a chat', async () => { // When const res = await global .graphql() .set('Cookie', cookie) .send({ query: ` mutation { startChat(userIds: ["${m.id}"]) { id title } } ` }) // Then const title = `${name}, ${m.name}` const chat = await Chat.findOne({ title }).select('_id') expect(res.status).toBe(200) expect(res.body).toEqual({ data: { startChat: { id: chat!.id, title } } }) }) }) }) describe('Chat', () => { describe('messages', () => { it('should return chat messages', async () => { // Given const chat = await Chat.create({ users: [a.id, m.id] }) await a.updateOne({ $push: { chats: chat } }) const messages = await Message.insertMany([ { body: 'Hi', sender: a, chat }, { body: "What's up", sender: m, chat } ]) // When const res = await global .graphql() .set('Cookie', cookie) .send({ query: ` { me { chats { id messages { body } } } } ` }) // Then const results = messages.map(m => ({ body: m.body })) expect(res.status).toBe(200) expect(res.body).toEqual({ data: { me: { chats: [{ id: chat.id, messages: results }] } } }) }) }) }) ================================================ FILE: packages/api/src/resolvers/__tests__/user.test.ts ================================================ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { User, Chat } from '../../models' import { alex, max, users } from '../__fixtures__' const { name, username, email, password } = alex afterEach(async () => { await User.deleteMany({}) }) describe('Mutation', () => { describe('signUp', () => { it('should return a new user and set a cookie', async () => { // When const res = await global.graphql().send({ query: ` mutation { signUp( name: "${name}", username: "${username}", email: "${email}", password: "${password}" ) { id } } ` }) // Then const user = await User.findOne({ email }).select('_id') expect(res.status).toBe(200) expect(res.body).toEqual({ data: { signUp: { id: user!.id } } }) expect(res.header['set-cookie'][0]).toContain('sid=s%3A') }) }) describe('signIn', () => { it('should return an existing user and set a cookie', async () => { // Given const { id } = await User.create(alex) // When const res = await global.signIn(email, password) // Then expect(res.status).toBe(200) expect(res.body).toEqual({ data: { signIn: { id } } }) expect(res.header['set-cookie'][0]).toContain('sid=s%3A') }) }) describe('signOut', () => { it('should return true and clear the cookie', async () => { // Given await User.create(alex) const { header: { 'set-cookie': [cookie] } } = await global.signIn(email, password) // When const res = await global .graphql() .set('Cookie', cookie) .send({ query: ` mutation { signOut } ` }) // Then expect(res.status).toBe(200) expect(res.body).toEqual({ data: { signOut: true } }) expect(res.header['set-cookie'][0]).toContain('sid=;') }) }) }) describe('Query', () => { let id: string, cookie: string beforeEach(async () => { ;({ id } = await User.create(alex)) ;({ header: { 'set-cookie': [cookie] } } = await global.signIn(email, password)) }) describe('me', () => { it('should return the signed-in user', async () => { // When const res = await global .graphql() .set('Cookie', cookie) .send({ query: ` { me { id email username } } ` }) // Then expect(res.status).toBe(200) expect(res.body).toEqual({ data: { me: { id, email, username } } }) }) }) describe('user', () => { it('should return the user with a given id', async () => { // Given const { id, username, email } = await User.create(max) // When const res = await global .graphql() .set('Cookie', cookie) .send({ query: ` { user(id: "${id}") { id email username } } ` }) // Then expect(res.status).toBe(200) expect(res.body).toEqual({ data: { user: { id, email, username } } }) }) }) describe('users', () => { it('should return existing users', async () => { // Given const docs = await User.insertMany(users) // When const res = await global .graphql() .set('Cookie', cookie) .send({ query: ` { users { id email username } } ` }) // Then const results = [ { id, email, username }, ...docs.map(({ id, email, username }) => ({ id, email, username })) ] expect(res.status).toBe(200) expect(res.body).toEqual({ data: { users: results } }) }) }) }) describe('User', () => { describe('chats', () => { it('should return user chats', async () => { // Given const [a, { id: m }] = await Promise.all([ User.create(alex), User.create(max) ]) const { id } = await Chat.create({ users: [a.id, m] }) await a.updateOne({ $push: { chats: id } }) const { header: { 'set-cookie': [cookie] } } = await global.signIn(email, password) // When const res = await global .graphql() .set('Cookie', cookie) .send({ query: ` { me { chats { id title } } } ` }) // Then expect(res.status).toBe(200) expect(res.body).toEqual({ data: { me: { chats: [{ id, title: `${name}, ${max.name}` }] } } }) }) }) }) ================================================ FILE: packages/api/src/resolvers/chat.ts ================================================ import { IResolvers, UserInputError, ForbiddenError } from 'apollo-server-express' import { startChat, inviteUsers } from '../validators' import { User, Chat, Message } from '../models' import { Request, ChatDocument, UserDocument, MessageDocument } from '../types' import { fields } from '../utils' const resolvers: IResolvers = { Mutation: { startChat: async ( root, args: { title: string; userIds: [string] }, { req }: { req: Request } ): Promise => { const { userId } = req.session const { title, userIds } = args await startChat(userId).validateAsync(args, { abortEarly: false }) userIds.push(userId) const chat = await Chat.create({ title, users: userIds }) await User.updateMany( { _id: { $in: userIds } }, { $push: { chats: chat } } ) return chat }, inviteUsers: async ( root, args: { chatId: string; userIds: [string] }, { req }: { req: Request } ) => { const { userId } = req.session const { chatId, userIds } = args await inviteUsers(userId).validateAsync(args, { abortEarly: false }) const chat = await Chat.findById(chatId) if (!chat) { throw new UserInputError('Chat was not found') } if (!chat.users.includes(userId)) { throw new ForbiddenError( 'You are not a member of this chat. Please ask for an invite.' ) } const idsFound = await User.where('_id') .in(userIds) .countDocuments() if (idsFound !== userIds.length) { throw new UserInputError('One or more User IDs are invalid.') } console.log(chat.users, chat.users.includes(userId), userId) return } }, Chat: { messages: ( chat: ChatDocument, args, ctx, info ): Promise => { // TODO: pagination return Message.find({ chat: chat.id }, fields(info)).exec() }, users: async ( chat: ChatDocument, args, ctx, info ): Promise => { return (await chat.populate('users', fields(info)).execPopulate()).users }, lastMessage: async ( chat: ChatDocument, args, ctx, info ): Promise => { return (await chat.populate('lastMessage', fields(info)).execPopulate()) .lastMessage } } } export default resolvers ================================================ FILE: packages/api/src/resolvers/index.ts ================================================ import chat from './chat' import message from './message' import user from './user' export default [chat, message, user] ================================================ FILE: packages/api/src/resolvers/message.ts ================================================ import { Types } from 'mongoose' import { IResolvers, UserInputError, ForbiddenError, withFilter } from 'apollo-server-express' import { Request, MessageDocument, UserDocument } from '../types' import { sendMessage } from '../validators' import { Chat, Message } from '../models' import { fields, hasSubfields } from '../utils' import pubsub from '../pubsub' const MESSAGE_SENT = 'MESSAGE_SENT' const resolvers: IResolvers = { Mutation: { sendMessage: async ( root, args: { chatId: string; body: string }, { req }: { req: Request } ): Promise => { await sendMessage.validateAsync(args, { abortEarly: false }) const { userId } = req.session const { chatId, body } = args const chat = await Chat.findById(chatId).select('users') if (!chat) { throw new UserInputError('Chat was not found.') } else if (!chat.users.some((id: Types.ObjectId) => id.equals(userId))) { throw new ForbiddenError( 'Cannot join the chat. Please ask for an invite.' ) } const message = await Message.create({ body, sender: userId, chat: chatId }) pubsub.publish(MESSAGE_SENT, { messageSent: message, users: chat.users }) chat.lastMessage = message await chat.save() return message } }, Subscription: { messageSent: { resolve: ( { messageSent }: { messageSent: MessageDocument }, args, ctx, info ) => { return hasSubfields(info) ? Message.findById(messageSent._id, fields(info)) : messageSent }, subscribe: withFilter( () => pubsub.asyncIterator(MESSAGE_SENT), async ( { messageSent, users }: { messageSent: MessageDocument; users: [string] }, { chatId }: { chatId: string }, { req }: { req: Request } ) => { return ( messageSent.chat === chatId && users.includes(req.session.userId) ) } ) } }, Message: { sender: async ( message: MessageDocument, args, ctx, info ): Promise => { return (await message.populate('sender', fields(info)).execPopulate()) .sender } } } export default resolvers ================================================ FILE: packages/api/src/resolvers/user.ts ================================================ import { IResolvers } from 'apollo-server-express' import { Request, Response, UserDocument, ChatDocument } from '../types' import { signUp, signIn, objectId } from '../validators' import { attemptSignIn, signOut } from '../auth' import { User } from '../models' import { fields } from '../utils' const resolvers: IResolvers = { Query: { me: ( root, args, { req }: { req: Request }, info ): Promise => { return User.findById(req.session.userId, fields(info)).exec() }, users: (root, args, ctx, info): Promise => { // TODO: pagination return User.find({}, fields(info)).exec() }, user: async ( root, args: { id: string }, ctx, info ): Promise => { await objectId.validateAsync(args) return User.findById(args.id, fields(info)) } }, Mutation: { signUp: async ( root, args: { email: string; username: string; name: string; password: string }, { req }: { req: Request } ): Promise => { await signUp.validateAsync(args, { abortEarly: false }) const user = await User.create(args) req.session.userId = user.id return user }, signIn: async ( root, args: { email: string; password: string }, { req }: { req: Request }, info ): Promise => { await signIn.validateAsync(args, { abortEarly: false }) const user = await attemptSignIn(args, fields(info)) req.session.userId = user.id return user }, signOut: ( root, args, { req, res }: { req: Request; res: Response } ): Promise => { return signOut(req, res) } }, User: { chats: async ( user: UserDocument, args, { req }: { req: Request }, info ): Promise => { if (user.id !== req.session.userId) { return [] } await user .populate({ // TODO: paginate path: 'chats', select: fields(info) }) .execPopulate() return user.chats } } } export default resolvers ================================================ FILE: packages/api/src/typeDefs/chat.ts ================================================ import { gql } from 'apollo-server-express' export default gql` extend type Mutation { startChat(title: String, userIds: [ID!]!): Chat @auth inviteUsers(chatId: ID!, userIds: [ID!]!): Chat @auth } type Chat { id: ID! title: String users: [User!]! messages: [Message!]! lastMessage: Message createdAt: String! updatedAt: String! } ` ================================================ FILE: packages/api/src/typeDefs/index.ts ================================================ import chat from './chat' import message from './message' import root from './root' import user from './user' export default [root, chat, message, user] ================================================ FILE: packages/api/src/typeDefs/message.ts ================================================ import { gql } from 'apollo-server-express' export default gql` extend type Mutation { sendMessage(chatId: ID!, body: String!): Message @auth } extend type Subscription { messageSent(chatId: ID!): Message @auth } type Message { id: ID! body: String! sender: User! createdAt: String! updatedAt: String! } ` ================================================ FILE: packages/api/src/typeDefs/root.ts ================================================ import { gql } from 'apollo-server-express' export default gql` directive @auth on FIELD_DEFINITION directive @guest on FIELD_DEFINITION type Query { _: String } type Mutation { _: String } type Subscription { _: String } ` ================================================ FILE: packages/api/src/typeDefs/user.ts ================================================ import { gql } from 'apollo-server-express' export default gql` extend type Query { me: User @auth user(id: ID!): User @auth users: [User!]! @auth } extend type Mutation { signUp( email: String! username: String! name: String! password: String! ): User @guest signIn(email: String!, password: String!): User @guest signOut: Boolean @auth } type User { id: ID! email: String! username: String! name: String! chats: [Chat!]! createdAt: String! updatedAt: String! } ` ================================================ FILE: packages/api/src/types/chat.d.ts ================================================ import { Document } from 'mongoose' import { UserDocument, MessageDocument } from './' export interface ChatDocument extends Document { title: string users: [UserDocument['_id']] lastMessage: MessageDocument['_id'] } ================================================ FILE: packages/api/src/types/express.d.ts ================================================ import { Request as ExpressRequest, Response as ExpressResponse } from 'express' export type Request = ExpressRequest & { session: Express.Session } export type Response = ExpressResponse ================================================ FILE: packages/api/src/types/index.d.ts ================================================ export * from './chat' export * from './express' export * from './message' export * from './user' ================================================ FILE: packages/api/src/types/message.d.ts ================================================ import { Document } from 'mongoose' import { UserDocument, ChatDocument } from './' export interface MessageDocument extends Document { body: string sender: UserDocument['_id'] chat: ChatDocument['_id'] } ================================================ FILE: packages/api/src/types/user.d.ts ================================================ import { Document, Model } from 'mongoose' import { ChatDocument } from './' export interface UserDocument extends Document { name: string email: string username: string password: string chats: [ChatDocument['_id']] matchesPassword: (password: string) => Promise } export interface UserModel extends Model { hash: (password: string) => Promise } ================================================ FILE: packages/api/src/utils/graphql.ts ================================================ import graphqlFields from 'graphql-fields' import { GraphQLResolveInfo } from 'graphql' interface FieldsMap { [field: string]: {} | FieldsMap } const fieldsMap = (info: GraphQLResolveInfo): FieldsMap => { return graphqlFields(info, {}, { excludedFields: ['__typename'] }) } const isEmpty = (obj: object): boolean => { return Object.getOwnPropertyNames(obj).length === 0 } // Whether the info object contains any non-flat (i.e. nested) fields export const hasSubfields = (info: GraphQLResolveInfo): boolean => { return Object.values(fieldsMap(info)).some( (subfields: object) => !isEmpty(subfields) ) } // Space-separated fields as requested in the info object export const fields = (info: GraphQLResolveInfo): string => { return Object.keys(fieldsMap(info)).join(' ') } ================================================ FILE: packages/api/src/utils/index.ts ================================================ export * from './graphql' ================================================ FILE: packages/api/src/validators/chat.ts ================================================ import Joi from './joi' const title = Joi.string() .min(6) .max(30) .label('Title') const userIds = (userId: string) => Joi.array() .min(1) .max(100) .unique() .items( Joi.objectId() .not(userId) .label('User ID') ) .label('User IDs') export const startChat = (userId: string) => Joi.object().keys({ title, userIds: userIds(userId) }) export const inviteUsers = (userId: string) => Joi.object().keys({ chatId: Joi.objectId().required(), userIds: userIds(userId) }) ================================================ FILE: packages/api/src/validators/index.ts ================================================ export * from './chat' export * from './message' export * from './user' export * from './utils' ================================================ FILE: packages/api/src/validators/joi.ts ================================================ import Joi, { ExtensionFactory } from '@hapi/joi' import mongoose from 'mongoose' const objectId: ExtensionFactory = joi => ({ type: 'objectId', base: joi.string(), messages: { objectId: '"{{#label}}" must be a valid Object ID' }, validate(value, helpers) { if (!mongoose.Types.ObjectId.isValid(value)) { return { value, errors: helpers.error('objectId') } } } }) export default Joi.extend(objectId) ================================================ FILE: packages/api/src/validators/message.ts ================================================ import Joi from './joi' export const sendMessage = Joi.object().keys({ chatId: Joi.objectId() .required() .label('Chat ID'), body: Joi.string() .required() .max(4_000) // TODO: Truncate into multiple msgs .label('Body') }) ================================================ FILE: packages/api/src/validators/user.ts ================================================ import Joi from '@hapi/joi' const email = Joi.string() .email() .min(8) .max(254) .trim() .lowercase() .required() .label('Email') const username = Joi.string() .alphanum() .min(3) .max(50) .trim() .required() .label('Username') const name = Joi.string() .max(100) .trim() .required() .label('Name') const password = Joi.string() .min(8) .max(100) .regex(/^(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\d).*$/) .message( 'must have at least one lowercase letter, one uppercase letter, and one digit.' ) .required() .label('Password') export const signUp = Joi.object().keys({ email, username, name, password }) export const signIn = Joi.object().keys({ email, password }) ================================================ FILE: packages/api/src/validators/utils.ts ================================================ import Joi from './joi' export const objectId = Joi.object().keys({ id: Joi.objectId().label('Object ID') }) ================================================ FILE: packages/api/test/global.d.ts ================================================ declare namespace NodeJS { interface Global { graphql: Function signIn: Function } } ================================================ FILE: packages/api/test/setup.ts ================================================ import request, { Test } from 'supertest' import { MongoMemoryServer } from 'mongodb-memory-server' import mongoose from 'mongoose' import createApp from '../src/app' import { DB_OPTIONS } from '../src/config' const { app, server: { graphqlPath } } = createApp() const req = request(app) global.graphql = (): Test => req.post(graphqlPath) global.signIn = (email: string, password: string) => global.graphql().send({ query: ` mutation { signIn( email: "${email}", password: "${password}" ) { id } } ` }) let mongod: MongoMemoryServer beforeAll(async () => { mongod = new MongoMemoryServer() const uri = await mongod.getConnectionString() await mongoose.connect(uri, DB_OPTIONS) }) afterAll(async () => { await mongoose.disconnect() await mongod.stop() }) ================================================ FILE: packages/api/tsconfig.json ================================================ { "compilerOptions": { /* Basic Options */ "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "declaration": true, /* Generates corresponding '.d.ts' file. */ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ "outDir": "./dist", /* Redirect output structure to the directory. */ "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ // "incremental": true, /* Enable incremental compilation */ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ "removeComments": true, /* Do not emit comments to output. */ // "noEmit": true, /* Do not emit outputs. */ // "importHelpers": true, /* Import emit helpers from 'tslib'. */ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ /* Strict Type-Checking Options */ "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ /* Additional Checks */ // "noUnusedLocals": true, /* Report errors on unused locals. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ /* Module Resolution Options */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ } } ================================================ FILE: packages/api/tsconfig.prod.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "./src" }, // test is included in tsconfig.json to resolve @types/jest in the IDE "exclude": ["src/**/__tests__", "src/**/__fixtures__", "test"] } ================================================ FILE: packages/web/.dockerignore ================================================ dist node_modules .dockerignore Dockerfile *.log readme.md ================================================ FILE: packages/web/Dockerfile ================================================ FROM node:12-alpine AS builder WORKDIR /usr/src/app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /usr/src/app/dist/ /usr/share/nginx/html COPY proxy.conf /etc/nginx/conf.d/default.conf ================================================ FILE: packages/web/index.html ================================================ GraphQL Chat
================================================ FILE: packages/web/package.json ================================================ { "name": "@graphql-chat/web", "version": "1.0.0", "private": true, "main": "dist/main.js", "description": "Real-time GraphQL chat UI", "author": "Alex", "license": "MIT", "scripts": { "dev": "webpack-dev-server --mode development", "prebuild": "rimraf dist", "build": "webpack --mode production --progress" }, "homepage": "https://github.com/alex996/graphql-chat#readme", "repository": { "type": "git", "url": "git+https://github.com/alex996/graphql-chat.git" }, "bugs": { "url": "https://github.com/alex996/graphql-chat/issues" }, "dependencies": { "@apollo/react-hooks": "^3.1.3", "@material-ui/core": "^4.7.0", "@material-ui/icons": "^4.5.1", "apollo-cache-inmemory": "^1.6.3", "apollo-client": "^2.6.4", "apollo-link": "^1.2.13", "apollo-link-error": "^1.1.12", "apollo-link-http": "^1.5.16", "graphql": "^14.5.8", "graphql-tag": "^2.10.1", "react": "^16.12.0", "react-dom": "^16.12.0", "react-router-dom": "^5.1.2" }, "devDependencies": { "@types/graphql": "^14.5.0", "@types/react": "^16.9.13", "@types/react-dom": "^16.9.4", "@types/react-router-dom": "^5.1.3", "html-webpack-plugin": "^4.0.0-beta.11", "rimraf": "^3.0.0", "script-ext-html-webpack-plugin": "^2.1.4", "ts-loader": "^6.2.1", "typescript": "^3.7.2", "webpack": "^4.41.2", "webpack-cli": "^3.3.10", "webpack-dev-server": "^3.9.0" } } ================================================ FILE: packages/web/proxy.conf ================================================ server { listen 80; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html =404; } } ================================================ FILE: packages/web/readme.md ================================================ # @graphql-chat/web Real-time GraphQL chat web UI. ================================================ FILE: packages/web/src/App.tsx ================================================ import React from 'react' import { CssBaseline } from '@material-ui/core' import { styled, Theme } from '@material-ui/core/styles' import { ApolloProvider } from '@apollo/react-hooks' import { BrowserRouter, Switch, Route } from 'react-router-dom' import { NavBar, Welcome, Home, Login, Register, NotFound } from './views' import { PublicRoute, PrivateRoute } from './components' import client from './apollo' const Main = styled('main')(({ theme }: { theme: Theme }) => ({ display: 'flex', [theme.breakpoints.up('sm')]: { marginTop: 64 }, [theme.breakpoints.only('xs')]: { marginTop: 56 } })) const App = () => (
) export default App ================================================ FILE: packages/web/src/apollo.ts ================================================ import { ApolloClient } from 'apollo-client' import { InMemoryCache } from 'apollo-cache-inmemory' import { HttpLink } from 'apollo-link-http' import { onError } from 'apollo-link-error' import { ApolloLink } from 'apollo-link' const client = new ApolloClient({ link: ApolloLink.from([ onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) { graphQLErrors.map(({ message, locations, path }) => console.log( `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` ) ) } if (networkError) console.log(`[Network error]: ${networkError}`) }), new HttpLink({ uri: process.env.API_URI, credentials: 'same-origin' }) ]), cache: new InMemoryCache() }) export default client ================================================ FILE: packages/web/src/auth.ts ================================================ const IS_LOGGED_IN = 'isLoggedIn' export const rememberLogin = () => localStorage.setItem(IS_LOGGED_IN, '') export const forgetLogin = () => localStorage.removeItem(IS_LOGGED_IN) export const isLoggedIn = () => IS_LOGGED_IN in localStorage ================================================ FILE: packages/web/src/components/AdapterLink.tsx ================================================ import React, { forwardRef, RefForwardingComponent } from 'react' import { Link, LinkProps } from 'react-router-dom' const AdapterLink: RefForwardingComponent = ( props, ref ) => export default forwardRef(AdapterLink) ================================================ FILE: packages/web/src/components/CallToAction.tsx ================================================ import React from 'react' import { makeStyles, Theme, Button } from '@material-ui/core' import { ButtonProps } from '@material-ui/core/Button' import { AdapterLink } from './' // Required to use hook API until material-ui@15695 is fixed const useStyles = makeStyles((theme: Theme) => ({ root: { width: '100%', borderRadius: theme.spacing(4), padding: theme.spacing(1.5) } })) interface Props extends ButtonProps { to?: string } // A full-width fab, optionally outlined const CallToAction = (props: Props) => { const classes = useStyles() return (