Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e81dccef6 | |||
| adeb8c4267 | |||
| eb6c3a40d7 | |||
| 4d4a0b5401 | |||
| 490860159e | |||
| ed01fe6db5 | |||
| cfb8d20ad2 | |||
| d44cae397d | |||
| b97fff05b3 | |||
| 22703bfbcb | |||
| 14a3c9eabe | |||
| 8e90a4a540 | |||
| 1d3c94a1aa | |||
| ca5e79ccf3 | |||
| 03acce1907 | |||
| c0b7280533 | |||
| 2925cce7ca | |||
| b90670b922 | |||
| 290f25f898 | |||
| 3cdef433f9 | |||
| 53c8ddab54 | |||
| 4e94c5338d | |||
| 55ff5765c6 | |||
| d486059430 | |||
| 9d8c948dbb | |||
| b21fa8db9e | |||
| c130b74e2d | |||
| d37b93d5f7 | |||
| 829cb2e3da | |||
| 29f0dbb1ca | |||
| 13ad7d2435 | |||
| e30853e0cd | |||
| 1e16a96f86 | |||
| cff73107b2 | |||
| 76417747b3 | |||
| b8323723ed | |||
| 415b2f32f1 | |||
| a0dbe0aee1 | |||
| f415a7ece2 | |||
| 9c863f309f | |||
| 7547b1717e | |||
| 22e6286e0a | |||
| c766c43223 | |||
| bb1590bae7 | |||
| 5f7b9dda3a | |||
| 051f3dfb2a | |||
| 85456e0892 | |||
| 6130337846 | |||
| ac99f3bf38 | |||
| 20e94db628 | |||
| bcb4a37f5a | |||
| 3b0d3c87b1 | |||
| 51a116503d | |||
| eb370663d2 | |||
| 462116d980 | |||
| af06ddc06d | |||
| f937ee47db | |||
| 6d0dd419ca | |||
| 2919467dff | |||
| 269caac88c | |||
| 69c297fa74 | |||
| 0f538be3e5 | |||
| f85b6e8793 | |||
| 2af9532930 | |||
| 191da50740 | |||
| f24c7990f6 | |||
| 7a48780f50 | |||
| 33952a9896 | |||
| 8076b0cc40 | |||
| e216474196 | |||
| 2532bf1867 | |||
| 93a3c30e81 | |||
| ae8c82e83a | |||
| bc6a795750 | |||
| 9947ef13c1 | |||
| d3f0b1ac7d | |||
| b5f9c45a1b | |||
| 5189415e1a | |||
| be793ae2ff | |||
| 19f5c20056 | |||
| 7923f51aae | |||
| 793e54e334 | |||
| 4fa54668e6 | |||
| ff0230b77f | |||
| 6b53bee033 | |||
| 4280319443 | |||
| 9788552d0c | |||
| 3fd76375dd | |||
| 9c8ece5c06 | |||
| 9201e9374b | |||
| 8c8b78462f | |||
| 07e578b4c4 | |||
| 732eeb933e | |||
| 104e15d714 | |||
| 2c9871d82a | |||
| f5f44ae9f7 | |||
| 1c3d24575e | |||
| 13e3ce6db8 | |||
| 8561219542 | |||
| e288a43519 | |||
| 211eb460cb | |||
| c2f1cd76e9 | |||
| c629192d04 | |||
| b78b034d6d | |||
| 741d6d163b | |||
| f9df2722e8 | |||
| 11d49f981d | |||
| e930fd2173 | |||
| 8a920c5316 | |||
| d66d0bc9da | |||
| 9e0d58e999 | |||
| 9c294ab961 | |||
| 9b6c5af09d | |||
| 8f3ab27106 | |||
| 253fd494ae | |||
| 34b6a25bb5 | |||
| b6e707d047 | |||
| 457cc85ad4 | |||
| e1a20411a0 | |||
| b8e4b33421 | |||
| 44553836c7 | |||
| b5b1b987b1 | |||
| d29d256e5f | |||
| 7c09ac46da | |||
| e3e5af4af0 | |||
| dccf55c973 | |||
| 488f975e98 | |||
| b0ac371a43 | |||
| 0dcd092856 | |||
| 06949d384a | |||
| 69b82fce3f | |||
| 2c9ef261c1 | |||
| 9a7e9313d8 | |||
| 34a5444705 |
@@ -1,17 +1,15 @@
|
|||||||
WEB_PROTOCOL=http
|
|
||||||
WEB_ADDRESS=localhost
|
|
||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
|
|
||||||
MAIL_SMTP=smtp.example.com
|
DB_HOSTNAME=localhost
|
||||||
MAIL_USERNAME=foobar@example.com
|
|
||||||
MAIL_PASSWORD=foobar
|
|
||||||
MAIL_PHYSICAL=42 Placeholder Ave, Placeholder, 0000, USA
|
|
||||||
|
|
||||||
DB_HOSTNAME=127.0.0.1
|
|
||||||
DB_DATABASE=template
|
DB_DATABASE=template
|
||||||
DB_USERNAME=template
|
DB_USERNAME=template
|
||||||
DB_PASSWORD=pikachu
|
DB_PASSWORD=pikachu
|
||||||
|
|
||||||
|
# Select a "TZ database name" that suits your needs: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||||
DB_TIMEZONE=Australia/Sydney
|
DB_TIMEZONE=Australia/Sydney
|
||||||
|
|
||||||
SESSION_SECRET=secret
|
# Give this any value to enable database logging (such as "true")
|
||||||
SESSION_ADMIN=adminsecret
|
DB_LOGGING=
|
||||||
|
|
||||||
|
# Make sure this value matches the system that you connect to
|
||||||
|
SECRET_ACCESS=access
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
patreon: krgamestudios
|
||||||
|
ko_fi: krgamestudios
|
||||||
|
custom: ["https://www.paypal.com/donate/?hosted_button_id=73Q82T2ZHV8AA"]
|
||||||
+119
-118
@@ -1,118 +1,119 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
*.seed
|
*.seed
|
||||||
*.pid.lock
|
*.pid.lock
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
lib-cov
|
lib-cov
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage
|
coverage
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|
||||||
# nyc test coverage
|
# nyc test coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
.grunt
|
.grunt
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
# Bower dependency directory (https://bower.io/)
|
||||||
bower_components
|
bower_components
|
||||||
|
|
||||||
# node-waf configuration
|
# node-waf configuration
|
||||||
.lock-wscript
|
.lock-wscript
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
build/Release
|
build/Release
|
||||||
|
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
jspm_packages/
|
||||||
|
|
||||||
# TypeScript v1 declaration files
|
# TypeScript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
||||||
# TypeScript cache
|
# TypeScript cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Optional npm cache directory
|
# Optional npm cache directory
|
||||||
.npm
|
.npm
|
||||||
|
|
||||||
# Optional eslint cache
|
# Optional eslint cache
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
# Microbundle cache
|
# Microbundle cache
|
||||||
.rpt2_cache/
|
.rpt2_cache/
|
||||||
.rts2_cache_cjs/
|
.rts2_cache_cjs/
|
||||||
.rts2_cache_es/
|
.rts2_cache_es/
|
||||||
.rts2_cache_umd/
|
.rts2_cache_umd/
|
||||||
|
|
||||||
# Optional REPL history
|
# Optional REPL history
|
||||||
.node_repl_history
|
.node_repl_history
|
||||||
|
|
||||||
# Output of 'npm pack'
|
# Output of 'npm pack'
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
# Yarn Integrity file
|
# Yarn Integrity file
|
||||||
.yarn-integrity
|
.yarn-integrity
|
||||||
|
|
||||||
# dotenv environment variables file
|
# dotenv environment variables file
|
||||||
.env
|
.env
|
||||||
.env.test
|
.env.test
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
# Next.js build output
|
# Next.js build output
|
||||||
.next
|
.next
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
# Nuxt.js build / generate output
|
||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
# public
|
# public
|
||||||
|
|
||||||
# vuepress build output
|
# vuepress build output
|
||||||
.vuepress/dist
|
.vuepress/dist
|
||||||
|
|
||||||
# Serverless directories
|
# Serverless directories
|
||||||
.serverless/
|
.serverless/
|
||||||
|
|
||||||
# FuseBox cache
|
# FuseBox cache
|
||||||
.fusebox/
|
.fusebox/
|
||||||
|
|
||||||
# DynamoDB Local files
|
# DynamoDB Local files
|
||||||
.dynamodb/
|
.dynamodb/
|
||||||
|
|
||||||
# TernJS port file
|
# TernJS port file
|
||||||
.tern-port
|
.tern-port
|
||||||
|
|
||||||
# Output files
|
# Output files
|
||||||
public/*.html
|
public/*.html
|
||||||
public/*.js
|
public/*.js
|
||||||
public/*.css
|
public/*.css
|
||||||
public/*.map
|
public/*.map
|
||||||
public/*.gz
|
public/*.gz
|
||||||
|
public/*.txt
|
||||||
letsencrypt/
|
|
||||||
mysql/
|
letsencrypt/
|
||||||
Dockerfile
|
mysql/
|
||||||
docker-compose.yml
|
Dockerfile
|
||||||
startup.sql
|
docker-compose.yml
|
||||||
|
startup.sql
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +1,94 @@
|
|||||||
# MERN-template
|
# MERN-template
|
||||||
|
|
||||||
A website template using the MERN stack.
|
A website template using the MERN stack. It is geared towards Persistent Browser Based Games (think neopets), but is flexible enough for a number of different uses.
|
||||||
|
|
||||||
# Setup Development
|
The primary technology involved is:
|
||||||
|
|
||||||
To set up this template, please ensure mariadb is running on the host computer, and run `npm install` as normal.
|
* React
|
||||||
|
* Nodejs
|
||||||
|
* MariaDB (with Sequelize)
|
||||||
|
* Docker (with docker-compose)
|
||||||
|
|
||||||
1. Run `sql/create_database.sql`
|
This template is designed to support the development of persistent browser based games (PBBGs), but it, and it's component microservices, can be used elsewhere.
|
||||||
2. Run `cp .envdev .env` and enter your details into the new file
|
|
||||||
3. Execute `npm run dev`
|
|
||||||
|
|
||||||
This should get the template working in development mode.
|
This template is released under the zlib license (see LICENSE).
|
||||||
|
|
||||||
# Setup Deployment
|
See the [github wiki](https://github.com/krgamestudios/MERN-template/wiki) for full documentation.
|
||||||
|
|
||||||
Eventually, a clean install will be this easy:
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone https://github.com/krgamestudios/MERN-template.git
|
|
||||||
npm run configure
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
# Microservices
|
# Microservices
|
||||||
|
|
||||||
There are external components to this template referred to as "microservices". These can be omitted entirely by simply removing the React component that accesses them.
|
There are external components to this template referred to as "microservices". These can be omitted entirely by simply removing the React components that access them. These are also available via [docker hub](https://hub.docker.com/u/krgamestudios).
|
||||||
|
|
||||||
* News Server: https://github.com/krgamestudios/news-server
|
* News Server: https://github.com/krgamestudios/news-server
|
||||||
* Chat Server: Coming soon...
|
* Auth Server: https://github.com/krgamestudios/auth-server
|
||||||
|
* Chat Server: https://github.com/krgamestudios/chat-server
|
||||||
|
|
||||||
# TODO list
|
# Setup Deployment
|
||||||
|
|
||||||
- ~~Legal Requirements:~~
|
A clean install is this easy:
|
||||||
- ~~Physical Mailing Address Config (for emails)~~
|
|
||||||
- ~~Opt-out option (for emails)~~
|
|
||||||
- ~~Privacy policy & data collection notices~~
|
|
||||||
- ~~LICENSE file~~
|
|
||||||
- ~~annoying "This site uses cookies" message~~
|
|
||||||
- Account system
|
|
||||||
- ~~sign up~~
|
|
||||||
- ~~validate email~~
|
|
||||||
- ~~login (with cookies)~~
|
|
||||||
- ~~logout (with cookies)~~
|
|
||||||
- ~~account deletion~~
|
|
||||||
- ~~Change passwords~~
|
|
||||||
- Administration Panel
|
|
||||||
- ~~Default admin account~~
|
|
||||||
- ~~Exclusive to admin accounts~~
|
|
||||||
- inspect aggregate user data
|
|
||||||
- ~~News blog system (microservice)~~
|
|
||||||
- ~~build the microservice to provide the news feed~~
|
|
||||||
- ~~access an external news feed~~
|
|
||||||
- ~~admin panel for publishing and editing news~~
|
|
||||||
- ~~"created at" and "updated at" in the response~~
|
|
||||||
- Chat system (microservice)
|
|
||||||
- Based on usernames
|
|
||||||
- Chat logs
|
|
||||||
- Direct Messages & rooms
|
|
||||||
- admin panel banning/unbanning (currently borked)
|
|
||||||
- ~~Configuraton Script:~~
|
|
||||||
- ~~Default UUID keys~~
|
|
||||||
- ~~Docker, docker, docker.~~
|
|
||||||
- Better compression for client files
|
|
||||||
- Full tutorial for setting up and using the site
|
|
||||||
- Start here page
|
|
||||||
- Security holes
|
|
||||||
- HTTPS
|
|
||||||
- Default admin account
|
|
||||||
- Information about legal requirements of the developers using this template
|
|
||||||
- Privacy policy & data collection notices
|
|
||||||
|
|
||||||
# Email settings
|
```
|
||||||
|
git clone https://github.com/krgamestudios/MERN-template.git
|
||||||
|
cd MERN-template
|
||||||
|
node configure-script.js
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
Some of the external requirements can be tricky, so let me outline what is needed. If you decide to use gmail as your email provider, then use the following `.env` settings:
|
# Setup Development
|
||||||
|
|
||||||
MAIL_SMTP=smtp.gmail.com
|
To set up this template in development mode:
|
||||||
MAIL_USERNAME=you@gmail.com
|
|
||||||
MAIL_PASSWORD=yourpassword
|
|
||||||
|
|
||||||
you'll also need to enable "less secure apps" for the specified email address. Remember - don't ever commit the `.env` file! You might even want to create a dedicated email address just for your project.
|
1. Ensure mariadb is running in your development environment
|
||||||
|
2. Run `mariadb tools/create_database.sql` as the root user
|
||||||
|
3. Run `npm install`
|
||||||
|
4. Run `cp .envdev .env` and enter your details into the `.env` file
|
||||||
|
5. Execute `npm run dev`
|
||||||
|
6. Navigate to `http://localhost:3001` in your web browser
|
||||||
|
|
||||||
|
# Features List
|
||||||
|
|
||||||
|
- Mainly one language across the codebase (JavaScript)
|
||||||
|
- Full documentation
|
||||||
|
- Setup tutorial
|
||||||
|
- Fully Featured Account System (as a microservice)
|
||||||
|
- Email validation
|
||||||
|
- Logging in and out
|
||||||
|
- Account deletion
|
||||||
|
- Password management
|
||||||
|
- JSON web token authentication
|
||||||
|
- Optional post validation hook
|
||||||
|
- Fully Featured News Blog (as a microservice)
|
||||||
|
- Publish, edit or delete articles as needed
|
||||||
|
- Secured via admin panel
|
||||||
|
- Fully Featured Chat System (as a microservice)
|
||||||
|
- Available when logged in
|
||||||
|
- Chat logs saved to the database
|
||||||
|
- Room-based chat (type `/room name` to access a specific room)
|
||||||
|
- Moderation tools
|
||||||
|
- Permanently banning users
|
||||||
|
- Chat-muting users for a time period
|
||||||
|
- Users reporting offensive chat-content
|
||||||
|
- Easy To Use Configuration Script
|
||||||
|
- Sets up everything via docker
|
||||||
|
- A default admin account (if desired)
|
||||||
|
|
||||||
|
# Coming Soon
|
||||||
|
|
||||||
|
- Full documentation
|
||||||
|
- Modding tutorials
|
||||||
|
- Fully Featured News Blog (as a microservice)
|
||||||
|
- Individual pages for news articles
|
||||||
|
|
||||||
|
# Coming Eventually
|
||||||
|
|
||||||
|
- Fully Featured News Blog (as a microservice)
|
||||||
|
- Restore deleted articles
|
||||||
|
- Undo edits
|
||||||
|
- Fully Featured Chat System (as a microservice)
|
||||||
|
- Custom emoji
|
||||||
|
- Private messaging
|
||||||
|
- Broadcasting to all channels
|
||||||
|
- Badges next to usernames
|
||||||
|
- Backend for leaderboards (modding tutorial?)
|
||||||
|
- Backend for energy systems (modding tutorial?)
|
||||||
|
- Backend for items, shops, trading and currency (modding tutorial?)
|
||||||
|
|||||||
+4
-5
@@ -1,16 +1,15 @@
|
|||||||
//polyfills
|
//polyfills
|
||||||
import 'core-js/stable';
|
|
||||||
import 'regenerator-runtime/runtime';
|
import 'regenerator-runtime/runtime';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { CookiesProvider } from 'react-cookie';
|
|
||||||
|
|
||||||
import App from './components/app';
|
import App from './pages/app';
|
||||||
|
import TokenProvider from './pages/utilities/token-provider';
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<CookiesProvider>
|
<TokenProvider>
|
||||||
<App />
|
<App />
|
||||||
</CookiesProvider>,
|
</TokenProvider>,
|
||||||
document.querySelector('#root')
|
document.querySelector('#root')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
//react
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { BrowserRouter, Switch } from 'react-router-dom';
|
|
||||||
import { useCookies } from 'react-cookie';
|
|
||||||
|
|
||||||
//library components
|
|
||||||
import LazyRoute from './lazy-route';
|
|
||||||
import Markdown from './panels/markdown';
|
|
||||||
|
|
||||||
//styling
|
|
||||||
//TODO: styling import
|
|
||||||
|
|
||||||
//common components
|
|
||||||
import Header from './panels/header.jsx';
|
|
||||||
import Footer from './panels/footer.jsx';
|
|
||||||
|
|
||||||
const App = props => {
|
|
||||||
//handle cookies prompt
|
|
||||||
const [cookies, setCookie] = useCookies();
|
|
||||||
|
|
||||||
if (!cookies['accept-cookies']) {
|
|
||||||
const accept = confirm('This website uses cookies to operate correctly. By clicking "ok", you agree to accept said cookies.');
|
|
||||||
|
|
||||||
if (accept) {
|
|
||||||
setCookie('accept-cookies', true);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>This website won't operate correctly without cookies.</p>
|
|
||||||
<button onClick={() => window.location.reload()}>Reload Page</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//default render
|
|
||||||
return (
|
|
||||||
<BrowserRouter>
|
|
||||||
<Header />
|
|
||||||
<Switch>
|
|
||||||
<LazyRoute exact path='/' component={() => import('./pages/homepage')} />
|
|
||||||
|
|
||||||
<LazyRoute path='/signup' component={() => import('./pages/signup')} />
|
|
||||||
<LazyRoute path='/login' component={() => import('./pages/login')} />
|
|
||||||
<LazyRoute path='/account' component={() => import('./pages/account')} />
|
|
||||||
|
|
||||||
<LazyRoute path='/admin' component={() => import('./pages/admin')} />
|
|
||||||
|
|
||||||
<LazyRoute path='/privacypolicy' component={async () => () => <Markdown content={require('../markdown/privacy-policy.md').default} />} />
|
|
||||||
<LazyRoute path='/credits' component={async () => () => <Markdown content={require('../markdown/credits.md').default} />} />
|
|
||||||
|
|
||||||
<LazyRoute path='*' component={() => import('./pages/not-found')} />
|
|
||||||
</Switch>
|
|
||||||
<Footer />
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Route } from 'react-router-dom';
|
|
||||||
import Loadable from 'react-loadable';
|
|
||||||
|
|
||||||
const Loading = props => {
|
|
||||||
if (props.error) {
|
|
||||||
return <p>{props.error}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.timedOut) {
|
|
||||||
return (
|
|
||||||
<div className='page'>
|
|
||||||
<p>Page Timed Out</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.pastDelay) {
|
|
||||||
return (
|
|
||||||
<div className='page'>
|
|
||||||
<p>Page Loading...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LazyRoute = lazyProps => {
|
|
||||||
const component = Loadable({
|
|
||||||
loader: lazyProps.component,
|
|
||||||
loading: Loading,
|
|
||||||
timeout: 10000
|
|
||||||
});
|
|
||||||
|
|
||||||
return <Route {...lazyProps} component={component} />
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LazyRoute;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
import { useCookies } from 'react-cookie';
|
|
||||||
|
|
||||||
import DeleteAccount from '../panels/delete-account';
|
|
||||||
|
|
||||||
const Account = props => {
|
|
||||||
const [cookies, setCookie] = useCookies();
|
|
||||||
|
|
||||||
//check for logged in redirect
|
|
||||||
if (!cookies['loggedin']) {
|
|
||||||
return <Redirect to='/' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
//refs
|
|
||||||
let contactElement, passwordElement, retypeElement;
|
|
||||||
|
|
||||||
//once before render
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/accounts')
|
|
||||||
.then(blob => blob.json())
|
|
||||||
.then(json => {
|
|
||||||
contactElement.checked = json.contact;
|
|
||||||
})
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='page'>
|
|
||||||
<h1 className='centered'>Account</h1>
|
|
||||||
<form className='constricted' onSubmit={async evt => {
|
|
||||||
evt.preventDefault();
|
|
||||||
await update(contactElement.checked, passwordElement.value, retypeElement.value);
|
|
||||||
passwordElement.value = retypeElement.value = '';
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor='contact'>Allow Promotional Emails:</label>
|
|
||||||
<input type='checkbox' name='contact' ref={e => contactElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='password'>Change Password:</label>
|
|
||||||
<input type='password' name='password' ref={e => passwordElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='retype'>Retype Password:</label>
|
|
||||||
<input type='password' name='retype' ref={e => retypeElement = e} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit'>Update Information</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<DeleteAccount className='constricted' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const update = async (contact, password, retype) => {
|
|
||||||
if (password != retype) {
|
|
||||||
alert('Passwords do not match');
|
|
||||||
}
|
|
||||||
|
|
||||||
//generate a new formdata payload
|
|
||||||
let formData = new FormData();
|
|
||||||
|
|
||||||
formData.append('contact', contact);
|
|
||||||
|
|
||||||
if (password) {
|
|
||||||
formData.append('password', password);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await fetch('/api/accounts', { method: 'PATCH', body: formData });
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
alert(await result.text());
|
|
||||||
} else {
|
|
||||||
alert(await result.text());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Account;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
import { useCookies } from 'react-cookie';
|
|
||||||
|
|
||||||
//import BannedEmails from '../panels/banned-emails';
|
|
||||||
import NewsPublisher from '../panels/news-publisher';
|
|
||||||
import NewsEditor from '../panels/news-editor';
|
|
||||||
|
|
||||||
const Admin = props => {
|
|
||||||
const [cookies, setCookie] = useCookies();
|
|
||||||
|
|
||||||
//check for logged in redirect
|
|
||||||
if (!cookies['admin']) {
|
|
||||||
return <Redirect to='/' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='page'>
|
|
||||||
<h1 className='centered'>Administration</h1>
|
|
||||||
<NewsPublisher uri={process.env.NEWS_URI} newsKey={process.env.NEWS_KEY} />
|
|
||||||
<NewsEditor uri={process.env.NEWS_URI} newsKey={process.env.NEWS_KEY} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Admin;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import NewsFeed from '../panels/news-feed';
|
|
||||||
|
|
||||||
const HomePage = props => {
|
|
||||||
//TODO: move the URIs into the config files
|
|
||||||
return (
|
|
||||||
<div className='page'>
|
|
||||||
<p>This is the MERN template homepage.</p>
|
|
||||||
<NewsFeed uri={process.env.NEWS_URI} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HomePage;
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
import { useCookies } from 'react-cookie';
|
|
||||||
|
|
||||||
//utilities
|
|
||||||
const validateEmail = require('../../../common/utilities/validate-email.js');
|
|
||||||
|
|
||||||
const LogIn = props => {
|
|
||||||
const [cookies, setCookie] = useCookies();
|
|
||||||
|
|
||||||
//check for logged in redirect
|
|
||||||
if (cookies['loggedin']) {
|
|
||||||
return <Redirect to='/' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
//refs
|
|
||||||
let emailElement, passwordElement;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='page'>
|
|
||||||
<h1 className='centered'>Login</h1>
|
|
||||||
<form className='constricted' onSubmit={
|
|
||||||
evt => {
|
|
||||||
evt.preventDefault();
|
|
||||||
handleSubmit(emailElement.value, passwordElement.value)
|
|
||||||
.then(([res, ok]) => {
|
|
||||||
alert(res);
|
|
||||||
if (ok) {
|
|
||||||
window.location.reload(true); //BUFGIX: force reload of the header element
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email">Email:</label>
|
|
||||||
<input type="email" name="email" ref={e => emailElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password">Password:</label>
|
|
||||||
<input type="password" name="password" ref={e => passwordElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit'>Login</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
//DOCS: returns two values: response and OK
|
|
||||||
const handleSubmit = async (email, password) => {
|
|
||||||
email = email.trim();
|
|
||||||
|
|
||||||
//generate a new formdata payload
|
|
||||||
let formData = new FormData();
|
|
||||||
|
|
||||||
formData.append('email', email);
|
|
||||||
formData.append('password', password);
|
|
||||||
|
|
||||||
const result = await fetch('/api/accounts/login', { method: 'POST', body: formData });
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
return [await result.text(), true];
|
|
||||||
} else {
|
|
||||||
return [await result.text(), false];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LogIn;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const NotFound = props => {
|
|
||||||
return (
|
|
||||||
<div className='page'>
|
|
||||||
<h1 className='middle centered'>Not Found</h1>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotFound;
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
import { useCookies } from 'react-cookie';
|
|
||||||
|
|
||||||
//utilities
|
|
||||||
const validateEmail = require('../../../common/utilities/validate-email.js');
|
|
||||||
const validateUsername = require('../../../common/utilities/validate-username.js');
|
|
||||||
|
|
||||||
const SignUp = props => {
|
|
||||||
const [cookies, setCookie] = useCookies();
|
|
||||||
|
|
||||||
//check for logged in redirect
|
|
||||||
if (cookies['loggedin']) {
|
|
||||||
return <Redirect to='/' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
//refs
|
|
||||||
let emailElement, usernameElement, passwordElement, retypeElement, contactElement;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='page'>
|
|
||||||
<h1 className='centered'>Signup</h1>
|
|
||||||
<form className='constricted' onSubmit={
|
|
||||||
evt => {
|
|
||||||
evt.preventDefault();
|
|
||||||
handleSubmit(emailElement.value, usernameElement.value, passwordElement.value, retypeElement.value, contactElement.checked)
|
|
||||||
.then(res => res ? alert(res) : null)
|
|
||||||
.then(() => emailElement.value = usernameElement.value = passwordElement.value = retypeElement.value = '') //clear input
|
|
||||||
.then(() => contactElement.checked = false)
|
|
||||||
.then(() => props.history.push('/'))
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor='email'>Email:</label>
|
|
||||||
<input type='email' name='email' ref={e => emailElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='username'>Username:</label>
|
|
||||||
<input type='text' name='username' ref={e => usernameElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='password'>Password:</label>
|
|
||||||
<input type='password' name='password' ref={e => passwordElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='retype'>Retype Password:</label>
|
|
||||||
<input type='password' name='retype' ref={e => retypeElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='contact'>Allow Promotional Emails:</label>
|
|
||||||
<input type='checkbox' name='contact' ref={e => contactElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit'>Signup</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (email, username, password, retype, contact) => {
|
|
||||||
email = email.trim();
|
|
||||||
username = username.trim();
|
|
||||||
|
|
||||||
const err = handleValidation(email, username, password, retype);
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
//generate a new formdata payload
|
|
||||||
let formData = new FormData();
|
|
||||||
|
|
||||||
formData.append('email', email);
|
|
||||||
formData.append('username', username);
|
|
||||||
formData.append('password', password);
|
|
||||||
formData.append('contact', contact)
|
|
||||||
|
|
||||||
const result = await fetch('/api/accounts/signup', { method: 'POST', body: formData });
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
return result.text();
|
|
||||||
} else {
|
|
||||||
return result.text();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//returns an error message, or null on success
|
|
||||||
const handleValidation = (email, username, password, retype) => {
|
|
||||||
if (!validateEmail(email)) {
|
|
||||||
return 'invalid email';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateUsername(username)) {
|
|
||||||
return 'invalid username';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
return 'invalid password (Must be at least 8 characters long)';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== retype) {
|
|
||||||
return 'passwords do not match';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SignUp;
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
const BannedEmails = props => {
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
let usernameElement, emailElement, expiryElement, reasonElement;
|
|
||||||
let unbanElement;
|
|
||||||
|
|
||||||
fetch('/api/admin/banned', { method: 'GET' })
|
|
||||||
.then(banned => banned.json())
|
|
||||||
.then(banned => !data ? setData(banned) : null)
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Banned Accounts</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Username</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Privilege</th>
|
|
||||||
<th>Expiry</th>
|
|
||||||
<th>Reason</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(data || []).map((entry, index) =>
|
|
||||||
<tr key={index}>
|
|
||||||
<td>{entry.username}</td>
|
|
||||||
<td>{entry.email}</td>
|
|
||||||
<td>{entry.privilege}</td>
|
|
||||||
<td>{entry.expiry ? (new Date(entry.expiry)).toISOString() : null}</td>
|
|
||||||
<td>{entry.reason}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>Ban</h2>
|
|
||||||
<form onSubmit={async e => { e.preventDefault(); await handleBan(usernameElement.value, emailElement.value, expiryElement.value, reasonElement.value); }}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor='username'>Username: </label>
|
|
||||||
<input type='text' name='username' ref={e => usernameElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='email'>Email: </label>
|
|
||||||
<input type='email' name='email' ref={e => emailElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='expiry'>Expiry: </label>
|
|
||||||
<input type='date' name='expiry' ref={e => expiryElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='reason'>Reason: </label>
|
|
||||||
<textarea rows='4' cols='50' name='reason' ref={e => reasonElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit'>Drop The Banhammer</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2>Unban</h2>
|
|
||||||
<form onSubmit={async e => { e.preventDefault(); await handleUnban(unbanElement.value); }}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor='entry'>Unban User: </label>
|
|
||||||
<input type='text' name='entry' ref={e => unbanElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit'>Release From Horny Jail</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBan = async (username, email, expiry, reason) => {
|
|
||||||
username = username.trim();
|
|
||||||
email = email.trim();
|
|
||||||
reason = reason.trim();
|
|
||||||
|
|
||||||
//generate a new formdata payload
|
|
||||||
let formData = new FormData();
|
|
||||||
|
|
||||||
formData.append('username', username);
|
|
||||||
formData.append('email', email);
|
|
||||||
formData.append('expiry', expiry);
|
|
||||||
formData.append('reason', reason);
|
|
||||||
|
|
||||||
const result = await fetch('/api/admin/ban', { method: 'POST', body: formData });
|
|
||||||
|
|
||||||
alert(await result.text());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnban = async (entry) => {
|
|
||||||
entry = entry.trim();
|
|
||||||
|
|
||||||
let formData = new FormData();
|
|
||||||
|
|
||||||
formData.append('entry', entry);
|
|
||||||
|
|
||||||
const result = await fetch('/api/admin/unban', { method: 'POST', body: formData });
|
|
||||||
|
|
||||||
alert(await result.text());
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BannedEmails;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
//DOCS: isolated the delete account button into it's own panel, so it can be easily moved as needed
|
|
||||||
const DeleteAccount = props => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return <button onClick={() => setOpen(true)} className={props.className}>Delete Account</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
let passwordElement;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className={props.className} onSubmit={async evt => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const password = passwordElement.value;
|
|
||||||
passwordElement.value = '';
|
|
||||||
await handleSubmit(password);
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password">Password:</label>
|
|
||||||
<input type="password" name="password" ref={e => passwordElement = e} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit'>Delete Account</button>
|
|
||||||
<button type='cancel' onClick={() => { passwordElement.value = ''; setOpen(false); }}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (password) => {
|
|
||||||
//generate a new formdata payload
|
|
||||||
let formData = new FormData();
|
|
||||||
|
|
||||||
formData.append('password', password);
|
|
||||||
|
|
||||||
const result = await fetch('/api/accounts/deletion', { method: 'DELETE', body: formData });
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
alert(await result.text());
|
|
||||||
} else {
|
|
||||||
//force logout
|
|
||||||
fetch('/api/accounts/logout', { method: 'POST' })
|
|
||||||
.then(alert(await result.text()))
|
|
||||||
.then(() => window.location.reload(true)) //BUFGIX: force reload of the header element
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DeleteAccount;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const Footer = () => {
|
|
||||||
return (
|
|
||||||
<footer>
|
|
||||||
<p className='centered'>MERN template designed by <a href='https://krgamestudios.com'>Kayne Ruse, KR Game Studios</a> - <Link to='/privacypolicy'>Privacy Policy</Link> - <Link to='/credits'>Credits</Link></p>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Footer;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useCookies } from 'react-cookie';
|
|
||||||
|
|
||||||
const Visitor = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<a href='/signup'>Sign Up</a>
|
|
||||||
<em> - </em>
|
|
||||||
<a href='/login'>Log In</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Member = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<a href='/account'>Account</a>
|
|
||||||
<em> - </em>
|
|
||||||
<a href='/' onClick={logout}>Log out</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
await fetch('/api/accounts/logout', { method: 'POST' })
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Header = () => {
|
|
||||||
const [cookies, setCookie] = useCookies(['loggedin']);
|
|
||||||
|
|
||||||
let Options;
|
|
||||||
|
|
||||||
//check for logged in/out status
|
|
||||||
if (cookies['loggedin']) {
|
|
||||||
Options = Member;
|
|
||||||
} else {
|
|
||||||
Options = Visitor;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header>
|
|
||||||
<h1><a href='/'>MERN Template</a></h1>
|
|
||||||
<Options />
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import ReactMarkdown from 'react-markdown/with-html';
|
|
||||||
|
|
||||||
const Markdown = props => {
|
|
||||||
//content?
|
|
||||||
let [contentHook, setContentHook] = useState(null);
|
|
||||||
|
|
||||||
//check arguments
|
|
||||||
if (!props.content) {
|
|
||||||
if (!props.uri) {
|
|
||||||
throw 'Markdown requires either content or uri prop';
|
|
||||||
}
|
|
||||||
|
|
||||||
//once
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(props.uri)
|
|
||||||
.then(blob => blob.text())
|
|
||||||
.then(blob => setContentHook(blob))
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
}, []);
|
|
||||||
} else
|
|
||||||
|
|
||||||
//assume raw info
|
|
||||||
if (!contentHook) {
|
|
||||||
setContentHook(props.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReactMarkdown escapeHtml={false} props={{...props}}>{contentHook}</ReactMarkdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Markdown;
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import Select from 'react-dropdown-select';
|
|
||||||
|
|
||||||
//DOCS: props.uri is the address of a live news-server
|
|
||||||
//DOCS: props.newsKey is the key of the live news-server
|
|
||||||
const NewsEditor = props => {
|
|
||||||
let titleElement, authorElement, bodyElement;
|
|
||||||
const [articles, setArticles] = useState(null);
|
|
||||||
const [index, setIndex] = useState(null);
|
|
||||||
|
|
||||||
if (!articles) {
|
|
||||||
fetch(`${props.uri}/titles?limit=999`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Control-Allow-Origin': '*'
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(a => {
|
|
||||||
if (!a.ok) {
|
|
||||||
throw `Network error ${a.status}: ${a.statusText} ${a.url}`;
|
|
||||||
}
|
|
||||||
return a.json();
|
|
||||||
})
|
|
||||||
.then(a => setArticles(a))
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2 className='centered'>News Editor</h2>
|
|
||||||
<div>
|
|
||||||
<label htmlFor='article'>Article: </label>
|
|
||||||
<Select
|
|
||||||
options={(articles || []).map(article => { return { label: article.title, value: article.index }; })}
|
|
||||||
onChange={values => setIndex(fetchSelection(values[0].value, titleElement, authorElement, bodyElement, props.uri))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(index, titleElement.value, authorElement.value, bodyElement.value, props.uri, props.newsKey);
|
|
||||||
titleElement.value = authorElement.value = bodyElement.value = '';
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor='title'>Title: </label>
|
|
||||||
<input type='text' name='title' ref={ e => titleElement = e } />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='author'>Author: </label>
|
|
||||||
<input type='text' name='author' ref={ e => authorElement = e } />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='body'>Body: </label>
|
|
||||||
<textarea name='body' rows='10' cols='150' ref={ e => bodyElement = e } />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit'>Update</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSelection = (index, titleElement, authorElement, bodyElement, uri) => {
|
|
||||||
fetch(`${uri}/archive/${index}`, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Control-Allow-Origin': '*'
|
|
||||||
})
|
|
||||||
.then(blob => blob.json())
|
|
||||||
.then(article => {
|
|
||||||
titleElement.value = article.title;
|
|
||||||
authorElement.value = article.author;
|
|
||||||
bodyElement.value = article.body;
|
|
||||||
})
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
|
|
||||||
return index; //this is admittedly odd
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (index, title, author, body, uri, newsKey) => {
|
|
||||||
title = title.trim();
|
|
||||||
author = author.trim();
|
|
||||||
body = body.trim();
|
|
||||||
uri = uri.trim();
|
|
||||||
newsKey = newsKey.trim();
|
|
||||||
|
|
||||||
//fetch POST json data
|
|
||||||
const raw = await fetch(
|
|
||||||
`${uri}/${index}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Control-Allow-Origin': '*'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ title: title, author: author, body: body, key: newsKey })
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (raw.ok) {
|
|
||||||
const result = await raw.json();
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
alert(`Updated article index ${index}`);
|
|
||||||
} else {
|
|
||||||
alert(result.error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert(raw.statusText);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NewsEditor;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import dateFormat from 'dateformat';
|
|
||||||
|
|
||||||
//DOCS: props.uri is the address of a live news-server
|
|
||||||
const NewsFeed = props => {
|
|
||||||
const [articles, setArticles] = useState(null);
|
|
||||||
|
|
||||||
if (!articles) {
|
|
||||||
fetch(props.uri, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Control-Allow-Origin': '*'
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(a => {
|
|
||||||
if (!a.ok) {
|
|
||||||
throw `Network error ${a.status}: ${a.statusText} ${a.url}`;
|
|
||||||
}
|
|
||||||
return a.json();
|
|
||||||
})
|
|
||||||
.then(a => setArticles(a))
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className='centered'>News Feed</h1>
|
|
||||||
{(articles || []).map((article, index) => {
|
|
||||||
return (
|
|
||||||
<div key={index}>
|
|
||||||
<hr />
|
|
||||||
<h2>{article.title}</h2>
|
|
||||||
<p>Written by <strong>{article.author}</strong>, {
|
|
||||||
article.edits > 0 ?
|
|
||||||
<span>Last Updated {dateFormat(articles.updatedAt, 'fullDate')} ({`${article.edits} edit${article.edits > 1 ? 's': ''}`})</span> :
|
|
||||||
<span>Published {dateFormat(articles.createdAt, 'fullDate')}</span>
|
|
||||||
}</p>
|
|
||||||
<p style={{whiteSpace: 'pre-wrap'}}>{article.body}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NewsFeed;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
//DOCS: props.uri is the address of a live news-server
|
|
||||||
//DOCS: props.newsKey is the key of the live news-server
|
|
||||||
const NewsPublisher = props => {
|
|
||||||
let titleElement, authorElement, bodyElement;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2 className='centered'>News Publisher</h2>
|
|
||||||
<form onSubmit={async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(titleElement.value, authorElement.value, bodyElement.value, props.uri, props.newsKey);
|
|
||||||
titleElement.value = authorElement.value = bodyElement.value = '';
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor='title'>Title: </label>
|
|
||||||
<input type='text' name='title' ref={ e => titleElement = e } />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='author'>Author: </label>
|
|
||||||
<input type='text' name='author' ref={ e => authorElement = e } />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor='body'>Body: </label>
|
|
||||||
<textarea name='body' rows='10' cols='150' ref={ e => bodyElement = e } />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit'>Publish</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (title, author, body, uri, newsKey) => {
|
|
||||||
title = title.trim();
|
|
||||||
author = author.trim();
|
|
||||||
body = body.trim();
|
|
||||||
uri = uri.trim();
|
|
||||||
newsKey = newsKey.trim();
|
|
||||||
|
|
||||||
//fetch POST json data
|
|
||||||
const raw = await fetch(
|
|
||||||
uri,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Control-Allow-Origin': '*'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ title: title, author: author, body: body, key: newsKey })
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (raw.ok) {
|
|
||||||
const result = await raw.json();
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
alert(`Published article index ${result.index}`);
|
|
||||||
} else {
|
|
||||||
alert(result.error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert(raw.statusText);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NewsPublisher;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Credits
|
|
||||||
|
|
||||||
MERN Template developed by Kayne Ruse, KR Game Studios
|
|
||||||
|
|
||||||
[https://github.com/krgamestudios/MERN-template](https://github.com/krgamestudios/MERN-template)
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Privacy Policy
|
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useEffect, useContext, useRef } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from '../utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from '../utilities/token-provider';
|
||||||
|
|
||||||
|
import DeleteAccount from './panels/delete-account';
|
||||||
|
|
||||||
|
const Account = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//misplaced?
|
||||||
|
if (!authTokens.accessToken) {
|
||||||
|
return <Redirect to='/' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
//refs
|
||||||
|
const passwordRef = useRef();
|
||||||
|
const retypeRef = useRef();
|
||||||
|
const contactRef = useRef();
|
||||||
|
|
||||||
|
//grab the user's info
|
||||||
|
useEffect(() => {
|
||||||
|
authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/account`)
|
||||||
|
.then(blob => blob.json())
|
||||||
|
.then(json => contactRef.current.checked = json.contact)
|
||||||
|
.catch(e => console.error(e))
|
||||||
|
;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
//render the thing
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page'>
|
||||||
|
<div className='central panel centered middle'>
|
||||||
|
<div className='panel'>
|
||||||
|
<h1 className='text centered'>Account</h1>
|
||||||
|
<div className='panel'>
|
||||||
|
<form className='constrained' onSubmit={async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err] = await update(passwordRef.current.value, retypeRef.current.value, contactRef.current.checked, authTokens.tokenFetch);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Details updated');
|
||||||
|
passwordRef.current.value = retypeRef.current.value = '';
|
||||||
|
}}>
|
||||||
|
<input type='password' name='password' placeholder='New Password' ref={passwordRef} />
|
||||||
|
<input type='password' name='retype' placeholder='Retype New Password' ref={retypeRef} />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<label htmlFor='contact'>Allow Promotional Emails:</label>
|
||||||
|
<input type='checkbox' name='contact' ref={contactRef} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button type='submit'>Update Information</button>
|
||||||
|
</form>
|
||||||
|
<DeleteAccount />
|
||||||
|
</div>
|
||||||
|
<Link to='/' className='text centered'>Return Home</Link>\
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = async (password, retype, contact, tokenFetch) => {
|
||||||
|
if (password != retype) {
|
||||||
|
return ['Passwords do not match'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password && password.length < 8) {
|
||||||
|
return ['Password is too short'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tokenFetch(`${process.env.AUTH_URI}/auth/account`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: password ? password : null,
|
||||||
|
contact
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return [`${await result.status}: ${await result.text()}`];
|
||||||
|
} else {
|
||||||
|
return [null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Account;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useContext, useRef } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from '../utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from '../utilities/token-provider';
|
||||||
|
|
||||||
|
const validateEmail = require('../../../common/utilities/validate-email');
|
||||||
|
|
||||||
|
const Login = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//misplaced?
|
||||||
|
if (authTokens.accessToken) {
|
||||||
|
return <Redirect to='/' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
//refs
|
||||||
|
const emailRef = useRef();
|
||||||
|
const passwordRef = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page'>
|
||||||
|
<div className='central panel centered middle'>
|
||||||
|
<div className='panel'>
|
||||||
|
<h1 className='text centered'>Login</h1>
|
||||||
|
<form className='constrained' onSubmit={
|
||||||
|
async evt => {
|
||||||
|
//on submit
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err, accessToken] = await handleSubmit(emailRef.current.value, passwordRef.current.value);
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
//save auth tokens and redirect
|
||||||
|
if (accessToken) {
|
||||||
|
authTokens.setAccessToken(accessToken);
|
||||||
|
|
||||||
|
props.history.push('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<input type='email' name='email' placeholder='your@email.com' ref={emailRef} />
|
||||||
|
<input type='password' name='password' placeholder='********' ref={passwordRef} />
|
||||||
|
<button type='submit'>Login</button>
|
||||||
|
</form>
|
||||||
|
<Link to='/recover' className='text centered'>Forgot Password?</Link>
|
||||||
|
<Link to='/' className='text centered'>Return Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
//DOCS: returns two values: err and authTokens
|
||||||
|
const handleSubmit = async (email, password) => {
|
||||||
|
email = email.trim();
|
||||||
|
|
||||||
|
const err = handleValidation(email, password);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return [err, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
//send to the auth server
|
||||||
|
const result = await fetch(`${process.env.AUTH_URI}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
//handle errors
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.error(err);
|
||||||
|
return [err, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
//return the new auth tokens
|
||||||
|
const accessToken = await result.text();
|
||||||
|
return [null, accessToken];
|
||||||
|
};
|
||||||
|
|
||||||
|
//returns an error message, or null on success
|
||||||
|
const handleValidation = (email, password) => {
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
return 'invalid email';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return 'invalid password (Must be at least 8 characters long)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { useState, useContext, useRef } from 'react';
|
||||||
|
|
||||||
|
import { TokenContext } from '../../utilities/token-provider';
|
||||||
|
|
||||||
|
//DOCS: isolated the delete account button into it's own panel, so it can be easily moved as needed
|
||||||
|
const DeleteAccount = props => {
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const passwordRef = useRef();
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOpen(true)}>Delete Account</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='panel centered middle'>
|
||||||
|
<h2 className='text centered'>Delete Your Account?</h2>
|
||||||
|
<form className='constrained' onSubmit={async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err] = await handleSubmit(passwordRef.current.value, authTokens);
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<input type="password" name="password" placeholder='Password' ref={passwordRef} />
|
||||||
|
|
||||||
|
<button type='submit' style={{backgroundColor: 'red'}}>Delete Account</button>
|
||||||
|
<button type='cancel' onClick={() => { passwordRef.current.value = ''; setOpen(false); }}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (password, authTokens) => {
|
||||||
|
//schedule a deletion
|
||||||
|
const result = await authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/account`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
password
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return [`${await result.status}: ${await result.text()}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
//force a logout
|
||||||
|
const result2 = await authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/logout`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result2.ok) {
|
||||||
|
return [`${await result2.status}: ${await result2.text()}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
authTokens.setAccessToken('');
|
||||||
|
|
||||||
|
return [null];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteAccount;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React, { useContext, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { TokenContext } from '../../utilities/token-provider';
|
||||||
|
|
||||||
|
//TODO: make this an ACTUAL BUTTON
|
||||||
|
const Logout = () => {
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ /* Logout logs you out of the server too */ }
|
||||||
|
<Link to='/' onClick={async () => {
|
||||||
|
const result = await authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/logout`, { //NOTE: this gets overwritten as a bugfix
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
//any problems?
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(await result.text());
|
||||||
|
} else {
|
||||||
|
authTokens.setAccessToken('');
|
||||||
|
}
|
||||||
|
}}>Logout</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logout;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useContext, useRef } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from '../utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from '../utilities/token-provider';
|
||||||
|
|
||||||
|
//utilities
|
||||||
|
const validateEmail = require('../../../common/utilities/validate-email');
|
||||||
|
|
||||||
|
const Recover = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//misplaced?
|
||||||
|
if (authTokens.accessToken) {
|
||||||
|
return <Redirect to='/' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
//refs
|
||||||
|
const emailRef = useRef();
|
||||||
|
const recoverRef = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page'>
|
||||||
|
<div className='central panel centered middle'>
|
||||||
|
<h1 className='text centered'>Forgot Password</h1>
|
||||||
|
<form className='constrained' onSubmit={
|
||||||
|
async evt => { //on submit
|
||||||
|
recoverRef.current.disabled = true;
|
||||||
|
evt.preventDefault();
|
||||||
|
const [result, redirect] = await handleSubmit(emailRef.current.value);
|
||||||
|
if (result) {
|
||||||
|
alert(result);
|
||||||
|
recoverRef.current.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
//redirect
|
||||||
|
if (redirect) {
|
||||||
|
props.history.push('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<input type='email' name='email' placeholder='your@email.com' ref={emailRef} />
|
||||||
|
<button type='submit' ref={recoverRef}>Recover Password</button>
|
||||||
|
</form>
|
||||||
|
<Link to='/' className='text centered'>Return Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (email) => {
|
||||||
|
email = email.trim();
|
||||||
|
|
||||||
|
const err = handleValidation(email);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return [err];
|
||||||
|
}
|
||||||
|
|
||||||
|
//send to the auth server
|
||||||
|
const result = await fetch(`${process.env.AUTH_URI}/auth/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.error(err);
|
||||||
|
return [err, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [await result.text(), true];
|
||||||
|
};
|
||||||
|
|
||||||
|
//returns an error message, or null on success
|
||||||
|
const handleValidation = (email) => {
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
return 'invalid email';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Recover;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useContext, useRef } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
import queryString from 'query-string';
|
||||||
|
|
||||||
|
import ApplyToBody from '../utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from '../utilities/token-provider';
|
||||||
|
|
||||||
|
const Reset = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//query
|
||||||
|
const query = queryString.parse(props.location.search);
|
||||||
|
|
||||||
|
//misplaced?
|
||||||
|
if (authTokens.accessToken || !query.email || !query.token) {
|
||||||
|
return <Redirect to='/' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
//refs
|
||||||
|
const passwordRef = useRef();
|
||||||
|
const retypeRef = useRef();
|
||||||
|
|
||||||
|
//render the thing
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page'>
|
||||||
|
<div className='central panel centered middle'>
|
||||||
|
<h1 className='text centered'>Reset Password</h1>
|
||||||
|
<form className='constrained' onSubmit={async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err, redirect] = await update(passwordRef.current.value, retypeRef.current.value, query);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Details updated'); //TODO: replace with a message from the auth server
|
||||||
|
|
||||||
|
//redirect
|
||||||
|
if (redirect) {
|
||||||
|
props.history.push('/');
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<input type='password' name='password' placeholder='New Password' ref={passwordRef} />
|
||||||
|
<input type='password' name='retype' placeholder='Retype New Password' ref={retypeRef} />
|
||||||
|
<button type='submit'>Update Information</button>
|
||||||
|
</form>
|
||||||
|
<Link to='/' className='text centered'>Return Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = async (password, retype, query) => {
|
||||||
|
if (password != retype) {
|
||||||
|
return ['Passwords do not match'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password && password.length < 8) {
|
||||||
|
return ['Password is too short'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetch(`${process.env.AUTH_URI}/auth/reset?email=${query.email}&token=${query.token}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: password ? password : null,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return [`${await result.status}: ${await result.text()}`];
|
||||||
|
} else {
|
||||||
|
return [null, true];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Reset;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useContext, useRef } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from '../utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from '../utilities/token-provider';
|
||||||
|
|
||||||
|
//utilities
|
||||||
|
const validateEmail = require('../../../common/utilities/validate-email');
|
||||||
|
const validateUsername = require('../../../common/utilities/validate-username');
|
||||||
|
|
||||||
|
const Signup = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//misplaced?
|
||||||
|
if (authTokens.accessToken) {
|
||||||
|
return <Redirect to='/' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
//refs
|
||||||
|
const emailRef = useRef();
|
||||||
|
const usernameRef = useRef();
|
||||||
|
const passwordRef = useRef();
|
||||||
|
const retypeRef = useRef();
|
||||||
|
const contactRef = useRef();
|
||||||
|
const signupRef = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page'>
|
||||||
|
<div className='central panel centered middle'>
|
||||||
|
<h1 className='text centered'>Signup</h1>
|
||||||
|
<form className='constrained' onSubmit={
|
||||||
|
async evt => { //on submit
|
||||||
|
signupRef.current.disabled = true;
|
||||||
|
evt.preventDefault();
|
||||||
|
const [result, redirect] = await handleSubmit(emailRef.current.value, usernameRef.current.value, passwordRef.current.value, retypeRef.current.value, contactRef.current.checked);
|
||||||
|
if (result) {
|
||||||
|
alert(result);
|
||||||
|
signupRef.current.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
//redirect
|
||||||
|
if (redirect) {
|
||||||
|
props.history.push('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
|
||||||
|
<input type='email' name='email' placeholder='your@email.com' ref={emailRef} />
|
||||||
|
<input type='text' name='username' placeholder='Username' ref={usernameRef} />
|
||||||
|
<input type='password' name='password' placeholder='********' ref={passwordRef} />
|
||||||
|
<input type='password' name='retype' placeholder='********' ref={retypeRef} />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<label htmlFor='contact'>Allow Emails:</label>
|
||||||
|
<input type='checkbox' name='contact' ref={contactRef} defaultChecked='true' />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button type='submit' ref={signupRef}>Signup</button>
|
||||||
|
</form>
|
||||||
|
<Link to='/recover' className='text centered'>Forgot Password?</Link>
|
||||||
|
<Link to='/' className='text centered'>Return Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (email, username, password, retype, contact) => {
|
||||||
|
email = email.trim();
|
||||||
|
username = username.trim();
|
||||||
|
|
||||||
|
const err = handleValidation(email, username, password, retype);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return [err];
|
||||||
|
}
|
||||||
|
|
||||||
|
//send to the auth server
|
||||||
|
const result = await fetch(`${process.env.AUTH_URI}/auth/signup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
contact
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.error(err);
|
||||||
|
return [err, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [await result.text(), true];
|
||||||
|
};
|
||||||
|
|
||||||
|
//returns an error message, or null on success
|
||||||
|
const handleValidation = (email, username, password, retype) => {
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
return 'invalid email';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateUsername(username)) {
|
||||||
|
return 'invalid username';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return 'invalid password (Must be at least 8 characters long)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== retype) {
|
||||||
|
return 'passwords do not match';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Signup;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from '../utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from '../utilities/token-provider';
|
||||||
|
|
||||||
|
import GrantAdmin from './panels/grant-admin';
|
||||||
|
import GrantMod from './panels/grant-mod';
|
||||||
|
|
||||||
|
const Admin = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//misplaced? (admin only)
|
||||||
|
if (!authTokens.accessToken || !authTokens.getPayload().admin) {
|
||||||
|
return <Redirect to='/' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page panel'>
|
||||||
|
<div className='central panel'>
|
||||||
|
<h1 className='text centered'>Administration Tools</h1>
|
||||||
|
<br />
|
||||||
|
<GrantAdmin />
|
||||||
|
<br />
|
||||||
|
<GrantMod />
|
||||||
|
<Link to='/' className='text centered'>Return Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Admin;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from '../utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from '../utilities/token-provider';
|
||||||
|
|
||||||
|
import NewsPublisher from './panels/news-publisher';
|
||||||
|
import NewsEditor from './panels/news-editor';
|
||||||
|
|
||||||
|
import ChatReports from './panels/chat-reports';
|
||||||
|
import BanUser from './panels/ban-user';
|
||||||
|
|
||||||
|
const Mod = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//misplaced? (admin only)
|
||||||
|
if (!authTokens.accessToken || !(authTokens.getPayload().admin || authTokens.getPayload().mod)) {
|
||||||
|
return <Redirect to='/' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page panel'>
|
||||||
|
<div className='central panel'>
|
||||||
|
<h1 className='text centered'>Moderation Tools</h1>
|
||||||
|
<NewsPublisher />
|
||||||
|
<br />
|
||||||
|
<NewsEditor />
|
||||||
|
<br />
|
||||||
|
<BanUser />
|
||||||
|
<br />
|
||||||
|
<ChatReports />
|
||||||
|
<Link to='/' className='text centered'>Return Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Mod;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React, { useRef, useContext } from 'react';
|
||||||
|
|
||||||
|
import { TokenContext } from '../../utilities/token-provider';
|
||||||
|
|
||||||
|
const BanUser = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//ref
|
||||||
|
const usernameRef = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='panel'>
|
||||||
|
<h2 className='text centered'>Permanently Ban User</h2>
|
||||||
|
<form className='constrained'>
|
||||||
|
<input type='text' name='username' placeholder='Username' ref={usernameRef} />
|
||||||
|
|
||||||
|
<button type='button' onClick={async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const yes = confirm('Permanently ban this user from the website?');
|
||||||
|
|
||||||
|
if (!yes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
usernameRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}>Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleButtonPress = async (username, tokenFetch) => {
|
||||||
|
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/banuser`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.log(err);
|
||||||
|
return [err, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, true];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BanUser;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
|
import { TokenContext } from '../../utilities/token-provider';
|
||||||
|
import dateFormat from 'dateformat';
|
||||||
|
|
||||||
|
const ChatReports = props => {
|
||||||
|
const [reports, setReports] = useState([]);
|
||||||
|
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
useEffect(async () => {
|
||||||
|
const result = await authTokens.tokenFetch(`${process.env.CHAT_URI}/admin/reports`);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.log(err);
|
||||||
|
alert(err);
|
||||||
|
} else {
|
||||||
|
setReports(await result.json());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='panel' style={{minWidth: '100%'}}>
|
||||||
|
<h2 className='text centered'>Chat Reports</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th className='mobile hide'>Room Name</th>
|
||||||
|
<th>Content</th>
|
||||||
|
<th>Reported By</th>
|
||||||
|
<th className='mobile hide'>Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{reports.map((report, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className='text centered'>{dateFormat(report.chatlog.createdAt, 'yyyy-mm-dd, H:MM:ss')}</td>
|
||||||
|
<td className='text centered'>{report.chatlog.username}</td>
|
||||||
|
<td className='text mobile hide centered'>{report.chatlog.room}</td>
|
||||||
|
<td className='text centered'>{report.chatlog.text}</td>
|
||||||
|
<td className='text centered'>{report.reporter.join(', ')}</td>
|
||||||
|
<td className='text mobile hide centered'><button onClick={() => deleteReportsFor(report.chatlogIndex, authTokens.tokenFetch, setReports)}>Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteReportsFor = (chatlogIndex, tokenFetch, setReports) => {
|
||||||
|
tokenFetch(`${process.env.CHAT_URI}/admin/reports`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ chatlogIndex })
|
||||||
|
});
|
||||||
|
|
||||||
|
setReports(reports => reports.filter(report => report.chatlogIndex != chatlogIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatReports;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useRef, useContext } from 'react';
|
||||||
|
|
||||||
|
import { TokenContext } from '../../utilities/token-provider';
|
||||||
|
|
||||||
|
const GrantAdmin = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//ref
|
||||||
|
const usernameRef = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='panel'>
|
||||||
|
<h2 className='text centered'>Grant Admin Privileges</h2>
|
||||||
|
<form className='constrained'>
|
||||||
|
<input type='text' name='username' placeholder='Username' ref={usernameRef} />
|
||||||
|
|
||||||
|
<button type='button' onClick={async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'POST');
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
alert('admin set');
|
||||||
|
usernameRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}>Submit</button>
|
||||||
|
|
||||||
|
<button type='button' onClick={async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'DELETE');
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
alert('admin removed');
|
||||||
|
usernameRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}>Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleButtonPress = async (username, tokenFetch, method) => {
|
||||||
|
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/admin`, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.log(err);
|
||||||
|
return [err, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, true];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GrantAdmin;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useRef, useContext } from 'react';
|
||||||
|
|
||||||
|
import { TokenContext } from '../../utilities/token-provider';
|
||||||
|
|
||||||
|
const GrantMod = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//ref
|
||||||
|
const usernameRef = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='panel'>
|
||||||
|
<h2 className='text centered'>Grant Moderation Privileges</h2>
|
||||||
|
<form className='constrained'>
|
||||||
|
<input type='text' name='username' placeholder='Username' ref={usernameRef} />
|
||||||
|
|
||||||
|
<button type='button' onClick={async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'POST');
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
alert('mod set');
|
||||||
|
usernameRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}>Submit</button>
|
||||||
|
|
||||||
|
<button type='button' onClick={async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'DELETE');
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
alert('mod removed');
|
||||||
|
usernameRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}>Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleButtonPress = async (username, tokenFetch, method) => {
|
||||||
|
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/mod`, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.log(err);
|
||||||
|
return [err, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, true];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GrantMod;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useState, useEffect, useContext, useRef } from 'react';
|
||||||
|
import Select from 'react-select';
|
||||||
|
|
||||||
|
import { TokenContext } from '../../utilities/token-provider';
|
||||||
|
|
||||||
|
const NewsEditor = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//refs
|
||||||
|
const titleRef = useRef();
|
||||||
|
const authorRef = useRef();
|
||||||
|
const bodyRef = useRef();
|
||||||
|
|
||||||
|
//state
|
||||||
|
const [articles, setArticles] = useState([]);
|
||||||
|
const [index, setIndex] = useState(null);
|
||||||
|
|
||||||
|
//run once
|
||||||
|
useEffect(async () => {
|
||||||
|
const result = await fetch(`${process.env.NEWS_URI}/news/metadata?limit=999`);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.log(err);
|
||||||
|
alert(err);
|
||||||
|
} else {
|
||||||
|
setArticles(await result.json());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='panel'>
|
||||||
|
<h2 className='text centered'>News Editor</h2>
|
||||||
|
<Select
|
||||||
|
options={articles.map(article => { return { label: article.title, index: article.index }; })}
|
||||||
|
onChange={async ({index}) => {
|
||||||
|
//fetch this article
|
||||||
|
const result = await fetch(`${process.env.NEWS_URI}/news/archive/${index}`);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
console.log(err);
|
||||||
|
alert(err);
|
||||||
|
} else {
|
||||||
|
const article = await result.json();
|
||||||
|
titleRef.current.value = article.title;
|
||||||
|
authorRef.current.value = article.author;
|
||||||
|
bodyRef.current.value = article.body;
|
||||||
|
setIndex(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder='Select Article'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form className='constrained' onSubmit={async evt => {
|
||||||
|
//onSubmit
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err] = await handleSubmit(titleRef.current.value, authorRef.current.value, bodyRef.current.value, index, authTokens.tokenFetch);
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
} else {
|
||||||
|
titleRef.current.value = authorRef.current.value = bodyRef.current.value = '';
|
||||||
|
alert(`Edited as article index ${index}`);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<input type='text' name='title' placeholder='Title' ref={titleRef} />
|
||||||
|
<input type='text' name='author' placeholder='Author' ref={authorRef} />
|
||||||
|
<textarea name='body' rows='10' cols='150' placeholder='Body of the article goes here...' ref={bodyRef} />
|
||||||
|
|
||||||
|
<button type='submit'>Update</button>
|
||||||
|
<button type='button' onClick={async evt => {
|
||||||
|
//onDelete
|
||||||
|
const [err, result] = await handleDelete(index, authTokens.tokenFetch);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
titleRef.current.value = authorRef.current.value = bodyRef.current.value = '';
|
||||||
|
alert(`Article deleted`);
|
||||||
|
}
|
||||||
|
}}>Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (title, author, body, index, tokenFetch) => {
|
||||||
|
title = title.trim();
|
||||||
|
author = author.trim();
|
||||||
|
body = body.trim();
|
||||||
|
|
||||||
|
//fetch POST json data
|
||||||
|
const result = await tokenFetch(`${process.env.NEWS_URI}/news/${index}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
body
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return [`${result.status}: ${await result.text()}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (index, tokenFetch) => {
|
||||||
|
const conf = confirm('Are you sure you want to delete this article?');
|
||||||
|
|
||||||
|
if (conf) {
|
||||||
|
const result = await tokenFetch(`${process.env.NEWS_URI}/news/${index}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = `${result.status}: ${await result.text()}`;
|
||||||
|
return [err, false];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, conf];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewsEditor;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useContext, useRef } from 'react';
|
||||||
|
|
||||||
|
import { TokenContext } from '../../utilities/token-provider';
|
||||||
|
|
||||||
|
const NewsPublisher = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//refs
|
||||||
|
const titleRef = useRef();
|
||||||
|
const authorRef = useRef();
|
||||||
|
const bodyRef = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='panel'>
|
||||||
|
<h2 className='text centered'>News Publisher</h2>
|
||||||
|
<form className='constrained' onSubmit={async evt => {
|
||||||
|
//on submit
|
||||||
|
evt.preventDefault();
|
||||||
|
const [err, index] = await handleSubmit(titleRef.current.value, authorRef.current.value, bodyRef.current.value, authTokens.tokenFetch);
|
||||||
|
if (err) {
|
||||||
|
alert(err);
|
||||||
|
} else {
|
||||||
|
titleRef.current.value = authorRef.current.value = bodyRef.current.value = ''; //TODO: null bug here?
|
||||||
|
alert(`Published as article index ${index}`);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<input type='text' name='title' placeholder='Title' ref={titleRef} />
|
||||||
|
<input type='text' name='author' placeholder='Author' ref={authorRef} />
|
||||||
|
<textarea name='body' rows='10' cols='150' placeholder='Body of the article goes here...' ref={bodyRef} />
|
||||||
|
|
||||||
|
<button type='submit'>Publish</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (title, author, body, tokenFetch) => {
|
||||||
|
title = title.trim();
|
||||||
|
author = author.trim();
|
||||||
|
body = body.trim();
|
||||||
|
|
||||||
|
//fetch POST json data
|
||||||
|
const result = await tokenFetch(
|
||||||
|
`${process.env.NEWS_URI}/news`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
body
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return [`${result.status}: ${await result.text()}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await result.json();
|
||||||
|
|
||||||
|
return [null, json.index];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewsPublisher;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//react
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { BrowserRouter, Switch } from 'react-router-dom';
|
||||||
|
import { TokenContext } from './utilities/token-provider';
|
||||||
|
|
||||||
|
//library components
|
||||||
|
import LazyRoute from './utilities/lazy-route';
|
||||||
|
|
||||||
|
//styling
|
||||||
|
import '../styles/styles.css';
|
||||||
|
|
||||||
|
//common components
|
||||||
|
import Footer from './panels/footer';
|
||||||
|
import PopupChat from './panels/popup-chat';
|
||||||
|
|
||||||
|
const App = props => {
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//default render
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Switch>
|
||||||
|
<LazyRoute exact path='/' component={() => import('./homepage')} />
|
||||||
|
|
||||||
|
<LazyRoute path='/signup' component={() => import('./accounts/signup')} />
|
||||||
|
<LazyRoute path='/login' component={() => import('./accounts/login')} />
|
||||||
|
<LazyRoute path='/account' component={() => import('./accounts/account')} />
|
||||||
|
<LazyRoute path='/dashboard' component={() => import('./dashboard')} />
|
||||||
|
|
||||||
|
<LazyRoute path='/recover' component={() => import('./accounts/recover')} />
|
||||||
|
<LazyRoute path='/reset' component={() => import('./accounts/reset')} />
|
||||||
|
|
||||||
|
<LazyRoute path='/admin' component={() => import('./administration/admin')} />
|
||||||
|
<LazyRoute path='/mod' component={() => import('./administration/mod')} />
|
||||||
|
|
||||||
|
<LazyRoute path='/privacypolicy' component={() => import('./static/privacy-policy')} />
|
||||||
|
<LazyRoute path='/credits' component={() => import('./static/credits')} />
|
||||||
|
|
||||||
|
<LazyRoute path='*' component={() => import('./not-found')} />
|
||||||
|
</Switch>
|
||||||
|
{ authTokens.accessToken ? <PopupChat /> : <></> }
|
||||||
|
<Footer />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from './utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from './utilities/token-provider';
|
||||||
|
|
||||||
|
import Logout from './accounts/panels/logout';
|
||||||
|
|
||||||
|
const Dashboard = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//misplaced?
|
||||||
|
if (!authTokens.accessToken) {
|
||||||
|
return <Redirect to='/' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page'>
|
||||||
|
<div className='central panel centered middle'>
|
||||||
|
<Link to='/account'>Account</Link>
|
||||||
|
{ authTokens.getPayload().admin ? <Link to='/admin' className='text centered'>Admin</Link> : <></> }
|
||||||
|
{ authTokens.getPayload().mod ? <Link to='/mod' className='text centered'>Mod</Link> : <></> }
|
||||||
|
<Logout />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Link, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from './utilities/apply-to-body';
|
||||||
|
|
||||||
|
import { TokenContext } from './utilities/token-provider';
|
||||||
|
|
||||||
|
import NewsFeed from './panels/news-feed';
|
||||||
|
|
||||||
|
const HomePage = props => {
|
||||||
|
//context
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
//misplaced?
|
||||||
|
if (authTokens.accessToken) {
|
||||||
|
return <Redirect to='/dashboard' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='homepage' />
|
||||||
|
<div className='page'>
|
||||||
|
<div className='panel above'>
|
||||||
|
<header>
|
||||||
|
<h1 className='text centered'>MERN Template</h1>
|
||||||
|
<h2 className='text centered'>This is the MERN-template</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className='panel centered middle'>
|
||||||
|
<Link to='/signup'><button>Sign Up</button></Link>
|
||||||
|
<Link to='/login'><button>Login</button></Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='panel below'>
|
||||||
|
<div className='central'>
|
||||||
|
<NewsFeed />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ApplyToBody from './utilities/apply-to-body';
|
||||||
|
|
||||||
|
const NotFound = props => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ApplyToBody className='dashboard' />
|
||||||
|
<div className='page'>
|
||||||
|
<div className='central panel centered middle'>
|
||||||
|
<h1 className='text centered'>Page Not Found</h1>
|
||||||
|
<br />
|
||||||
|
<Link className='text centered' to='/'>Return Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFound;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const Break = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className='mobile hide'> - </span>
|
||||||
|
<br className='mobile show' />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
return (
|
||||||
|
<footer>
|
||||||
|
<p className='text centered'>© <a href='https://krgamestudios.com'>KR Game Studios</a> 2020-2022<Break /><Link to='/privacypolicy'>Privacy Policy</Link><Break /><Link to='/credits'>Credits</Link></p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import dateFormat from 'dateformat';
|
||||||
|
|
||||||
|
const NewsFeed = props => {
|
||||||
|
const [articles, setArticles] = useState([]);
|
||||||
|
const aborter = useRef(new AbortController()); //BUGFIX: double-renders = double fetches + react update after unmount
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
//this... um...
|
||||||
|
fetch(`${process.env.NEWS_URI}/news`, {
|
||||||
|
signal: aborter.current.signal //oh dear
|
||||||
|
})
|
||||||
|
.then(blob => blob.json())
|
||||||
|
.then(json => setArticles(json))
|
||||||
|
.catch(e => null) //swallow errors
|
||||||
|
;
|
||||||
|
|
||||||
|
return () => aborter.current.abort(); //This is an ugly, ugly solution, but it's the only one that works
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='panel'>
|
||||||
|
<h1 className='text centered'>News Feed</h1>
|
||||||
|
{articles.map((article, index) => {
|
||||||
|
console.log(article)
|
||||||
|
return (
|
||||||
|
<div key={index} className='panel'>
|
||||||
|
<hr />
|
||||||
|
<h2>{article.title}</h2>
|
||||||
|
<br />
|
||||||
|
<p><em>Written by <strong>{article.author}</strong>, {
|
||||||
|
article.edits > 0 ?
|
||||||
|
<span>Last Updated {dateFormat(article.updatedAt, 'fullDate')} ({`${article.edits} edit${article.edits > 1 ? 's': ''}`})</span> :
|
||||||
|
<span>Published {dateFormat(article.createdAt, 'fullDate')}</span>
|
||||||
|
}</em></p>
|
||||||
|
<br />
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: article.rendered }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewsFeed;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useContext } from 'react';
|
||||||
|
import { TokenContext } from '../utilities/token-provider';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
import '../../styles/popup-chat.css';
|
||||||
|
|
||||||
|
//TODO: I very much need to move this out of global state
|
||||||
|
const socket = io(`${process.env.CHAT_URI}/chat`);
|
||||||
|
|
||||||
|
const PopupChat = props => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [chatlog, setChatlog] = useState([{ emphasis: true, text: 'If chat doesn\'t load, reload the page' }]);
|
||||||
|
|
||||||
|
const inputRef = useRef();
|
||||||
|
const sendRef = useRef();
|
||||||
|
const endRef = useRef();
|
||||||
|
|
||||||
|
const authTokens = useContext(TokenContext);
|
||||||
|
|
||||||
|
const pushChatlog = line => setChatlog(prevChatlog => [...prevChatlog, line]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
socket.on('message', message => pushChatlog(message));
|
||||||
|
socket.on('backlog', messages => setChatlog(prev => [...prev, ...messages]));
|
||||||
|
socket.on('disconnect', reason => pushChatlog({ emphasis: true, text: 'Lost connection' }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
endRef.current.scrollIntoView();
|
||||||
|
}
|
||||||
|
}, [chatlog, open]);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<div className='chat'>
|
||||||
|
<button type='button' className='open' onClick={() => authTokens.tokenCallback(accessToken => handleOpen(setOpen, accessToken))}>Open Chat</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='chat'>
|
||||||
|
<div className='log'>
|
||||||
|
<ul className='scrollable'>
|
||||||
|
{chatlog.map((line, index) => processLine(line, index, authTokens.accessToken))}
|
||||||
|
<li ref={endRef} />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<input type='text' className='input' placeholder='message' onKeyPress={evt => evt.key == 'Enter' ? sendRef.current.click() : ''} ref={inputRef} />
|
||||||
|
<button type='button' className='send' onClick={() => authTokens.tokenCallback(accessToken => handleSend(inputRef, pushChatlog, authTokens.getPayload().username, accessToken))} ref={sendRef}>Send</button>
|
||||||
|
<button type='button' className='close' onClick={() => handleClose(setOpen)}>Close Chat</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
//handlers
|
||||||
|
const handleOpen = (setOpen, accessToken) => {
|
||||||
|
setOpen(true);
|
||||||
|
|
||||||
|
socket.emit('open chat', {
|
||||||
|
accessToken
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = setOpen => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = (inputRef, pushChatlog, username, accessToken) => {
|
||||||
|
if (inputRef.current.value == '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit('message', {
|
||||||
|
accessToken,
|
||||||
|
text: inputRef.current.value
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inputRef.current.value.startsWith('/')) {
|
||||||
|
pushChatlog({ createdAt: (new Date(Date.now())).toISOString(), username: username, text: inputRef.current.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
inputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
//render each line
|
||||||
|
const processLine = (line, index, accessToken) => {
|
||||||
|
//utility functions
|
||||||
|
const isValidDate = d => {
|
||||||
|
return d instanceof Date && !isNaN(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = d => {
|
||||||
|
const now = new Date(Date.now());
|
||||||
|
return d.getDate() == now.getDate() && d.getMonth() == now.getMonth() && d.getFullYear() == now.getFullYear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isThisYear = d => {
|
||||||
|
const now = new Date(Date.now());
|
||||||
|
return d.getFullYear() == now.getFullYear();
|
||||||
|
};
|
||||||
|
|
||||||
|
//parse the date
|
||||||
|
const date = new Date(line.createdAt);
|
||||||
|
|
||||||
|
//split it up so we can format each field individually
|
||||||
|
const year = `${date.getFullYear()}`;
|
||||||
|
const month = `${date.getMonth() + 1}`;
|
||||||
|
const day = `${date.getDate()}`;
|
||||||
|
const hours = `${date.getHours()}`;
|
||||||
|
const minutes = `${date.getMinutes()}`.padStart(2, '0');
|
||||||
|
|
||||||
|
//combine into the final timestamp
|
||||||
|
const timestamp = !isValidDate(date) ? '' : isToday(date) ? `${hours}:${minutes}` : isThisYear(date) ? `${month}/${day}` : `${year}`;
|
||||||
|
|
||||||
|
//generate the content string
|
||||||
|
let content = <div className='content row'>{timestamp.length > 0 ? <span className='timestamp col'>{timestamp}</span> : null }<span className='inner col'>{line.username ? <span className='username'>{line.username}: </span> : ''}{line.text ? <span className='text'>{line.text}</span> : ''}</span></div>;
|
||||||
|
|
||||||
|
//decorators
|
||||||
|
if (line.emphasis) {
|
||||||
|
content = <em>{content}</em>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.strong) {
|
||||||
|
content = <strong>{content}</strong>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return <li key={index} className='line table noCollapse'>{content}<a className='report' onClick={() => processReport(line, accessToken)}>!!!</a></li>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processReport = (line, accessToken) => {
|
||||||
|
const yes = confirm('Report this message?');
|
||||||
|
|
||||||
|
if (yes) {
|
||||||
|
socket.emit('report', {
|
||||||
|
accessToken,
|
||||||
|
index: line.index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopupChat;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const Static = props => {
|
||||||
|
return (
|
||||||
|
<div className='page central'>
|
||||||
|
<header>
|
||||||
|
<h1 className='text centered'>Credits</h1>
|
||||||
|
</header>
|
||||||
|
<h2 className='text centered'>MERN-template</h2>
|
||||||
|
<p>The <a href='https://github.com/krgamestudios/MERN-template'>MERN-template</a> developed by Kayne Ruse, KR Game Studios</p>
|
||||||
|
|
||||||
|
<Link className='text centered' to='/'>Return Home</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Static;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const Static = props => {
|
||||||
|
return (
|
||||||
|
<div className='page central'>
|
||||||
|
<header>
|
||||||
|
<h1 className="text centered">Privacy Policy</h1>
|
||||||
|
|
||||||
|
<Link className='text centered' to='/'>Return Home</Link>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Static;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
//applies the classname of 'body'
|
||||||
|
const ApplyToBody = (props) => {
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.add(props.className);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove(props.className);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApplyToBody;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Route } from 'react-router-dom';
|
||||||
|
import loadable from '@loadable/component';
|
||||||
|
|
||||||
|
const LazyRoute = props => {
|
||||||
|
const { component, ...lazyProps } = props;
|
||||||
|
|
||||||
|
const lazyComponent = loadable(component);
|
||||||
|
|
||||||
|
return <Route {...lazyProps} component={lazyComponent} />
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LazyRoute;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useState, useEffect, createContext } from 'react';
|
||||||
|
import decode from 'jwt-decode';
|
||||||
|
|
||||||
|
export const TokenContext = createContext();
|
||||||
|
|
||||||
|
//DOCS: tokenFetch() and tokenCallback() are actually closures here
|
||||||
|
|
||||||
|
const TokenProvider = props => {
|
||||||
|
//state to be used
|
||||||
|
const [accessToken, setAccessToken] = useState('');
|
||||||
|
|
||||||
|
//make the access token persist between reloads
|
||||||
|
useEffect(() => {
|
||||||
|
setAccessToken(localStorage.getItem("accessToken") || '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
//update the stored copies
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("accessToken", accessToken);
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
//wrap the default fetch function
|
||||||
|
const tokenFetch = async (url, options) => {
|
||||||
|
//use this?
|
||||||
|
let bearer = accessToken;
|
||||||
|
|
||||||
|
//if expired (10 minutes, normally)
|
||||||
|
const expired = new Date(decode(accessToken).exp * 1000) < Date.now();
|
||||||
|
|
||||||
|
if (expired) {
|
||||||
|
//BUGFIX: if logging out, just skip over the refresh token
|
||||||
|
if (url === `${process.env.AUTH_URI}/auth/logout`) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${bearer}`
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//ping the auth server for a new access token
|
||||||
|
const response = await fetch(`${process.env.AUTH_URI}/auth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
//any errors, throw them
|
||||||
|
if (!response.ok) {
|
||||||
|
throw `${response.status}: ${await response.text()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
//save the new auth stuff (setting bearer as well)
|
||||||
|
const newAuth = await response.json();
|
||||||
|
|
||||||
|
setAccessToken(newAuth.accessToken);
|
||||||
|
bearer = newAuth.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
//finally, delegate to fetch
|
||||||
|
return fetch(url, {
|
||||||
|
...(options || {}),
|
||||||
|
headers: {
|
||||||
|
...(options || { headers: {} }).headers,
|
||||||
|
'Authorization': `Bearer ${bearer}`
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//access the refreshed token via callback
|
||||||
|
const tokenCallback = async (cb) => {
|
||||||
|
//if expired (10 minutes, normally)
|
||||||
|
const expired = new Date(decode(accessToken).exp * 1000) < Date.now();
|
||||||
|
|
||||||
|
if (expired) {
|
||||||
|
//ping the auth server for a new token
|
||||||
|
const response = await fetch(`${process.env.AUTH_URI}/auth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
//any errors, throw them
|
||||||
|
if (!response.ok) {
|
||||||
|
throw `${response.status}: ${await response.text()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
//save the new auth stuff (setting bearer as well)
|
||||||
|
const newAuth = await response.json();
|
||||||
|
|
||||||
|
setAccessToken(newAuth.accessToken);
|
||||||
|
|
||||||
|
//finally
|
||||||
|
return cb(newAuth.accessToken);
|
||||||
|
} else {
|
||||||
|
return cb(accessToken);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TokenContext.Provider value={{ accessToken, setAccessToken, tokenFetch, tokenCallback, getPayload: () => decode(accessToken) }}>
|
||||||
|
{props.children}
|
||||||
|
</TokenContext.Provider>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TokenProvider;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/* clear from the rest of the CSS files */
|
||||||
|
|
||||||
|
.chat button, .chat input {
|
||||||
|
border-radius: unset !important;
|
||||||
|
font-size: unset !important;
|
||||||
|
margin: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 3.6em; /* Allow space for the footer */
|
||||||
|
right: 28px;
|
||||||
|
width: 280px;
|
||||||
|
border: solid;
|
||||||
|
border-color: black;
|
||||||
|
border-width: 2px;
|
||||||
|
background-color: #CCC;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat button.open {
|
||||||
|
color: white;
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat button.send {
|
||||||
|
color: white;
|
||||||
|
background-color: green;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: darkslategray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat button.close {
|
||||||
|
color: black;
|
||||||
|
background-color: red;
|
||||||
|
border-color: maroon;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat button {
|
||||||
|
width: 100%;
|
||||||
|
height: 2em;
|
||||||
|
opacity: 0.8;
|
||||||
|
border: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .input {
|
||||||
|
width: 100%;
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .log {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .line {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .report {
|
||||||
|
color: red;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .line:hover {
|
||||||
|
background-color: #BBB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .line:hover .report {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .timestamp {
|
||||||
|
max-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .inner {
|
||||||
|
flex: 1 !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .username {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat .scrollable {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-x: wrap;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.chat {
|
||||||
|
position: unset;
|
||||||
|
bottom: unset;
|
||||||
|
right: unset;
|
||||||
|
width: calc(100% + 20px);
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/* global defaults */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, #root {
|
||||||
|
font: 12pt Helvetica, Arial;
|
||||||
|
min-width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #fefefe;
|
||||||
|
color: #010101;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 40pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 24pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
list-style-position: inside;
|
||||||
|
padding-bottom: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
background-color: lightgray;
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: blue;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding-top: 0.5em;
|
||||||
|
padding-left: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
border-left: 3px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text.left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text.centered {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.middle {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* header */
|
||||||
|
header {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
justify-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* footer */
|
||||||
|
footer {
|
||||||
|
padding-top: 0.5em;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
justify-self: flex-end;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* central display */
|
||||||
|
.central {
|
||||||
|
padding: 0 10px;
|
||||||
|
margin: 0 20%;
|
||||||
|
min-height: calc(100vh - 3.6em);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.central {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* components */
|
||||||
|
.page {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page.centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page.middle {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.middle {
|
||||||
|
align-items: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button {
|
||||||
|
text-indent: 0.3em;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin: 0.2em;
|
||||||
|
padding: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
margin-right: -0.4em;
|
||||||
|
max-height: none !important;
|
||||||
|
resize: vertical;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "constrained" means reusable input area */
|
||||||
|
.constrained {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
align-self: center;
|
||||||
|
max-width: 480px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constrained > * {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
max-height: 2em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constrained button, button.constrained {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constrained label {
|
||||||
|
font-size: 1.8em;
|
||||||
|
text-indent: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.constrained {
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* flexbox tables */
|
||||||
|
.table {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .row {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.table .row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.table .row.tabletCollapse {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .row .col {
|
||||||
|
flex: 1 1 1%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .row .col.double {
|
||||||
|
flex: 2 1 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .row .col.half {
|
||||||
|
flex: 0.5 1 0.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.table .row .col.double {
|
||||||
|
flex: 2 1 2.5%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table.noCollapse .row, .table .row.noCollapse {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mobile control */
|
||||||
|
.mobile.show {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.mobile.centered {
|
||||||
|
text-align: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile.show {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile.centered {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hybrid of table and mobile control */
|
||||||
|
.mobile.hide.col {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile.col.half {
|
||||||
|
flex: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tablet control */
|
||||||
|
.tablet.show {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.tablet.centered {
|
||||||
|
text-align: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablet.show {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablet.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablet.centered {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hybrid of table and tablet control */
|
||||||
|
.tablet.hide.col {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablet.col.half {
|
||||||
|
flex: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
-87
@@ -12,77 +12,137 @@ const rl = readline.createInterface({
|
|||||||
});
|
});
|
||||||
|
|
||||||
//manually promisify this (util didn't work)
|
//manually promisify this (util didn't work)
|
||||||
const question = (prompt, def) => {
|
const question = (prompt, def = null) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
rl.question(`${prompt} (${def}): `, answer => {
|
rl.question(`${prompt}${def ? ` (${def})` : ''}: `, answer => {
|
||||||
resolve(answer || def);
|
//loop on required
|
||||||
|
if (def === null && !answer) {
|
||||||
|
return resolve(question(prompt, def));
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(answer || def);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
//questions
|
//questions
|
||||||
(async () => {
|
(async () => {
|
||||||
|
console.log(
|
||||||
|
`This configure script will generate the following files:
|
||||||
|
|
||||||
|
* docker-compose.yml
|
||||||
|
* Dockerfile
|
||||||
|
* startup.sql
|
||||||
|
|
||||||
|
Currently, all microservices are mandatory; you'll have to mess with the result
|
||||||
|
and the source code if you wish to be more selective. Microservices currently
|
||||||
|
impelented are:
|
||||||
|
|
||||||
|
* auth-server
|
||||||
|
* news-server
|
||||||
|
* chat-server
|
||||||
|
|
||||||
|
See https://github.com/krgamestudios/MERN-template/wiki for help.
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
//determine local computer address for mac user vs everyone else
|
||||||
|
let macUser = '';
|
||||||
|
while (macUser.toLowerCase() !== 'yes' && macUser.toLowerCase() !== 'no') {
|
||||||
|
macUser = await question('Will the MERN Template be running locally on a MacOS system? (yes or no)', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAddress = macUser ? 'localhost' : '%';
|
||||||
|
|
||||||
//project configuration
|
//project configuration
|
||||||
const projectName = await question('Project Name', 'template');
|
const projectName = await question('Project Name', 'template');
|
||||||
const projectWebAddress = await question('Project Web Address', 'example.com');
|
const projectWebAddress = await question('Project Web Address', 'example.com');
|
||||||
const projectMailSMTP = await question('Project Mail SMTP', 'smtp.example.com');
|
|
||||||
const projectMailUser = await question('Project Mail Username', 'foobar@example.com');
|
const projectDBUser = await question('Project DB Username', projectName);
|
||||||
const projectMailPass = await question('Project Mail Password', 'foobar');
|
const projectDBPass = await question('Project DB Password', 'pikachu');
|
||||||
const projectMailPhysical = await question('Project Physical Mailing Address', '');
|
|
||||||
const projectDBUser = await question('Project Database Username', projectName);
|
|
||||||
const projectDBPass = await question('Project Database Password', 'pikachu');
|
|
||||||
|
|
||||||
//news configuration
|
//news configuration
|
||||||
const newsName = await question('News Name', 'news');
|
const newsName = await question('News Name', 'news');
|
||||||
const newsWebAddress = await question('News Web Address', 'news.example.com');
|
const newsWebAddress = await question('News Web Address', `${newsName}.${projectWebAddress}`);
|
||||||
const newsDBUser = await question('News Database Username', newsName);
|
const newsDBUser = await question('News DB Username', newsName);
|
||||||
const newsDBPass = await question('News Database Password', 'charizard');
|
const newsDBPass = await question('News DB Password', 'venusaur');
|
||||||
const newsKey = await question('News Query Key', uuid());
|
|
||||||
|
|
||||||
//TODO: chat configuration
|
//auth configuration
|
||||||
|
const authName = await question('Auth Name', 'auth');
|
||||||
|
const authWebAddress = await question('Auth Web Address', `${authName}.${projectWebAddress}`);
|
||||||
|
const authPostValidationHookArray = await question('Auth Post Validation Hook Array', '');
|
||||||
|
const authResetAddress = await question('Auth Reset Addr', `${projectWebAddress}/reset`);
|
||||||
|
const authDBUser = await question('Auth DB Username', authName);
|
||||||
|
const authDBPass = await question('Auth DB Password', 'charizard');
|
||||||
|
|
||||||
|
const emailSMTP = await question('Email SMTP', 'smtp.example.com');
|
||||||
|
const emailUser = await question('Email Address', 'foobar@example.com');
|
||||||
|
const emailPass = await question('Email Password', 'foobar');
|
||||||
|
const emailPhysical = await question('Physical Mailing Address', '');
|
||||||
|
|
||||||
|
//chat goes here
|
||||||
|
const chatName = await question('Chat Name', 'chat');
|
||||||
|
const chatWebAddress = await question('Chat Web Address', `${chatName}.${projectWebAddress}`);
|
||||||
|
const chatDBUser = await question('Chat DB Username', chatName);
|
||||||
|
const chatDBPass = await question('Chat DB Password', 'blastoise');
|
||||||
|
|
||||||
//database configuration
|
//database configuration
|
||||||
const databaseRootPassword = await question('Database Root Password', 'password');
|
const dbRootPassword = await question('Database Root Password', 'password');
|
||||||
const databaseTimeZone = await question('Database Timezone', 'Australia/Sydney');
|
const dbTimeZone = await question('Database Timezone', 'Australia/Sydney');
|
||||||
|
|
||||||
|
//joint configuration
|
||||||
|
const accessToken = await question('Access Token Secret', uuid(32));
|
||||||
|
const refreshToken = await question('Refresh Token Secret', uuid(32));
|
||||||
|
|
||||||
|
console.log('--Leave "Default User" blank if you don\'t want one--');
|
||||||
|
const defaultUser = await question('Default Admin User', '');
|
||||||
|
|
||||||
|
//MUST be at least 8 chars
|
||||||
|
let tmpPass = '';
|
||||||
|
while (defaultUser && tmpPass.length < 8) {
|
||||||
|
console.log('--All passwords must be at least 8 characters long--');
|
||||||
|
tmpPass = await question('Default Admin Pass', '');
|
||||||
|
}
|
||||||
|
const defaultPass = tmpPass;
|
||||||
|
|
||||||
|
if (defaultUser) {
|
||||||
|
console.log(`Default user email will be: ${defaultUser}@${authWebAddress}`);
|
||||||
|
}
|
||||||
|
|
||||||
//traefic configuration
|
//traefic configuration
|
||||||
const supportEmail = await question('Support Email', projectMailUser);
|
const supportEmail = await question('Support Email', emailUser);
|
||||||
|
|
||||||
//other random values
|
//misc. configuration
|
||||||
const sessionSecret = uuid(); //for session randomness
|
const projectPort = 3000;
|
||||||
const sessionAdmin = uuid(128); //for checking if user is admin
|
const newsPort = 3100;
|
||||||
|
const authPort = 3200;
|
||||||
|
const chatPort = 3300;
|
||||||
|
|
||||||
const yml = `
|
const ymlfile = `
|
||||||
version: "3.6"
|
version: "3.6"
|
||||||
services:
|
services:
|
||||||
${projectName}:
|
${projectName}:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3000"
|
- "${projectPort}"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- traefik.enable=true
|
||||||
- "traefik.http.routers.${projectName}router.rule=Host(\`${projectWebAddress}\`)"
|
- traefik.http.routers.${projectName}router.rule=Host(\`${projectWebAddress}\`)
|
||||||
- "traefik.http.routers.${projectName}router.entrypoints=websecure"
|
- traefik.http.routers.${projectName}router.entrypoints=websecure
|
||||||
- "traefik.http.routers.${projectName}router.tls.certresolver=myresolver"
|
- traefik.http.routers.${projectName}router.tls.certresolver=myresolver
|
||||||
- "traefik.http.routers.${projectName}router.service=${projectName}service@docker"
|
- traefik.http.routers.${projectName}router.service=${projectName}service@docker
|
||||||
- "traefik.http.services.${projectName}service.loadbalancer.server.port=3000"
|
- traefik.http.services.${projectName}service.loadbalancer.server.port=${projectPort}
|
||||||
environment:
|
environment:
|
||||||
- WEB_PROTOCOL=https
|
- WEB_PORT=${projectPort}
|
||||||
- WEB_ADDRESS=${projectWebAddress}
|
|
||||||
- WEB_PORT=3000
|
|
||||||
- MAIL_SMTP=${projectMailSMTP}
|
|
||||||
- MAIL_USERNAME=${projectMailUser}
|
|
||||||
- MAIL_PASSWORD=${projectMailPass}
|
|
||||||
- MAIL_PHYSICAL=${projectMailPhysical}
|
|
||||||
- DB_HOSTNAME=database
|
- DB_HOSTNAME=database
|
||||||
- DB_DATABASE=${projectName}
|
- DB_DATABASE=${projectName}
|
||||||
- DB_USERNAME=${projectDBUser}
|
- DB_USERNAME=${projectDBUser}
|
||||||
- DB_PASSWORD=${projectDBPass}
|
- DB_PASSWORD=${projectDBPass}
|
||||||
- DB_TIMEZONE=${databaseTimeZone}
|
- DB_TIMEZONE=${dbTimeZone}
|
||||||
- SESSION_SECRET=${sessionSecret}
|
- NEWS_URI=https://${newsWebAddress}
|
||||||
- SESSION_ADMIN=${sessionAdmin}
|
- AUTH_URI=https://${authWebAddress}
|
||||||
- NEWS_URI=https://${newsWebAddress}/news
|
- CHAT_URI=https://${chatWebAddress}
|
||||||
- NEWS_KEY=${newsKey}
|
- SECRET_ACCESS=${accessToken}
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -92,63 +152,123 @@ services:
|
|||||||
${newsName}:
|
${newsName}:
|
||||||
image: krgamestudios/news-server:latest
|
image: krgamestudios/news-server:latest
|
||||||
ports:
|
ports:
|
||||||
- "3100"
|
- ${newsPort}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- traefik.enable=true
|
||||||
- "traefik.http.routers.${newsName}router.rule=Host(\`${newsWebAddress}\`)"
|
- traefik.http.routers.${newsName}router.rule=Host(\`${newsWebAddress}\`)
|
||||||
- "traefik.http.routers.${newsName}router.entrypoints=websecure"
|
- traefik.http.routers.${newsName}router.entrypoints=websecure
|
||||||
- "traefik.http.routers.${newsName}router.tls.certresolver=myresolver"
|
- traefik.http.routers.${newsName}router.tls.certresolver=myresolver
|
||||||
- "traefik.http.routers.${newsName}router.service=newsservice@docker"
|
- traefik.http.routers.${newsName}router.service=${newsName}service@docker
|
||||||
- "traefik.http.services.${newsName}service.loadbalancer.server.port=3100"
|
- traefik.http.services.${newsName}service.loadbalancer.server.port=${newsPort}
|
||||||
environment:
|
environment:
|
||||||
- WEB_PORT=3100
|
- WEB_PORT=${newsPort}
|
||||||
- DB_HOSTNAME=database
|
- DB_HOSTNAME=database
|
||||||
- DB_DATABASE=news
|
- DB_DATABASE=${newsName}
|
||||||
- DB_USERNAME=${newsDBUser}
|
- DB_USERNAME=${newsDBUser}
|
||||||
- DB_PASSWORD=${newsDBPass}
|
- DB_PASSWORD=${newsDBPass}
|
||||||
- DB_TIMEZONE=${databaseTimeZone}
|
- DB_TIMEZONE=${dbTimeZone}
|
||||||
- QUERY_LIMIT=10
|
- QUERY_LIMIT=10
|
||||||
- QUERY_KEY=${newsKey}
|
- SECRET_ACCESS=${accessToken}
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- database
|
- database
|
||||||
- traefik
|
- traefik
|
||||||
|
|
||||||
#chat:
|
${authName}:
|
||||||
# image: krgamestudios/chat-server
|
image: krgamestudios/auth-server:latest
|
||||||
# ports:
|
ports:
|
||||||
# - "3200:3200"
|
- ${authPort}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.${authName}router.rule=Host(\`${authWebAddress}\`)
|
||||||
|
- traefik.http.routers.${authName}router.entrypoints=websecure
|
||||||
|
- traefik.http.routers.${authName}router.tls.certresolver=myresolver
|
||||||
|
- traefik.http.routers.${authName}router.service=${authName}service@docker
|
||||||
|
- traefik.http.services.${authName}service.loadbalancer.server.port=${authPort}
|
||||||
|
environment:
|
||||||
|
- WEB_PROTOCOL=https
|
||||||
|
- WEB_ADDRESS=${authWebAddress}
|
||||||
|
- HOOK_POST_VALIDATION_ARRAY=${authPostValidationHookArray}
|
||||||
|
- WEB_RESET_ADDRESS=${authResetAddress}
|
||||||
|
- WEB_PORT=${authPort}
|
||||||
|
- DB_HOSTNAME=database
|
||||||
|
- DB_DATABASE=${authName}
|
||||||
|
- DB_USERNAME=${authDBUser}
|
||||||
|
- DB_PASSWORD=${authDBPass}
|
||||||
|
- DB_TIMEZONE=${dbTimeZone}
|
||||||
|
- MAIL_SMTP=${emailSMTP}
|
||||||
|
- MAIL_USERNAME=${emailUser}
|
||||||
|
- MAIL_PASSWORD=${emailPass}
|
||||||
|
- MAIL_PHYSICAL=${emailPhysical}
|
||||||
|
- ADMIN_DEFAULT_USERNAME=${defaultUser}
|
||||||
|
- ADMIN_DEFAULT_PASSWORD=${defaultPass}
|
||||||
|
- SECRET_ACCESS=${accessToken}
|
||||||
|
- SECRET_REFRESH=${refreshToken}
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
${chatName}:
|
||||||
|
image: krgamestudios/chat-server:latest
|
||||||
|
ports:
|
||||||
|
- ${chatPort}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.${chatName}router.rule=Host(\`${chatWebAddress}\`)
|
||||||
|
- traefik.http.routers.${chatName}router.entrypoints=websecure
|
||||||
|
- traefik.http.routers.${chatName}router.tls.certresolver=myresolver
|
||||||
|
- traefik.http.routers.${chatName}router.service=${chatName}service@docker
|
||||||
|
- traefik.http.services.${chatName}service.loadbalancer.server.port=${chatPort}
|
||||||
|
environment:
|
||||||
|
- WEB_PORT=${chatPort}
|
||||||
|
- DB_HOSTNAME=database
|
||||||
|
- DB_DATABASE=${chatName}
|
||||||
|
- DB_USERNAME=${chatDBUser}
|
||||||
|
- DB_PASSWORD=${chatDBPass}
|
||||||
|
- DB_TIMEZONE=${dbTimeZone}
|
||||||
|
- SECRET_ACCESS=${accessToken}
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
- traefik
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: mariadb
|
image: mariadb
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${databaseRootPassword}
|
MYSQL_ROOT_PASSWORD: ${dbRootPassword}
|
||||||
volumes:
|
volumes:
|
||||||
- ./mysql:/var/lib/mysql
|
- ./mysql:/var/lib/mysql
|
||||||
|
- ./startup.sql:/docker-entrypoint-initdb.d/startup.sql:ro
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v2.4"
|
image: traefik:v2.4
|
||||||
container_name: "traefik"
|
container_name: traefik
|
||||||
command:
|
command:
|
||||||
- "--log.level=ERROR"
|
- --log.level=ERROR
|
||||||
- "--api.insecure=false"
|
- --api.insecure=false
|
||||||
- "--providers.docker=true"
|
- --providers.docker=true
|
||||||
- "--providers.docker.exposedbydefault=false"
|
- --providers.docker.exposedbydefault=false
|
||||||
- "--entrypoints.websecure.address=:443"
|
- --entrypoints.web.address=:80
|
||||||
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
|
- --entrypoints.web.http.redirections.entryPoint.to=websecure
|
||||||
- "--certificatesresolvers.myresolver.acme.email=${supportEmail}"
|
- --entrypoints.web.http.redirections.entryPoint.scheme=https
|
||||||
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
|
- --entrypoints.web.http.redirections.entrypoint.permanent=true
|
||||||
- " traefik.docker.network=app-network"
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --certificatesresolvers.myresolver.acme.tlschallenge=true
|
||||||
|
- --certificatesresolvers.myresolver.acme.email=${supportEmail}
|
||||||
|
- --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- 80:80
|
||||||
- "443:443"
|
- 443:443
|
||||||
volumes:
|
volumes:
|
||||||
- "./letsencrypt:/letsencrypt"
|
- ./letsencrypt:/letsencrypt
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
@@ -158,36 +278,43 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const dockerfile = `
|
const dockerfile = `
|
||||||
FROM node:15
|
FROM node:16
|
||||||
WORKDIR "/app"
|
WORKDIR "/app"
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
RUN apt-get update
|
|
||||||
RUN apt-get install -y mariadb-client
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
EXPOSE 3000
|
RUN mkdir /app/public
|
||||||
|
RUN chown node:node /app/public
|
||||||
|
RUN npm install --production
|
||||||
|
EXPOSE ${projectPort}
|
||||||
|
USER node
|
||||||
ENTRYPOINT ["bash", "-c"]
|
ENTRYPOINT ["bash", "-c"]
|
||||||
CMD ["mysql --host=database --user=root --password=${databaseRootPassword} < ./startup.sql && npm start"]
|
CMD ["sleep 10 && npm start"]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const scriptfile = `
|
const sqlfile = `
|
||||||
CREATE DATABASE IF NOT EXISTS ${projectName};
|
CREATE DATABASE IF NOT EXISTS ${projectName};
|
||||||
CREATE USER IF NOT EXISTS '${projectDBUser}'@'%' IDENTIFIED BY '${projectDBPass}';
|
CREATE USER IF NOT EXISTS '${projectDBUser}'@'${localAddress}' IDENTIFIED BY '${projectDBPass}';
|
||||||
GRANT ALL PRIVILEGES ON ${projectName}.* TO '${projectDBUser}'@'%';
|
GRANT ALL PRIVILEGES ON ${projectName}.* TO '${projectDBUser}'@'${localAddress}';
|
||||||
|
|
||||||
CREATE DATABASE IF NOT EXISTS ${newsName};
|
CREATE DATABASE IF NOT EXISTS ${newsName};
|
||||||
CREATE USER IF NOT EXISTS '${newsDBUser}'@'%' IDENTIFIED BY '${newsDBPass}';
|
CREATE USER IF NOT EXISTS '${newsDBUser}'@'${localAddress}' IDENTIFIED BY '${newsDBPass}';
|
||||||
GRANT ALL PRIVILEGES ON ${newsName}.* TO '${newsDBUser}'@'%';
|
GRANT ALL PRIVILEGES ON ${newsName}.* TO '${newsDBUser}'@'${localAddress}';
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS ${authName};
|
||||||
|
CREATE USER IF NOT EXISTS '${authDBUser}'@'${localAddress}' IDENTIFIED BY '${authDBPass}';
|
||||||
|
GRANT ALL PRIVILEGES ON ${authName}.* TO '${authDBUser}'@'${localAddress}';
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS ${chatName};
|
||||||
|
CREATE USER IF NOT EXISTS '${chatDBUser}'@'${localAddress}' IDENTIFIED BY '${chatDBPass}';
|
||||||
|
GRANT ALL PRIVILEGES ON ${chatName}.* TO '${chatDBUser}'@'${localAddress}';
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
fs.writeFileSync('docker-compose.yml', yml);
|
|
||||||
|
fs.writeFileSync('docker-compose.yml', ymlfile);
|
||||||
fs.writeFileSync('Dockerfile', dockerfile);
|
fs.writeFileSync('Dockerfile', dockerfile);
|
||||||
fs.writeFileSync('startup.sql', scriptfile);
|
fs.writeFileSync('startup.sql', sqlfile);
|
||||||
})()
|
})()
|
||||||
.then(() => rl.close())
|
.then(() => rl.close())
|
||||||
.catch(e => console.error(e))
|
.catch(e => console.error(e))
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
# Setup Tutorial
|
|
||||||
|
|
||||||
Last Updated February 15th 2020
|
|
||||||
|
|
||||||
Hello! This is the tutorial for setting up the MERN-template. If you haven't already, I recommend you download the MERN-template from here:
|
|
||||||
|
|
||||||
https://github.com/krgamestudios/MERN-template
|
|
||||||
|
|
||||||
Remember: This project isn't ready for deployment yet - it still has bugs galore.
|
|
||||||
|
|
||||||
## The What-Template?
|
|
||||||
|
|
||||||
MERN can stand for a few things, but in this case it means "MariaDB, Express, React and Nodejs". This are a series of technologies commonly used to create websites.
|
|
||||||
|
|
||||||
I determined that I might want to reuse some parts of the website I was planning to make, so I wrote the template first, to make it easier to reuse. Then, I released it so other people could use it too.
|
|
||||||
|
|
||||||
You're currently reading the tutorial on how to set up the template website, both in development mode and deployment mode. This tutorial will likely evolve over time as the template does - and there will likely be other guides going into how the template is built and how to modify it.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
There are some requirements for this template, such as required software and a dedicated email. Software needed includes:
|
|
||||||
|
|
||||||
* Git
|
|
||||||
* Nodejs
|
|
||||||
* MariaDB
|
|
||||||
* Docker
|
|
||||||
* docker-compose
|
|
||||||
|
|
||||||
You'll also need an email address - if you use google, you'll need to enable "[less secure apps](https://support.google.com/accounts/answer/6010255?hl=en#zippy=%2Cif-less-secure-app-access-is-off-for-your-account%2Cif-less-secure-app-access-is-on-for-your-account)" so external apps can access it. I've only used this site with google so far, but feel free to experiment with other mail hosts.
|
|
||||||
|
|
||||||
## Setting Up Development
|
|
||||||
|
|
||||||
For development, you'll need Nodejs and MariaDB installed and working. Remember to run `npm install` in the git repo after cloning.
|
|
||||||
|
|
||||||
First, run `sql/create_database.sql` on your mariaDB instance - this will create a database called `template` and a user called `template`@`%`. You can of course mess with this, but everything else here assumes you do so consistently.
|
|
||||||
|
|
||||||
Next, copy `.envdev` into `.env`, then fill out `.env` with your details. Here's a breakdown of each field and what they mean:
|
|
||||||
|
|
||||||
```
|
|
||||||
WEB_PROTOCOL=http # are you using HTTP or HTTPS?
|
|
||||||
WEB_ADDRESS=localhost # what is your web domain?
|
|
||||||
WEB_PORT=3000 # what port is the game running on?
|
|
||||||
```
|
|
||||||
|
|
||||||
The first two are used mainly for the email validation link at the moment - but they should still be configured correctly. `WEB_PORT` is used to specify which port the game operates on - when you reach the webpack stage, you won't access this port directly.
|
|
||||||
|
|
||||||
```
|
|
||||||
MAIL_SMTP=smtp.example.com # SMTP server
|
|
||||||
MAIL_USERNAME=foobar@example.com # Email to be used
|
|
||||||
MAIL_PASSWORD=foobar # Password of that email account
|
|
||||||
MAIL_PHYSICAL=42 Placeholder Ave, Placeholder, 0000, USA # Your physical mailing address
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, I've use exclusively google for this - and it shows. The first argument is usually set to `smtp.gmail.com`. The second is to your email account (a fake one is set in the code, `signup@WEB_ADDRESS`, but google overwrites this). The third is the plaintext password for this account (so you should almost certainly use a dedicated email for this). The fourth is actually included in the validation email itself - it's a legal requirement of the USA's [CAN-SPAM](https://en.wikipedia.org/wiki/CAN-SPAM_Act_of_2003) act of 2003, which goes over my head just a little (I try to appease as many jurisdictions as possible).
|
|
||||||
|
|
||||||
```
|
|
||||||
DB_HOSTNAME=127.0.0.1 # Database address
|
|
||||||
DB_DATABASE=template # database name
|
|
||||||
DB_USERNAME=template # database user
|
|
||||||
DB_PASSWORD=pikachu # database password
|
|
||||||
DB_TIMEZONE=Australia/Sydney # database timezone
|
|
||||||
```
|
|
||||||
|
|
||||||
This is fairly simple - but if you tinkered in `sql/create_database.sql`, do so here as well. `127.0.0.1` just means "this machine", of course. The timezone is my own timezone, but can be set to [any of these values](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (I think - haven't tested them).
|
|
||||||
|
|
||||||
```
|
|
||||||
SESSION_SECRET=secret
|
|
||||||
SESSION_ADMIN=adminsecret
|
|
||||||
```
|
|
||||||
|
|
||||||
Finally, these are crucial in production - so much so that they're completely randomized by the configure script. Here, however, you can set them to anything. `SESSION_SECRET` is used by `express-session` to save user session details, while `SESSION_ADMIN` is used as a sort of password for the administrator accounts.
|
|
||||||
|
|
||||||
Finally, it's time to run `npm run dev`. This will begin the server in dev mode - it'll use the `concurrently` library to run both client and server in the same window, so you don't need two monitor terminals. If all goes well, the server should start pretty quickly, though the client will take a moment to compile.
|
|
||||||
|
|
||||||
When they're both ready, you can access `http://localhost:3001/` in a browser (NOT 3000) and the template site should load.
|
|
||||||
|
|
||||||
At this stage, your brand new website will call out to the `dev-news` server for news postings, so you'll get some lorem ipsum, and possibly other content (it's a publicly available server - don't blame me!).
|
|
||||||
|
|
||||||
The server should've created a default administration account (and outputted the email and password). By logging in with this, you can access `http://localhost:3001/admin` and post to or edit existing news posts.
|
|
||||||
|
|
||||||
From here, you can now start exploring and fiddling with the code. Feel free to contribute any changes via pull requests on github; I'm likely to accept them if they improve the overall experience. Even these docs (and this tutorial) are subject to updates, so check back if you need to.
|
|
||||||
|
|
||||||
## Setting Up Deployment
|
|
||||||
|
|
||||||
In a perfect world, deploying to a server would be as easy as:
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone https://github.com/krgamestudios/MERN-template.git
|
|
||||||
npm run configure
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Sadly, this isn't a perfect world. So let's instead break down what I did for the tentative deployment.
|
|
||||||
|
|
||||||
First, you'll need a server with a domain name. I personally pointed both `dev.eggtrainer.com` and `news.eggtrainer.com` at the same server (You might be able to separate the news server later - that's why there's two URLs). I'm using linode for this, but be aware that linode blocks email access until you open a ticket requesting permission to use emails from their servers. It's easy - just don't abuse their goodwill. I ended up using debian as the OS, but anything that runs node and docker should work.
|
|
||||||
|
|
||||||
Then, I installed git, node (for npm) and docker-compose on the new server. Then I cloned the MERN-template into the server. Note that I didn't install mariaDB or run `npm install` - docker-compose handles these.
|
|
||||||
|
|
||||||
Next I ran `npm run configure`, which takes in a number of arguments and spits out a number of config files. Here's the default prompts:
|
|
||||||
|
|
||||||
```
|
|
||||||
Project Name (template):
|
|
||||||
Project Web Address (example.com):
|
|
||||||
Project Mail SMTP (smtp.example.com):
|
|
||||||
Project Mail Username (foobar@example.com):
|
|
||||||
Project Mail Password (foobar):
|
|
||||||
Project Physical Mailing Address (<empty>):
|
|
||||||
Project Database Username (template):
|
|
||||||
Project Database Password (pikachu):
|
|
||||||
News Name (news):
|
|
||||||
News Web Address (news.example.com):
|
|
||||||
News Database Username (news):
|
|
||||||
News Database Password (charizard):
|
|
||||||
News Query Key (<random>):
|
|
||||||
Database Root Password (password):
|
|
||||||
Database Timezone (Australia/Sydney):
|
|
||||||
Support Email (foobar@example.com):
|
|
||||||
```
|
|
||||||
|
|
||||||
These should generally be fairly self-explanatory, except the values in the parentheses can changed based on previous entries. If you make a mistake here, just re-run this. This script produces three files:
|
|
||||||
|
|
||||||
* docker-compose.yml
|
|
||||||
* Dockerfile
|
|
||||||
* setup.sql
|
|
||||||
|
|
||||||
`setup.sql` is invoked by Dockerfile to create the database if it doesn't already exist, and `docker-compose.yml` invokes Dockerfile, among a number of other built-in containers (mariaDB and news-server). This will update regularly so check back often. If you want to delete any files created by configure, just run `npm run clean`.
|
|
||||||
|
|
||||||
Finally, it's time to run `sudo docker-compose up --build`. You might actually need to run it several times, killing the first attempts, as I haven't weeded out certain bugs yet. Remember - it's only in alpha, and not ready for prime time just yet.
|
|
||||||
|
|
||||||
You now have a self-contained MERN-template container, mariaDB container, news-server container and [traefik](https://traefik.io/) container.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**Deploying the project didn't work?**
|
|
||||||
|
|
||||||
Try again. There are timing issues between the different containers that I still need to sort out. If it still doesn't work after 5-ish attempts, keep reading.
|
|
||||||
|
|
||||||
**Sequelize throws an error that a certain field is missing?**
|
|
||||||
|
|
||||||
If you just upgraded the template, try checking if any changes to the sequelize models have occured. If so, you'll have to go into the mariaDB container and alter the database directly.
|
|
||||||
|
|
||||||
Generated
+7142
-13301
File diff suppressed because it is too large
Load Diff
+35
-40
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "mern-template",
|
"name": "mern-template",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"description": "A website template using the MERN stack.",
|
"description": "A website template using the MERN stack.",
|
||||||
"main": "server/server.js",
|
"main": "server/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"configure": "node configure-script.js",
|
|
||||||
"clean": "rm docker-compose.yml; rm Dockerfile; rm startup.sql",
|
|
||||||
"start": "npm run build && node server/server.js",
|
"start": "npm run build && node server/server.js",
|
||||||
"build": "npm run build:server && npm run build:client",
|
"build": "npm run build:server && npm run build:client",
|
||||||
"build:server": "exit 0",
|
"build:server": "exit 0",
|
||||||
"build:client": "webpack --env=production --config webpack.config.js",
|
"build:client": "webpack --env=production --config webpack.config.js",
|
||||||
"dev": "concurrently npm:watch:server npm:watch:client",
|
"dev": "concurrently npm:dev:server npm:dev:client",
|
||||||
"watch:server": "nodemon . --ext js,jsx,json --ignore 'node_modules/*'",
|
"dev:server": "nodemon --ext js,jsx,json --ignore 'node_modules/*'",
|
||||||
"watch:client": "webpack serve --env=development --config webpack.config.js",
|
"dev:client": "webpack serve --env=development --config webpack.config.js",
|
||||||
"analyzer": "webpack --env=production --analyzer --config webpack.config.js"
|
"local": "concurrently npm:local:server npm:local:client",
|
||||||
|
"local:server": "nodemon --ext js,jsx,json --ignore 'node_modules/*'",
|
||||||
|
"local:client": "webpack serve --env=local --config webpack.config.js",
|
||||||
|
"analyze": "webpack --env=production --env=analyze --config webpack.config.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -26,42 +27,36 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/KRGameStudios/MERN-template#readme",
|
"homepage": "https://github.com/KRGameStudios/MERN-template#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"@babel/core": "^7.14.8",
|
||||||
"connect-session-sequelize": "^7.1.0",
|
"@babel/preset-env": "^7.14.8",
|
||||||
"cookie-parser": "^1.4.5",
|
"@babel/preset-react": "^7.14.5",
|
||||||
"core-js": "^3.8.3",
|
"@loadable/component": "^5.15.0",
|
||||||
"dateformat": "^4.5.1",
|
|
||||||
"dotenv": "^8.2.0",
|
|
||||||
"express": "^4.17.1",
|
|
||||||
"express-formidable": "^1.2.0",
|
|
||||||
"express-session": "^1.17.1",
|
|
||||||
"mariadb": "^2.5.2",
|
|
||||||
"node-cron": "^2.0.3",
|
|
||||||
"nodemailer": "^6.4.17",
|
|
||||||
"react-cookie": "^4.0.3",
|
|
||||||
"react-dropdown-select": "^4.7.3",
|
|
||||||
"react-markdown": "^5.0.3",
|
|
||||||
"regenerator-runtime": "^0.13.7",
|
|
||||||
"sequelize": "^6.4.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.12.10",
|
|
||||||
"@babel/preset-env": "^7.12.11",
|
|
||||||
"@babel/preset-react": "^7.12.10",
|
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
"clean-webpack-plugin": "^3.0.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"concurrently": "^5.3.0",
|
"compression-webpack-plugin": "^8.0.1",
|
||||||
"html-webpack-plugin": "^5.0.0-alpha.14",
|
"concurrently": "^6.2.0",
|
||||||
"nodemon": "^2.0.7",
|
"css-loader": "^6.2.0",
|
||||||
"raw-loader": "^4.0.2",
|
"dateformat": "^4.5.1",
|
||||||
"react": "^17.0.1",
|
"dotenv": "^10.0.0",
|
||||||
"react-dom": "^17.0.1",
|
"express": "^4.17.1",
|
||||||
"react-loadable": "^5.5.0",
|
"html-webpack-plugin": "^5.3.2",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
|
"mariadb": "^2.5.4",
|
||||||
|
"query-string": "^7.0.1",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
"react-router": "^5.2.0",
|
"react-router": "^5.2.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"webpack": "^5.15.0",
|
"react-select": "^5.2.1",
|
||||||
"webpack-bundle-analyzer": "^4.3.0",
|
"sequelize": "^6.6.5",
|
||||||
"webpack-cli": "^4.3.1",
|
"socket.io-client": "^4.1.3",
|
||||||
"webpack-dev-server": "^3.11.2"
|
"style-loader": "^3.2.1",
|
||||||
|
"webpack": "^5.46.0",
|
||||||
|
"webpack-bundle-analyzer": "^4.4.2",
|
||||||
|
"webpack-cli": "^4.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^2.0.12",
|
||||||
|
"webpack-dev-server": "^4.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
//libraries
|
|
||||||
const utils = require('util');
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
var cron = require('node-cron');
|
|
||||||
|
|
||||||
const Sequelize = require('sequelize');
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
const { accounts } = require('../database/models');
|
|
||||||
|
|
||||||
//api/accounts/deletion
|
|
||||||
const route = async (req, res) => {
|
|
||||||
//make sure the account is logged in
|
|
||||||
if (req.cookies['loggedin'] !== process.env.WEB_ADDRESS) {
|
|
||||||
return res.status(401).send('invalid session status');
|
|
||||||
}
|
|
||||||
|
|
||||||
//compare the user's password
|
|
||||||
const compare = utils.promisify(bcrypt.compare);
|
|
||||||
const match = await compare(req.fields.password, req.session.account.hash);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return res.status(401).send('incorrect password');
|
|
||||||
}
|
|
||||||
|
|
||||||
//set the deletion time (2 days from now)
|
|
||||||
const interval = new Date(new Date().setDate(new Date().getDate() + 2)); //wow
|
|
||||||
await accounts.update({
|
|
||||||
deletion: interval
|
|
||||||
},
|
|
||||||
{
|
|
||||||
where: {
|
|
||||||
id: req.session.account.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//finally
|
|
||||||
return res.status(200).send('account will be deleted in two days - log in to cancel');
|
|
||||||
};
|
|
||||||
|
|
||||||
//actually delete the accounts
|
|
||||||
cron.schedule('0 * * * *', () => {
|
|
||||||
accounts.destroy({
|
|
||||||
where: {
|
|
||||||
deletion: {
|
|
||||||
[Op.lt]: Sequelize.fn('NOW')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
//basic account management
|
|
||||||
router.get('/', require('./query'));
|
|
||||||
router.patch('/', require('./update'));
|
|
||||||
|
|
||||||
//signup -> login -> logout
|
|
||||||
router.post('/signup', require('./signup'));
|
|
||||||
router.get('/validation', require('./validation'));
|
|
||||||
router.post('/login', require('./login'));
|
|
||||||
router.post('/logout', require('./logout'));
|
|
||||||
|
|
||||||
//account deletion
|
|
||||||
router.delete('/deletion', require('./deletion'));
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
//libraries
|
|
||||||
const utils = require('util');
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
|
|
||||||
const Sequelize = require('sequelize');
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
const { bannedEmails, accounts } = require('../database/models');
|
|
||||||
|
|
||||||
//utilities
|
|
||||||
const validateEmail = require('../../common/utilities/validate-email.js');
|
|
||||||
|
|
||||||
//api/accounts/login
|
|
||||||
const route = async (req, res) => {
|
|
||||||
//validate the given details
|
|
||||||
const validateErr = await validateDetails(req.fields);
|
|
||||||
if (validateErr) {
|
|
||||||
return res.status(401).send(validateErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
//get the existing account
|
|
||||||
const account = await accounts.findOne({
|
|
||||||
where: {
|
|
||||||
email: req.fields.email
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
return res.status(401).send('incorrect email or password');
|
|
||||||
}
|
|
||||||
|
|
||||||
//compare passwords
|
|
||||||
const compare = utils.promisify(bcrypt.compare);
|
|
||||||
const match = await compare(req.fields.password, account.hash);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return res.status(401).send('incorrect email or password');
|
|
||||||
}
|
|
||||||
|
|
||||||
//save the session and cookie data
|
|
||||||
req.session.account = JSON.parse(JSON.stringify(account.dataValues));
|
|
||||||
res.cookie('loggedin', process.env.WEB_ADDRESS);
|
|
||||||
|
|
||||||
if (account.privilege == 'administrator') {
|
|
||||||
res.cookie('admin', process.env.SESSION_ADMIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
//cancel deletion if any
|
|
||||||
await accounts.update({ deletion: null }, {
|
|
||||||
where: {
|
|
||||||
id: account.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//finally
|
|
||||||
res.status(200).send('login succeeded');
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateDetails = async (fields) => {
|
|
||||||
//basic formatting (with an exception for the default admin account)
|
|
||||||
if (!validateEmail(fields.email) && fields.email != `admin@${process.env.WEB_ADDRESS}`) {
|
|
||||||
return 'invalid email';
|
|
||||||
}
|
|
||||||
|
|
||||||
//check for existing (banned)
|
|
||||||
const banned = await bannedEmails.findAll({
|
|
||||||
where: {
|
|
||||||
[Op.and]: {
|
|
||||||
email: fields.email,
|
|
||||||
expiry: {
|
|
||||||
[Op.or]: {
|
|
||||||
[Op.gt]: Sequelize.fn('NOW'),
|
|
||||||
[Op.eq]: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (banned.length > 0) {
|
|
||||||
return 'banned email';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
const route = (req, res) => {
|
|
||||||
//clear cookies and stored data
|
|
||||||
req.session.account = null;
|
|
||||||
res.clearCookie('loggedin');
|
|
||||||
res.clearCookie('admin');
|
|
||||||
|
|
||||||
return res.status(200).end();
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
const { accounts } = require('../database/models');
|
|
||||||
|
|
||||||
const route = async (req, res) => {
|
|
||||||
if (!req.session.account || !req.session.account.id) {
|
|
||||||
res.status(401).send('Unknown account');
|
|
||||||
}
|
|
||||||
|
|
||||||
//update the reference
|
|
||||||
req.session.account = (await accounts.findOne({
|
|
||||||
where: {
|
|
||||||
id: req.session.account.id
|
|
||||||
}
|
|
||||||
})).dataValues;
|
|
||||||
|
|
||||||
//respond with the private-facing data
|
|
||||||
res.status(200).json({
|
|
||||||
contact: req.session.account.contact
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
//libraries
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const nodemailer = require('nodemailer');
|
|
||||||
|
|
||||||
const Sequelize = require('sequelize');
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
const { bannedEmails, accounts, pendingSignups } = require('../database/models');
|
|
||||||
|
|
||||||
//utilities
|
|
||||||
const validateEmail = require('../../common/utilities/validate-email.js');
|
|
||||||
const validateUsername = require('../../common/utilities/validate-username.js');
|
|
||||||
|
|
||||||
//api/accounts/signup
|
|
||||||
const route = async (req, res) => {
|
|
||||||
//validate the given details
|
|
||||||
const validateErr = await validateDetails(req.fields);
|
|
||||||
if (validateErr) {
|
|
||||||
return res.status(401).send(validateErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
//generate the password hash
|
|
||||||
const salt = await bcrypt.genSalt(11);
|
|
||||||
const hash = await bcrypt.hash(req.fields.password, salt);
|
|
||||||
|
|
||||||
//generate the validation field
|
|
||||||
const token = Math.floor(Math.random() * 2000000000);
|
|
||||||
|
|
||||||
//register signup
|
|
||||||
const signupErr = await registerPendingSignup(req.fields, hash, token);
|
|
||||||
if (signupErr) {
|
|
||||||
return res.status(500).send(signupErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
//send the validation email
|
|
||||||
const emailErr = await sendValidationEmail(req.fields.email, req.fields.username, token);
|
|
||||||
if (emailErr) {
|
|
||||||
return res.status(500).send(emailErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
//finally
|
|
||||||
res.status(200).send("Validation email sent!");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateDetails = async (fields) => {
|
|
||||||
//basic formatting
|
|
||||||
if (!validateEmail(fields.email)) {
|
|
||||||
return 'invalid email';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateUsername(fields.username)) {
|
|
||||||
return 'invalid username';
|
|
||||||
}
|
|
||||||
|
|
||||||
//check for existing (banned)
|
|
||||||
const banned = await bannedEmails.findAll({
|
|
||||||
where: {
|
|
||||||
[Op.and]: {
|
|
||||||
email: fields.email,
|
|
||||||
expiry: {
|
|
||||||
[Op.or]: {
|
|
||||||
[Op.gt]: Sequelize.fn('NOW'),
|
|
||||||
[Op.eq]: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (banned.length > 0) {
|
|
||||||
return 'banned email';
|
|
||||||
}
|
|
||||||
|
|
||||||
//check for existing email
|
|
||||||
const email = await accounts.findOne({
|
|
||||||
where: {
|
|
||||||
email: fields.email
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
return 'email already exists';
|
|
||||||
}
|
|
||||||
|
|
||||||
//check for existing username
|
|
||||||
const username = await accounts.findOne({
|
|
||||||
where: {
|
|
||||||
username: fields.username
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (username) {
|
|
||||||
return 'username already exists';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const registerPendingSignup = async (fields, hash, token) => {
|
|
||||||
const record = await pendingSignups.upsert({
|
|
||||||
email: fields.email,
|
|
||||||
username: fields.username,
|
|
||||||
hash: hash,
|
|
||||||
contact: fields.contact,
|
|
||||||
token: token
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendValidationEmail = async (email, username, token) => {
|
|
||||||
const addr = `${process.env.WEB_PROTOCOL}://${process.env.WEB_ADDRESS}/api/accounts/validation?username=${username}&token=${token}`;
|
|
||||||
const msg = `Hello ${username}!
|
|
||||||
|
|
||||||
Please visit the following link to validate your account: ${addr}
|
|
||||||
|
|
||||||
You can contact us directly at our physical mailing address here: ${process.env.MAIL_PHYSICAL}
|
|
||||||
`;
|
|
||||||
|
|
||||||
let transporter, info;
|
|
||||||
|
|
||||||
//what exactly is a transport?
|
|
||||||
try {
|
|
||||||
transporter = nodemailer.createTransport({
|
|
||||||
host: process.env.MAIL_SMTP,
|
|
||||||
port: 465,
|
|
||||||
secure: true,
|
|
||||||
auth: {
|
|
||||||
user: process.env.MAIL_USERNAME,
|
|
||||||
pass: process.env.MAIL_PASSWORD
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch(e) {
|
|
||||||
return `failed to create transport: ${e}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// send mail with defined transport object
|
|
||||||
try {
|
|
||||||
info = await transporter.sendMail({
|
|
||||||
from: `signup@${process.env.WEB_ADDRESS}`, //WARNING: google overwrites this
|
|
||||||
to: email,
|
|
||||||
subject: 'Email Validation',
|
|
||||||
text: msg
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch(e) {
|
|
||||||
return `failed to send mail ${e}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.accepted[0] != email) {
|
|
||||||
return 'validation email failed to send';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const { accounts } = require('../database/models');
|
|
||||||
|
|
||||||
const route = async (req, res) => {
|
|
||||||
if (!req.session.account.id) {
|
|
||||||
return res.status(500).send('missing account data');
|
|
||||||
}
|
|
||||||
|
|
||||||
//generate the password hash
|
|
||||||
const salt = await bcrypt.genSalt(11);
|
|
||||||
const hash = await bcrypt.hash(req.fields.password, salt);
|
|
||||||
|
|
||||||
//update the account
|
|
||||||
await accounts.update({
|
|
||||||
contact: req.fields.contact,
|
|
||||||
hash: hash
|
|
||||||
}, {
|
|
||||||
where: {
|
|
||||||
id: req.session.account.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//update the reference
|
|
||||||
req.session.account = (await accounts.findOne({
|
|
||||||
where: {
|
|
||||||
id: req.session.account.id
|
|
||||||
}
|
|
||||||
})).dataValues;
|
|
||||||
|
|
||||||
//respond with an OK
|
|
||||||
res.status(200).send('Information updated');
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
const { pendingSignups, accounts } = require('../database/models');
|
|
||||||
|
|
||||||
//api/accounts/validation
|
|
||||||
const route = async (req, res) => {
|
|
||||||
//get the existing pending signup
|
|
||||||
const info = await pendingSignups.findOne({
|
|
||||||
where: {
|
|
||||||
username: req.query.username
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//check the given info
|
|
||||||
if (!info) {
|
|
||||||
return res.status(401).send('validation failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.token != req.query.token) {
|
|
||||||
return res.status(401).send('tokens do not match');
|
|
||||||
}
|
|
||||||
|
|
||||||
//delete the pending signup
|
|
||||||
pendingSignups.destroy({
|
|
||||||
where: {
|
|
||||||
username: req.query.username
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//move data to the accounts table
|
|
||||||
accounts.create({
|
|
||||||
email: info.email,
|
|
||||||
username: info.username,
|
|
||||||
hash: info.hash,
|
|
||||||
contact: info.contact
|
|
||||||
});
|
|
||||||
|
|
||||||
//finally
|
|
||||||
res.status(200).send('Validation succeeded!');
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
const { Op } = require('sequelize');
|
|
||||||
const { bannedEmails, accounts } = require('../database/models');
|
|
||||||
|
|
||||||
const route = async (req, res) => {
|
|
||||||
//fetch the account based on the email or username
|
|
||||||
const account = await accounts.findOne({
|
|
||||||
attrubutes: ['username', 'email'],
|
|
||||||
where: {
|
|
||||||
[Op.or]: {
|
|
||||||
username: {
|
|
||||||
[Op.eq]: req.fields.username,
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
[Op.eq]: req.fields.email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//just in case
|
|
||||||
if (account && account.privilege == 'administrator') {
|
|
||||||
return res.status(401).send('Couldn\'t ban an admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
//need either an email or an account
|
|
||||||
if (!account && !req.fields.email) {
|
|
||||||
return res.status(401).send('Couldn\'t determine the ban info');
|
|
||||||
}
|
|
||||||
|
|
||||||
//apply the ban
|
|
||||||
await bannedEmails.upsert({
|
|
||||||
email: (account || req.fields).email,
|
|
||||||
reason: req.fields.reason ? req.fields.reason : null,
|
|
||||||
expiry: req.fields.expiry ? new Date(Date.parse(req.fields.expiry)) : null
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).send(`Email ${(account || req.fields).email} banned (username ${account ? account.username : 'not found'})`);
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
const { Op } = require('sequelize');
|
|
||||||
const { bannedEmails, accounts } = require('../database/models');
|
|
||||||
|
|
||||||
const route = async (req, res) => {
|
|
||||||
//merge the banned accounts with the account data, if any
|
|
||||||
const data = await bannedEmails.findAll()
|
|
||||||
.then(bans => bans.map(async ban => {
|
|
||||||
//find a matching account
|
|
||||||
const account = await accounts.findOne({
|
|
||||||
attrubutes: ['username', 'privilege'],
|
|
||||||
where: {
|
|
||||||
email: {
|
|
||||||
[Op.eq]: ban.email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) || {};
|
|
||||||
|
|
||||||
//merge the data and return (becomes a promise)
|
|
||||||
return {
|
|
||||||
username: account.username,
|
|
||||||
email: ban.email,
|
|
||||||
privilege: account.privilege,
|
|
||||||
expiry: ban.expiry,
|
|
||||||
reason: ban.reason
|
|
||||||
};
|
|
||||||
}))
|
|
||||||
.then(promises => Promise.all(promises)) //resolve promises
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
;
|
|
||||||
|
|
||||||
return res.status(200).json(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
//DOCS: this whole file is just a big bugfix
|
|
||||||
//DOCS: ensure that there is at least one administration account
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const { accounts } = require('../database/models');
|
|
||||||
|
|
||||||
const defaultAdminAccount = async () => {
|
|
||||||
const admin = await accounts.findOne({
|
|
||||||
where: {
|
|
||||||
privilege: 'administrator'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (admin == null) {
|
|
||||||
await accounts.create({
|
|
||||||
privilege: 'administrator',
|
|
||||||
email: `admin@${process.env.WEB_ADDRESS}`,
|
|
||||||
username: `admin`,
|
|
||||||
hash: await bcrypt.hash('password', await bcrypt.genSalt(11))
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Created default admin account (email: admin@${process.env.WEB_ADDRESS}; password: password)`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = defaultAdminAccount;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
//middleware
|
|
||||||
router.use((req, res, next) => {
|
|
||||||
//make sure the account is an admin
|
|
||||||
if (req.cookies['admin'] !== process.env.SESSION_ADMIN) {
|
|
||||||
return res.status(401).send('invalid admin status');
|
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//basic account ban management
|
|
||||||
router.get('/banned', require('./banned'));
|
|
||||||
router.post('/ban', require('./ban'));
|
|
||||||
router.post('/unban', require('./unban'));
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
const Sequelize = require('sequelize');
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
const { bannedEmails, accounts } = require('../database/models');
|
|
||||||
var cron = require('node-cron');
|
|
||||||
|
|
||||||
const route = async (req, res) => {
|
|
||||||
console.log(req.fields.entry)
|
|
||||||
//get the account, if one is found
|
|
||||||
const account = await accounts.findOne({
|
|
||||||
where: {
|
|
||||||
[Op.or]: {
|
|
||||||
email: {
|
|
||||||
[Op.eq]: req.fields.entry
|
|
||||||
},
|
|
||||||
username: {
|
|
||||||
[Op.eq]: req.fields.entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
//accept either email or username
|
|
||||||
const affectedRows = await bannedEmails.destroy({
|
|
||||||
where: {
|
|
||||||
email: {
|
|
||||||
[Op.eq]: account?.email || req.fields.entry || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).send(`${affectedRows} emails unbanned`);
|
|
||||||
};
|
|
||||||
|
|
||||||
//delete any expired bans
|
|
||||||
cron.schedule('0 * * * *', () => {
|
|
||||||
bannedEmails.destroy({
|
|
||||||
where: {
|
|
||||||
expiry: {
|
|
||||||
[Op.lt]: Sequelize.fn('NOW'),
|
|
||||||
[Op.not]: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = route;
|
|
||||||
@@ -4,9 +4,9 @@ const sequelize = new Sequelize(process.env.DB_DATABASE, process.env.DB_USERNAME
|
|||||||
host: process.env.DB_HOSTNAME,
|
host: process.env.DB_HOSTNAME,
|
||||||
dialect: 'mariadb',
|
dialect: 'mariadb',
|
||||||
timezone: process.env.DB_TIMEZONE,
|
timezone: process.env.DB_TIMEZONE,
|
||||||
logging: false
|
logging: process.env.DB_LOGGING ? console.log : false
|
||||||
});
|
});
|
||||||
|
|
||||||
sequelize.sync();
|
sequelize.sync();
|
||||||
|
|
||||||
module.exports = sequelize;
|
module.exports = sequelize;
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
const Sequelize = require('sequelize');
|
|
||||||
const sequelize = require('..');
|
|
||||||
|
|
||||||
module.exports = sequelize.define('accounts', {
|
|
||||||
id: {
|
|
||||||
type: Sequelize.INTEGER(11),
|
|
||||||
allowNull: false,
|
|
||||||
autoIncrement: true,
|
|
||||||
primaryKey: true,
|
|
||||||
unique: true
|
|
||||||
},
|
|
||||||
|
|
||||||
privilege: {
|
|
||||||
type: Sequelize.ENUM,
|
|
||||||
values: ['administrator', 'moderator', 'alpha', 'beta', 'gamma', 'normal'],
|
|
||||||
defaultValue: 'normal'
|
|
||||||
},
|
|
||||||
|
|
||||||
email: {
|
|
||||||
type: 'varchar(320)',
|
|
||||||
unique: true
|
|
||||||
},
|
|
||||||
|
|
||||||
username: {
|
|
||||||
type: 'varchar(320)',
|
|
||||||
unique: true
|
|
||||||
},
|
|
||||||
|
|
||||||
hash: 'varchar(100)', //for passwords
|
|
||||||
|
|
||||||
contact: {
|
|
||||||
type: Sequelize.BOOLEAN,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: false
|
|
||||||
},
|
|
||||||
|
|
||||||
deletion: {
|
|
||||||
type: 'DATETIME',
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
const Sequelize = require('sequelize');
|
|
||||||
const sequelize = require('..');
|
|
||||||
|
|
||||||
module.exports = sequelize.define('bannedEmails', {
|
|
||||||
id: {
|
|
||||||
type: Sequelize.INTEGER(11),
|
|
||||||
allowNull: false,
|
|
||||||
autoIncrement: true,
|
|
||||||
primaryKey: true,
|
|
||||||
unique: true
|
|
||||||
},
|
|
||||||
|
|
||||||
email: {
|
|
||||||
type: 'varchar(320)',
|
|
||||||
unique: true
|
|
||||||
},
|
|
||||||
|
|
||||||
reason: Sequelize.TEXT,
|
|
||||||
|
|
||||||
expiry: {
|
|
||||||
type: 'DATETIME',
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
bannedEmails: require('./banned-emails'),
|
//import models
|
||||||
accounts: require('./accounts'),
|
|
||||||
pendingSignups: require('./pending-signups')
|
|
||||||
}
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
const Sequelize = require('sequelize');
|
|
||||||
const sequelize = require('..');
|
|
||||||
|
|
||||||
module.exports = sequelize.define('pendingSignups', {
|
|
||||||
email: {
|
|
||||||
type: 'varchar(320)',
|
|
||||||
unique: true
|
|
||||||
},
|
|
||||||
|
|
||||||
username: {
|
|
||||||
type: 'varchar(320)',
|
|
||||||
unique: true
|
|
||||||
},
|
|
||||||
|
|
||||||
hash: 'varchar(100)', //for passwords
|
|
||||||
|
|
||||||
contact: {
|
|
||||||
type: Sequelize.BOOLEAN,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: false
|
|
||||||
},
|
|
||||||
|
|
||||||
token: Sequelize.INTEGER(11)
|
|
||||||
});
|
|
||||||
+22
-26
@@ -1,39 +1,34 @@
|
|||||||
//environment variables
|
//environment variables
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
//libraries
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
//create the server
|
//create the server
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = require('http').Server(app);
|
const server = require('http').Server(app);
|
||||||
|
|
||||||
//libraries used here
|
//config
|
||||||
const path = require('path');
|
app.use(express.json());
|
||||||
const formidable = require('express-formidable');
|
|
||||||
const cookieParser = require('cookie-parser');
|
//handle compressed files (middleware)
|
||||||
const session = require('express-session');
|
app.get('*.js', (req, res, next) => {
|
||||||
const SequelizeStore = require("connect-session-sequelize")(session.Store);
|
req.url = req.url + '.gz';
|
||||||
|
res.set('Content-Encoding', 'gzip');
|
||||||
|
res.set('Content-Type', 'text/javascript');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('*.css', (req, res, next) => {
|
||||||
|
req.url = req.url + '.gz';
|
||||||
|
res.set('Content-Encoding', 'gzip');
|
||||||
|
res.set('Content-Type', 'text/css');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
//database connection
|
//database connection
|
||||||
const database = require('./database');
|
const database = require('./database');
|
||||||
const models = require('./database/models'); //invoke all models
|
|
||||||
|
|
||||||
app.use(formidable());
|
|
||||||
app.use(cookieParser());
|
|
||||||
app.use(session({
|
|
||||||
secret: process.env.SESSION_SECRET,
|
|
||||||
resave: true,
|
|
||||||
saveUninitialized: true,
|
|
||||||
store: new SequelizeStore({
|
|
||||||
db: database
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
//account management
|
|
||||||
app.use('/api/accounts', require('./accounts'));
|
|
||||||
|
|
||||||
//administration
|
|
||||||
app.use('/api/admin', require('./admin'));
|
|
||||||
require('./admin/bookkeeper')(); //BUGFIX
|
|
||||||
|
|
||||||
//send static files
|
//send static files
|
||||||
app.use('/', express.static(path.resolve(__dirname, '..', 'public')));
|
app.use('/', express.static(path.resolve(__dirname, '..', 'public')));
|
||||||
@@ -44,6 +39,7 @@ app.get('*', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
//startup
|
//startup
|
||||||
server.listen(process.env.WEB_PORT || 3000, (err) => {
|
server.listen(process.env.WEB_PORT || 3000, async (err) => {
|
||||||
|
await database.sync();
|
||||||
console.log(`listening to localhost:${process.env.WEB_PORT || 3000}`);
|
console.log(`listening to localhost:${process.env.WEB_PORT || 3000}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
#This file should be used for altering the database in production - make sure it works!
|
|
||||||
|
|
||||||
+42
-47
@@ -2,23 +2,24 @@
|
|||||||
const { DefinePlugin } = require('webpack');
|
const { DefinePlugin } = require('webpack');
|
||||||
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
|
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const CompressionPlugin = require('compression-webpack-plugin');
|
||||||
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
||||||
|
|
||||||
//libraries
|
//libraries
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
//the exported config function
|
//the exported config function
|
||||||
module.exports = ({ production, analyzer }) => {
|
module.exports = ({ production, development, local, analyze }) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: production ? "production" : "development",
|
mode: production ? "production" : "development",
|
||||||
entry: path.resolve(__dirname, 'client', 'client.jsx'),
|
entry: path.resolve(__dirname, 'client', 'client.jsx'),
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, 'public'),
|
path: path.resolve(__dirname, 'public'),
|
||||||
|
publicPath: '/',
|
||||||
filename: '[name].[chunkhash].js',
|
filename: '[name].[chunkhash].js',
|
||||||
sourceMapFilename: '[name].[chunkhash].js.map'
|
sourceMapFilename: '[name].[chunkhash].js.map'
|
||||||
},
|
},
|
||||||
devtool: 'eval-source-map',
|
devtool: production ? false : 'eval-source-map',
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.js', '.jsx']
|
extensions: ['.js', '.jsx']
|
||||||
},
|
},
|
||||||
@@ -32,26 +33,24 @@ module.exports = ({ production, analyzer }) => {
|
|||||||
loader: 'babel-loader',
|
loader: 'babel-loader',
|
||||||
options: {
|
options: {
|
||||||
presets: ['@babel/preset-env', '@babel/preset-react'],
|
presets: ['@babel/preset-env', '@babel/preset-react'],
|
||||||
plugins: ['react-loadable/babel', '@babel/plugin-syntax-dynamic-import']
|
plugins: ['@babel/plugin-syntax-dynamic-import']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.(md)$/,
|
test: /\.(css)$/,
|
||||||
use: [
|
use: ['style-loader', 'css-loader']
|
||||||
{
|
|
||||||
loader: 'raw-loader'
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new DefinePlugin({
|
new DefinePlugin({
|
||||||
'process.env': {
|
'process.env': {
|
||||||
'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"http://dev-news.eggtrainer.com:3100/news"',
|
'PRODUCTION': production,
|
||||||
'NEWS_KEY': production ? `"${process.env.NEWS_KEY}"` : '"key"',
|
'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : development ? '"https://dev-news.krgamestudios.com"' : '"http://localhost:3100"',
|
||||||
|
'AUTH_URI': production ? `"${process.env.AUTH_URI}"` : development ? '"https://dev-auth.krgamestudios.com"' : '"http://localhost:3200"',
|
||||||
|
'CHAT_URI': production ? `"${process.env.CHAT_URI}"` : development ? '"https://dev-chat.krgamestudios.com"' : '"http://localhost:3300"',
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
new CleanWebpackPlugin({
|
new CleanWebpackPlugin({
|
||||||
@@ -65,46 +64,42 @@ module.exports = ({ production, analyzer }) => {
|
|||||||
removeAttributeQuotes: production
|
removeAttributeQuotes: production
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
new CompressionPlugin({
|
||||||
|
filename: "[path][base].gz[query]",
|
||||||
|
algorithm: "gzip",
|
||||||
|
test: /\.js$|\.css$/,
|
||||||
|
minRatio: 0.8
|
||||||
|
}),
|
||||||
new BundleAnalyzerPlugin({
|
new BundleAnalyzerPlugin({
|
||||||
analyzerMode: analyzer ? 'server' : 'disabled'
|
analyzerMode: analyze ? 'server' : 'disabled'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
contentBase: path.resolve(__dirname, 'public'),
|
|
||||||
compress: true,
|
|
||||||
port: 3001,
|
|
||||||
proxy: {
|
|
||||||
'/api/': 'http://localhost:3000/'
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
errors: true
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
colors: true,
|
|
||||||
hash: false,
|
|
||||||
version: false,
|
|
||||||
timings: false,
|
|
||||||
assets: false,
|
|
||||||
chunks: false,
|
|
||||||
modules: false,
|
|
||||||
reasons: false,
|
|
||||||
children: false,
|
|
||||||
source: false,
|
|
||||||
errors: true,
|
|
||||||
errorDetails: false,
|
|
||||||
warnings: true,
|
|
||||||
publicPath: false
|
|
||||||
},
|
|
||||||
host: '0.0.0.0',
|
|
||||||
disableHostCheck: true,
|
|
||||||
clientLogLevel: 'silent',
|
|
||||||
historyApiFallback: true,
|
|
||||||
hot: true,
|
hot: true,
|
||||||
injectHot: true
|
host: 'localhost',
|
||||||
},
|
port: 3001,
|
||||||
watchOptions: {
|
client: {
|
||||||
ignored: /(node_modules)/
|
overlay: {
|
||||||
|
errors: true,
|
||||||
|
warnings: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watchFiles: {
|
||||||
|
options: {
|
||||||
|
ignored: ['node_modules/**']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
static: '/public',
|
||||||
|
|
||||||
|
historyApiFallback: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user