src/handlers/user.js
// @flow
import { Handler, HandlerLibrary } from './handler'
import TokenHandler from './token'
import BookingHandler from './booking'
import LaundryInvitationHandler from './laundry_invitation'
import UserModel from '../db/models/user'
import type { Profile, UserRole } from '../db/models/user'
import * as str from '../utils/string'
import * as error from '../utils/error'
import * as pwd from '../utils/password'
import uuid from 'uuid'
import config from 'config'
import Debug from 'debug'
import LaundryHandler from './laundry'
import type { User } from 'laundree-sdk/lib/redux'
import type { User as RestUser } from 'laundree-sdk/lib/sdk'
import type { EventOption as CalEvent } from 'ical-generator'
import type { ObjectId } from 'mongoose'
import type { LocaleType } from '../locales'
const debug = Debug('laundree.handlers.user')
class UserHandlerLibrary extends HandlerLibrary<User, UserModel, RestUser, *> {
constructor () {
super(UserHandler, UserModel, {
update: (obj) => typeof obj === 'string' ? null : {type: 'UPDATE_USER', payload: obj.reduxModel()}
})
}
/**
* Find a user from given email address.
* @param {string} email
* @return {Promise.<UserHandler>}
*/
async findFromEmail (email: string): Promise<?UserHandler> {
const [user] = await this.find({'profiles.emails.value': email.toLowerCase().trim()})
return user
}
/**
* Find or create a user from profile.
* @param {Profile} profile
* @return {Promise.<UserHandler>}
*/
async findOrCreateFromProfile (profile: Profile) {
if (!profile.emails || !profile.emails.length) {
return null
}
const user = await this.findFromEmail(profile.emails[0].value)
return user
? user.updateProfile(profile)
: this.createUserFromProfile(profile)
}
/**
*
* @param {string} userId
* @param {string} secret
* @returns {Promise.<{user: UserHandler=, token: TokenHandler=}>}
*/
async findFromIdWithTokenSecret (userId: string, secret: string): {} | { user: UserHandler, token: TokenHandler } {
const user = await this.findFromId(userId)
if (!user) {
return {}
}
const token = await user.findAuthTokenFromSecret(secret)
if (!token) {
return {}
}
return {user, token}
}
/**
* Finds a user from verified email and valid password
* @param email
* @param password
* @returns {Promise.<UserHandler>}
*/
async findFromVerifiedEmailAndVerifyPassword (email: string, password: string) {
const user = await this.findFromEmail(email)
if (!user) {
return null
}
if (!user.isVerified(email)) {
return null
}
const result = await user.verifyPassword(password)
return result
? user
: null
}
/**
* Create a new user from given profile.
* @param {Profile} profile
* @return {Promise.<UserHandler>}
*/
async createUserFromProfile (profile: Profile) {
if (!profile.emails || !profile.emails.length) {
return null
}
const role = profile.emails.reduce((role, {value}) => role || config.get('defaultUsers')[value], null) || 'user'
const model = await new UserModel({
docVersion: 1,
profiles: [Object.assign({}, profile, {emails: profile.emails.map(({value}) => ({value: value.toLowerCase()}))})],
latestProvider: profile.provider,
role
}).save()
const handler = new UserHandler(model)
this.emitEvent('create', handler)
await handler.addLaundriesFromInvites()
return handler
}
/**
* @param {String} displayName
* @param {String} email
* @param {String} password
* @returns {Promise.<UserHandler>}
*/
async createUserWithPassword (displayName: string, email: string, password: string): Promise<?UserHandler> {
displayName = displayName.split(' ').filter((name) => name.length).join(' ')
const profile = {
id: email,
displayName: displayName,
name: displayNameToName(displayName),
provider: 'local',
emails: [{value: email}],
photos: [{value: `/identicon/${str.hash(email)}/150.svg`}]
}
const [user, passwordHash] = await Promise.all([
this.createUserFromProfile(profile),
pwd.hashPassword(password)])
if (!user) {
return null
}
user.model.password = passwordHash
await user.model.save()
return user
}
/**
* Create a demo user
* @returns {Promise.<{email: string, user: UserHandler, password: string}>}
*/
async createDemoUser () {
const displayName = 'Demo user'
const email = `demo-user-${uuid.v4()}@laundree.io`
const profile = {
id: email,
displayName,
name: displayNameToName(displayName),
provider: 'local',
emails: [{value: email}],
photos: [{value: `/identicon/${str.hash(email)}/150.svg`}]
}
const [user, {token, hash}] = await Promise
.all([
this.createUserFromProfile(profile),
pwd.generateTokenAndHash()
])
if (!user) {
return null
}
user.model.oneTimePassword = hash
user.model.demo = true
user.model.explicitVerifiedEmails.push(email)
await user.model.save()
return {password: token, user, email}
}
}
/**
* @param {string}displayName
* @return {{givenName: string=, middleName: string=, lastName: string=}}
*/
function displayNameToName (displayName) {
const names = displayName.split(' ').filter(name => name.length)
const noNames = names.length
if (noNames === 0) return {}
if (noNames === 1) return {givenName: names[0]}
if (noNames === 2) return {givenName: names[0], familyName: names[1]}
return {
givenName: names[0],
middleName: names.slice(1, names.length - 1).join(' '),
familyName: names[names.length - 1]
}
}
/**
* @typedef {{provider: string, id: string, displayName: string, name: {familyName: string=, middleName: string=, givenName: string=}, emails: {value: string, type: string=}[], photos: {value: string}[]=}} Profile
*/
const restUrlPrefix = `${config.get('api.base')}/users/`
export default class UserHandler extends Handler<UserModel, User, RestUser> {
static lib = new UserHandlerLibrary()
lib = UserHandler.lib
static restSummary (i: ObjectId | UserHandler) {
const id = Handler.handlerOrObjectIdToString(i)
return {id, href: restUrlPrefix + id.toString()}
}
updateActions = [
async (user: UserHandler) => {
user.model.calendarTokensReferences = []
user.model.docVersion = 1
await user.model.save()
return new UserHandler(user.model)
}
]
/**
* Will eventually add profile if a profile with the same provider isn't present, or
* replace the existing profile if it is.
*
* @param {Profile} profile
* @return {Promise.<UserHandler>}
*/
updateProfile (profile: Profile): Promise<UserHandler> {
this.model.profiles = this.model.profiles.filter((p) => p.provider !== profile.provider)
this.model.profiles.push(profile)
this.model.latestProvider = profile.provider
return this.save()
}
/**
* Add one-signal player id
* @param playId
* @returns {Promise.<number>}
*/
async addOneSignalPlayerId (playId: string) {
if (this.model.oneSignalPlayerIds.includes(playId)) {
return 0
}
this.model.oneSignalPlayerIds.push(playId)
await this.save()
this._updateBookings().catch(error.logError)
return 1
}
async _updateBookings () {
debug('Updating bookigns with playId', this.model.oneSignalPlayerIds)
const bookings = await BookingHandler.lib.find({owner: this.model.id, from: {$gte: new Date()}})
debug('Found bookings', bookings)
return Promise.all(bookings.map(booking => booking._updateNotification(this.model.oneSignalPlayerIds)))
}
/**
* Will create a new password-reset token with 1h. expiration.
* @return {Promise.<TokenHandler>}
*/
generateResetToken () {
debug('Generating reset token')
return this._generateToken(uuid.v4(), 'reset')
.then(token => this._revokeResetToken().then(() => {
debug('Generated token', token.model._id)
this.model.resetPassword = {
token: token.model._id,
expire: Date.now() + 3600000
}
return this.model.save().then(() => token)
}))
}
_fetchPasswordResetToken () {
return TokenHandler
.lib.findFromId(this.model.resetPassword.token)
}
async _revokeResetToken () {
if (!this.model.resetPassword.token) return
const token = await this._fetchPasswordResetToken()
this.model.resetPassword = {}
await Promise.all([token && token.deleteToken(), this.model.save()])
}
/**
* Create a new calendar token
* @returns {Promise.<TokenHandler>}
*/
generateCalendarToken (name: string) {
debug('Generating calendar token')
return this
._generateToken(name, 'calendar')
.then(token => {
debug('Token ', token.model.id)
this.model.calendarTokensReferences.push(token.model._id)
return this.model.save().then(() => token)
})
}
/**
* Verifies a calendar token
* @param {string} secret
* @returns {Promise.<boolean>|*}
*/
verifyCalendarToken (secret: string) {
debug('Verifying calendar token', this.model.calendarTokensReferences)
return TokenHandler.lib
.findTokenFromSecret(secret, {_id: {$in: this.model.calendarTokensReferences}})
.then(Boolean)
}
/**
* Lists bookings as events
* @returns {Promise.<{start: Date, end: Date, uid: string, timestamp: Date, url: string, summary: string}[]>}
*/
generateEvents (): Promise<CalEvent[]> {
return this
.fetchLaundries()
.then(laundries => Promise.all(laundries.map(l => l.generateEvents())))
.then(events => events.reduce((l1, l2) => l1.concat(l2), []))
}
/**
* Finds the laundries owned by this user
* @return {Promise<LaundryHandler[]>}
*/
findOwnedLaundries () {
return LaundryHandler.lib.find({owners: this.model._id})
}
/**
* Find a auth token from given secret
* @param secret
* @return {Promise.<TokenHandler>}
*/
findAuthTokenFromSecret (secret: string): Promise<?TokenHandler> {
return TokenHandler.lib.findTokenFromSecret(secret, {_id: {$in: this.model.authTokens}})
}
async verifyAuthToken (secret: string): Promise<boolean> {
const token = await this.findAuthTokenFromSecret(secret)
return Boolean(token)
}
fetchAuthTokens () {
return TokenHandler.lib.find({_id: {$in: this.model.authTokens}})
}
_fetchUserTokens () {
return TokenHandler.lib.find({owner: this.model._id})
}
/**
* Generates a new auth token associated with this account.
* @param {string} name
* @return {Promise.<TokenHandler>}
*/
async generateAuthToken (name: string): Promise<TokenHandler> {
const token = await this._generateToken(name, 'auth')
this.model.authTokens.push(token.model._id)
await this.model.save()
return token
}
_generateToken (name: string, type: *) {
return TokenHandler.lib._createToken(this, name, type)
}
/**
* Update the name of the user.
*/
async update (opts: { name?: string, locale?: LocaleType }) {
if (opts.name) {
this.model.overrideDisplayName = opts.name
}
if (opts.locale) {
this.model.locale = opts.locale
}
if (!opts.locale && !opts.name) {
return
}
await this.model.save()
this.lib.emitEvent('update', this)
}
/**
* Update the role of the user
* @param role
* @returns {Promise}
*/
async updateRole (role: UserRole) {
this.model.role = role
await this.model.save()
this.lib.emitEvent('update', this)
}
/**
* Create a new laundry with the current user as owner.
* @param {string} name
* @param {string=} timeZone
* @param {string=} googlePlaceId
* @return {Promise.<LaundryHandler>}
*/
createLaundry (name: string, timeZone: string = '', googlePlaceId: string = '') {
return LaundryHandler.lib.createLaundry(this, name, false, timeZone, googlePlaceId)
}
/**
* Add this user as a user on the given laundry.
* @param {LaundryHandler} laundry
* @return {Promise.<UserHandler>}
*/
_addLaundry (laundry: LaundryHandler) {
this.model.laundries.push(laundry.model._id)
return this.save()
}
_removeLaundry (laundry: LaundryHandler) {
this.model.laundries.pull(laundry.model._id)
return this.save()
}
/**
* Remove provided token
* @param {TokenHandler} token
* @return {Promise.<UserHandler>}
*/
async removeAuthToken (token: TokenHandler) {
await token.deleteToken()
this.model.authTokens.pull(token.model._id)
await this.model.save()
return this
}
fetchLaundries (): Promise<LaundryHandler[]> {
return LaundryHandler.lib.find({_id: {$in: this.model.laundries}})
}
/**
* Will create a new email verification.
* @param {string} email
* @return {Promise.<TokenHandler>}
*/
async generateVerifyEmailToken (email: string) {
email = email.toLowerCase()
if (this.model.emails.indexOf(email) < 0) return Promise.resolve()
const token = await this._generateToken(uuid.v4(), 'verification')
const cleanTokens = this.model.pendingExplicitEmailVerifications.filter(v => v.email !== email)
cleanTokens.push({email: email, token: token.model._id})
this.model.pendingExplicitEmailVerifications = cleanTokens
await this.model.save()
return token
}
/**
* Will verify a given email against the latest generated token.
* @param {string} email
* @param {string} token
* @returns {Promise.<boolean>}
*/
async verifyEmail (email: string, token: string): Promise<boolean> {
email = email.toLowerCase()
const storedToken = this.model.pendingExplicitEmailVerifications.find(element => element.email === email)
if (!storedToken) return false
const t = await TokenHandler.lib.findFromId(storedToken.token)
if (!t) return false
const result = await t.verify(token)
if (!result) return false
this.model.pendingExplicitEmailVerifications = this.model.pendingExplicitEmailVerifications
.filter(element => element.email !== email)
this.model.explicitVerifiedEmails.push(email)
await this.model.save()
return true
}
isVerified (email: string) {
return this.model.verifiedEmails.indexOf(email.toLowerCase()) >= 0
}
async addLaundriesFromInvites () {
const invites = await LaundryInvitationHandler.lib.find({email: {$in: this.model.emails}})
const laundries = await LaundryHandler.lib.find({_id: {$in: invites.map(({model: {laundry}}) => laundry)}})
await Promise.all(laundries.map(laundry => laundry.addUser(this)))
await Promise.all(invites.map(invite => invite.markUsed()))
}
resetPassword (password: string) {
return Promise.all([
pwd.hashPassword(password)
.then(hash => {
this.model.password = hash
return this.model.save()
}),
this._revokeResetToken()])
}
/**
* Verifies given password.
* @param {string} password
* @return {Promise.<boolean>}
*/
verifyPassword (password: string) {
if (this.model.password) return pwd.comparePassword(password, this.model.password)
return this.verifyOneTimePassword(password)
}
async verifyOneTimePassword (password: string) {
if (!this.model.oneTimePassword) {
return false
}
const result = await pwd.comparePassword(password, this.model.oneTimePassword)
if (!result) return result
this.model.oneTimePassword = undefined
await this.model.save()
return true
}
/**
* Verifies a given token.
* @param {string} token
* @return {Promise.<boolean>}
*/
async verifyResetPasswordToken (token: string) {
debug('Verify reset password token', token, this.model.resetPassword)
if (!this.model.resetPassword.token) return false
if (!this.model.resetPassword.expire) return false
if (new Date() > this.model.resetPassword.expire) return false
const tok = await this._fetchPasswordResetToken()
if (!tok) {
return false
}
return tok.verify(token)
}
deleteUser () {
return Promise.all([
this.fetchLaundries().then(ls => Promise.all(ls.map(l => l.removeUser(this)))),
this._fetchUserTokens().then(ts => Promise.all(ts.map(t => t.deleteToken())))
])
.then(() => this.model.remove())
.then(() => this)
}
/**
* Update the last seen variable to now
* @return {Promise.<Date>}
*/
async seen () {
const date = new Date()
this.model.lastSeen = date
this.model = await this.model.save()
return date
}
restUrl = `/users/${this.model.id}`
photo (): ?string {
const profile = this.model.latestProfile
const photo = profile.photos && profile.photos.length && profile.photos[0].value
if (!photo) return null
if (profile.provider !== 'google') return photo || undefined
const matches = photo.match(/sz=([0-9]+)$/)
if (!matches) return photo
return photo.substr(0, photo.length - matches[1].length) + '200'
}
isDemo (): boolean {
return this.model.demo
}
isAdmin (): boolean {
return this.model.role === 'admin'
}
hasPassword (): boolean {
return Boolean(this.model.password)
}
toRest (): RestUser {
return {
id: this.model.id,
displayName: this.model.displayName,
lastSeen: this.model.lastSeen ? this.model.lastSeen.toISOString() : undefined,
name: {
familyName: this.model.name.familyName,
givenName: this.model.name.givenName,
middleName: this.model.name.middleName
},
locale: this.model.locale || 'en',
laundries: this.model.laundries.map(LaundryHandler.restSummary),
tokens: this.model.authTokens.map(TokenHandler.restSummary),
photo: this.photo() || `/identicon/${str.hash(this.model.id)}/150.svg`,
demo: Boolean(this.model.demo),
role: this.model.role,
href: this.restUrl
}
}
reduxModel (): User {
return {
id: this.model.id,
photo: this.photo() || `/identicon/${str.hash(this.model.id)}/150.svg`,
displayName: this.model.displayName,
laundries: this.model.laundries.map((id) => id.toString()),
lastSeen: this.model.lastSeen ? this.model.lastSeen.toISOString() : undefined,
role: this.model.role,
demo: Boolean(this.model.demo)
}
}
}