Account functionality extended, API authentication implemented

- Can now add a second email address to your account and delete one of 
your email addresses
- Reset password now works
- Resetting the password of an account without a password set will set 
one
- Email templates overhauled
- Login tokens limited to close previously devastating ($800 database 
bill) security hole
- Login with REST API now works
- Once logged in, authentication of API calls with token works
- Creatures can now be fetched using the API
This commit is contained in:
Stefan Zermatten
2022-02-10 19:02:18 +02:00
parent 3948d20f46
commit 359f18988c
27 changed files with 852 additions and 11 deletions

View File

@@ -45,3 +45,5 @@ akryum:vue-router2
percolate:migrations
meteortesting:mocha
ostrio:files
simple:rest-bearer-token-parser
simple:rest-json-error-handler

View File

@@ -108,6 +108,8 @@ sha@1.0.9
shell-server@0.5.0
simple:json-routes@2.3.1
simple:rest@1.2.1
simple:rest-bearer-token-parser@1.1.1
simple:rest-json-error-handler@1.1.1
simple:rest-method-mixin@1.1.0
socket-stream-client@0.4.0
spacebars-compiler@1.3.0

View File

@@ -1,5 +1,10 @@
import Creatures from '/imports/api/creature/creatures/Creatures.js';
export default function writeScope(creatureId, scope){
// Remove large properties that aren't likely to be accessed
for (const key in scope){
delete scope[key].parent;
delete scope[key].ancestors;
}
Creatures.update(creatureId, {$set: {variables: scope}});
}

View File

@@ -4,7 +4,7 @@ import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
function assertIdValid(userId){
if (!userId || typeof userId !== 'string'){
throw new Meteor.Error('Permission denied',
'No user ID given for edit permission check');
'No user ID. Are you logged in?');
}
}

View File

@@ -1,7 +1,10 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import '/imports/api/users/deleteMyAccount.js';
import '/imports/api/users/methods/deleteMyAccount.js';
import '/imports/api/users/methods/addEmail.js';
import '/imports/api/users/methods/removeEmail.js';
import { some } from 'lodash';
const defaultLibraries = process.env.DEFAULT_LIBRARIES && process.env.DEFAULT_LIBRARIES.split(',') || [];

View File

@@ -0,0 +1,34 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
const addEmail = new ValidatedMethod({
name: 'users.addEmail',
validate: new SimpleSchema({
email: {
type: String,
regEx: SimpleSchema.RegEx.Email,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 1,
timeInterval: 5000,
},
run({email}){
const userId = Meteor.userId();
const user = Meteor.users.findOne(userId);
if (!user) throw new Meteor.Error('No user',
'You must be logged in to add an email address');
if (user.emails && user.emails.length >= 2){
throw new Meteor.Error('Emails full',
'You may only have up to 2 email addresses per account');
}
if (Meteor.isServer){
Accounts.addEmail(userId, email);
Accounts.sendVerificationEmail(userId, email);
}
}
});
export default addEmail;

View File

@@ -15,7 +15,7 @@ Meteor.users.deleteMyAccount = new ValidatedMethod({
run(){
let userId = Meteor.userId();
if (!userId) throw new Meteor.Error('No user',
'You must be logged into to delete your account');
'You must be logged in to delete your account');
// Delete all creatures
let creatures = Creatures.find({owner: userId}, {fields: {_id: 1}}).fetch();

View File

@@ -0,0 +1,37 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
const removeEmail = new ValidatedMethod({
name: 'users.removeEmail',
validate: new SimpleSchema({
email: {
type: String,
regEx: SimpleSchema.RegEx.Email,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 1,
timeInterval: 5000,
},
run({email}){
const userId = Meteor.userId();
const user = Meteor.users.findOne(userId);
if (!user) throw new Meteor.Error('No user',
'You must be logged in to remove an email address');
if (!user.emails){
throw new Meteor.Error('No email to remove',
'No email addresses are associated with this account');
}
if (user.emails.length == 1){
throw new Meteor.Error('Can\'t remove last email',
'You may not remove the last email address from your account');
}
if (Meteor.isServer){
Accounts.removeEmail(userId, email);
}
}
});
export default removeEmail;

View File

@@ -1,8 +1,7 @@
import { JsonRoutes } from 'meteor/simple:json-routes';
import { JsonRoutes, RestMiddleware } from 'meteor/simple:json-routes';
import { SimpleRest } from 'meteor/simple:rest';
Meteor.startup(() => {
//
// Enable cross origin requests for all endpoints
JsonRoutes.setResponseHeaders({
'Cache-Control': 'no-store',
@@ -17,3 +16,18 @@ SimpleRest.configure({
// No default collection methods get end points
collections: [],
});
// All errors are handled as JSON
JsonRoutes.ErrorMiddleware.use(RestMiddleware.handleErrorAsJson);
// Hack to stop simple:rest adding routes automatically unless their URL
// has been explicitly set to 'api/...'
const oldAdd = JsonRoutes.add;
JsonRoutes.add = function(method, path, handler){
if (path.substring(0,4) !== 'api/'){
return;
}
oldAdd(method, path, handler);
}
import '/imports/server/rest/restLogin.js';

View File

@@ -1,4 +1,35 @@
import { Accounts } from 'meteor/accounts-base'
import emailTemplate from './emailTemplate.js';
Accounts.emailTemplates.from = 'no-reply@dicecloud.com';
Accounts.emailTemplates.siteName = 'DiceCloud';
Accounts.emailTemplates.enrollAccount = {
subject: () => 'DiceCloud Invite',
html: (user, url) => emailTemplate({
heading: 'DiceCloud Invite',
text: 'You have been invited to DiceCloud, click the button below to begin.',
buttonText: 'Get Started',
url: url.replace( '#/', '' ),
}),
};
Accounts.emailTemplates.resetPassword = {
subject: () => 'DiceCloud Password Reset',
html: (user, url) => emailTemplate({
heading: 'Password Reset',
text: 'If you did not request this password reset, please ignore this email.',
buttonText: 'Reset Password',
url: url.replace( '#/', '' ),
}),
};
Accounts.emailTemplates.verifyEmail = {
subject: () => 'DiceCloud Email Verification',
html: (user, url) => emailTemplate({
heading: 'DiceCloud Email Verification',
text: 'Click below to verify your email address',
buttonText: 'Verify Email',
url: url.replace( '#/', '' ),
}),
};

View File

@@ -0,0 +1,167 @@
export default function emailTemplate({heading, text, buttonText, url}){
return `
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html>
<head>
<!-- Compiled with Bootstrap Email version: 1.1.2 --><meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style type="text/css">
body,table,td{font-family:Helvetica,Arial,sans-serif !important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body a,#MessageViewBody a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-]) td{border-spacing:0px;border-collapse:collapse}@media screen and (max-width: 600px){.w-full,.w-full>tbody>tr>td{width:100% !important}*[class*=s-lg-]>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-2>tbody>tr>td{font-size:8px !important;line-height:8px !important;height:8px !important}.s-5>tbody>tr>td{font-size:20px !important;line-height:20px !important;height:20px !important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px !important;height:40px !important}}
</style>
</head>
<body class="bg-light" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<table class="bg-light body" valign="top" role="presentation" border="0" cellpadding="0" cellspacing="0" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<tbody>
<tr>
<td valign="top" style="line-height: 24px; font-size: 16px; margin: 0;" align="left" bgcolor="#f7fafc">
<table class="container" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td align="center" style="line-height: 24px; font-size: 16px; margin: 0; padding: 0 16px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" role="presentation">
<tbody>
<tr>
<td width="600">
<![endif]-->
<table align="center" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 600px; margin: 0 auto;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<table class="s-10 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;" align="left" width="100%" height="40">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="card" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" bgcolor="#ffffff">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left" bgcolor="#ffffff">
<table class="card-body" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 20px;" align="left">
<img src="https://dicecloud.com/favicon-96x96.png" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;">
<h1 class="h3" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 28px; line-height: 33.6px; margin: 0;" align="left">DiceCloud</h1>
<table class="s-2 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 8px; font-size: 8px; width: 100%; height: 8px; margin: 0;" align="left" width="100%" height="8">
&#160;
</td>
</tr>
</tbody>
</table>
<h5 class="text-teal-700" style="color: #13795b; padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 20px; line-height: 24px; margin: 0;" align="left">${heading}</h5>
<table class="s-5 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" align="left" width="100%" height="20">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-5 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" align="left" width="100%" height="20">
&#160;
</td>
</tr>
</tbody>
</table>
<div class="space-y-3">
<p class="text-gray-700" style="line-height: 24px; font-size: 16px; color: #4a5568; width: 100%; margin: 0;" align="left">
${text}
</p>
</div>
<table class="s-5 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" align="left" width="100%" height="20">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-5 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 20px; font-size: 20px; width: 100%; height: 20px; margin: 0;" align="left" width="100%" height="20">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="btn btn-primary" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-radius: 6px; margin: 0;" align="center" bgcolor="#0d6efd">
<a href="${url}" target="_blank" style="color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #0d6efd; padding: 8px 12px; border: 1px solid #0d6efd;">${buttonText}</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table class="s-10 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;" align="left" width="100%" height="40">
&#160;
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</tbody>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>
`
}

View File

@@ -0,0 +1,21 @@
const MAX_LOGIN_TOKENS = 20;
Accounts._insertHashedLoginToken = function(userId, hashedToken, query) {
query = query ? { ...query } : {};
query._id = userId;
const user = Accounts.users.findOne(query);
let loginTokenLength = user?.services?.resume?.loginTokens?.length;
while (loginTokenLength >= MAX_LOGIN_TOKENS){
loginTokenLength -=1;
Accounts.users.update(query, {
$pop: {
'services.resume.loginTokens': -1
}
});
}
Accounts.users.update(query, {
$addToSet: {
'services.resume.loginTokens': hashedToken
}
});
};

View File

@@ -14,7 +14,11 @@ let schema = new SimpleSchema({
});
Meteor.publish('singleCharacter', function(creatureId){
schema.validate({ creatureId });
try {
schema.validate({ creatureId });
} catch (e){
this.error(e);
}
this.autorun(function (computation){
let userId = this.userId;
let creatureCursor

View File

@@ -0,0 +1,46 @@
import SimpleSchema from 'simpl-schema';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { assertViewPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import computeCreature from '/imports/api/engine/computeCreature.js';
import VERSION from '/imports/constants/VERSION.js';
Meteor.publish('api-creature', function(creatureId){
try {
new SimpleSchema({
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
}).validate({ creatureId });
} catch (e){
this.error(e);
return;
}
const userId = this.userId;
const creatureCursor = Creatures.find({
_id: creatureId,
});
const creature = creatureCursor.fetch()[0];
try {
assertViewPermission(creature, userId)
} catch(e){
this.error(e);
return;
}
if (creature.computeVersion !== VERSION){
try {
computeCreature(creatureId)
} catch(e){
console.error(e)
}
}
return [
creatureCursor,
CreatureProperties.find({
'ancestors.id': creatureId,
}),
];
}, {
url: 'api/creature/:0'
});

View File

@@ -0,0 +1 @@
import './creature.js';

View File

@@ -0,0 +1,2 @@
import './restLogin.js';
import './apiPublications/index.js';

View File

@@ -0,0 +1,57 @@
var Fiber = Npm.require('fibers');
import { RestMiddleware } from 'meteor/simple:json-routes';
/**
* SimpleRest middleware for validating a Meteor.user's login token
*
* This middleware must be processed after the request.token has been set to a
* valid login token for a Meteor.user account (from a separate layer of
* middleware). If authentication is successful, the request.userId will be set
* to the ID of the authenticated user. An invalid token will result in a error.
*
* @middleware
*/
const authenticateMeteorUserByToken =
function (req, res, next) {
Fiber(function () {
let userId;
try {
userId = getUserIdFromAuthToken(req.authToken);
} catch (e){
RestMiddleware.handleErrorAsJson(e, req, res, next);
return;
}
if (userId) {
req.userId = userId;
}
next();
}).run();
};
/**
* Retrieves the ID of the Meteor.user that the given auth token belongs to
*
* @param token An unhashed auth token
* @returns {String} The ID of the authenticated Meteor.user, or null if token
* is invalid
*/
function getUserIdFromAuthToken(token) {
if (!token) {
return null;
}
var user = Meteor.users.findOne({
'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(token),
});
if (user) {
return user._id;
} else {
const error = new Meteor.Error('Permission denied',
'Invalid authentication token');
error.statusCode = 403;
throw error;
}
}
export default authenticateMeteorUserByToken;

View File

@@ -0,0 +1,85 @@
import { JsonRoutes } from 'meteor/simple:json-routes';
import authenticateMeteorUserByToken from './middleware/authenticateUserByToken.js';
/**
* Login with username/email and password:
* POST /api/login
* Body: {"username": "<your username>", "password": "<your password>"}
* Alternative Body: {"email": "<your email>", "password": "<your password>"}
* Successful response:
* {
* "id": "<your userId>",
* "token": "<your user token, save this>",
* "tokenExpires": "<date string of token expiry date>"
* }
*
* Warning: Your token may expire before the given date.
* Since each user has a limited pool of login tokens. If you get a permission
* error, you may need to login again to refresh your token
*
* Once you have your token, you can use it as a standard bearer token header
* in other API endpoints:
* HTTP.post("/methods/return-five-auth", {
* headers: { Authorization: "Bearer <token>" }
* }, callback);
**/
JsonRoutes.Middleware.use(JsonRoutes.Middleware.parseBearerToken);
JsonRoutes.Middleware.use(authenticateMeteorUserByToken);
JsonRoutes.add('options', 'api/login', function (req, res) {
JsonRoutes.sendResult(res);
});
JsonRoutes.add('post', 'api/login', function (req, res) {
var options = req.body;
var user;
if (options.email) {
check(options, {
email: String,
password: String,
});
user = Accounts.findUserByEmail(options.email);
} else {
check(options, {
username: String,
password: String,
});
user = Accounts.findUserByUsername(options.username);
}
if (!user) {
throw new Meteor.Error('not-found',
'User with that username or email address not found.');
}
var result = Accounts._checkPassword(user, options.password);
check(result, {
userId: String,
error: Match.Optional(Meteor.Error),
});
if (result.error) {
throw result.error;
}
var stampedLoginToken = Accounts._generateStampedLoginToken();
check(stampedLoginToken, {
token: String,
when: Date,
});
Accounts._insertLoginToken(result.userId, stampedLoginToken);
var tokenExpiration = Accounts._tokenExpiration(stampedLoginToken.when);
check(tokenExpiration, Date);
JsonRoutes.sendResult(res, {
data: {
id: result.userId,
token: stampedLoginToken.token,
tokenExpires: tokenExpiration,
},
});
});

View File

@@ -62,10 +62,60 @@
v-for="email in emails"
:key="email.address"
>
<v-list-item-action v-if="emails.length > 1">
<v-btn
icon
small
@click="removeEmail(email.address)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-action>
<v-list-item-title>
{{ email.address }}
</v-list-item-title>
</v-list-item>
<v-expand-transition>
<v-alert
v-if="removeEmailError"
type="error"
>
{{ removeEmailError }}
</v-alert>
</v-expand-transition>
<v-slide-x-transition
hide-on-leave
>
<v-text-field
v-if="showEmailInput"
v-model="inputEmail"
label="Add Email Address"
:error-messages="addEmailError"
outlined
>
<v-btn
slot="prepend"
icon
@click="clearEmailInput"
>
<v-icon>mdi-close</v-icon>
</v-btn>
<v-btn
slot="append"
icon
@click="addEmail"
>
<v-icon>mdi-send</v-icon>
</v-btn>
</v-text-field>
<v-btn
v-else-if="!emails || emails.length < 2"
icon
@click="showEmailInput = true"
>
<v-icon>mdi-plus</v-icon>
</v-btn>
</v-slide-x-transition>
<v-subheader>
Patreon
</v-subheader>
@@ -167,8 +217,10 @@
import router from '/imports/ui/router.js';
import getEntitledCents from '/imports/api/users/patreon/getEntitledCents.js';
import Invites from '/imports/api/users/Invites.js';
import linkWithPatreon from '/imports/api/users/linkWithPatreon.js'
import linkWithPatreon from '/imports/api/users/methods/linkWithPatreon.js'
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import addEmail from '/imports/api/users/methods/addEmail.js';
import removeEmail from '/imports/api/users/methods/removeEmail.js';
export default {
meteor: {
@@ -216,6 +268,14 @@
linkPatreonError: '',
updatePatreonError: '',
updatePatreonLoading: false,
// Add email
showEmailInput: false,
addEmailLoading: false,
inputEmail: '',
addEmailError: undefined,
// Remove email
removeEmailLoading: undefined,
removeEmailError: undefined,
}},
computed: {
entitledCents(){
@@ -233,6 +293,33 @@
elementId: 'username',
});
},
clearEmailInput(){
this.showEmailInput = false;
this.addEmailError = undefined;
this.inputEmail = '';
},
addEmail(){
this.addEmailLoading = true;
addEmail.call({email: this.inputEmail}, error => {
this.addEmailError = error && error.message;
this.addEmailLoading = false;
if (!error){
this.showEmailInput = false;
this.inputEmail = '';
}
});
},
removeEmail(address){
this.removeEmailLoading = address;
removeEmail.call({email: address}, error => {
this.removeEmailError = error && error.message;
this.removeEmailLoading = undefined;
if (!error){
this.showEmailInput = false;
this.inputEmail = '';
}
});
},
signOut(){
Meteor.logout();
router.push('/');

View File

@@ -0,0 +1,30 @@
<template lang="html">
<div>
<v-layout
column
align-center
justify-center
>
<h2 style="margin: 48px 28px 16px">
Email Verification Error
</h2>
<h3>
{{ error.reason || error.message || error }}
</h3>
</v-layout>
</div>
</template>
<script lang="js">
export default {
props: {
error: {
type: [Object, String],
default: '',
}
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,24 @@
<template lang="html">
<div>
<v-layout
column
align-center
justify-center
>
<h2 style="margin: 48px 28px 16px">
Email Verified
</h2>
<h3>
Your email address has been verified
</h3>
</v-layout>
</div>
</template>
<script lang="js">
export default {
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,139 @@
<template>
<div>
<v-form
ref="form"
class="mt-4"
>
<v-layout
column
align-center
>
<v-img
src="crown-dice-logo-cropped-transparent.png"
width="120px"
class="ma-3"
/>
<template v-if="token">
<v-text-field
v-model="password"
type="password"
label="New Password"
:rules="passwordRules"
class="ma-2"
outlined
required
@keyup.enter="submit"
/>
<v-text-field
v-model="password2"
type="password"
label="Password Again"
:rules="password2Rules"
class="ma-2"
outlined
required
@keyup.enter="submit"
/>
</template>
<v-text-field
v-else
v-model="email"
type="text"
label="Email"
:rules="emailRules"
class="ma-2"
outlined
required
@keyup.enter="submit"
/>
<v-expand-transition>
<v-alert
v-if="error"
type="error"
>
{{ error }}
</v-alert>
</v-expand-transition>
<v-expand-transition>
<v-alert
v-if="info"
type="info"
>
{{ info }}
</v-alert>
</v-expand-transition>
<v-layout>
<v-btn
:disabled="!valid"
color="accent"
@click="submit"
>
Reset Password
</v-btn>
</v-layout>
</v-layout>
</v-form>
</div>
</template>
<script lang="js">
export default {
data() {
return {
valid: true,
submitLoading: false,
email: '',
emailRules: [
v => !!v || 'E-mail is required',
v => /.+@.+/.test(v) || 'E-mail must be valid',
],
password: '',
passwordRules: [
v => !!v || 'Password is required',
],
password2: '',
password2Rules: [
v => !!v || 'Password is required',
v => v == this.password || 'Passwords don\'t match',
],
error: '',
info: '',
}
},
computed: {
token(){
return this.$route.params.token;
},
},
methods: {
submit () {
if (this.$refs.form.validate()) {
if (this.token){
this.submitLoading = true;
Accounts.resetPassword(this.token, this.password, error => {
this.submitLoading = false;
this.error = error && error.message;
this.info = '';
if (!error){
this.$router.push('/characterList');
}
});
} else {
this.submitLoading = true;
Accounts.forgotPassword({email: this.email}, error => {
this.submitLoading = false;
this.error = error && error.message;
this.info = '';
if (!error){
this.info = `Password reset link sent to ${this.email}`;
this.email = '';
this.valid = true;
}
});
}
}
},
},
}
</script>

View File

@@ -33,7 +33,10 @@
required
@keyup.enter="submit"
/>
<v-btn text>
<v-btn
text
to="/reset-password"
>
Reset Password
</v-btn>
<div class="error--text">
@@ -50,7 +53,7 @@
</v-btn>
<v-btn
color="accent"
:to="{ name: 'register', query: { redirect: this.$route.query.redirect} }"
:to="{ name: 'register', query: { redirect: $route.query.redirect} }"
class="ma-2"
>
Register

View File

@@ -19,6 +19,9 @@ const Feedback = () => import('/imports/ui/pages/Feedback.vue' );
const Account = () => import('/imports/ui/pages/Account.vue' );
const InviteSuccess = () => import('/imports/ui/pages/InviteSuccess.vue' );
const InviteError = () => import('/imports/ui/pages/InviteError.vue' );
const EmailVerificationSuccess = () => import('/imports/ui/pages/EmailVerificationSuccess.vue' );
const EmailVerificationError = () => import('/imports/ui/pages/EmailVerificationError.vue' );
const ResetPassword = () => import('/imports/ui/pages/ResetPassword.vue' );
const NotImplemented = () => import('/imports/ui/pages/NotImplemented.vue');
const PatreonLevelTooLow = () => import('/imports/ui/pages/PatreonLevelTooLow.vue');
const Tabletops = () => import('/imports/ui/pages/Tabletops.vue');
@@ -94,6 +97,17 @@ function claimInvite(to, from, next){
});
}
function verifyEmail(to, from, next){
const token = to.params.token;
Accounts.verifyEmail(token, error => {
if (error){
next({name: 'emailVerificationError', params: {error}});
} else {
next('/email-verification-success')
}
});
}
RouterFactory.configure(router => {
router.addRoutes([{
path: '/',
@@ -213,6 +227,9 @@ RouterFactory.configure(router => {
},{
path: '/invite/:inviteToken',
beforeEnter: claimInvite,
},{
path: '/verify-email/:token',
beforeEnter: verifyEmail,
},{
name: 'inviteError',
path: '/invite-error',
@@ -233,6 +250,34 @@ RouterFactory.configure(router => {
meta: {
title: 'Invite Success',
},
},{
name: 'emailVerificationError',
path: '/email-verification-error',
components: {
default: EmailVerificationError,
},
props: {
default: true,
},
meta: {
title: 'Email Verification Error',
},
},{
path: '/email-verification-success',
components: {
default: EmailVerificationSuccess,
},
meta: {
title: 'Email Verification Success',
},
},{
path: '/reset-password/:token?',
components: {
default: ResetPassword,
},
meta: {
title: 'Reset Password',
},
},{
path: '/patreon-level-too-low',
components: {

View File

@@ -48,7 +48,7 @@
<script lang="js">
import TIERS, { getUserTier } from '/imports/api/users/patreon/tiers.js';
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import linkWithPatreon from '/imports/api/users/linkWithPatreon.js'
import linkWithPatreon from '/imports/api/users/methods/linkWithPatreon.js'
export default {
components: {

View File

@@ -1,6 +1,8 @@
import '/imports/api/simpleSchemaConfig.js';
import '/imports/server/config/accountsEmailConfig.js';
import '/imports/server/config/SimpleRestConfig.js';
import '/imports/server/config/limitLoginTokens.js';
import '/imports/server/rest/index.js';
import '/imports/server/config/accountsEmailConfig.js';
import '/imports/server/config/simpleSchemaDebug.js';
import '/imports/server/config/SyncedCronConfig.js';
import '/imports/server/publications/index.js';