mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
Reset token (#786)
feat: Add Reset token functionality This allows admin users to create a reset token for other users. Thus allowing resetting their password. Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com> fixes: #778
This commit is contained in:
parent
23ea21babf
commit
b55c85783b
17
package.json
17
package.json
@ -72,6 +72,7 @@
|
|||||||
"connect-session-knex": "^2.0.0",
|
"connect-session-knex": "^2.0.0",
|
||||||
"cookie-parser": "^1.4.4",
|
"cookie-parser": "^1.4.4",
|
||||||
"cookie-session": "^2.0.0-rc.1",
|
"cookie-session": "^2.0.0-rc.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"db-migrate": "0.11.11",
|
"db-migrate": "0.11.11",
|
||||||
"db-migrate-pg": "^1.2.2",
|
"db-migrate-pg": "^1.2.2",
|
||||||
"db-migrate-shared": "^1.2.0",
|
"db-migrate-shared": "^1.2.0",
|
||||||
@ -108,22 +109,22 @@
|
|||||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||||
"@passport-next/passport": "^3.1.0",
|
"@passport-next/passport": "^3.1.0",
|
||||||
"@passport-next/passport-google-oauth2": "^1.0.0",
|
"@passport-next/passport-google-oauth2": "^1.0.0",
|
||||||
|
"@types/bcrypt": "^3.0.0",
|
||||||
"@types/express": "^4.17.11",
|
"@types/express": "^4.17.11",
|
||||||
"@types/node": "^14.14.37",
|
"@types/node": "^14.14.37",
|
||||||
"@types/nodemailer": "^6.4.1",
|
"@types/nodemailer": "^6.4.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.15.2",
|
|
||||||
"@typescript-eslint/parser": "^4.15.2",
|
|
||||||
"@types/bcrypt": "^3.0.0",
|
|
||||||
"@types/owasp-password-strength-test": "^1.3.0",
|
"@types/owasp-password-strength-test": "^1.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
||||||
|
"@typescript-eslint/parser": "^4.22.0",
|
||||||
"ava": "^3.7.0",
|
"ava": "^3.7.0",
|
||||||
"copyfiles": "^2.4.1",
|
"copyfiles": "^2.4.1",
|
||||||
"coveralls": "^3.1.0",
|
"coveralls": "^3.1.0",
|
||||||
"eslint": "^6.8.0",
|
"eslint": "^6.8.0",
|
||||||
"eslint-config-airbnb-base": "^14.1.0",
|
"eslint-config-airbnb-base": "^14.2.1",
|
||||||
"eslint-config-airbnb-typescript": "^12.3.1",
|
"eslint-config-airbnb-typescript": "^12.3.1",
|
||||||
"eslint-config-prettier": "^6.10.1",
|
"eslint-config-prettier": "^8.1.0",
|
||||||
"eslint-plugin-import": "^2.20.2",
|
"eslint-plugin-import": "^2.22.1",
|
||||||
"eslint-plugin-prettier": "^3.1.3",
|
"eslint-plugin-prettier": "^3.3.1",
|
||||||
"faker": "^5.3.1",
|
"faker": "^5.3.1",
|
||||||
"fetch-mock": "^9.11.0",
|
"fetch-mock": "^9.11.0",
|
||||||
"husky": "^4.2.3",
|
"husky": "^4.2.3",
|
||||||
@ -141,7 +142,7 @@
|
|||||||
"supertest": "^5.0.0",
|
"supertest": "^5.0.0",
|
||||||
"ts-node": "^9.1.1",
|
"ts-node": "^9.1.1",
|
||||||
"tsc-watch": "^4.2.9",
|
"tsc-watch": "^4.2.9",
|
||||||
"typescript": "^4.1.5"
|
"typescript": "^4.2.4"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"set-value": "^2.0.1",
|
"set-value": "^2.0.1",
|
||||||
|
@ -38,9 +38,10 @@ class Addon {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
if (retries > 0 && retryCodes.includes(res.status)) {
|
if (retries > 0 && retryCodes.includes(res.status)) {
|
||||||
setTimeout(() => {
|
setTimeout(
|
||||||
return this.fetchRetry(url, options, retries - 1, backoff * 2);
|
() => this.fetchRetry(url, options, retries - 1, backoff * 2),
|
||||||
}, backoff);
|
backoff,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import apiTokenMiddleware from './middleware/api-token-middleware';
|
|||||||
import { AuthenticationType } from './types/core';
|
import { AuthenticationType } from './types/core';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
const compression = require('compression');
|
const compression = require('compression');
|
||||||
const favicon = require('serve-favicon');
|
const favicon = require('serve-favicon');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
@ -33,6 +33,10 @@ module.exports = function(config, services = {}) {
|
|||||||
config.preHook(app, config, services);
|
config.preHook(app, config, services);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
app.use(cors());
|
||||||
|
}
|
||||||
|
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(express.json({ strict: false }));
|
app.use(express.json({ strict: false }));
|
||||||
|
@ -272,13 +272,11 @@ class FeatureToggleStore {
|
|||||||
const rows = await this.db(FEATURE_TAG_TABLE).select(
|
const rows = await this.db(FEATURE_TAG_TABLE).select(
|
||||||
FEATURE_TAG_COLUMNS,
|
FEATURE_TAG_COLUMNS,
|
||||||
);
|
);
|
||||||
return rows.map(row => {
|
return rows.map(row => ({
|
||||||
return {
|
featureName: row.feature_name,
|
||||||
featureName: row.feature_name,
|
tagType: row.tag_type,
|
||||||
tagType: row.tag_type,
|
tagValue: row.tag_value,
|
||||||
tagValue: row.tag_value,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async dropFeatureTags() {
|
async dropFeatureTags() {
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import { AccessStore } from './access-store';
|
import { AccessStore } from './access-store';
|
||||||
|
import { ResetTokenStore } from './reset-token-store';
|
||||||
|
|
||||||
const { createDb } = require('./db-pool');
|
const { createDb } = require('./db-pool');
|
||||||
const EventStore = require('./event-store');
|
const EventStore = require('./event-store');
|
||||||
@ -57,5 +58,6 @@ module.exports.createStores = (config, eventBus) => {
|
|||||||
addonStore: new AddonStore(db, eventBus, getLogger),
|
addonStore: new AddonStore(db, eventBus, getLogger),
|
||||||
accessStore: new AccessStore(db, eventBus, getLogger),
|
accessStore: new AccessStore(db, eventBus, getLogger),
|
||||||
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
|
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
|
||||||
|
resetTokenStore: new ResetTokenStore(db, eventBus, getLogger),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
131
src/lib/db/reset-token-store.ts
Normal file
131
src/lib/db/reset-token-store.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import metricsHelper from '../metrics-helper';
|
||||||
|
import { DB_TIME } from '../events';
|
||||||
|
import { Logger, LogProvider } from '../logger';
|
||||||
|
import NotFoundError from '../error/notfound-error';
|
||||||
|
|
||||||
|
const TABLE = 'reset_tokens';
|
||||||
|
|
||||||
|
interface IResetTokenTable {
|
||||||
|
reset_token: string;
|
||||||
|
user_id: number;
|
||||||
|
expires_at: Date;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string;
|
||||||
|
used_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResetTokenCreate {
|
||||||
|
reset_token: string;
|
||||||
|
user_id: number;
|
||||||
|
expires_at: Date;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResetToken {
|
||||||
|
userId: number;
|
||||||
|
token: string;
|
||||||
|
createdBy: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
usedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResetQuery {
|
||||||
|
userId: number;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResetTokenQuery {
|
||||||
|
user_id: number;
|
||||||
|
reset_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowToResetToken = (row: IResetTokenTable): IResetToken => {
|
||||||
|
return {
|
||||||
|
userId: row.user_id,
|
||||||
|
token: row.reset_token,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
usedAt: row.used_at,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ResetTokenStore {
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
private timer: Function;
|
||||||
|
|
||||||
|
private db: Knex;
|
||||||
|
|
||||||
|
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||||
|
this.db = db;
|
||||||
|
this.logger = getLogger('db/reset-token-store.js');
|
||||||
|
this.timer = (action: string) =>
|
||||||
|
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||||
|
store: 'reset-tokens',
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActive(token: string): Promise<IResetToken> {
|
||||||
|
const row = await this.db<IResetTokenTable>(TABLE)
|
||||||
|
.where({ reset_token: token })
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.first();
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundError('Could not find an active token');
|
||||||
|
}
|
||||||
|
return rowToResetToken(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insert(newToken: IResetTokenCreate): Promise<IResetToken> {
|
||||||
|
const [row] = await this.db<IResetTokenTable>(TABLE)
|
||||||
|
.insert(newToken)
|
||||||
|
.returning(['created_at']);
|
||||||
|
return {
|
||||||
|
userId: newToken.user_id,
|
||||||
|
token: newToken.reset_token,
|
||||||
|
expiresAt: newToken.expires_at,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
createdBy: newToken.created_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async useToken(token: IResetQuery): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.db<IResetTokenTable>(TABLE)
|
||||||
|
.update({ used_at: new Date() })
|
||||||
|
.where({ reset_token: token.token, user_id: token.userId });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete({ reset_token }: IResetTokenQuery): Promise<void> {
|
||||||
|
return this.db(TABLE)
|
||||||
|
.where(reset_token)
|
||||||
|
.del();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAll(): Promise<void> {
|
||||||
|
return this.db(TABLE).del();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExpired(): Promise<void> {
|
||||||
|
return this.db(TABLE)
|
||||||
|
.where('expires_at', '<', new Date())
|
||||||
|
.del();
|
||||||
|
}
|
||||||
|
|
||||||
|
async expireExistingTokensForUser(user_id: number): Promise<void> {
|
||||||
|
await this.db<IResetTokenTable>(TABLE)
|
||||||
|
.where({ user_id })
|
||||||
|
.update({
|
||||||
|
expires_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
24
src/lib/error/invalid-token-error.ts
Normal file
24
src/lib/error/invalid-token-error.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
class InvalidTokenError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.message = 'Token was not valid';
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): any {
|
||||||
|
const obj = {
|
||||||
|
isJoi: true,
|
||||||
|
name: this.constructor.name,
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
message: this.message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InvalidTokenError;
|
||||||
|
module.exports = InvalidTokenError;
|
29
src/lib/error/owasp-validation-error.ts
Normal file
29
src/lib/error/owasp-validation-error.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { TestResult } from 'owasp-password-strength-test';
|
||||||
|
|
||||||
|
class OwaspValidationError extends Error {
|
||||||
|
private errors: string[];
|
||||||
|
|
||||||
|
constructor(testResult: TestResult) {
|
||||||
|
super(testResult.errors[0]);
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.errors = testResult.errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): any {
|
||||||
|
const obj = {
|
||||||
|
isJoi: true,
|
||||||
|
name: this.constructor.name,
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
validationErrors: this.errors,
|
||||||
|
message: this.errors[0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OwaspValidationError;
|
||||||
|
module.exports = OwaspValidationError;
|
24
src/lib/error/used-token-error.ts
Normal file
24
src/lib/error/used-token-error.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
class UsedTokenError extends Error {
|
||||||
|
constructor(usedAt: Date) {
|
||||||
|
super();
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.message = `Token was already used at ${usedAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): any {
|
||||||
|
const obj = {
|
||||||
|
isJoi: true,
|
||||||
|
name: this.constructor.name,
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
message: this.message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsedTokenError;
|
||||||
|
module.exports = UsedTokenError;
|
@ -101,6 +101,7 @@ test('should not add user if disabled', async t => {
|
|||||||
enableApiToken: false,
|
enableApiToken: false,
|
||||||
createAdminUser: false,
|
createAdminUser: false,
|
||||||
},
|
},
|
||||||
|
unleashUrl: 'http://localhost:4242',
|
||||||
};
|
};
|
||||||
|
|
||||||
const func = apiTokenMiddleware(disabledConfig, { apiTokenService });
|
const func = apiTokenMiddleware(disabledConfig, { apiTokenService });
|
||||||
|
@ -11,26 +11,20 @@ const mockRequest = contentType => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const returns415 = t => {
|
const returns415 = t => ({
|
||||||
return {
|
status: code => {
|
||||||
status: code => {
|
t.is(415, code);
|
||||||
t.is(415, code);
|
return {
|
||||||
return {
|
end: t.pass,
|
||||||
end: t.pass,
|
};
|
||||||
};
|
},
|
||||||
},
|
});
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectNoCall = t => {
|
const expectNoCall = t => ({
|
||||||
return {
|
status: () => ({
|
||||||
status: () => {
|
end: t.fail,
|
||||||
return {
|
}),
|
||||||
end: t.fail,
|
});
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
test('Content-type middleware should by default only support application/json', t => {
|
test('Content-type middleware should by default only support application/json', t => {
|
||||||
const middleware = requireContentType();
|
const middleware = requireContentType();
|
||||||
|
@ -3,13 +3,12 @@ const AuthenticationRequired = require('../authentication-required');
|
|||||||
function ossAuthHook(app, config) {
|
function ossAuthHook(app, config) {
|
||||||
const { baseUriPath } = config;
|
const { baseUriPath } = config;
|
||||||
|
|
||||||
const generateAuthResponse = async () => {
|
const generateAuthResponse = async () =>
|
||||||
return new AuthenticationRequired({
|
new AuthenticationRequired({
|
||||||
type: 'password',
|
type: 'password',
|
||||||
path: `${baseUriPath}/auth/simple/login`,
|
path: `${baseUriPath}/auth/simple/login`,
|
||||||
message: 'You must sign in order to use Unleash',
|
message: 'You must sign in order to use Unleash',
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
app.use(`${baseUriPath}/api`, async (req, res, next) => {
|
app.use(`${baseUriPath}/api`, async (req, res, next) => {
|
||||||
if (req.session && req.session.user) {
|
if (req.session && req.session.user) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import { ADMIN } from '../../permissions';
|
||||||
import { TemplateFormat } from '../../services/email-service';
|
import { TemplateFormat } from '../../services/email-service';
|
||||||
import { handleErrors } from './util';
|
import { handleErrors } from './util';
|
||||||
import { ADMIN } from '../../permissions';
|
|
||||||
|
|
||||||
const Controller = require('../controller');
|
const Controller = require('../controller');
|
||||||
|
|
||||||
|
@ -2,8 +2,11 @@ import Controller from '../controller';
|
|||||||
import { ADMIN } from '../../permissions';
|
import { ADMIN } from '../../permissions';
|
||||||
import { IUnleashConfig } from '../../types/core';
|
import { IUnleashConfig } from '../../types/core';
|
||||||
import UserService from '../../services/user-service';
|
import UserService from '../../services/user-service';
|
||||||
import { AccessService, RoleName } from '../../services/access-service';
|
import { AccessService } from '../../services/access-service';
|
||||||
import { Logger } from '../../logger';
|
import { Logger } from '../../logger';
|
||||||
|
import { handleErrors } from './util';
|
||||||
|
|
||||||
|
const getCreatorUsernameOrPassword = req => req.user.username || req.user.email;
|
||||||
|
|
||||||
class UserAdminController extends Controller {
|
class UserAdminController extends Controller {
|
||||||
private userService: UserService;
|
private userService: UserService;
|
||||||
@ -25,6 +28,21 @@ class UserAdminController extends Controller {
|
|||||||
this.put('/:id', this.updateUser, ADMIN);
|
this.put('/:id', this.updateUser, ADMIN);
|
||||||
this.post('/:id/change-password', this.changePassword, ADMIN);
|
this.post('/:id/change-password', this.changePassword, ADMIN);
|
||||||
this.delete('/:id', this.deleteUser, ADMIN);
|
this.delete('/:id', this.deleteUser, ADMIN);
|
||||||
|
this.post('/reset-password', this.resetPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(req, res) {
|
||||||
|
try {
|
||||||
|
const requester = getCreatorUsernameOrPassword(req);
|
||||||
|
const receiver = req.body.id;
|
||||||
|
const resetPasswordUrl = await this.userService.createResetPasswordEmail(
|
||||||
|
receiver,
|
||||||
|
requester,
|
||||||
|
);
|
||||||
|
res.json({ resetPasswordUrl });
|
||||||
|
} catch (e) {
|
||||||
|
handleErrors(res, this.logger, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsers(req, res) {
|
async getUsers(req, res) {
|
||||||
|
@ -5,7 +5,7 @@ const Controller = require('../controller');
|
|||||||
class UserController extends Controller {
|
class UserController extends Controller {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
super(config);
|
super(config);
|
||||||
|
this.logger = config.getLogger('admin-api/user.js');
|
||||||
this.get('/', this.getUser);
|
this.get('/', this.getUser);
|
||||||
this.get('/logout', this.logout);
|
this.get('/logout', this.logout);
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,21 @@ const handleErrors = (res, logger, error) => {
|
|||||||
.status(409)
|
.status(409)
|
||||||
.json(error)
|
.json(error)
|
||||||
.end();
|
.end();
|
||||||
|
case 'UsedTokenError':
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json(error)
|
||||||
|
.end();
|
||||||
|
case 'InvalidTokenError':
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json(error)
|
||||||
|
.end();
|
||||||
|
case 'OwaspValidationError':
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json(error)
|
||||||
|
.end();
|
||||||
default:
|
default:
|
||||||
logger.error('Server failed executing request', error);
|
logger.error('Server failed executing request', error);
|
||||||
return res.status(500).end();
|
return res.status(500).end();
|
||||||
|
89
src/lib/routes/auth/reset-password-controller.ts
Normal file
89
src/lib/routes/auth/reset-password-controller.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import Controller from '../controller';
|
||||||
|
import UserService from '../../services/user-service';
|
||||||
|
import { IUnleashConfig } from '../../types/core';
|
||||||
|
import { Logger } from '../../logger';
|
||||||
|
import { handleErrors } from '../admin-api/util';
|
||||||
|
|
||||||
|
interface IServices {
|
||||||
|
userService: UserService;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IValidateQuery {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IChangePasswordBody {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNLEASH = 'Unleash';
|
||||||
|
class ResetPasswordController extends Controller {
|
||||||
|
userService: UserService;
|
||||||
|
|
||||||
|
logger: Logger;
|
||||||
|
|
||||||
|
constructor(config: IUnleashConfig, { userService }: IServices) {
|
||||||
|
super(config);
|
||||||
|
this.logger = config.getLogger(
|
||||||
|
'lib/routes/auth/reset-password-controller.ts',
|
||||||
|
);
|
||||||
|
this.userService = userService;
|
||||||
|
this.get('/validate', this.validateToken);
|
||||||
|
this.post('/password', this.changePassword);
|
||||||
|
this.post('/validate-password', this.validatePassword);
|
||||||
|
this.post('/password-email', this.sendResetPasswordEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendResetPasswordEmail(req: Request, res: Response): Promise<void> {
|
||||||
|
const { email } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.userService.createResetPasswordEmail(email, UNLEASH);
|
||||||
|
res.status(200).end();
|
||||||
|
} catch (e) {
|
||||||
|
handleErrors(res, this.logger, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validatePassword(req: Request, res: Response): Promise<void> {
|
||||||
|
const { password } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.userService.validatePassword(password);
|
||||||
|
res.status(200).end();
|
||||||
|
} catch (e) {
|
||||||
|
handleErrors(res, this.logger, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateToken(
|
||||||
|
req: Request<unknown, unknown, unknown, IValidateQuery>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { token } = req.query;
|
||||||
|
try {
|
||||||
|
const user = await this.userService.getUserForToken(token);
|
||||||
|
res.status(200).json(user);
|
||||||
|
} catch (e) {
|
||||||
|
handleErrors(res, this.logger, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(
|
||||||
|
req: Request<unknown, unknown, IChangePasswordBody, unknown>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { token, password } = req.body;
|
||||||
|
try {
|
||||||
|
await this.userService.resetPassword(token, password);
|
||||||
|
res.status(200).end();
|
||||||
|
} catch (e) {
|
||||||
|
handleErrors(res, this.logger, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResetPasswordController;
|
||||||
|
module.exports = ResetPasswordController;
|
@ -4,19 +4,17 @@ const { Router } = require('express');
|
|||||||
const NoAccessError = require('../error/no-access-error');
|
const NoAccessError = require('../error/no-access-error');
|
||||||
const requireContentType = require('../middleware/content_type_checker');
|
const requireContentType = require('../middleware/content_type_checker');
|
||||||
|
|
||||||
const checkPermission = permission => {
|
const checkPermission = permission => async (req, res, next) => {
|
||||||
return async (req, res, next) => {
|
if (!permission) {
|
||||||
if (!permission) {
|
return next();
|
||||||
return next();
|
}
|
||||||
}
|
if (req.checkRbac && (await req.checkRbac(permission))) {
|
||||||
if (req.checkRbac && (await req.checkRbac(permission))) {
|
return next();
|
||||||
return next();
|
}
|
||||||
}
|
return res
|
||||||
return res
|
.status(403)
|
||||||
.status(403)
|
.json(new NoAccessError(permission))
|
||||||
.json(new NoAccessError(permission))
|
.end();
|
||||||
.end();
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { BackstageController } from './backstage';
|
import { BackstageController } from './backstage';
|
||||||
|
import ResetPasswordController from './auth/reset-password-controller';
|
||||||
|
|
||||||
const AdminApi = require('./admin-api');
|
const AdminApi = require('./admin-api');
|
||||||
const ClientApi = require('./client-api');
|
const ClientApi = require('./client-api');
|
||||||
@ -18,6 +19,10 @@ class IndexRouter extends Controller {
|
|||||||
'/auth/simple',
|
'/auth/simple',
|
||||||
new SimplePasswordProvider(config, services).router,
|
new SimplePasswordProvider(config, services).router,
|
||||||
);
|
);
|
||||||
|
this.use(
|
||||||
|
'/auth/reset',
|
||||||
|
new ResetPasswordController(config, services).router,
|
||||||
|
);
|
||||||
this.get(api.uri, this.index);
|
this.get(api.uri, this.index);
|
||||||
this.use(api.links.admin.uri, new AdminApi(config, services).router);
|
this.use(api.links.admin.uri, new AdminApi(config, services).router);
|
||||||
this.use(api.links.client.uri, new ClientApi(config, services).router);
|
this.use(api.links.client.uri, new ClientApi(config, services).router);
|
||||||
|
@ -85,9 +85,9 @@ async function createApp(options) {
|
|||||||
const stop = () => {
|
const stop = () => {
|
||||||
logger.info('Shutting down Unleash...');
|
logger.info('Shutting down Unleash...');
|
||||||
|
|
||||||
return closeServer({ server, metricsMonitor }).then(() => {
|
return closeServer({ server, metricsMonitor }).then(() =>
|
||||||
return destroyDatabase(stores);
|
destroyDatabase(stores),
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
server.keepAliveTimeout = options.keepAliveTimeout;
|
server.keepAliveTimeout = options.keepAliveTimeout;
|
||||||
|
@ -88,13 +88,11 @@ module.exports = class ClientMetricsService {
|
|||||||
if (this.clientAppStore) {
|
if (this.clientAppStore) {
|
||||||
const appsToAnnounce = await this.clientAppStore.setUnannouncedToAnnounced();
|
const appsToAnnounce = await this.clientAppStore.setUnannouncedToAnnounced();
|
||||||
if (appsToAnnounce.length > 0) {
|
if (appsToAnnounce.length > 0) {
|
||||||
const events = appsToAnnounce.map(app => {
|
const events = appsToAnnounce.map(app => ({
|
||||||
return {
|
type: APPLICATION_CREATED,
|
||||||
type: APPLICATION_CREATED,
|
createdBy: app.createdBy || 'unknown',
|
||||||
createdBy: app.createdBy || 'unknown',
|
data: app,
|
||||||
data: app,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
await this.eventStore.batchStore(events);
|
await this.eventStore.batchStore(events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,42 +5,43 @@ import noLoggerProvider from '../../test/fixtures/no-logger';
|
|||||||
test('Can send reset email', async t => {
|
test('Can send reset email', async t => {
|
||||||
const emailService = new EmailService(
|
const emailService = new EmailService(
|
||||||
{
|
{
|
||||||
host: '',
|
host: 'test',
|
||||||
port: 587,
|
port: 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
auth: {
|
auth: {
|
||||||
user: '',
|
user: '',
|
||||||
password: '',
|
pass: '',
|
||||||
},
|
},
|
||||||
sender: 'noreply@getunleash.ai',
|
sender: 'noreply@getunleash.ai',
|
||||||
transporterType: TransporterType.JSON,
|
transporterType: TransporterType.JSON,
|
||||||
},
|
},
|
||||||
noLoggerProvider,
|
noLoggerProvider,
|
||||||
);
|
);
|
||||||
|
const resetLinkUrl =
|
||||||
|
'https://unleash-hosted.com/reset-password?token=$2b$10$M06Ysso6KL4ueH/xR6rdSuY5GSymdIwmIkEUJMRkB.Qn26r5Gi5vW';
|
||||||
|
|
||||||
const content = await emailService.sendResetMail(
|
const content = await emailService.sendResetMail(
|
||||||
'Some username',
|
'Some username',
|
||||||
'test@test.com',
|
'test@resetLinkUrl.com',
|
||||||
'abc123',
|
resetLinkUrl,
|
||||||
);
|
);
|
||||||
const message = JSON.parse(content.message);
|
const message = JSON.parse(content.message);
|
||||||
t.is(message.from.address, 'noreply@getunleash.ai');
|
t.is(message.from.address, 'noreply@getunleash.ai');
|
||||||
t.is(message.subject, 'Someone has requested to reset your password');
|
t.is(message.subject, 'Unleash - Reset your password');
|
||||||
t.true(message.html.indexOf('Some username') > 0);
|
t.true(message.html.includes(resetLinkUrl));
|
||||||
t.true(message.text.indexOf('Some username') > 0);
|
t.true(message.text.includes(resetLinkUrl));
|
||||||
t.true(message.html.indexOf('abc123') > 0);
|
|
||||||
t.true(message.text.indexOf('abc123') > 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Can send welcome mail', async t => {
|
test('Can send welcome mail', async t => {
|
||||||
const emailService = new EmailService(
|
const emailService = new EmailService(
|
||||||
{
|
{
|
||||||
host: '',
|
host: 'test',
|
||||||
port: 9999,
|
port: 9999,
|
||||||
secure: false,
|
secure: false,
|
||||||
sender: 'noreply@getunleash.ai',
|
sender: 'noreply@getunleash.ai',
|
||||||
auth: {
|
auth: {
|
||||||
user: '',
|
user: '',
|
||||||
password: '',
|
pass: '',
|
||||||
},
|
},
|
||||||
transporterType: TransporterType.JSON,
|
transporterType: TransporterType.JSON,
|
||||||
},
|
},
|
||||||
@ -53,8 +54,5 @@ test('Can send welcome mail', async t => {
|
|||||||
);
|
);
|
||||||
const message = JSON.parse(content.message);
|
const message = JSON.parse(content.message);
|
||||||
t.is(message.from.address, 'noreply@getunleash.ai');
|
t.is(message.from.address, 'noreply@getunleash.ai');
|
||||||
t.is(
|
t.is(message.subject, 'Welcome to Unleash');
|
||||||
message.subject,
|
|
||||||
'Welcome to Unleash. Please configure your password.',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
@ -7,7 +7,7 @@ import NotFoundError from '../error/notfound-error';
|
|||||||
|
|
||||||
export interface IAuthOptions {
|
export interface IAuthOptions {
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
pass: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TemplateFormat {
|
export enum TemplateFormat {
|
||||||
@ -29,9 +29,8 @@ export interface IEmailOptions {
|
|||||||
transporterType: TransporterType;
|
transporterType: TransporterType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESET_MAIL_SUBJECT = 'Someone has requested to reset your password';
|
const RESET_MAIL_SUBJECT = 'Unleash - Reset your password';
|
||||||
const GETTING_STARTED_SUBJECT =
|
const GETTING_STARTED_SUBJECT = 'Welcome to Unleash';
|
||||||
'Welcome to Unleash. Please configure your password.';
|
|
||||||
|
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -42,12 +41,12 @@ export class EmailService {
|
|||||||
|
|
||||||
constructor(email: IEmailOptions | undefined, getLogger: LogProvider) {
|
constructor(email: IEmailOptions | undefined, getLogger: LogProvider) {
|
||||||
this.logger = getLogger('services/email-service.ts');
|
this.logger = getLogger('services/email-service.ts');
|
||||||
if (email) {
|
if (email && email.host) {
|
||||||
this.sender = email.sender;
|
this.sender = email.sender;
|
||||||
if (email.transporterType === TransporterType.JSON) {
|
if (email.transporterType === TransporterType.JSON) {
|
||||||
this.mailer = createTransport({ jsonTransport: true });
|
this.mailer = createTransport({ jsonTransport: true });
|
||||||
} else {
|
} else {
|
||||||
const connectionString = `${email.auth.user}:${email.auth.password}@${email.host}:${email.port}`;
|
const connectionString = `${email.auth.user}:${email.auth.pass}@${email.host}:${email.port}`;
|
||||||
this.mailer = email.secure
|
this.mailer = email.secure
|
||||||
? createTransport(`smtps://${connectionString}`)
|
? createTransport(`smtps://${connectionString}`)
|
||||||
: createTransport(`smtp://${connectionString}`);
|
: createTransport(`smtp://${connectionString}`);
|
||||||
|
@ -12,6 +12,7 @@ const { EmailService } = require('./email-service');
|
|||||||
const { AccessService } = require('./access-service');
|
const { AccessService } = require('./access-service');
|
||||||
const { ApiTokenService } = require('./api-token-service');
|
const { ApiTokenService } = require('./api-token-service');
|
||||||
const UserService = require('./user-service');
|
const UserService = require('./user-service');
|
||||||
|
const ResetTokenService = require('./reset-token-service');
|
||||||
|
|
||||||
module.exports.createServices = (stores, config) => {
|
module.exports.createServices = (stores, config) => {
|
||||||
const accessService = new AccessService(stores, config);
|
const accessService = new AccessService(stores, config);
|
||||||
@ -31,7 +32,12 @@ module.exports.createServices = (stores, config) => {
|
|||||||
const versionService = new VersionService(stores, config);
|
const versionService = new VersionService(stores, config);
|
||||||
const apiTokenService = new ApiTokenService(stores, config);
|
const apiTokenService = new ApiTokenService(stores, config);
|
||||||
const emailService = new EmailService(config.email, config.getLogger);
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
const userService = new UserService(stores, config, accessService);
|
const resetTokenService = new ResetTokenService(stores, config);
|
||||||
|
const userService = new UserService(stores, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessService,
|
accessService,
|
||||||
@ -48,5 +54,6 @@ module.exports.createServices = (stores, config) => {
|
|||||||
apiTokenService,
|
apiTokenService,
|
||||||
emailService,
|
emailService,
|
||||||
userService,
|
userService,
|
||||||
|
resetTokenService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
110
src/lib/services/reset-token-service.ts
Normal file
110
src/lib/services/reset-token-service.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { URL } from 'url';
|
||||||
|
import {
|
||||||
|
ResetTokenStore,
|
||||||
|
IResetToken,
|
||||||
|
IResetQuery,
|
||||||
|
} from '../db/reset-token-store';
|
||||||
|
import { Logger } from '../logger';
|
||||||
|
import UserStore from '../db/user-store';
|
||||||
|
import UsedTokenError from '../error/used-token-error';
|
||||||
|
import { IUnleashConfig } from '../types/core';
|
||||||
|
import InvalidTokenError from '../error/invalid-token-error';
|
||||||
|
|
||||||
|
const ONE_DAY = 86_400_000;
|
||||||
|
|
||||||
|
interface IStores {
|
||||||
|
resetTokenStore: ResetTokenStore;
|
||||||
|
userStore: UserStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ResetTokenService {
|
||||||
|
private store: ResetTokenStore;
|
||||||
|
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
private readonly unleashBase: URL;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
stores: IStores,
|
||||||
|
{
|
||||||
|
getLogger,
|
||||||
|
baseUriPath,
|
||||||
|
unleashUrl = 'http://localhost:4242',
|
||||||
|
}: IUnleashConfig,
|
||||||
|
) {
|
||||||
|
this.store = stores.resetTokenStore;
|
||||||
|
this.logger = getLogger('/services/reset-token-service.ts');
|
||||||
|
this.unleashBase = new URL(baseUriPath, unleashUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async useAccessToken(token: IResetQuery): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.isValid(token.token);
|
||||||
|
await this.store.useToken(token);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isValid(token: string): Promise<IResetToken> {
|
||||||
|
let t;
|
||||||
|
try {
|
||||||
|
t = await this.store.getActive(token);
|
||||||
|
if (!t.usedAt) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new InvalidTokenError();
|
||||||
|
}
|
||||||
|
throw new UsedTokenError(t.usedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createResetUrl(
|
||||||
|
forUser: number,
|
||||||
|
creator: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<URL> {
|
||||||
|
const token = await this.createToken(forUser, creator);
|
||||||
|
return Promise.resolve(
|
||||||
|
new URL(`${path}?token=${token.token}`, this.unleashBase),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWelcomeUrl(forUser: number, creator: string): Promise<URL> {
|
||||||
|
const path = '/#/new-user';
|
||||||
|
return this.createResetUrl(forUser, creator, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createResetPasswordUrl(
|
||||||
|
forUser: number,
|
||||||
|
creator: string,
|
||||||
|
): Promise<URL> {
|
||||||
|
const path = '/#/reset-password';
|
||||||
|
return this.createResetUrl(forUser, creator, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createToken(
|
||||||
|
tokenUser: number,
|
||||||
|
creator: string,
|
||||||
|
expiryDelta: number = ONE_DAY,
|
||||||
|
): Promise<IResetToken> {
|
||||||
|
const token = await this.generateToken();
|
||||||
|
const expiry = new Date(Date.now() + expiryDelta);
|
||||||
|
await this.store.expireExistingTokensForUser(tokenUser);
|
||||||
|
return this.store.insert({
|
||||||
|
reset_token: token,
|
||||||
|
user_id: tokenUser,
|
||||||
|
expires_at: expiry,
|
||||||
|
created_by: creator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateToken(): Promise<string> {
|
||||||
|
return bcrypt.hash(crypto.randomBytes(32), 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ResetTokenService;
|
@ -183,13 +183,11 @@ class StateService {
|
|||||||
const importedProjects = await this.projectStore.importProjects(
|
const importedProjects = await this.projectStore.importProjects(
|
||||||
projectsToImport,
|
projectsToImport,
|
||||||
);
|
);
|
||||||
const importedProjectEvents = importedProjects.map(project => {
|
const importedProjectEvents = importedProjects.map(project => ({
|
||||||
return {
|
type: PROJECT_IMPORT,
|
||||||
type: PROJECT_IMPORT,
|
createdBy: userName,
|
||||||
createdBy: userName,
|
data: project,
|
||||||
data: project,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
await this.eventStore.batchStore(importedProjectEvents);
|
await this.eventStore.batchStore(importedProjectEvents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -272,13 +270,11 @@ class StateService {
|
|||||||
const importedFeatureTags = await this.toggleStore.importFeatureTags(
|
const importedFeatureTags = await this.toggleStore.importFeatureTags(
|
||||||
featureTagsToInsert,
|
featureTagsToInsert,
|
||||||
);
|
);
|
||||||
const importedFeatureTagEvents = importedFeatureTags.map(tag => {
|
const importedFeatureTagEvents = importedFeatureTags.map(tag => ({
|
||||||
return {
|
type: FEATURE_TAG_IMPORT,
|
||||||
type: FEATURE_TAG_IMPORT,
|
createdBy: userName,
|
||||||
createdBy: userName,
|
data: tag,
|
||||||
data: tag,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
await this.eventStore.batchStore(importedFeatureTagEvents);
|
await this.eventStore.batchStore(importedFeatureTagEvents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -294,13 +290,11 @@ class StateService {
|
|||||||
);
|
);
|
||||||
if (tagsToInsert.length > 0) {
|
if (tagsToInsert.length > 0) {
|
||||||
const importedTags = await this.tagStore.bulkImport(tagsToInsert);
|
const importedTags = await this.tagStore.bulkImport(tagsToInsert);
|
||||||
const importedTagEvents = importedTags.map(tag => {
|
const importedTagEvents = importedTags.map(tag => ({
|
||||||
return {
|
type: TAG_IMPORT,
|
||||||
type: TAG_IMPORT,
|
createdBy: userName,
|
||||||
createdBy: userName,
|
data: tag,
|
||||||
data: tag,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
await this.eventStore.batchStore(importedTagEvents);
|
await this.eventStore.batchStore(importedTagEvents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -315,13 +309,11 @@ class StateService {
|
|||||||
const importedTagTypes = await this.tagTypeStore.bulkImport(
|
const importedTagTypes = await this.tagTypeStore.bulkImport(
|
||||||
tagTypesToInsert,
|
tagTypesToInsert,
|
||||||
);
|
);
|
||||||
const importedTagTypeEvents = importedTagTypes.map(tagType => {
|
const importedTagTypeEvents = importedTagTypes.map(tagType => ({
|
||||||
return {
|
type: TAG_TYPE_IMPORT,
|
||||||
type: TAG_TYPE_IMPORT,
|
createdBy: userName,
|
||||||
createdBy: userName,
|
data: tagType,
|
||||||
data: tagType,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
await this.eventStore.batchStore(importedTagTypeEvents);
|
await this.eventStore.batchStore(importedTagTypeEvents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,36 +2,28 @@ const fs = require('fs');
|
|||||||
const mime = require('mime');
|
const mime = require('mime');
|
||||||
const YAML = require('js-yaml');
|
const YAML = require('js-yaml');
|
||||||
|
|
||||||
const readFile = file => {
|
const readFile = file =>
|
||||||
return new Promise((resolve, reject) =>
|
new Promise((resolve, reject) =>
|
||||||
fs.readFile(file, (err, v) => (err ? reject(err) : resolve(v))),
|
fs.readFile(file, (err, v) => (err ? reject(err) : resolve(v))),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const parseFile = (file, data) =>
|
||||||
|
mime.getType(file) === 'text/yaml' ? YAML.safeLoad(data) : JSON.parse(data);
|
||||||
|
|
||||||
|
const filterExisting = (keepExisting, existingArray = []) => item => {
|
||||||
|
if (keepExisting) {
|
||||||
|
const found = existingArray.find(t => t.name === item.name);
|
||||||
|
return !found;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseFile = (file, data) => {
|
const filterEqual = (existingArray = []) => item => {
|
||||||
return mime.getType(file) === 'text/yaml'
|
const toggle = existingArray.find(t => t.name === item.name);
|
||||||
? YAML.safeLoad(data)
|
if (toggle) {
|
||||||
: JSON.parse(data);
|
return JSON.stringify(toggle) !== JSON.stringify(item);
|
||||||
};
|
}
|
||||||
|
return true;
|
||||||
const filterExisting = (keepExisting, existingArray = []) => {
|
|
||||||
return item => {
|
|
||||||
if (keepExisting) {
|
|
||||||
const found = existingArray.find(t => t.name === item.name);
|
|
||||||
return !found;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterEqual = (existingArray = []) => {
|
|
||||||
return item => {
|
|
||||||
const toggle = existingArray.find(t => t.name === item.name);
|
|
||||||
if (toggle) {
|
|
||||||
return JSON.stringify(toggle) !== JSON.stringify(item);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
@ -3,20 +3,35 @@ import UserService from './user-service';
|
|||||||
import UserStoreMock from '../../test/fixtures/fake-user-store';
|
import UserStoreMock from '../../test/fixtures/fake-user-store';
|
||||||
import AccessServiceMock from '../../test/fixtures/access-service-mock';
|
import AccessServiceMock from '../../test/fixtures/access-service-mock';
|
||||||
import noLogger from '../../test/fixtures/no-logger';
|
import noLogger from '../../test/fixtures/no-logger';
|
||||||
import { RoleName } from './access-service';
|
|
||||||
import { IUnleashConfig } from '../types/core';
|
import { IUnleashConfig } from '../types/core';
|
||||||
|
import { ResetTokenStoreMock } from '../../test/fixtures/fake-reset-token-store';
|
||||||
|
import ResetTokenService from './reset-token-service';
|
||||||
|
import { EmailService } from './email-service';
|
||||||
|
import OwaspValidationError from '../error/owasp-validation-error';
|
||||||
|
|
||||||
const config: IUnleashConfig = {
|
const config: IUnleashConfig = {
|
||||||
getLogger: noLogger,
|
getLogger: noLogger,
|
||||||
baseUriPath: '',
|
baseUriPath: '',
|
||||||
authentication: { enableApiToken: true, createAdminUser: false },
|
authentication: { enableApiToken: true, createAdminUser: false },
|
||||||
|
unleashUrl: 'http://localhost:4242',
|
||||||
|
email: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
test('Should create new user', async t => {
|
test('Should create new user', async t => {
|
||||||
const userStore = new UserStoreMock();
|
const userStore = new UserStoreMock();
|
||||||
const accessService = new AccessServiceMock();
|
const accessService = new AccessServiceMock();
|
||||||
|
const resetTokenStore = new ResetTokenStoreMock();
|
||||||
|
const resetTokenService = new ResetTokenService(
|
||||||
|
{ userStore, resetTokenStore },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
const service = new UserService({ userStore }, config, accessService);
|
const service = new UserService({ userStore }, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
const user = await service.createUser({
|
const user = await service.createUser({
|
||||||
username: 'test',
|
username: 'test',
|
||||||
rootRole: 1,
|
rootRole: 1,
|
||||||
@ -33,7 +48,18 @@ test('Should create new user', async t => {
|
|||||||
test('Should create default user', async t => {
|
test('Should create default user', async t => {
|
||||||
const userStore = new UserStoreMock();
|
const userStore = new UserStoreMock();
|
||||||
const accessService = new AccessServiceMock();
|
const accessService = new AccessServiceMock();
|
||||||
const service = new UserService({ userStore }, config, accessService);
|
const resetTokenStore = new ResetTokenStoreMock();
|
||||||
|
const resetTokenService = new ResetTokenService(
|
||||||
|
{ userStore, resetTokenStore },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
const service = new UserService({ userStore }, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
await service.initAdminUser();
|
await service.initAdminUser();
|
||||||
|
|
||||||
@ -44,7 +70,19 @@ test('Should create default user', async t => {
|
|||||||
test('Should be a valid password', async t => {
|
test('Should be a valid password', async t => {
|
||||||
const userStore = new UserStoreMock();
|
const userStore = new UserStoreMock();
|
||||||
const accessService = new AccessServiceMock();
|
const accessService = new AccessServiceMock();
|
||||||
const service = new UserService({ userStore }, config, accessService);
|
const resetTokenStore = new ResetTokenStoreMock();
|
||||||
|
const resetTokenService = new ResetTokenService(
|
||||||
|
{ userStore, resetTokenStore },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
const service = new UserService({ userStore }, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
const valid = service.validatePassword('this is a strong password!');
|
const valid = service.validatePassword('this is a strong password!');
|
||||||
|
|
||||||
@ -54,47 +92,106 @@ test('Should be a valid password', async t => {
|
|||||||
test('Password must be at least 10 chars', async t => {
|
test('Password must be at least 10 chars', async t => {
|
||||||
const userStore = new UserStoreMock();
|
const userStore = new UserStoreMock();
|
||||||
const accessService = new AccessServiceMock();
|
const accessService = new AccessServiceMock();
|
||||||
const service = new UserService({ userStore }, config, accessService);
|
const resetTokenStore = new ResetTokenStoreMock();
|
||||||
|
const resetTokenService = new ResetTokenService(
|
||||||
|
{ userStore, resetTokenStore },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
const service = new UserService({ userStore }, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
t.throws(() => service.validatePassword('admin'), {
|
t.throws(() => service.validatePassword('admin'), {
|
||||||
message: 'The password must be at least 10 characters long.',
|
message: 'The password must be at least 10 characters long.',
|
||||||
|
instanceOf: OwaspValidationError,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('The password must contain at least one uppercase letter.', async t => {
|
test('The password must contain at least one uppercase letter.', async t => {
|
||||||
const userStore = new UserStoreMock();
|
const userStore = new UserStoreMock();
|
||||||
const accessService = new AccessServiceMock();
|
const accessService = new AccessServiceMock();
|
||||||
const service = new UserService({ userStore }, config, accessService);
|
const resetTokenStore = new ResetTokenStoreMock();
|
||||||
|
const resetTokenService = new ResetTokenService(
|
||||||
|
{ userStore, resetTokenStore },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
const service = new UserService({ userStore }, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
t.throws(() => service.validatePassword('qwertyabcde'), {
|
t.throws(() => service.validatePassword('qwertyabcde'), {
|
||||||
message: 'The password must contain at least one uppercase letter.',
|
message: 'The password must contain at least one uppercase letter.',
|
||||||
|
instanceOf: OwaspValidationError,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('The password must contain at least one number', async t => {
|
test('The password must contain at least one number', async t => {
|
||||||
const userStore = new UserStoreMock();
|
const userStore = new UserStoreMock();
|
||||||
const accessService = new AccessServiceMock();
|
const accessService = new AccessServiceMock();
|
||||||
const service = new UserService({ userStore }, config, accessService);
|
const resetTokenStore = new ResetTokenStoreMock();
|
||||||
|
const resetTokenService = new ResetTokenService(
|
||||||
|
{ userStore, resetTokenStore },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
const service = new UserService({ userStore }, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
t.throws(() => service.validatePassword('qwertyabcdE'), {
|
t.throws(() => service.validatePassword('qwertyabcdE'), {
|
||||||
message: 'The password must contain at least one number.',
|
message: 'The password must contain at least one number.',
|
||||||
|
instanceOf: OwaspValidationError,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('The password must contain at least one special character', async t => {
|
test('The password must contain at least one special character', async t => {
|
||||||
const userStore = new UserStoreMock();
|
const userStore = new UserStoreMock();
|
||||||
const accessService = new AccessServiceMock();
|
const accessService = new AccessServiceMock();
|
||||||
const service = new UserService({ userStore }, config, accessService);
|
const resetTokenStore = new ResetTokenStoreMock();
|
||||||
|
const resetTokenService = new ResetTokenService(
|
||||||
|
{ userStore, resetTokenStore },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
const service = new UserService({ userStore }, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
t.throws(() => service.validatePassword('qwertyabcdE2'), {
|
t.throws(() => service.validatePassword('qwertyabcdE2'), {
|
||||||
message: 'The password must contain at least one special character.',
|
message: 'The password must contain at least one special character.',
|
||||||
|
instanceOf: OwaspValidationError,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should be a valid password with special chars', async t => {
|
test('Should be a valid password with special chars', async t => {
|
||||||
const userStore = new UserStoreMock();
|
const userStore = new UserStoreMock();
|
||||||
const accessService = new AccessServiceMock();
|
const accessService = new AccessServiceMock();
|
||||||
const service = new UserService({ userStore }, config, accessService);
|
const resetTokenStore = new ResetTokenStoreMock();
|
||||||
|
const resetTokenService = new ResetTokenService(
|
||||||
|
{ userStore, resetTokenStore },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
const service = new UserService({ userStore }, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
const valid = service.validatePassword('this is a strong password!');
|
const valid = service.validatePassword('this is a strong password!');
|
||||||
|
|
||||||
|
@ -3,13 +3,20 @@ import bcrypt from 'bcrypt';
|
|||||||
import owasp from 'owasp-password-strength-test';
|
import owasp from 'owasp-password-strength-test';
|
||||||
import Joi from 'joi';
|
import Joi from 'joi';
|
||||||
|
|
||||||
|
import { URL } from 'url';
|
||||||
import UserStore, { IUserSearch } from '../db/user-store';
|
import UserStore, { IUserSearch } from '../db/user-store';
|
||||||
import { Logger } from '../logger';
|
import { Logger } from '../logger';
|
||||||
import { IUnleashConfig } from '../types/core';
|
import { IUnleashConfig } from '../types/core';
|
||||||
import User, { IUser } from '../user';
|
import User, { IUser } from '../user';
|
||||||
import isEmail from '../util/is-email';
|
import isEmail from '../util/is-email';
|
||||||
import { AccessService, RoleName } from './access-service';
|
import { AccessService, IRoleData, RoleName } from './access-service';
|
||||||
import { ADMIN } from '../permissions';
|
import { ADMIN } from '../permissions';
|
||||||
|
import ResetTokenService from './reset-token-service';
|
||||||
|
import InvalidTokenError from '../error/invalid-token-error';
|
||||||
|
import NotFoundError from '../error/notfound-error';
|
||||||
|
import OwaspValidationError from '../error/owasp-validation-error';
|
||||||
|
import { EmailService } from './email-service';
|
||||||
|
import { IRole } from '../db/access-store';
|
||||||
|
|
||||||
export interface ICreateUser {
|
export interface ICreateUser {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -29,11 +36,27 @@ export interface IUpdateUser {
|
|||||||
interface IUserWithRole extends IUser {
|
interface IUserWithRole extends IUser {
|
||||||
rootRole: number;
|
rootRole: number;
|
||||||
}
|
}
|
||||||
|
interface IRoleDescription {
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
interface ITokenUser extends IUpdateUser {
|
||||||
|
createdBy: string;
|
||||||
|
token: string;
|
||||||
|
role: IRoleDescription;
|
||||||
|
}
|
||||||
|
|
||||||
interface IStores {
|
interface IStores {
|
||||||
userStore: UserStore;
|
userStore: UserStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IServices {
|
||||||
|
accessService: AccessService;
|
||||||
|
resetTokenService: ResetTokenService;
|
||||||
|
emailService: EmailService;
|
||||||
|
}
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
|
||||||
class UserService {
|
class UserService {
|
||||||
@ -43,15 +66,20 @@ class UserService {
|
|||||||
|
|
||||||
private accessService: AccessService;
|
private accessService: AccessService;
|
||||||
|
|
||||||
|
private resetTokenService: ResetTokenService;
|
||||||
|
|
||||||
|
private emailService: EmailService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
stores: IStores,
|
stores: IStores,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
accessService: AccessService,
|
{ accessService, resetTokenService, emailService }: IServices,
|
||||||
) {
|
) {
|
||||||
this.logger = config.getLogger('service/user-service.js');
|
this.logger = config.getLogger('service/user-service.js');
|
||||||
this.store = stores.userStore;
|
this.store = stores.userStore;
|
||||||
this.accessService = accessService;
|
this.accessService = accessService;
|
||||||
|
this.resetTokenService = resetTokenService;
|
||||||
|
this.emailService = emailService;
|
||||||
if (config.authentication && config.authentication.createAdminUser) {
|
if (config.authentication && config.authentication.createAdminUser) {
|
||||||
process.nextTick(() => this.initAdminUser());
|
process.nextTick(() => this.initAdminUser());
|
||||||
}
|
}
|
||||||
@ -60,7 +88,7 @@ class UserService {
|
|||||||
validatePassword(password: string): boolean {
|
validatePassword(password: string): boolean {
|
||||||
const result = owasp.test(password);
|
const result = owasp.test(password);
|
||||||
if (!result.strong) {
|
if (!result.strong) {
|
||||||
throw new Error(result.errors[0]);
|
throw new OwaspValidationError(result);
|
||||||
} else return true;
|
} else return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,6 +145,10 @@ class UserService {
|
|||||||
return this.store.search(query);
|
return this.store.search(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getByEmail(email: string): Promise<User> {
|
||||||
|
return this.store.get({ email });
|
||||||
|
}
|
||||||
|
|
||||||
async createUser({
|
async createUser({
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
@ -231,6 +263,61 @@ class UserService {
|
|||||||
|
|
||||||
await this.store.delete(userId);
|
await this.store.delete(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserForToken(token: string): Promise<ITokenUser> {
|
||||||
|
const { createdBy, userId } = await this.resetTokenService.isValid(
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const user = await this.getUser(userId);
|
||||||
|
const role = await this.accessService.getRole(user.rootRole);
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
createdBy,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
id: user.id,
|
||||||
|
role: {
|
||||||
|
description: role.role.description,
|
||||||
|
type: role.role.type,
|
||||||
|
name: role.role.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(token: string, password: string): Promise<void> {
|
||||||
|
this.validatePassword(password);
|
||||||
|
const user = await this.getUserForToken(token);
|
||||||
|
const allowed = await this.resetTokenService.useAccessToken({
|
||||||
|
userId: user.id,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
if (allowed) {
|
||||||
|
await this.changePassword(user.id, password);
|
||||||
|
} else {
|
||||||
|
throw new InvalidTokenError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createResetPasswordEmail(
|
||||||
|
receiverEmail: string,
|
||||||
|
requester: string,
|
||||||
|
): Promise<URL> {
|
||||||
|
const receiver = await this.getByEmail(receiverEmail);
|
||||||
|
if (!receiver) {
|
||||||
|
throw new NotFoundError(`Could not find ${receiverEmail}`);
|
||||||
|
}
|
||||||
|
const resetLink = await this.resetTokenService.createResetPasswordUrl(
|
||||||
|
receiver.id,
|
||||||
|
requester,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.emailService.sendResetMail(
|
||||||
|
receiver.name,
|
||||||
|
receiver.email,
|
||||||
|
resetLink.toString(),
|
||||||
|
);
|
||||||
|
return resetLink;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = UserService;
|
module.exports = UserService;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { LogProvider } from '../logger';
|
import { LogProvider } from '../logger';
|
||||||
|
import { IEmailOptions } from '../services/email-service';
|
||||||
|
|
||||||
interface IExperimentalFlags {
|
interface IExperimentalFlags {
|
||||||
[key: string]: boolean;
|
[key: string]: boolean;
|
||||||
@ -12,6 +13,8 @@ export interface IUnleashConfig {
|
|||||||
enableApiToken: boolean;
|
enableApiToken: boolean;
|
||||||
createAdminUser: boolean;
|
createAdminUser: boolean;
|
||||||
};
|
};
|
||||||
|
unleashUrl: string;
|
||||||
|
email?: IEmailOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuthenticationType {
|
export enum AuthenticationType {
|
||||||
|
@ -1,22 +1,528 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
<head>
|
<head>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<title>*|MC:SUBJECT|*</title>
|
||||||
<title>Reset your password {{ name }}</title>
|
<style type="text/css">
|
||||||
</head>
|
/* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */
|
||||||
<body>
|
#outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */
|
||||||
<header>
|
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */
|
||||||
|
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */
|
||||||
|
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */
|
||||||
|
table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */
|
||||||
|
img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */
|
||||||
|
|
||||||
</header>
|
/* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */
|
||||||
<section>
|
body{margin:0; padding:0;}
|
||||||
Someone has requested a reset of your password. If this was you, great, click here <a href="{{ resetLink }}"
|
img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;}
|
||||||
title="Password reset link">{{resetLink}}</a>
|
table{border-collapse:collapse !important;}
|
||||||
If this was not you, you might want to check your password still works
|
body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;}
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer>
|
/* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */
|
||||||
© {{ year }} - Unleash
|
|
||||||
</footer>
|
/* ========== Page Styles ========== */
|
||||||
</body>
|
|
||||||
</html>
|
#bodyCell{padding:20px;}
|
||||||
|
#templateContainer{width:600px;}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Page
|
||||||
|
* @section background style
|
||||||
|
* @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding.
|
||||||
|
* @theme page
|
||||||
|
*/
|
||||||
|
body, #bodyTable{
|
||||||
|
/*@editable*/ background-color:#DEE0E2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Page
|
||||||
|
* @section background style
|
||||||
|
* @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding.
|
||||||
|
* @theme page
|
||||||
|
*/
|
||||||
|
#bodyCell{
|
||||||
|
/*@editable*/ border-top:4px solid #BBBBBB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Page
|
||||||
|
* @section email border
|
||||||
|
* @tip Set the border for your email.
|
||||||
|
*/
|
||||||
|
#templateContainer{
|
||||||
|
/*@editable*/ border:1px solid #BBBBBB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Page
|
||||||
|
* @section heading 1
|
||||||
|
* @tip Set the styling for all first-level headings in your emails. These should be the largest of your headings.
|
||||||
|
* @style heading 1
|
||||||
|
*/
|
||||||
|
h1{
|
||||||
|
/*@editable*/ color:#202020 !important;
|
||||||
|
display:block;
|
||||||
|
/*@editable*/ font-family:Helvetica;
|
||||||
|
/*@editable*/ font-size:26px;
|
||||||
|
/*@editable*/ font-style:normal;
|
||||||
|
/*@editable*/ font-weight:bold;
|
||||||
|
/*@editable*/ line-height:100%;
|
||||||
|
/*@editable*/ letter-spacing:normal;
|
||||||
|
margin-top:0;
|
||||||
|
margin-right:0;
|
||||||
|
margin-bottom:10px;
|
||||||
|
margin-left:0;
|
||||||
|
/*@editable*/ text-align:left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Page
|
||||||
|
* @section heading 2
|
||||||
|
* @tip Set the styling for all second-level headings in your emails.
|
||||||
|
* @style heading 2
|
||||||
|
*/
|
||||||
|
h2{
|
||||||
|
/*@editable*/ color:#404040 !important;
|
||||||
|
display:block;
|
||||||
|
/*@editable*/ font-family:Helvetica;
|
||||||
|
/*@editable*/ font-size:20px;
|
||||||
|
/*@editable*/ font-style:normal;
|
||||||
|
/*@editable*/ font-weight:bold;
|
||||||
|
/*@editable*/ line-height:100%;
|
||||||
|
/*@editable*/ letter-spacing:normal;
|
||||||
|
margin-top:0;
|
||||||
|
margin-right:0;
|
||||||
|
margin-bottom:10px;
|
||||||
|
margin-left:0;
|
||||||
|
/*@editable*/ text-align:left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Page
|
||||||
|
* @section heading 3
|
||||||
|
* @tip Set the styling for all third-level headings in your emails.
|
||||||
|
* @style heading 3
|
||||||
|
*/
|
||||||
|
h3{
|
||||||
|
/*@editable*/ color:#606060 !important;
|
||||||
|
display:block;
|
||||||
|
/*@editable*/ font-family:Helvetica;
|
||||||
|
/*@editable*/ font-size:16px;
|
||||||
|
/*@editable*/ font-style:italic;
|
||||||
|
/*@editable*/ font-weight:normal;
|
||||||
|
/*@editable*/ line-height:100%;
|
||||||
|
/*@editable*/ letter-spacing:normal;
|
||||||
|
margin-top:0;
|
||||||
|
margin-right:0;
|
||||||
|
margin-bottom:10px;
|
||||||
|
margin-left:0;
|
||||||
|
/*@editable*/ text-align:left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Page
|
||||||
|
* @section heading 4
|
||||||
|
* @tip Set the styling for all fourth-level headings in your emails. These should be the smallest of your headings.
|
||||||
|
* @style heading 4
|
||||||
|
*/
|
||||||
|
h4{
|
||||||
|
/*@editable*/ color:#808080 !important;
|
||||||
|
display:block;
|
||||||
|
/*@editable*/ font-family:Helvetica;
|
||||||
|
/*@editable*/ font-size:14px;
|
||||||
|
/*@editable*/ font-style:italic;
|
||||||
|
/*@editable*/ font-weight:normal;
|
||||||
|
/*@editable*/ line-height:100%;
|
||||||
|
/*@editable*/ letter-spacing:normal;
|
||||||
|
margin-top:0;
|
||||||
|
margin-right:0;
|
||||||
|
margin-bottom:10px;
|
||||||
|
margin-left:0;
|
||||||
|
/*@editable*/ text-align:left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Header Styles ========== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Header
|
||||||
|
* @section preheader style
|
||||||
|
* @tip Set the background color and bottom border for your email's preheader area.
|
||||||
|
* @theme header
|
||||||
|
*/
|
||||||
|
#templatePreheader{
|
||||||
|
/*@editable*/ background-color:#fff;
|
||||||
|
/*@editable*/ border-bottom:1px solid #CCCCCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Header
|
||||||
|
* @section preheader text
|
||||||
|
* @tip Set the styling for your email's preheader text. Choose a size and color that is easy to read.
|
||||||
|
*/
|
||||||
|
.preheaderContent{
|
||||||
|
/*@editable*/ color:#808080;
|
||||||
|
/*@editable*/ font-family:Helvetica;
|
||||||
|
/*@editable*/ font-size:10px;
|
||||||
|
/*@editable*/ line-height:125%;
|
||||||
|
/*@editable*/ text-align:left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Header
|
||||||
|
* @section preheader link
|
||||||
|
* @tip Set the styling for your email's preheader links. Choose a color that helps them stand out from your text.
|
||||||
|
*/
|
||||||
|
.preheaderContent a:link, .preheaderContent a:visited, /* Yahoo! Mail Override */ .preheaderContent a .yshortcuts /* Yahoo! Mail Override */{
|
||||||
|
/*@editable*/ color:#fff;
|
||||||
|
/*@editable*/ font-weight:normal;
|
||||||
|
/*@editable*/ text-decoration:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Header
|
||||||
|
* @section header style
|
||||||
|
* @tip Set the background color and borders for your email's header area.
|
||||||
|
* @theme header
|
||||||
|
*/
|
||||||
|
#templateHeader{
|
||||||
|
/*@editable*/ background-color:#fff;
|
||||||
|
/*@editable*/ border-top:1px solid #FFFFFF;
|
||||||
|
/*@editable*/ border-bottom:1px solid #CCCCCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Header
|
||||||
|
* @section header text
|
||||||
|
* @tip Set the styling for your email's header text. Choose a size and color that is easy to read.
|
||||||
|
*/
|
||||||
|
.headerContent{
|
||||||
|
/*@editable*/ color:#505050;
|
||||||
|
/*@editable*/ font-family:Helvetica;
|
||||||
|
/*@editable*/ font-size:20px;
|
||||||
|
/*@editable*/ font-weight:bold;
|
||||||
|
/*@editable*/ line-height:100%;
|
||||||
|
/*@editable*/ padding-top:0;
|
||||||
|
/*@editable*/ padding-right:0;
|
||||||
|
/*@editable*/ padding-bottom:0;
|
||||||
|
/*@editable*/ padding-left:0;
|
||||||
|
/*@editable*/ text-align:left;
|
||||||
|
/*@editable*/ vertical-align:middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Header
|
||||||
|
* @section header link
|
||||||
|
* @tip Set the styling for your email's header links. Choose a color that helps them stand out from your text.
|
||||||
|
*/
|
||||||
|
.headerContent a:link, .headerContent a:visited, /* Yahoo! Mail Override */ .headerContent a .yshortcuts /* Yahoo! Mail Override */{
|
||||||
|
/*@editable*/ color:#fff;
|
||||||
|
/*@editable*/ font-weight:normal;
|
||||||
|
/*@editable*/ text-decoration:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#headerImage{
|
||||||
|
height:auto;
|
||||||
|
max-width:600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Body Styles ========== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Body
|
||||||
|
* @section body style
|
||||||
|
* @tip Set the background color and borders for your email's body area.
|
||||||
|
*/
|
||||||
|
#templateBody{
|
||||||
|
/*@editable*/ background-color:#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Body
|
||||||
|
* @section body text
|
||||||
|
* @tip Set the styling for your email's main content text. Choose a size and color that is easy to read.
|
||||||
|
* @theme main
|
||||||
|
*/
|
||||||
|
.bodyContent{
|
||||||
|
/*@editable*/ color:#505050;
|
||||||
|
/*@editable*/ font-family:Helvetica;
|
||||||
|
/*@editable*/ font-size:14px;
|
||||||
|
/*@editable*/ line-height:150%;
|
||||||
|
/*@editable*/ border-bottom: 1px solid #CCCCCC;
|
||||||
|
padding-top:20px;
|
||||||
|
padding-right:20px;
|
||||||
|
padding-bottom:20px;
|
||||||
|
padding-left:20px;
|
||||||
|
/*@editable*/ text-align:left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyContent img{
|
||||||
|
display:inline;
|
||||||
|
height:auto;
|
||||||
|
max-width:560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetPasswordLink {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #607d8b;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetPasswordLink:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Footer Styles ========== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Footer
|
||||||
|
* @section footer style
|
||||||
|
* @tip Set the background color and borders for your email's footer area.
|
||||||
|
* @theme footer
|
||||||
|
*/
|
||||||
|
#templateFooter{
|
||||||
|
/*@editable*/ background-color:#fff;
|
||||||
|
/*@editable*/ border-top:1px solid #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Footer
|
||||||
|
* @section footer text
|
||||||
|
* @tip Set the styling for your email's footer text. Choose a size and color that is easy to read.
|
||||||
|
* @theme footer
|
||||||
|
*/
|
||||||
|
.footerContent{
|
||||||
|
/*@editable*/ color:#808080;
|
||||||
|
/*@editable*/ font-family:Helvetica;
|
||||||
|
/*@editable*/ font-size:10px;
|
||||||
|
/*@editable*/ line-height:150%;
|
||||||
|
padding-top:20px;
|
||||||
|
padding-right:20px;
|
||||||
|
padding-bottom:20px;
|
||||||
|
padding-left:20px;
|
||||||
|
/*@editable*/ text-align:left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Footer
|
||||||
|
* @section footer link
|
||||||
|
* @tip Set the styling for your email's footer links. Choose a color that helps them stand out from your text.
|
||||||
|
*/
|
||||||
|
.footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{
|
||||||
|
/*@editable*/ color:#606060;
|
||||||
|
/*@editable*/ font-weight:normal;
|
||||||
|
/*@editable*/ text-decoration:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* /\/\/\/\/\/\/\/\/ MOBILE STYLES /\/\/\/\/\/\/\/\/ */
|
||||||
|
|
||||||
|
@media only screen and (max-width: 480px){
|
||||||
|
/* /\/\/\/\/\/\/ CLIENT-SPECIFIC MOBILE STYLES /\/\/\/\/\/\/ */
|
||||||
|
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:none !important;} /* Prevent Webkit platforms from changing default text sizes */
|
||||||
|
body{width:100% !important; min-width:100% !important;} /* Prevent iOS Mail from adding padding to the body */
|
||||||
|
|
||||||
|
/* /\/\/\/\/\/\/ MOBILE RESET STYLES /\/\/\/\/\/\/ */
|
||||||
|
#bodyCell{padding:10px !important;}
|
||||||
|
|
||||||
|
/* /\/\/\/\/\/\/ MOBILE TEMPLATE STYLES /\/\/\/\/\/\/ */
|
||||||
|
|
||||||
|
/* ======== Page Styles ======== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section template width
|
||||||
|
* @tip Make the template fluid for portrait or landscape view adaptability. If a fluid layout doesn't work for you, set the width to 300px instead.
|
||||||
|
*/
|
||||||
|
#templateContainer{
|
||||||
|
max-width:600px !important;
|
||||||
|
/*@editable*/ width:100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section heading 1
|
||||||
|
* @tip Make the first-level headings larger in size for better readability on small screens.
|
||||||
|
*/
|
||||||
|
h1{
|
||||||
|
/*@editable*/ font-size:24px !important;
|
||||||
|
/*@editable*/ line-height:100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section heading 2
|
||||||
|
* @tip Make the second-level headings larger in size for better readability on small screens.
|
||||||
|
*/
|
||||||
|
h2{
|
||||||
|
/*@editable*/ font-size:20px !important;
|
||||||
|
/*@editable*/ line-height:100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section heading 3
|
||||||
|
* @tip Make the third-level headings larger in size for better readability on small screens.
|
||||||
|
*/
|
||||||
|
h3{
|
||||||
|
/*@editable*/ font-size:18px !important;
|
||||||
|
/*@editable*/ line-height:100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section heading 4
|
||||||
|
* @tip Make the fourth-level headings larger in size for better readability on small screens.
|
||||||
|
*/
|
||||||
|
h4{
|
||||||
|
/*@editable*/ font-size:16px !important;
|
||||||
|
/*@editable*/ line-height:100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Header Styles ======== */
|
||||||
|
|
||||||
|
#templatePreheader{display:none !important;} /* Hide the template preheader to save space */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section header image
|
||||||
|
* @tip Make the main header image fluid for portrait or landscape view adaptability, and set the image's original width as the max-width. If a fluid setting doesn't work, set the image width to half its original size instead.
|
||||||
|
*/
|
||||||
|
#headerImage{
|
||||||
|
height:auto !important;
|
||||||
|
/*@editable*/ max-width:600px !important;
|
||||||
|
/*@editable*/ width:100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section header text
|
||||||
|
* @tip Make the header content text larger in size for better readability on small screens. We recommend a font size of at least 16px.
|
||||||
|
*/
|
||||||
|
.headerContent{
|
||||||
|
/*@editable*/ font-size:20px !important;
|
||||||
|
/*@editable*/ line-height:125% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Body Styles ======== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section body text
|
||||||
|
* @tip Make the body content text larger in size for better readability on small screens. We recommend a font size of at least 16px.
|
||||||
|
*/
|
||||||
|
.bodyContent{
|
||||||
|
/*@editable*/ font-size:18px !important;
|
||||||
|
/*@editable*/ line-height:125% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Footer Styles ======== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tab Mobile Styles
|
||||||
|
* @section footer text
|
||||||
|
* @tip Make the body content text larger in size for better readability on small screens.
|
||||||
|
*/
|
||||||
|
.footerContent{
|
||||||
|
/*@editable*/ font-size:14px !important;
|
||||||
|
/*@editable*/ line-height:115% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerContent a{display:block !important;} /* Place footer social and utility links on their own lines, for easier access */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
|
||||||
|
<center>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable">
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" id="bodyCell">
|
||||||
|
<!-- BEGIN TEMPLATE // -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" id="templateContainer">
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<!-- BEGIN PREHEADER // -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templatePreheader">
|
||||||
|
<tr>
|
||||||
|
<td valign="top" class="preheaderContent" style="padding-top:10px; padding-right:20px; padding-bottom:10px; padding-left:20px;" mc:edit="preheader_content00">
|
||||||
|
Someone has requested a password reset on your unleash account. Please find the reset link in the email below.
|
||||||
|
</td>
|
||||||
|
<!-- *|IFNOT:ARCHIVE_PAGE|* -->
|
||||||
|
<td valign="top" width="180" class="preheaderContent" style="padding-top:10px; padding-right:20px; padding-bottom:10px; padding-left:0;" mc:edit="preheader_content01">
|
||||||
|
Email not displaying correctly?<br /><a href="*|ARCHIVE|*" target="_blank">View it in your browser</a>.
|
||||||
|
</td>
|
||||||
|
<!-- *|END:IF|* -->
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- // END PREHEADER -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<!-- BEGIN HEADER // -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader">
|
||||||
|
<tr>
|
||||||
|
<td valign="top" class="headerContent">
|
||||||
|
<img src="https://www.unleash-hosted.com/wp-content/uploads/2019/12/unleash-hosted-logo-v2-mini-fit.png" style="max-width:600px;" id="headerImage" mc:label="header_image" mc:edit="header_image" mc:allowdesigner mc:allowtext />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- // END HEADER -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<!-- BEGIN BODY // -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody">
|
||||||
|
<tr>
|
||||||
|
<td valign="top" class="bodyContent" mc:edit="body_content">
|
||||||
|
<h1>Reset password</h1>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
Someone has requested to reset the password on your unleash account. If it was you, click the button below to complete the process. If not, you can ignore this email.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<a class="resetPasswordLink" href="{{{ resetLink }}}" target="_blank" rel="noopener noreferrer">Reset password<a/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- // END BODY -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<!-- BEGIN FOOTER // -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateFooter">
|
||||||
|
<tr>
|
||||||
|
<td valign="top" class="footerContent" mc:edit="footer_content00">
|
||||||
|
<a href="https://github.com/Unleash/unleash">Follow us on Github</a> <a href="*|FACEBOOK:PROFILEURL|*">Friend on Facebook</a> <a href="*|FORWARD|*">Forward to Friend</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top" class="footerContent" style="padding-top:0;" mc:edit="footer_content01">
|
||||||
|
<em>Copyright © {{ year }} | Unleash | All rights reserved.</em>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<strong>Our mailing address is: team@getunleash.io</strong>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<!-- // END FOOTER -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- // END TEMPLATE -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,4 +1,7 @@
|
|||||||
Hello {{ name }}
|
Reset password
|
||||||
Someone has requested a reset of your password.
|
|
||||||
If this was you, great, visit "{{ resetLink }}" to reset your password.
|
Someone has requested to reset the password on your unleash account.
|
||||||
If this was not you, you might want to check your password still works.
|
|
||||||
|
If it was you, click the link below to complete the process. If not, you can ignore this email.
|
||||||
|
|
||||||
|
Visit {{{ resetLink }}} to reset your password.
|
||||||
|
@ -4,16 +4,12 @@ const async = require('async');
|
|||||||
|
|
||||||
const settingsId = 'unleash.enterprise.api.keys';
|
const settingsId = 'unleash.enterprise.api.keys';
|
||||||
|
|
||||||
const toApiToken = legacyToken => {
|
const toApiToken = legacyToken => ({
|
||||||
return {
|
secret: legacyToken.key,
|
||||||
secret: legacyToken.key,
|
username: legacyToken.username,
|
||||||
username: legacyToken.username,
|
createdAt: legacyToken.created || new Date(),
|
||||||
createdAt: legacyToken.created || new Date(),
|
type: legacyToken.priviliges.some(n => n === 'ADMIN') ? 'admin' : 'client',
|
||||||
type: legacyToken.priviliges.some(n => n === 'ADMIN')
|
});
|
||||||
? 'admin'
|
|
||||||
: 'client',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.up = function(db, cb) {
|
exports.up = function(db, cb) {
|
||||||
db.runSql(
|
db.runSql(
|
||||||
|
26
src/migrations/20210409120136-create-reset-token-table.js
Normal file
26
src/migrations/20210409120136-create-reset-token-table.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
exports.up = function(db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS reset_tokens
|
||||||
|
(
|
||||||
|
reset_token text PRIMARY KEY NOT NULL,
|
||||||
|
user_id integer,
|
||||||
|
expires_at timestamp with time zone NOT NULL,
|
||||||
|
used_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone DEFAULT now(),
|
||||||
|
created_by text,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(db, cb) {
|
||||||
|
db.runSql('DROP TABLE reset_tokens;', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports._meta = {
|
||||||
|
version: 1,
|
||||||
|
};
|
@ -0,0 +1,12 @@
|
|||||||
|
exports.up = function(db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
UPDATE roles SET description = 'As an Editor you have access to most features in Unleash, but you can not manage users and roles in the global scope. If you create a project, you will become a project owner and receive superuser rights within the context of that project.' WHERE name = 'Regular';
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(db, cb) {
|
||||||
|
db.runSql(``, cb);
|
||||||
|
};
|
@ -16,7 +16,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ test.before(async () => {
|
|||||||
reset = db.reset;
|
reset = db.reset;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ test.serial('gets a strategy by name', async t => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.serial('cant get a strategy by name that dose not exist', async t => {
|
test.serial('cant get a strategy by name that does not exist', async t => {
|
||||||
t.plan(0);
|
t.plan(0);
|
||||||
const request = await setupApp(stores);
|
const request = await setupApp(stores);
|
||||||
return request
|
return request
|
||||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ test.before(async () => {
|
|||||||
adminRole = roles.find(r => r.name === RoleName.ADMIN);
|
adminRole = roles.find(r => r.name === RoleName.ADMIN);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
181
src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
Normal file
181
src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import test from 'ava';
|
||||||
|
import { URL } from 'url';
|
||||||
|
import dbInit from '../../helpers/database-init';
|
||||||
|
import getLogger from '../../../fixtures/no-logger';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AccessService,
|
||||||
|
RoleName,
|
||||||
|
} from '../../../../lib/services/access-service';
|
||||||
|
import ResetTokenService from '../../../../lib/services/reset-token-service';
|
||||||
|
import UserService from '../../../../lib/services/user-service';
|
||||||
|
import { IUnleashConfig } from '../../../../lib/types/core';
|
||||||
|
import { setupApp } from '../../helpers/test-helper';
|
||||||
|
import { EmailService } from '../../../../lib/services/email-service';
|
||||||
|
import User from '../../../../lib/user';
|
||||||
|
|
||||||
|
let stores;
|
||||||
|
let db;
|
||||||
|
const config: IUnleashConfig = {
|
||||||
|
getLogger,
|
||||||
|
unleashUrl: 'http://localhost:3000',
|
||||||
|
baseUriPath: '',
|
||||||
|
authentication: { enableApiToken: true, createAdminUser: false },
|
||||||
|
};
|
||||||
|
const password = 'DtUYwi&l5I1KX4@Le';
|
||||||
|
let userService: UserService;
|
||||||
|
let accessService: AccessService;
|
||||||
|
let resetTokenService: ResetTokenService;
|
||||||
|
let adminUser: User;
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
const getBackendResetUrl = (url: URL): string => {
|
||||||
|
const urlString = url.toString();
|
||||||
|
|
||||||
|
const params = urlString.substring(urlString.indexOf('?'));
|
||||||
|
return `/auth/reset/validate${params}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
db = await dbInit('reset_password_api_serial', getLogger);
|
||||||
|
stores = db.stores;
|
||||||
|
accessService = new AccessService(stores, config);
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
userService = new UserService(stores, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
resetTokenService = new ResetTokenService(stores, config);
|
||||||
|
const adminRole = await accessService.getRootRole(RoleName.ADMIN);
|
||||||
|
adminUser = await userService.createUser({
|
||||||
|
username: 'admin@test.com',
|
||||||
|
rootRole: adminRole.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRole = await accessService.getRootRole(RoleName.EDITOR);
|
||||||
|
user = await userService.createUser({
|
||||||
|
username: 'test@test.com',
|
||||||
|
email: 'test@test.com',
|
||||||
|
rootRole: userRole.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach.always(async () => {
|
||||||
|
await stores.resetTokenStore.deleteAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('Can validate token for password reset', async t => {
|
||||||
|
const request = await setupApp(stores);
|
||||||
|
const url = await resetTokenService.createResetPasswordUrl(
|
||||||
|
user.id,
|
||||||
|
adminUser.username,
|
||||||
|
);
|
||||||
|
const relative = getBackendResetUrl(url);
|
||||||
|
return request
|
||||||
|
.get(relative)
|
||||||
|
.expect(200)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(res => {
|
||||||
|
t.is(res.body.email, user.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('Can use token to reset password', async t => {
|
||||||
|
const request = await setupApp(stores);
|
||||||
|
const url = await resetTokenService.createResetPasswordUrl(
|
||||||
|
user.id,
|
||||||
|
adminUser.username,
|
||||||
|
);
|
||||||
|
const relative = getBackendResetUrl(url);
|
||||||
|
// Can't login before reset
|
||||||
|
t.throwsAsync<Error>(
|
||||||
|
async () => userService.loginUser(user.email, password),
|
||||||
|
{
|
||||||
|
instanceOf: Error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let token;
|
||||||
|
await request
|
||||||
|
.get(relative)
|
||||||
|
.expect(200)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(res => {
|
||||||
|
token = res.body.token;
|
||||||
|
});
|
||||||
|
await request
|
||||||
|
.post('/auth/reset/password')
|
||||||
|
.send({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
const loggedInUser = await userService.loginUser(user.email, password);
|
||||||
|
t.is(user.email, loggedInUser.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial(
|
||||||
|
'Trying to reset password with same token twice does not work',
|
||||||
|
async t => {
|
||||||
|
const request = await setupApp(stores);
|
||||||
|
const url = await resetTokenService.createResetPasswordUrl(
|
||||||
|
user.id,
|
||||||
|
adminUser.username,
|
||||||
|
);
|
||||||
|
const relative = getBackendResetUrl(url);
|
||||||
|
let token;
|
||||||
|
await request
|
||||||
|
.get(relative)
|
||||||
|
.expect(200)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(res => {
|
||||||
|
token = res.body.token;
|
||||||
|
});
|
||||||
|
await request
|
||||||
|
.post('/auth/reset/password')
|
||||||
|
.send({
|
||||||
|
email: user.email,
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
await request
|
||||||
|
.post('/auth/reset/password')
|
||||||
|
.send({
|
||||||
|
email: user.email,
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
.expect(403)
|
||||||
|
.expect(res => {
|
||||||
|
t.truthy(res.body.details[0].message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.serial('Invalid token should yield 401', async t => {
|
||||||
|
const request = await setupApp(stores);
|
||||||
|
return request.get('/auth/reset/validate?token=abc123').expect(res => {
|
||||||
|
t.is(res.status, 401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial(
|
||||||
|
'Trying to change password with an invalid token should yield 401',
|
||||||
|
async t => {
|
||||||
|
const request = await setupApp(stores);
|
||||||
|
return request
|
||||||
|
.post('/auth/reset/password')
|
||||||
|
.send({
|
||||||
|
token: 'abc123',
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
.expect(res => t.is(res.status, 401));
|
||||||
|
},
|
||||||
|
);
|
@ -13,7 +13,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ test.before(async () => {
|
|||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async () => {
|
test.after.always(async () => {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ async function resetDatabase(stores) {
|
|||||||
stores.db('tag_types').del(),
|
stores.db('tag_types').del(),
|
||||||
stores.db('addons').del(),
|
stores.db('addons').del(),
|
||||||
stores.db('users').del(),
|
stores.db('users').del(),
|
||||||
|
stores.db('reset_tokens').del(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ function createApp(stores, adminAuthentication = 'none', preHook) {
|
|||||||
authentication: {
|
authentication: {
|
||||||
customHook: () => {},
|
customHook: () => {},
|
||||||
},
|
},
|
||||||
|
unleashUrl: 'http://localhost:4242',
|
||||||
getLogger,
|
getLogger,
|
||||||
};
|
};
|
||||||
const services = createServices(stores, config);
|
const services = createServices(stores, config);
|
||||||
|
100
src/test/e2e/services/reset-token-service.e2e.test.ts
Normal file
100
src/test/e2e/services/reset-token-service.e2e.test.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import test from 'ava';
|
||||||
|
import { IUnleashConfig } from '../../../lib/types/core';
|
||||||
|
import dbInit from '../helpers/database-init';
|
||||||
|
import getLogger from '../../fixtures/no-logger';
|
||||||
|
import ResetTokenService from '../../../lib/services/reset-token-service';
|
||||||
|
import UserService from '../../../lib/services/user-service';
|
||||||
|
import { AccessService } from '../../../lib/services/access-service';
|
||||||
|
import NotFoundError from '../../../lib/error/notfound-error';
|
||||||
|
import { EmailService } from '../../../lib/services/email-service';
|
||||||
|
import User from '../../../lib/user';
|
||||||
|
|
||||||
|
const config: IUnleashConfig = {
|
||||||
|
getLogger,
|
||||||
|
baseUriPath: '',
|
||||||
|
authentication: { enableApiToken: true, createAdminUser: false },
|
||||||
|
unleashUrl: 'http://localhost:3000',
|
||||||
|
};
|
||||||
|
|
||||||
|
let stores;
|
||||||
|
let db;
|
||||||
|
let adminUser;
|
||||||
|
let userToCreateResetFor: User;
|
||||||
|
let userIdToCreateResetFor: number;
|
||||||
|
let accessService: AccessService;
|
||||||
|
let userService: UserService;
|
||||||
|
let resetTokenService: ResetTokenService;
|
||||||
|
test.before(async () => {
|
||||||
|
db = await dbInit('reset_token_service_serial', getLogger);
|
||||||
|
stores = db.stores;
|
||||||
|
accessService = new AccessService(stores, config);
|
||||||
|
resetTokenService = new ResetTokenService(stores, config);
|
||||||
|
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
userService = new UserService(stores, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
|
|
||||||
|
adminUser = await userService.createUser({
|
||||||
|
username: 'admin@test.com',
|
||||||
|
rootRole: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
userToCreateResetFor = await userService.createUser({
|
||||||
|
username: 'test@test.com',
|
||||||
|
rootRole: 2,
|
||||||
|
});
|
||||||
|
userIdToCreateResetFor = userToCreateResetFor.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after.always(async () => {
|
||||||
|
db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('Should create a reset link', async t => {
|
||||||
|
const url = await resetTokenService.createResetPasswordUrl(
|
||||||
|
userIdToCreateResetFor,
|
||||||
|
adminUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
t.true(url.toString().indexOf('/reset-password') > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('Should create a welcome link', async t => {
|
||||||
|
const url = await resetTokenService.createWelcomeUrl(
|
||||||
|
userIdToCreateResetFor,
|
||||||
|
adminUser.username,
|
||||||
|
);
|
||||||
|
t.true(url.toString().indexOf('/new-user') > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('Tokens should be one-time only', async t => {
|
||||||
|
const token = await resetTokenService.createToken(
|
||||||
|
userIdToCreateResetFor,
|
||||||
|
adminUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessGranted = await resetTokenService.useAccessToken(token);
|
||||||
|
t.is(accessGranted, true);
|
||||||
|
const secondGo = await resetTokenService.useAccessToken(token);
|
||||||
|
t.is(secondGo, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('Creating a new token should expire older tokens', async t => {
|
||||||
|
const firstToken = await resetTokenService.createToken(
|
||||||
|
userIdToCreateResetFor,
|
||||||
|
adminUser,
|
||||||
|
);
|
||||||
|
const secondToken = await resetTokenService.createToken(
|
||||||
|
userIdToCreateResetFor,
|
||||||
|
adminUser,
|
||||||
|
);
|
||||||
|
await t.throwsAsync<NotFoundError>(async () =>
|
||||||
|
resetTokenService.isValid(firstToken.token),
|
||||||
|
);
|
||||||
|
const validToken = await resetTokenService.isValid(secondToken.token);
|
||||||
|
t.is(secondToken.token, validToken.token);
|
||||||
|
});
|
@ -7,6 +7,8 @@ import UserStore from '../../../lib/db/user-store';
|
|||||||
import User from '../../../lib/user';
|
import User from '../../../lib/user';
|
||||||
import { IUnleashConfig } from '../../../lib/types/core';
|
import { IUnleashConfig } from '../../../lib/types/core';
|
||||||
import { IRole } from '../../../lib/db/access-store';
|
import { IRole } from '../../../lib/db/access-store';
|
||||||
|
import ResetTokenService from '../../../lib/services/reset-token-service';
|
||||||
|
import { EmailService } from '../../../lib/services/email-service';
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
let stores;
|
let stores;
|
||||||
@ -24,9 +26,17 @@ test.before(async () => {
|
|||||||
enableApiToken: false,
|
enableApiToken: false,
|
||||||
createAdminUser: false,
|
createAdminUser: false,
|
||||||
},
|
},
|
||||||
|
unleashUrl: 'http://localhost:4242',
|
||||||
};
|
};
|
||||||
const accessService = new AccessService(stores, config);
|
const accessService = new AccessService(stores, config);
|
||||||
userService = new UserService(stores, config, accessService);
|
const resetTokenService = new ResetTokenService(stores, config);
|
||||||
|
const emailService = new EmailService(config.email, config.getLogger);
|
||||||
|
|
||||||
|
userService = new UserService(stores, config, {
|
||||||
|
accessService,
|
||||||
|
resetTokenService,
|
||||||
|
emailService,
|
||||||
|
});
|
||||||
userStore = stores.userStore;
|
userStore = stores.userStore;
|
||||||
const rootRoles = await accessService.getRootRoles();
|
const rootRoles = await accessService.getRootRoles();
|
||||||
adminRole = rootRoles.find(r => r.name === RoleName.ADMIN);
|
adminRole = rootRoles.find(r => r.name === RoleName.ADMIN);
|
||||||
|
@ -159,9 +159,10 @@ test.serial('Multi row merge also works', async t => {
|
|||||||
clients.push(clientRegistration);
|
clients.push(clientRegistration);
|
||||||
}
|
}
|
||||||
await clientApplicationsStore.bulkUpsert(clients);
|
await clientApplicationsStore.bulkUpsert(clients);
|
||||||
const alteredClients = clients.map(c => {
|
const alteredClients = clients.map(c => ({
|
||||||
return { appName: c.appName, icon: 'red' };
|
appName: c.appName,
|
||||||
});
|
icon: 'red',
|
||||||
|
}));
|
||||||
await clientApplicationsStore.bulkUpsert(alteredClients);
|
await clientApplicationsStore.bulkUpsert(alteredClients);
|
||||||
const stored = await Promise.all(
|
const stored = await Promise.all(
|
||||||
clients.map(async c =>
|
clients.map(async c =>
|
||||||
|
4
src/test/fixtures/fake-addon-store.js
vendored
4
src/test/fixtures/fake-addon-store.js
vendored
@ -17,9 +17,7 @@ module.exports = () => {
|
|||||||
_addons.splice(id, 1);
|
_addons.splice(id, 1);
|
||||||
Promise.resolve();
|
Promise.resolve();
|
||||||
},
|
},
|
||||||
get: async id => {
|
get: async id => _addons[id],
|
||||||
return _addons[id];
|
|
||||||
},
|
|
||||||
getAll: () => Promise.resolve(_addons),
|
getAll: () => Promise.resolve(_addons),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
33
src/test/fixtures/fake-feature-toggle-store.js
vendored
33
src/test/fixtures/fake-feature-toggle-store.js
vendored
@ -73,25 +73,25 @@ module.exports = (databaseIsUp = true) => {
|
|||||||
const activeQueryKeys = Object.keys(query).filter(
|
const activeQueryKeys = Object.keys(query).filter(
|
||||||
t => query[t],
|
t => query[t],
|
||||||
);
|
);
|
||||||
const filtered = _features.filter(feature => {
|
const filtered = _features.filter(feature =>
|
||||||
return activeQueryKeys.every(key => {
|
activeQueryKeys.every(key => {
|
||||||
if (key === 'namePrefix') {
|
if (key === 'namePrefix') {
|
||||||
return feature.name.indexOf(query[key]) > -1;
|
return feature.name.indexOf(query[key]) > -1;
|
||||||
}
|
}
|
||||||
if (key === 'tag') {
|
if (key === 'tag') {
|
||||||
return query[key].some(tagQuery => {
|
return query[key].some(tagQuery =>
|
||||||
return _featureTags
|
_featureTags
|
||||||
.filter(t => t.featureName === feature.name)
|
.filter(t => t.featureName === feature.name)
|
||||||
.some(
|
.some(
|
||||||
tag =>
|
tag =>
|
||||||
tag.tagType === tagQuery[0] &&
|
tag.tagType === tagQuery[0] &&
|
||||||
tag.tagValue === tagQuery[1],
|
tag.tagValue === tagQuery[1],
|
||||||
);
|
),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
return query[key].some(v => v === feature[key]);
|
return query[key].some(v => v === feature[key]);
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
return Promise.resolve(filtered);
|
return Promise.resolve(filtered);
|
||||||
}
|
}
|
||||||
return Promise.resolve(_features);
|
return Promise.resolve(_features);
|
||||||
@ -112,18 +112,15 @@ module.exports = (databaseIsUp = true) => {
|
|||||||
);
|
);
|
||||||
_featureTags.splice(index, 1);
|
_featureTags.splice(index, 1);
|
||||||
},
|
},
|
||||||
getAllTagsForFeature: featureName => {
|
getAllTagsForFeature: featureName =>
|
||||||
return Promise.resolve(
|
Promise.resolve(
|
||||||
_featureTags
|
_featureTags
|
||||||
.filter(f => f.featureName === featureName)
|
.filter(f => f.featureName === featureName)
|
||||||
.map(t => {
|
.map(t => ({
|
||||||
return {
|
type: t.tagType,
|
||||||
type: t.tagType,
|
value: t.tagValue,
|
||||||
value: t.tagValue,
|
})),
|
||||||
};
|
),
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getAllFeatureTags: () => Promise.resolve(_featureTags),
|
getAllFeatureTags: () => Promise.resolve(_featureTags),
|
||||||
importFeatureTags: tags => {
|
importFeatureTags: tags => {
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
|
50
src/test/fixtures/fake-reset-token-store.ts
vendored
Normal file
50
src/test/fixtures/fake-reset-token-store.ts
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import {
|
||||||
|
IResetToken,
|
||||||
|
IResetTokenCreate,
|
||||||
|
IResetTokenQuery,
|
||||||
|
ResetTokenStore,
|
||||||
|
} from '../../lib/db/reset-token-store';
|
||||||
|
import noLoggerProvider from './no-logger';
|
||||||
|
import NotFoundError from '../../lib/error/notfound-error';
|
||||||
|
|
||||||
|
export class ResetTokenStoreMock extends ResetTokenStore {
|
||||||
|
data: IResetToken[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(undefined, new EventEmitter(), noLoggerProvider);
|
||||||
|
this.data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActive(token: string): Promise<IResetToken> {
|
||||||
|
const row = this.data.find(tokens => tokens.token === token);
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insert(newToken: IResetTokenCreate): Promise<IResetToken> {
|
||||||
|
const token = {
|
||||||
|
userId: newToken.user_id,
|
||||||
|
token: newToken.reset_token,
|
||||||
|
expiresAt: newToken.expires_at,
|
||||||
|
createdBy: newToken.created_by,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
this.data.push(token);
|
||||||
|
return Promise.resolve(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete({ reset_token }: IResetTokenQuery): Promise<void> {
|
||||||
|
this.data.splice(
|
||||||
|
this.data.findIndex(token => token.token === reset_token),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExpired(): Promise<void> {
|
||||||
|
throw new Error('Not implemented in mock');
|
||||||
|
}
|
||||||
|
}
|
133
yarn.lock
133
yarn.lock
@ -640,13 +640,13 @@
|
|||||||
"@types/mime" "^1"
|
"@types/mime" "^1"
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin@^4.15.2":
|
"@typescript-eslint/eslint-plugin@^4.22.0":
|
||||||
version "4.15.2"
|
version "4.22.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.15.2.tgz#981b26b4076c62a5a55873fbef3fe98f83360c61"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.22.0.tgz#3d5f29bb59e61a9dba1513d491b059e536e16dbc"
|
||||||
integrity sha512-uiQQeu9tWl3f1+oK0yoAv9lt/KXO24iafxgQTkIYO/kitruILGx3uH+QtIAHqxFV+yIsdnJH+alel9KuE3J15Q==
|
integrity sha512-U8SP9VOs275iDXaL08Ln1Fa/wLXfj5aTr/1c0t0j6CdbOnxh+TruXu1p4I0NAvdPBQgoPjHsgKn28mOi0FzfoA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/experimental-utils" "4.15.2"
|
"@typescript-eslint/experimental-utils" "4.22.0"
|
||||||
"@typescript-eslint/scope-manager" "4.15.2"
|
"@typescript-eslint/scope-manager" "4.22.0"
|
||||||
debug "^4.1.1"
|
debug "^4.1.1"
|
||||||
functional-red-black-tree "^1.0.1"
|
functional-red-black-tree "^1.0.1"
|
||||||
lodash "^4.17.15"
|
lodash "^4.17.15"
|
||||||
@ -654,19 +654,29 @@
|
|||||||
semver "^7.3.2"
|
semver "^7.3.2"
|
||||||
tsutils "^3.17.1"
|
tsutils "^3.17.1"
|
||||||
|
|
||||||
"@typescript-eslint/experimental-utils@4.15.2":
|
"@typescript-eslint/experimental-utils@4.22.0":
|
||||||
version "4.15.2"
|
version "4.22.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.2.tgz#5efd12355bd5b535e1831282e6cf465b9a71cf36"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.22.0.tgz#68765167cca531178e7b650a53456e6e0bef3b1f"
|
||||||
integrity sha512-Fxoshw8+R5X3/Vmqwsjc8nRO/7iTysRtDqx6rlfLZ7HbT8TZhPeQqbPjTyk2RheH3L8afumecTQnUc9EeXxohQ==
|
integrity sha512-xJXHHl6TuAxB5AWiVrGhvbGL8/hbiCQ8FiWwObO3r0fnvBdrbWEDy1hlvGQOAWc6qsCWuWMKdVWlLAEMpxnddg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/json-schema" "^7.0.3"
|
"@types/json-schema" "^7.0.3"
|
||||||
"@typescript-eslint/scope-manager" "4.15.2"
|
"@typescript-eslint/scope-manager" "4.22.0"
|
||||||
"@typescript-eslint/types" "4.15.2"
|
"@typescript-eslint/types" "4.22.0"
|
||||||
"@typescript-eslint/typescript-estree" "4.15.2"
|
"@typescript-eslint/typescript-estree" "4.22.0"
|
||||||
eslint-scope "^5.0.0"
|
eslint-scope "^5.0.0"
|
||||||
eslint-utils "^2.0.0"
|
eslint-utils "^2.0.0"
|
||||||
|
|
||||||
"@typescript-eslint/parser@^4.15.2", "@typescript-eslint/parser@^4.4.1":
|
"@typescript-eslint/parser@^4.22.0":
|
||||||
|
version "4.22.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.22.0.tgz#e1637327fcf796c641fe55f73530e90b16ac8fe8"
|
||||||
|
integrity sha512-z/bGdBJJZJN76nvAY9DkJANYgK3nlRstRRi74WHm3jjgf2I8AglrSY+6l7ogxOmn55YJ6oKZCLLy+6PW70z15Q==
|
||||||
|
dependencies:
|
||||||
|
"@typescript-eslint/scope-manager" "4.22.0"
|
||||||
|
"@typescript-eslint/types" "4.22.0"
|
||||||
|
"@typescript-eslint/typescript-estree" "4.22.0"
|
||||||
|
debug "^4.1.1"
|
||||||
|
|
||||||
|
"@typescript-eslint/parser@^4.4.1":
|
||||||
version "4.15.2"
|
version "4.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.15.2.tgz#c804474321ef76a3955aec03664808f0d6e7872e"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.15.2.tgz#c804474321ef76a3955aec03664808f0d6e7872e"
|
||||||
integrity sha512-SHeF8xbsC6z2FKXsaTb1tBCf0QZsjJ94H6Bo51Y1aVEZ4XAefaw5ZAilMoDPlGghe+qtq7XdTiDlGfVTOmvA+Q==
|
integrity sha512-SHeF8xbsC6z2FKXsaTb1tBCf0QZsjJ94H6Bo51Y1aVEZ4XAefaw5ZAilMoDPlGghe+qtq7XdTiDlGfVTOmvA+Q==
|
||||||
@ -684,11 +694,24 @@
|
|||||||
"@typescript-eslint/types" "4.15.2"
|
"@typescript-eslint/types" "4.15.2"
|
||||||
"@typescript-eslint/visitor-keys" "4.15.2"
|
"@typescript-eslint/visitor-keys" "4.15.2"
|
||||||
|
|
||||||
|
"@typescript-eslint/scope-manager@4.22.0":
|
||||||
|
version "4.22.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.22.0.tgz#ed411545e61161a8d702e703a4b7d96ec065b09a"
|
||||||
|
integrity sha512-OcCO7LTdk6ukawUM40wo61WdeoA7NM/zaoq1/2cs13M7GyiF+T4rxuA4xM+6LeHWjWbss7hkGXjFDRcKD4O04Q==
|
||||||
|
dependencies:
|
||||||
|
"@typescript-eslint/types" "4.22.0"
|
||||||
|
"@typescript-eslint/visitor-keys" "4.22.0"
|
||||||
|
|
||||||
"@typescript-eslint/types@4.15.2":
|
"@typescript-eslint/types@4.15.2":
|
||||||
version "4.15.2"
|
version "4.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.15.2.tgz#04acf3a2dc8001a88985291744241e732ef22c60"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.15.2.tgz#04acf3a2dc8001a88985291744241e732ef22c60"
|
||||||
integrity sha512-r7lW7HFkAarfUylJ2tKndyO9njwSyoy6cpfDKWPX6/ctZA+QyaYscAHXVAfJqtnY6aaTwDYrOhp+ginlbc7HfQ==
|
integrity sha512-r7lW7HFkAarfUylJ2tKndyO9njwSyoy6cpfDKWPX6/ctZA+QyaYscAHXVAfJqtnY6aaTwDYrOhp+ginlbc7HfQ==
|
||||||
|
|
||||||
|
"@typescript-eslint/types@4.22.0":
|
||||||
|
version "4.22.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.22.0.tgz#0ca6fde5b68daf6dba133f30959cc0688c8dd0b6"
|
||||||
|
integrity sha512-sW/BiXmmyMqDPO2kpOhSy2Py5w6KvRRsKZnV0c4+0nr4GIcedJwXAq+RHNK4lLVEZAJYFltnnk1tJSlbeS9lYA==
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree@4.15.2":
|
"@typescript-eslint/typescript-estree@4.15.2":
|
||||||
version "4.15.2"
|
version "4.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.15.2.tgz#c2f7a1e94f3428d229d5ecff3ead6581ee9b62fa"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.15.2.tgz#c2f7a1e94f3428d229d5ecff3ead6581ee9b62fa"
|
||||||
@ -702,6 +725,19 @@
|
|||||||
semver "^7.3.2"
|
semver "^7.3.2"
|
||||||
tsutils "^3.17.1"
|
tsutils "^3.17.1"
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree@4.22.0":
|
||||||
|
version "4.22.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.22.0.tgz#b5d95d6d366ff3b72f5168c75775a3e46250d05c"
|
||||||
|
integrity sha512-TkIFeu5JEeSs5ze/4NID+PIcVjgoU3cUQUIZnH3Sb1cEn1lBo7StSV5bwPuJQuoxKXlzAObjYTilOEKRuhR5yg==
|
||||||
|
dependencies:
|
||||||
|
"@typescript-eslint/types" "4.22.0"
|
||||||
|
"@typescript-eslint/visitor-keys" "4.22.0"
|
||||||
|
debug "^4.1.1"
|
||||||
|
globby "^11.0.1"
|
||||||
|
is-glob "^4.0.1"
|
||||||
|
semver "^7.3.2"
|
||||||
|
tsutils "^3.17.1"
|
||||||
|
|
||||||
"@typescript-eslint/visitor-keys@4.15.2":
|
"@typescript-eslint/visitor-keys@4.15.2":
|
||||||
version "4.15.2"
|
version "4.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.2.tgz#3d1c7979ce75bf6acf9691109bd0d6b5706192b9"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.2.tgz#3d1c7979ce75bf6acf9691109bd0d6b5706192b9"
|
||||||
@ -710,6 +746,14 @@
|
|||||||
"@typescript-eslint/types" "4.15.2"
|
"@typescript-eslint/types" "4.15.2"
|
||||||
eslint-visitor-keys "^2.0.0"
|
eslint-visitor-keys "^2.0.0"
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys@4.22.0":
|
||||||
|
version "4.22.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.22.0.tgz#169dae26d3c122935da7528c839f42a8a42f6e47"
|
||||||
|
integrity sha512-nnMu4F+s4o0sll6cBSsTeVsT4cwxB7zECK3dFxzEjPBii9xLpq4yqqsy/FU5zMfan6G60DKZSCXAa3sHJZrcYw==
|
||||||
|
dependencies:
|
||||||
|
"@typescript-eslint/types" "4.22.0"
|
||||||
|
eslint-visitor-keys "^2.0.0"
|
||||||
|
|
||||||
abbrev@1:
|
abbrev@1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
|
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
|
||||||
@ -1627,11 +1671,6 @@ confusing-browser-globals@^1.0.10:
|
|||||||
resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz#30d1e7f3d1b882b25ec4933d1d1adac353d20a59"
|
resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz#30d1e7f3d1b882b25ec4933d1d1adac353d20a59"
|
||||||
integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==
|
integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==
|
||||||
|
|
||||||
confusing-browser-globals@^1.0.9:
|
|
||||||
version "1.0.9"
|
|
||||||
resolved "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz"
|
|
||||||
integrity sha512-KbS1Y0jMtyPgIxjO7ZzMAuUpAKMt1SzCL9fsrKsX6b0zJPTaT0SiSPmewwVZg9UAO83HVIlEhZF84LIjZ0lmAw==
|
|
||||||
|
|
||||||
connect-session-knex@^2.0.0:
|
connect-session-knex@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/connect-session-knex/-/connect-session-knex-2.0.0.tgz#c49003b8edd3e4cd64c701356223920abd052053"
|
resolved "https://registry.yarnpkg.com/connect-session-knex/-/connect-session-knex-2.0.0.tgz#c49003b8edd3e4cd64c701356223920abd052053"
|
||||||
@ -1743,6 +1782,14 @@ core-util-is@1.0.2, core-util-is@~1.0.0:
|
|||||||
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
|
||||||
integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
|
integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
|
||||||
|
|
||||||
|
cors@^2.8.5:
|
||||||
|
version "2.8.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
|
||||||
|
integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
|
||||||
|
dependencies:
|
||||||
|
object-assign "^4"
|
||||||
|
vary "^1"
|
||||||
|
|
||||||
cosmiconfig@^6.0.0:
|
cosmiconfig@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz"
|
resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz"
|
||||||
@ -2306,15 +2353,6 @@ escape-string-regexp@^4.0.0:
|
|||||||
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"
|
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"
|
||||||
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
|
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
|
||||||
|
|
||||||
eslint-config-airbnb-base@^14.1.0:
|
|
||||||
version "14.2.0"
|
|
||||||
resolved "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.0.tgz"
|
|
||||||
integrity sha512-Snswd5oC6nJaevs3nZoLSTvGJBvzTfnBqOIArkf3cbyTyq9UD79wOk8s+RiL6bhca0p/eRO6veczhf6A/7Jy8Q==
|
|
||||||
dependencies:
|
|
||||||
confusing-browser-globals "^1.0.9"
|
|
||||||
object.assign "^4.1.0"
|
|
||||||
object.entries "^1.1.2"
|
|
||||||
|
|
||||||
eslint-config-airbnb-base@^14.2.0, eslint-config-airbnb-base@^14.2.1:
|
eslint-config-airbnb-base@^14.2.0, eslint-config-airbnb-base@^14.2.1:
|
||||||
version "14.2.1"
|
version "14.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz#8a2eb38455dc5a312550193b319cdaeef042cd1e"
|
resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz#8a2eb38455dc5a312550193b319cdaeef042cd1e"
|
||||||
@ -2342,12 +2380,10 @@ eslint-config-airbnb@^18.2.0:
|
|||||||
object.assign "^4.1.2"
|
object.assign "^4.1.2"
|
||||||
object.entries "^1.1.2"
|
object.entries "^1.1.2"
|
||||||
|
|
||||||
eslint-config-prettier@^6.10.1:
|
eslint-config-prettier@^8.1.0:
|
||||||
version "6.11.0"
|
version "8.1.0"
|
||||||
resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz"
|
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz#4ef1eaf97afe5176e6a75ddfb57c335121abc5a6"
|
||||||
integrity sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA==
|
integrity sha512-oKMhGv3ihGbCIimCAjqkdzx2Q+jthoqnXSP+d86M9tptwugycmTFdVR4IpLgq2c4SHifbwO90z2fQ8/Aio73yw==
|
||||||
dependencies:
|
|
||||||
get-stdin "^6.0.0"
|
|
||||||
|
|
||||||
eslint-import-resolver-node@^0.3.4:
|
eslint-import-resolver-node@^0.3.4:
|
||||||
version "0.3.4"
|
version "0.3.4"
|
||||||
@ -2365,9 +2401,9 @@ eslint-module-utils@^2.6.0:
|
|||||||
debug "^2.6.9"
|
debug "^2.6.9"
|
||||||
pkg-dir "^2.0.0"
|
pkg-dir "^2.0.0"
|
||||||
|
|
||||||
eslint-plugin-import@^2.20.2:
|
eslint-plugin-import@^2.22.1:
|
||||||
version "2.22.1"
|
version "2.22.1"
|
||||||
resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702"
|
||||||
integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==
|
integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==
|
||||||
dependencies:
|
dependencies:
|
||||||
array-includes "^3.1.1"
|
array-includes "^3.1.1"
|
||||||
@ -2384,10 +2420,10 @@ eslint-plugin-import@^2.20.2:
|
|||||||
resolve "^1.17.0"
|
resolve "^1.17.0"
|
||||||
tsconfig-paths "^3.9.0"
|
tsconfig-paths "^3.9.0"
|
||||||
|
|
||||||
eslint-plugin-prettier@^3.1.3:
|
eslint-plugin-prettier@^3.3.1:
|
||||||
version "3.1.4"
|
version "3.3.1"
|
||||||
resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.1.tgz#7079cfa2497078905011e6f82e8dd8453d1371b7"
|
||||||
integrity sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==
|
integrity sha512-Rq3jkcFY8RYeQLgk2cCwuc0P7SEFwDravPhsJZOQ5N4YI4DSg50NyqJ/9gdZHzQlHf8MvafSesbNJCcP/FF6pQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
prettier-linter-helpers "^1.0.0"
|
prettier-linter-helpers "^1.0.0"
|
||||||
|
|
||||||
@ -3070,11 +3106,6 @@ get-package-type@^0.1.0:
|
|||||||
resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz"
|
resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz"
|
||||||
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
|
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
|
||||||
|
|
||||||
get-stdin@^6.0.0:
|
|
||||||
version "6.0.0"
|
|
||||||
resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz"
|
|
||||||
integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==
|
|
||||||
|
|
||||||
get-stream@^4.1.0:
|
get-stream@^4.1.0:
|
||||||
version "4.1.0"
|
version "4.1.0"
|
||||||
resolved "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz"
|
resolved "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz"
|
||||||
@ -4845,7 +4876,7 @@ oauth@0.9.x:
|
|||||||
resolved "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz"
|
resolved "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz"
|
||||||
integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
|
integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
|
||||||
|
|
||||||
object-assign@^4.1.0, object-assign@^4.1.1:
|
object-assign@^4, object-assign@^4.1.0, object-assign@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
||||||
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
||||||
@ -6807,10 +6838,10 @@ typedarray@^0.0.6:
|
|||||||
resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz"
|
resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz"
|
||||||
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
|
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
|
||||||
|
|
||||||
typescript@^4.1.5:
|
typescript@^4.2.4:
|
||||||
version "4.1.5"
|
version "4.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.5.tgz#123a3b214aaff3be32926f0d8f1f6e704eb89a72"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
|
||||||
integrity sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==
|
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
|
||||||
|
|
||||||
uid-safe@~2.1.5:
|
uid-safe@~2.1.5:
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
@ -6969,7 +7000,7 @@ validate-npm-package-license@^3.0.1:
|
|||||||
spdx-correct "^3.0.0"
|
spdx-correct "^3.0.0"
|
||||||
spdx-expression-parse "^3.0.0"
|
spdx-expression-parse "^3.0.0"
|
||||||
|
|
||||||
vary@~1.1.2:
|
vary@^1, vary@~1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
|
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
|
||||||
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
|
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
|
||||||
|
Loading…
Reference in New Issue
Block a user