Added opt-in option for promotional emails
This commit is contained in:
@@ -5,6 +5,7 @@ WEB_PORT=3000
|
||||
MAIL_SMTP=smtp.example.com
|
||||
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
|
||||
|
||||
@@ -44,8 +44,8 @@ There are external components to this template referred to as "microservices". T
|
||||
# TODO list
|
||||
|
||||
- Legal Requirements:
|
||||
- Physical Mailing Address Config (for emails)
|
||||
- Opt-out option (for emails)
|
||||
- ~~Physical Mailing Address Config (for emails)~~
|
||||
- ~~Opt-out option (for emails)~~
|
||||
- Information about legal requirements of the developers using this template
|
||||
- Privacy policy & data collection notices
|
||||
- LICENSE file
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useCookies } from 'react-cookie';
|
||||
|
||||
@@ -12,12 +12,53 @@ const Account = props => {
|
||||
return <Redirect to='/' />;
|
||||
}
|
||||
|
||||
//refs
|
||||
let contactElement;
|
||||
|
||||
//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>
|
||||
<DeleteAccount />
|
||||
<form className='constricted' onSubmit={async evt => {
|
||||
evt.preventDefault();
|
||||
await update(contactElement.checked);
|
||||
}}>
|
||||
<div>
|
||||
<label htmlFor='contact'>Allow Promotional Emails:</label>
|
||||
<input type='checkbox' name='contact' ref={e => contactElement = e} />
|
||||
</div>
|
||||
|
||||
<button type='submit'>Update Information</button>
|
||||
</form>
|
||||
|
||||
<DeleteAccount className='constricted' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const update = async (contact) => {
|
||||
//generate a new formdata payload
|
||||
let formData = new FormData();
|
||||
|
||||
formData.append('contact', contact);
|
||||
|
||||
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;
|
||||
|
||||
@@ -15,7 +15,7 @@ const SignUp = props => {
|
||||
}
|
||||
|
||||
//refs
|
||||
let emailElement, usernameElement, passwordElement, retypeElement;
|
||||
let emailElement, usernameElement, passwordElement, retypeElement, contactElement;
|
||||
|
||||
return (
|
||||
<div className='page'>
|
||||
@@ -23,32 +23,38 @@ const SignUp = props => {
|
||||
<form className='constricted' onSubmit={
|
||||
evt => {
|
||||
evt.preventDefault();
|
||||
handleSubmit(emailElement.value, usernameElement.value, passwordElement.value, retypeElement.value)
|
||||
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} />
|
||||
<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} />
|
||||
<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} />
|
||||
<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} />
|
||||
<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>
|
||||
@@ -57,12 +63,12 @@ const SignUp = props => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (email, username, password, retype) => {
|
||||
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;
|
||||
}
|
||||
@@ -73,6 +79,7 @@ const handleSubmit = async (email, username, password, retype) => {
|
||||
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 });
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ const DeleteAccount = props => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!open) {
|
||||
return <button onClick={() => setOpen(true)}>Delete Account</button>
|
||||
return <button onClick={() => setOpen(true)} className={props.className}>Delete Account</button>
|
||||
}
|
||||
|
||||
let passwordElement;
|
||||
|
||||
return (
|
||||
<form className='constricted' onSubmit={async evt => {
|
||||
<form className={props.className} onSubmit={async evt => {
|
||||
evt.preventDefault();
|
||||
const password = passwordElement.value;
|
||||
passwordElement.value = '';
|
||||
@@ -34,7 +34,7 @@ const handleSubmit = async (password) => {
|
||||
|
||||
formData.append('password', password);
|
||||
|
||||
const result = await fetch('/api/accounts/deletion', { method: 'POST', body: formData });
|
||||
const result = await fetch('/api/accounts/deletion', { method: 'DELETE', body: formData });
|
||||
|
||||
if (!result.ok) {
|
||||
alert(await result.text());
|
||||
|
||||
+3
-1
@@ -25,6 +25,7 @@ const question = (prompt, def) => {
|
||||
const projectMailSMTP = await question('Project Mail SMTP', 'smtp.example.com');
|
||||
const projectMailUser = await question('Project Mail Username', 'foobar@example.com');
|
||||
const projectMailPass = await question('Project Mail Password', 'foobar');
|
||||
const projectMailPhysical = await question('Project Physical Mailing Address', '[Unknown]');
|
||||
const projectDBUser = await question('Project Database Username', projectName);
|
||||
const projectDBPass = await question('Project Database Password', 'pikachu');
|
||||
|
||||
@@ -64,7 +65,8 @@ services:
|
||||
- WEB_PORT=3000
|
||||
- MAIL_SMTP=${projectMailSMTP}
|
||||
- MAIL_USERNAME=${projectMailUser}
|
||||
- MAIL_PASSWORD=${projectMailPass}
|
||||
- MAIL_PASSWORD=${projectMailPass}
|
||||
- MAIL_PHYSICAL=${projectMailPhysical}
|
||||
- DB_HOSTNAME=database
|
||||
- DB_DATABASE=${projectName}
|
||||
- DB_USERNAME=${projectDBUser}
|
||||
|
||||
Generated
+658
-31
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
||||
"homepage": "https://github.com/KRGameStudios/MERN-template#readme",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-session-sequelize": "^7.1.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"core-js": "^3.8.3",
|
||||
"dateformat": "^4.5.1",
|
||||
|
||||
@@ -2,10 +2,16 @@ 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'));
|
||||
router.post('/deletion', require('./deletion'));
|
||||
|
||||
//account deletion
|
||||
router.delete('/deletion', require('./deletion'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -37,7 +37,7 @@ const route = async (req, res) => {
|
||||
}
|
||||
|
||||
//save the session and cookie data
|
||||
req.session.account = account;
|
||||
req.session.account = JSON.parse(JSON.stringify(account.dataValues));
|
||||
res.cookie('loggedin', process.env.WEB_ADDRESS);
|
||||
|
||||
if (account.privilege == 'administrator') {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -101,6 +101,7 @@ const registerPendingSignup = async (fields, hash, token) => {
|
||||
email: fields.email,
|
||||
username: fields.username,
|
||||
hash: hash,
|
||||
contact: fields.contact,
|
||||
token: token
|
||||
});
|
||||
|
||||
@@ -109,7 +110,12 @@ const registerPendingSignup = async (fields, hash, token) => {
|
||||
|
||||
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! Please visit the following address to validate your account: ${addr}`;
|
||||
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;
|
||||
|
||||
@@ -143,7 +149,7 @@ const sendValidationEmail = async (email, username, token) => {
|
||||
}
|
||||
|
||||
if (info.accepted[0] != email) {
|
||||
return 'validation email failed';
|
||||
return 'validation email failed to send';
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const { accounts } = require('../database/models');
|
||||
|
||||
const route = async (req, res) => {
|
||||
if (!req.session.account.id) {
|
||||
return res.status(500).send('missing account data');
|
||||
}
|
||||
|
||||
//update the account
|
||||
await accounts.update({
|
||||
contact: req.fields.contact
|
||||
}, {
|
||||
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;
|
||||
@@ -29,7 +29,8 @@ const route = async (req, res) => {
|
||||
accounts.create({
|
||||
email: info.email,
|
||||
username: info.username,
|
||||
hash: info.hash
|
||||
hash: info.hash,
|
||||
contact: info.contact
|
||||
});
|
||||
|
||||
//finally
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//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;
|
||||
@@ -16,27 +16,4 @@ router.get('/banned', require('./banned'));
|
||||
router.post('/ban', require('./ban'));
|
||||
router.post('/unban', require('./unban'));
|
||||
|
||||
//DOCS: ensure that there is at least one administration account
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { accounts } = require('../database/models');
|
||||
|
||||
(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 = router;
|
||||
@@ -28,6 +28,12 @@ module.exports = sequelize.define('accounts', {
|
||||
|
||||
hash: 'varchar(100)', //for passwords
|
||||
|
||||
contact: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
|
||||
deletion: {
|
||||
type: 'DATETIME',
|
||||
allowNull: true,
|
||||
|
||||
@@ -14,5 +14,11 @@ module.exports = sequelize.define('pendingSignups', {
|
||||
|
||||
hash: 'varchar(100)', //for passwords
|
||||
|
||||
contact: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
|
||||
token: Sequelize.INTEGER(11)
|
||||
});
|
||||
|
||||
+14
-4
@@ -11,19 +11,29 @@ const path = require('path');
|
||||
const formidable = require('express-formidable');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const session = require('express-session');
|
||||
|
||||
app.use(formidable());
|
||||
app.use(cookieParser());
|
||||
app.use(session({ secret: process.env.SESSION_SECRET, resave: true, saveUninitialized: true }));
|
||||
const SequelizeStore = require("connect-session-sequelize")(session.Store);
|
||||
|
||||
//database connection
|
||||
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
|
||||
app.use('/', express.static(path.resolve(__dirname, '..', 'public')));
|
||||
|
||||
Reference in New Issue
Block a user