nitro-web 0.0.183 → 0.0.185
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/app.tsx +3 -3
- package/client/index.ts +1 -0
- package/components/auth/auth.api.js +257 -177
- package/components/auth/inviteConfirm.tsx +106 -0
- package/components/auth/signup.tsx +6 -3
- package/package.json +1 -1
- package/server/constants.js +268 -0
- package/server/index.js +4 -1
- package/server/models/company.js +10 -9
- package/server/models/user.js +7 -6
- package/server/router.js +2 -2
- package/types/components/auth/auth.api.d.ts +41 -39
- package/types/components/auth/auth.api.d.ts.map +1 -1
- package/types/server/constants.d.ts +26 -0
- package/types/server/constants.d.ts.map +1 -0
- package/types/server/index.d.ts +10 -1
- package/types/server/index.d.ts.map +1 -1
- package/types/server/models/company.d.ts +16 -2
- package/types/server/models/company.d.ts.map +1 -1
- package/types/server/models/user.d.ts +19 -38
- package/types/server/models/user.d.ts.map +1 -1
- package/types.ts +1 -2
package/client/app.tsx
CHANGED
|
@@ -319,8 +319,8 @@ async function beforeApp(config: Config) {
|
|
|
319
319
|
|
|
320
320
|
export const middleware = {
|
|
321
321
|
// Default middleware that can referenced from component routes
|
|
322
|
-
isAdmin: (route: unknown, store: { user?: {
|
|
323
|
-
if (store.user?.
|
|
322
|
+
isAdmin: (route: unknown, store: { user?: { isAdmin?: boolean } }) => {
|
|
323
|
+
if (store.user?.isAdmin) return
|
|
324
324
|
else if (store.user) return { redirect: '/signin?unauth' }
|
|
325
325
|
else return { redirect: '/signin?signin' }
|
|
326
326
|
},
|
|
@@ -328,7 +328,7 @@ export const middleware = {
|
|
|
328
328
|
if (store.user?.company?.currentSubscription) return
|
|
329
329
|
else return { redirect: '/plans/subscribe' }
|
|
330
330
|
},
|
|
331
|
-
isUser: (route: unknown, store: { user?: {
|
|
331
|
+
isUser: (route: unknown, store: { user?: { isAdmin?: boolean } }) => {
|
|
332
332
|
if (store.user) return
|
|
333
333
|
else return { redirect: '/signin?signin' }
|
|
334
334
|
},
|
package/client/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { createStore, exposedStoreData, preloadedStoreData, setStoreWrapper } fr
|
|
|
16
16
|
export { Signin } from '../components/auth/signin'
|
|
17
17
|
export { Signup } from '../components/auth/signup'
|
|
18
18
|
export { ResetInstructions, ResetPassword } from '../components/auth/reset'
|
|
19
|
+
export { InviteConfirm } from '../components/auth/inviteConfirm'
|
|
19
20
|
export { Dashboard } from '../components/dashboard/dashboard'
|
|
20
21
|
export { NotFound } from '../components/partials/not-found'
|
|
21
22
|
export { Styleguide } from '../components/partials/styleguide'
|
|
@@ -4,50 +4,46 @@ import bcrypt from 'bcrypt'
|
|
|
4
4
|
import passport from 'passport'
|
|
5
5
|
import passportLocal from 'passport-local'
|
|
6
6
|
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'
|
|
7
|
-
import db from 'monastery'
|
|
7
|
+
import db, { isId } from 'monastery'
|
|
8
8
|
import jsonwebtoken from 'jsonwebtoken'
|
|
9
9
|
import { sendEmail } from 'nitro-web/server'
|
|
10
10
|
import { isArray, pick, ucFirst, fullNameSplit } from 'nitro-web/util'
|
|
11
|
+
// Todo: detect if the user is already invited to the company, instead of token error
|
|
11
12
|
|
|
12
|
-
let authConfig = null
|
|
13
13
|
const JWT_SECRET = process.env.JWT_SECRET || 'replace_this_with_secure_env_secret'
|
|
14
|
+
let authConfig = null
|
|
15
|
+
let auth = {
|
|
16
|
+
userFindFromProvider, userSigninGetStore, getStore,
|
|
17
|
+
userCreate, passwordValidate, tokenCreate, tokenParse, tokenSend,
|
|
18
|
+
tokenConfirmForReset, tokenConfirmForSingleTenant, tokenConfirmForMultiTenant,
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
export const routes = {
|
|
16
22
|
// Routes
|
|
17
23
|
'get /api/store': [store],
|
|
18
24
|
'get /api/signout': [signout],
|
|
19
|
-
'post /api/signin': [signin],
|
|
20
|
-
'post /api/signup': [signup],
|
|
25
|
+
'post /api/signin': [signin], // [todo: route gaurd basic limiter]
|
|
26
|
+
'post /api/signup': [signup], // [todo: route gaurd Altcha]
|
|
21
27
|
'post /api/reset-instructions': [resetInstructions],
|
|
22
|
-
'post /api/reset-
|
|
28
|
+
'post /api/reset-confirm': [resetConfirm], // was reset-password
|
|
23
29
|
'post /api/invite-instructions': [inviteInstructions],
|
|
24
|
-
'post /api/invite-
|
|
30
|
+
'post /api/invite-confirm': [inviteConfirm], // was invite-accept
|
|
25
31
|
'delete /api/account/:uid': [remove],
|
|
26
|
-
|
|
27
|
-
// todo:
|
|
28
|
-
// We dont need all of these overridable, just signinAndGetStore, findUserFromProvider,
|
|
29
|
-
// and getStore. So we will allow just these two to be passed around.
|
|
30
|
-
// userCreate not needed, they can just create their own signup function.
|
|
31
|
-
/// Maybe we can pass these into setup?
|
|
32
|
-
|
|
33
|
-
// Overridable helpers
|
|
32
|
+
// Setup (called automatically when express starts)
|
|
34
33
|
setup: setup,
|
|
35
|
-
findUserFromProvider: findUserFromProvider,
|
|
36
|
-
getStore: getStore,
|
|
37
|
-
signinAndGetStore: signinAndGetStore,
|
|
38
|
-
tokenCreate: tokenCreate,
|
|
39
|
-
tokenParse: tokenParse,
|
|
40
|
-
userCreate: userCreate,
|
|
41
|
-
validatePassword: validatePassword,
|
|
42
|
-
sendToken: sendToken,
|
|
43
|
-
inviteOrResetConfirm: inviteOrResetConfirm,
|
|
44
34
|
}
|
|
45
35
|
|
|
46
|
-
function setup(middleware, _config) {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const configKeys = ['baseUrl', 'emailFrom', 'env', 'name', 'mailgunDomain', 'mailgunKey', 'masterPassword', 'isNotMultiTenant'
|
|
36
|
+
function setup(middleware, _config, helpers = {}) {
|
|
37
|
+
// Tip: you can pass in your own helpers to override the default helpers, `this` context contains all helpers.
|
|
38
|
+
// E.g. setup: (middleware, config, helpers) => authRoutes.setup(middleware, config, { getStore, ... })
|
|
39
|
+
const configKeys = ['baseUrl', 'emailFrom', 'env', 'name', 'mailgunDomain', 'mailgunKey', 'masterPassword', 'isNotMultiTenant',
|
|
40
|
+
'confirmInvites']
|
|
50
41
|
authConfig = pick(_config, configKeys)
|
|
42
|
+
auth = { ...auth, ...helpers }
|
|
43
|
+
// Bind all the functions to the auth object (so the can be individually overridden and reference other helpers)
|
|
44
|
+
for (const key of Object.keys(auth)) {
|
|
45
|
+
if (typeof auth[key] === 'function') auth[key] = auth[key].bind(auth)
|
|
46
|
+
}
|
|
51
47
|
for (const key of ['baseUrl', 'emailFrom', 'env', 'name']) {
|
|
52
48
|
if (!authConfig[key]) throw new Error(`Missing config value for: config.${key}`)
|
|
53
49
|
}
|
|
@@ -57,7 +53,7 @@ function setup(middleware, _config) {
|
|
|
57
53
|
{ usernameField: 'email' },
|
|
58
54
|
async (email, password, next) => {
|
|
59
55
|
try {
|
|
60
|
-
const user = await
|
|
56
|
+
const user = await auth.userFindFromProvider({ email }, password)
|
|
61
57
|
next(null, user)
|
|
62
58
|
} catch (err) {
|
|
63
59
|
next(err.message)
|
|
@@ -74,7 +70,7 @@ function setup(middleware, _config) {
|
|
|
74
70
|
},
|
|
75
71
|
async (payload, done) => {
|
|
76
72
|
try {
|
|
77
|
-
const user = await
|
|
73
|
+
const user = await auth.userFindFromProvider({ _id: payload._id })
|
|
78
74
|
if (!user) return done(null, false)
|
|
79
75
|
return done(null, user)
|
|
80
76
|
} catch (err) {
|
|
@@ -109,19 +105,14 @@ function setup(middleware, _config) {
|
|
|
109
105
|
}
|
|
110
106
|
|
|
111
107
|
async function store(req, res) {
|
|
112
|
-
res.json(await
|
|
108
|
+
res.json(await auth.getStore(req.user))
|
|
113
109
|
}
|
|
114
110
|
|
|
115
111
|
async function signup(req, res) {
|
|
116
112
|
try {
|
|
117
113
|
const desktop = req.query.desktop
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
config: authConfig,
|
|
121
|
-
template: 'welcome',
|
|
122
|
-
to: `${ucFirst(user.firstName)}<${user.email}>`,
|
|
123
|
-
}).catch(console.error)
|
|
124
|
-
res.send(await this.signinAndGetStore(user, desktop, this.getStore))
|
|
114
|
+
const user = await auth.userCreate(req.body)
|
|
115
|
+
res.send(await auth.userSigninGetStore(user, desktop))
|
|
125
116
|
} catch (err) {
|
|
126
117
|
res.error(err)
|
|
127
118
|
}
|
|
@@ -136,7 +127,7 @@ function signin(req, res) {
|
|
|
136
127
|
if (err) return res.error(err)
|
|
137
128
|
if (!user && info) return res.error('email', info.message)
|
|
138
129
|
try {
|
|
139
|
-
const response = await
|
|
130
|
+
const response = await auth.userSigninGetStore(user, desktop)
|
|
140
131
|
res.send(response)
|
|
141
132
|
} catch (err) {
|
|
142
133
|
res.error(err)
|
|
@@ -167,18 +158,102 @@ async function remove(req, res) {
|
|
|
167
158
|
// }
|
|
168
159
|
await db.user.remove({ query: { _id: uid }})
|
|
169
160
|
// Logout now so that an error doesn't throw when naviating to /signout
|
|
170
|
-
req.logout()
|
|
161
|
+
await new Promise((resolve, reject) => req.logout(err => err ? reject(err) : resolve()))
|
|
171
162
|
res.send(`User: '${uid}' removed successfully`)
|
|
172
163
|
} catch (err) {
|
|
173
164
|
res.error(err)
|
|
174
165
|
}
|
|
175
166
|
}
|
|
176
167
|
|
|
168
|
+
export async function resetInstructions(req, res) {
|
|
169
|
+
try {
|
|
170
|
+
// const desktop = req.query.hasOwnProperty('desktop') ? '?desktop' : '' // see sendToken for future usage
|
|
171
|
+
let email = (req.body.email || '').trim().toLowerCase()
|
|
172
|
+
if (!email) throw { title: 'email', detail: 'The email you entered is incorrect.' }
|
|
173
|
+
|
|
174
|
+
let user = await db.user.findOne({ query: { email }, _privateData: true })
|
|
175
|
+
if (!user) throw { title: 'email', detail: 'The email you entered is incorrect.' }
|
|
176
|
+
|
|
177
|
+
// Send reset password email
|
|
178
|
+
await auth.tokenSend({ _id: user._id, email: user.email, firstName: user.firstName })
|
|
179
|
+
res.json({})
|
|
180
|
+
} catch (err) {
|
|
181
|
+
res.error(err)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function inviteInstructions(req, res) {
|
|
186
|
+
// Single-tenant:
|
|
187
|
+
//. - no user found: error (not supported, must be pre-created)
|
|
188
|
+
// - user pre-created: update user with token, and send them the token
|
|
189
|
+
// Multi-tenant:
|
|
190
|
+
// - user exists and confirmInvites=false: auto-add user to company.users
|
|
191
|
+
// - user exists and confirmInvites=true: add user to company.invites, and send them the token
|
|
192
|
+
//. - no user found: add user to company.invites and send them the token
|
|
193
|
+
try {
|
|
194
|
+
if (authConfig.isNotMultiTenant) {
|
|
195
|
+
const user = await db.user.findOne({ query: { _id: req.body._id }, _privateData: true })
|
|
196
|
+
if (!user) throw new Error('Please pre-create the user first, no user id found.')
|
|
197
|
+
await auth.tokenSend({ type: 'invite', _id: user._id, email: user.email, firstName: user.firstName })
|
|
198
|
+
res.json({})
|
|
199
|
+
|
|
200
|
+
} else {
|
|
201
|
+
const companyId = req.body.companyId
|
|
202
|
+
if (!req.user.isAdmin && (!companyId || req.user?.company?._id?.toString() !== companyId.toString())) {
|
|
203
|
+
throw new Error('You do not have permission to invite users to this company.')
|
|
204
|
+
}
|
|
205
|
+
const existingUser = await db.user.findOne({ query: { email: req.body.email } })
|
|
206
|
+
if (existingUser && !authConfig.confirmInvites) {
|
|
207
|
+
await db.company.update({
|
|
208
|
+
query: db.id(companyId),
|
|
209
|
+
$push: { users: { _id: existingUser._id, role: req.body.role, status: 'active' } },
|
|
210
|
+
})
|
|
211
|
+
} else {
|
|
212
|
+
await auth.tokenSend({
|
|
213
|
+
type: 'companyInvite', _id: companyId,
|
|
214
|
+
email: req.body.email,
|
|
215
|
+
firstName: existingUser?.firstName || req.body.firstName,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
res.json({})
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
res.error(err)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function resetConfirm(req, res) {
|
|
226
|
+
try {
|
|
227
|
+
res.send(await auth.tokenConfirmForReset(req))
|
|
228
|
+
} catch (err) {
|
|
229
|
+
res.error(err)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function inviteConfirm(req, res) {
|
|
234
|
+
// single tenant:
|
|
235
|
+
// - user pre-created: update user with new password (and any other inviteConfirm.tsx form fields)
|
|
236
|
+
//. - no user found: error (not supported, must be pre-created)
|
|
237
|
+
// multi tenant:
|
|
238
|
+
// - user exists and confirmInvites=false: no-op (i.e no token, user already added)
|
|
239
|
+
// - user exists and confirmInvites=true: update company (invite.tsx should display 'Invite Accepted' and redirect to home)
|
|
240
|
+
// - no user found: create new user with new password (and any other inviteConfirm.tsx form fields)
|
|
241
|
+
try {
|
|
242
|
+
const result = authConfig.isNotMultiTenant
|
|
243
|
+
? await auth.tokenConfirmForSingleTenant(req)
|
|
244
|
+
: await auth.tokenConfirmForMultiTenant(req)
|
|
245
|
+
res.send(result)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
res.error(err)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
177
251
|
/* ---- Overridable helpers ------------------ */
|
|
178
252
|
|
|
179
|
-
export async function
|
|
253
|
+
export async function userFindFromProvider(query, passwordToCheck) {
|
|
180
254
|
/**
|
|
181
|
-
* Find user for state (and verify password if signing in with email)
|
|
255
|
+
* Find user for state (and verify password if signing in with email).
|
|
256
|
+
* NOTE: the application needs to set user.company to the active company (if multi tenant)
|
|
182
257
|
* @param {object} query - e.g. { email: 'test@test.com' }
|
|
183
258
|
* @param {string} <passwordToCheck> - password to test
|
|
184
259
|
*/
|
|
@@ -199,10 +274,14 @@ export async function findUserFromProvider(query, passwordToCheck) {
|
|
|
199
274
|
}
|
|
200
275
|
if (!user) {
|
|
201
276
|
throw new Error(checkPassword ? 'Email or password is incorrect.' : 'Session-user is invalid.')
|
|
277
|
+
} else if (user.status && user.status !== 'active') {
|
|
278
|
+
throw new Error('This user account is not active.')
|
|
202
279
|
} else if (isMultiTenant && !user.company) {
|
|
203
280
|
throw new Error('The current company is no longer associated with this user')
|
|
204
281
|
} else if (isMultiTenant && user.company.status != 'active') {
|
|
205
282
|
throw new Error('This user is not associated with an active company')
|
|
283
|
+
} else if (isMultiTenant && !user.company.users?.find(u => u._id.toString() === user._id.toString())?.status === 'active') {
|
|
284
|
+
throw new Error('This user is not associated with an active company')
|
|
206
285
|
} else {
|
|
207
286
|
if (checkPassword) {
|
|
208
287
|
if (!user.password) {
|
|
@@ -219,6 +298,15 @@ export async function findUserFromProvider(query, passwordToCheck) {
|
|
|
219
298
|
}
|
|
220
299
|
}
|
|
221
300
|
|
|
301
|
+
export async function userSigninGetStore(user, isDesktop) {
|
|
302
|
+
if (user.loginActive === false) throw { title: 'error', detail: 'This user is not available.' }
|
|
303
|
+
user.desktop = isDesktop
|
|
304
|
+
|
|
305
|
+
const jwt = jsonwebtoken.sign({ _id: user._id }, JWT_SECRET, { expiresIn: '30d' })
|
|
306
|
+
const store = await this.getStore(user)
|
|
307
|
+
return { ...store, jwt }
|
|
308
|
+
}
|
|
309
|
+
|
|
222
310
|
export async function getStore(user) {
|
|
223
311
|
// Initial store
|
|
224
312
|
return {
|
|
@@ -226,45 +314,43 @@ export async function getStore(user) {
|
|
|
226
314
|
}
|
|
227
315
|
}
|
|
228
316
|
|
|
229
|
-
|
|
230
|
-
if (user.loginActive === false) throw 'This user is not available.'
|
|
231
|
-
if (!getStore) throw new Error('Please provide a getStore function')
|
|
232
|
-
user.desktop = isDesktop
|
|
317
|
+
/* ---- Helpers (not easily overridable) ----- */
|
|
233
318
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Creates a new user and company (if multi tenant and `user.company` is not an id)
|
|
321
|
+
* @param {object} userData - user data
|
|
322
|
+
* @param {string} [userData.password] - optional
|
|
323
|
+
* @param {string} [userData.password2] - optional, to confirm the password
|
|
324
|
+
* @param {string} [userData.company] - if multi tenant and `user.company` is not an id, create a new company
|
|
325
|
+
* @param {boolean} [skipSendEmail=false] - whether to skip sending the welcome email
|
|
326
|
+
* @returns {Promise<object>} - the created user
|
|
327
|
+
*/
|
|
328
|
+
export async function userCreate({ password, password2, company, ...userDataProp }, skipSendEmail) {
|
|
240
329
|
try {
|
|
241
|
-
if (!this.findUserFromProvider) {
|
|
242
|
-
throw new Error('this.findUserFromProvider doesn\'t exist, make sure the context is available when calling this function')
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const options = { blacklist: ['-_id'] }
|
|
246
|
-
const isMultiTenant = !authConfig.isNotMultiTenant
|
|
247
330
|
const userId = db.id()
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
331
|
+
const options = { blacklist: ['-_id'] }
|
|
332
|
+
const companyIsId = !authConfig.isNotMultiTenant && isId(company)
|
|
333
|
+
|
|
334
|
+
// Define new company data if applicable
|
|
335
|
+
const companyData = !authConfig.isNotMultiTenant && !companyIsId && {
|
|
336
|
+
_id: db.id(),
|
|
251
337
|
users: [{ _id: userId, role: 'owner', status: 'active' }],
|
|
338
|
+
...(company ? company : {}), // removed
|
|
252
339
|
}
|
|
340
|
+
|
|
341
|
+
// Define user data
|
|
253
342
|
const userData = {
|
|
254
343
|
...userDataProp,
|
|
255
344
|
_id: userId,
|
|
256
|
-
...(userDataProp.name ? {
|
|
257
|
-
firstName: fullNameSplit(userDataProp.name)[0],
|
|
258
|
-
lastName: fullNameSplit(userDataProp.name)[1],
|
|
259
|
-
} : {}),
|
|
260
345
|
password: password ? await bcrypt.hash(password, 10) : undefined,
|
|
261
|
-
...(
|
|
346
|
+
...(companyIsId ? { company: company } : (companyData ? { company: companyData._id } : {})), // AKA "active company"
|
|
347
|
+
...(userDataProp.name ? { firstName: fullNameSplit(userDataProp.name)[0], lastName: fullNameSplit(userDataProp.name)[1] } : {}),
|
|
262
348
|
}
|
|
263
349
|
// First validate the data so we don't have to create a transaction
|
|
264
350
|
const results = await Promise.allSettled([
|
|
265
351
|
db.user.validate(userData, options),
|
|
266
|
-
|
|
267
|
-
|
|
352
|
+
typeof password === 'undefined' ? Promise.resolve() : this.passwordValidate(password, password2),
|
|
353
|
+
...(companyData ? [db.company.validate(companyData, options)] : []),
|
|
268
354
|
])
|
|
269
355
|
|
|
270
356
|
// Throw all the errors from at once
|
|
@@ -277,10 +363,19 @@ export async function userCreate({ business, password, ...userDataProp }) {
|
|
|
277
363
|
|
|
278
364
|
// Insert company & user
|
|
279
365
|
await db.user.insert({ data: userData, ...options })
|
|
280
|
-
if (
|
|
366
|
+
if (companyData) await db.company.insert({ data: companyData, ...options })
|
|
367
|
+
|
|
368
|
+
// Send welcome email
|
|
369
|
+
if (!skipSendEmail) {
|
|
370
|
+
sendEmail({
|
|
371
|
+
config: authConfig,
|
|
372
|
+
template: 'welcome',
|
|
373
|
+
to: `${ucFirst(userData.firstName)}<${userData.email}>`,
|
|
374
|
+
}).catch(console.error)
|
|
375
|
+
}
|
|
281
376
|
|
|
282
377
|
// Return the user
|
|
283
|
-
return await
|
|
378
|
+
return await this.userFindFromProvider({ _id: userId })
|
|
284
379
|
|
|
285
380
|
} catch (err) {
|
|
286
381
|
if (!isArray(err)) throw err
|
|
@@ -288,30 +383,7 @@ export async function userCreate({ business, password, ...userDataProp }) {
|
|
|
288
383
|
}
|
|
289
384
|
}
|
|
290
385
|
|
|
291
|
-
export function
|
|
292
|
-
return new Promise((resolve) => {
|
|
293
|
-
crypto.randomBytes(16, (err, buff) => {
|
|
294
|
-
let hash = buff.toString('hex') // 32 chars
|
|
295
|
-
resolve(`${hash}${id || ''}:${Date.now()}`)
|
|
296
|
-
})
|
|
297
|
-
})
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export function tokenParse(token) {
|
|
301
|
-
let split = (token || '').split(':')
|
|
302
|
-
let hash = split[0].slice(0, 32)
|
|
303
|
-
let userId = split[0].slice(32)
|
|
304
|
-
let time = split[1]
|
|
305
|
-
if (!hash || !userId || !time) {
|
|
306
|
-
throw { title: 'error', detail: 'Sorry your code is invalid.' }
|
|
307
|
-
} else if (parseFloat(time) + 1000 * 60 * 60 * 24 < Date.now()) {
|
|
308
|
-
throw { title: 'error', detail: 'Sorry your code has timed out.' }
|
|
309
|
-
} else {
|
|
310
|
-
return userId
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
export async function validatePassword(password='', password2) {
|
|
386
|
+
export async function passwordValidate(password='', password2) {
|
|
315
387
|
// let hasLowerChar = password.match(/[a-z]/)
|
|
316
388
|
// let hasUpperChar = password.match(/[A-Z]/)
|
|
317
389
|
// let hasNumber = password.match(/\d/)
|
|
@@ -330,117 +402,125 @@ export async function validatePassword(password='', password2) {
|
|
|
330
402
|
}
|
|
331
403
|
}
|
|
332
404
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// const desktop = req.query.hasOwnProperty('desktop') ? '?desktop' : '' // see sendToken for future usage
|
|
341
|
-
let email = (req.body.email || '').trim().toLowerCase()
|
|
342
|
-
if (!email) throw { title: 'email', detail: 'The email you entered is incorrect.' }
|
|
343
|
-
|
|
344
|
-
let user = await db.user.findOne({ query: { email }, _privateData: true })
|
|
345
|
-
if (!user) throw { title: 'email', detail: 'The email you entered is incorrect.' }
|
|
346
|
-
|
|
347
|
-
// Send reset password email
|
|
348
|
-
await sendToken({ type: 'reset', user: user })
|
|
349
|
-
res.json({})
|
|
350
|
-
} catch (err) {
|
|
351
|
-
res.error(err)
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
export async function inviteInstructions(req, res) {
|
|
356
|
-
try {
|
|
357
|
-
// const desktop = req.query.hasOwnProperty('desktop') ? '?desktop' : '' // see sendToken for future usage
|
|
358
|
-
let user = await db.user.findOne({ query: { _id: req.params._id }, _privateData: true })
|
|
359
|
-
if (!user) throw new Error('Invalid user id')
|
|
360
|
-
// Send invite instructions email
|
|
361
|
-
await sendToken({ type: 'invite', user: user })
|
|
362
|
-
res.json({})
|
|
363
|
-
} catch (err) {
|
|
364
|
-
res.error(err)
|
|
365
|
-
}
|
|
405
|
+
export function tokenCreate(modelName, id) {
|
|
406
|
+
return new Promise((resolve) => {
|
|
407
|
+
crypto.randomBytes(16, (err, buff) => {
|
|
408
|
+
let hash = buff.toString('hex') // 32 chars
|
|
409
|
+
resolve(`${hash}:${modelName}:${id || ''}:${Date.now()}`)
|
|
410
|
+
})
|
|
411
|
+
})
|
|
366
412
|
}
|
|
367
413
|
|
|
368
|
-
export
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
414
|
+
export function tokenParse(token, modelName, maxAgeMs = 1000 * 60 * 60 * 24) {
|
|
415
|
+
let [hash, modelNameParsed, id, time] = (token || '').split(':')
|
|
416
|
+
if (!hash || !id || !time) {
|
|
417
|
+
throw { title: 'error', detail: 'Sorry your code is invalid.' }
|
|
418
|
+
} else if (modelNameParsed !== modelName) {
|
|
419
|
+
throw { title: 'error', detail: 'Sorry we are detecting a token mismatch.' }
|
|
420
|
+
} else if (parseFloat(time) + maxAgeMs < Date.now()) {
|
|
421
|
+
throw { title: 'error', detail: 'Sorry your code has timed out.' }
|
|
422
|
+
} else {
|
|
423
|
+
return id
|
|
373
424
|
}
|
|
374
425
|
}
|
|
375
426
|
|
|
376
|
-
export async function
|
|
377
|
-
|
|
378
|
-
res.send(await this.inviteOrResetConfirm('reset', req))
|
|
379
|
-
} catch (err) {
|
|
380
|
-
res.error(err)
|
|
381
|
-
}
|
|
427
|
+
export async function tokenConfirmForReset(req) {
|
|
428
|
+
return await this.tokenConfirmForSingleTenant(req, true)
|
|
382
429
|
}
|
|
383
430
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const { token, password, password2 } = req.body
|
|
388
|
-
const name = type === 'invite' ? 'inviteToken' : 'resetToken'
|
|
431
|
+
export async function tokenConfirmForSingleTenant(req, isReset) {
|
|
432
|
+
const { token, password, password2, ...userData } = req.body
|
|
433
|
+
const tokenName = isReset ? 'resetToken' : 'inviteToken'
|
|
389
434
|
const desktop = req.query.desktop
|
|
390
|
-
const id = tokenParse(token)
|
|
391
|
-
await
|
|
435
|
+
const id = this.tokenParse(token, 'user')
|
|
436
|
+
await this.passwordValidate(password, password2)
|
|
392
437
|
|
|
393
|
-
|
|
394
|
-
if (!user || user[
|
|
438
|
+
const user = await db.user.findOne({ query: id, blacklist: ['-' + tokenName], _privateData: true })
|
|
439
|
+
if (!user || user[tokenName] !== token) throw new Error('Sorry your token is invalid or has already been used.')
|
|
395
440
|
|
|
396
441
|
await db.user.update({
|
|
397
442
|
query: user._id,
|
|
398
443
|
data: {
|
|
399
444
|
password: await bcrypt.hash(password, 10),
|
|
400
|
-
[
|
|
445
|
+
[tokenName]: '', // remove token
|
|
446
|
+
...userData,
|
|
401
447
|
},
|
|
402
|
-
blacklist: ['-' +
|
|
448
|
+
blacklist: ['-' + tokenName, '-password'],
|
|
403
449
|
})
|
|
404
|
-
|
|
405
|
-
|
|
450
|
+
return await this.userSigninGetStore({ ...user, [tokenName]: undefined }, desktop)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function tokenConfirmForMultiTenant(req) {
|
|
454
|
+
const { token, ...userData } = req.body
|
|
455
|
+
const desktop = req.query.desktop
|
|
456
|
+
const companyId = db.id(this.tokenParse(token, 'company'))
|
|
457
|
+
|
|
458
|
+
// Find the invite from the token (company.invites[] entry)
|
|
459
|
+
const company = await db.company.findOne({ query: { _id: companyId, 'invites.inviteToken': token }, _privateData: true })
|
|
460
|
+
if (!company) throw new Error('Sorry your token is invalid or has already been used.')
|
|
461
|
+
const invite = company.invites.find(inv => inv.inviteToken === token)
|
|
462
|
+
|
|
463
|
+
// Has the user already been added to the company (company.users[] entry)?
|
|
464
|
+
const existingUser = await db.user.findOne({ query: { email: userData.email }, _privateData: true })
|
|
465
|
+
if (existingUser && company.users.some(u => u._id.toString() === existingUser._id.toString())) {
|
|
466
|
+
throw new Error('This user has already been added to the company.')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Find or create new user
|
|
470
|
+
const user = existingUser || await this.userCreate({ ...userData, company: companyId }, true) // AKA "active company"
|
|
471
|
+
|
|
472
|
+
// Add the user to the company
|
|
473
|
+
await db.company.update({
|
|
474
|
+
query: companyId,
|
|
475
|
+
$push: { users: { _id: user._id, role: invite.role, status: 'active' } }, // add user to company
|
|
476
|
+
$pull: { invites: { inviteToken: token } }, // remove invite
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// Signin
|
|
480
|
+
return await this.userSigninGetStore(user, desktop)
|
|
406
481
|
}
|
|
407
482
|
|
|
408
483
|
/**
|
|
409
|
-
*
|
|
484
|
+
* Creates and sends a reset or invite token to a user or company
|
|
410
485
|
* @param {object} options
|
|
411
|
-
* @param {'reset' | 'invite'} options.type -
|
|
412
|
-
* @param {
|
|
413
|
-
* @param {
|
|
414
|
-
* @param {
|
|
486
|
+
* @param {'reset' | 'invite' | 'companyInvite'} options.type - token type (default: 'reset')
|
|
487
|
+
* @param {string} options._id - user or company id
|
|
488
|
+
* @param {string} options.email - recipient email
|
|
489
|
+
* @param {string} options.firstName - recipient first name
|
|
490
|
+
* @param {function} [options.beforeUpdate] - runs before updating the model with the token, return null to skip update
|
|
491
|
+
* @param {function} [options.beforeSendEmail] - runs before sending the email, receives (options, token)
|
|
415
492
|
* @returns {Promise<{token: string, mailgunPromise: Promise<unknown>}>}
|
|
416
493
|
*/
|
|
417
|
-
export async function
|
|
418
|
-
if (!
|
|
419
|
-
if (!
|
|
420
|
-
if (!
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
494
|
+
export async function tokenSend({ type = 'reset', _id, email, firstName, beforeUpdate, beforeSendEmail }) {
|
|
495
|
+
if (!_id) throw new Error(`${type === 'companyInvite' ? 'company' : 'user'} id is required`)
|
|
496
|
+
if (!email) throw new Error('email is required')
|
|
497
|
+
if (!firstName) throw new Error('firstName is required')
|
|
498
|
+
|
|
499
|
+
const tokenName = type === 'companyInvite' ? 'inviteToken' : type + 'Token'
|
|
500
|
+
const modelName = type === 'companyInvite' ? 'company' : 'user'
|
|
501
|
+
const token = await this.tokenCreate(modelName, _id)
|
|
502
|
+
const _beforeUpdate = beforeUpdate || (o => o)
|
|
503
|
+
|
|
504
|
+
if (modelName === 'company') {
|
|
505
|
+
// For companies, add the token to company.invites[].inviteToken
|
|
506
|
+
await db.company.update({ query: db.id(_id), $pull: { invites: { email } } })
|
|
507
|
+
const result = await db.company.update({ query: db.id(_id), $push: { invites: _beforeUpdate({ email: email, inviteToken: token }) }})
|
|
508
|
+
if (!result._output.matchedCount) throw new Error('Invalid company id to update the token for')
|
|
509
|
+
} else {
|
|
510
|
+
// For users, add the token to user.inviteToken|resetToken
|
|
511
|
+
const result = await db[modelName].update({
|
|
512
|
+
query: db.id(_id),
|
|
513
|
+
$set: _beforeUpdate({ [tokenName]: token, isInvited: type === 'invite' ? true : undefined }),
|
|
514
|
+
})
|
|
515
|
+
if (!result._output.matchedCount) throw new Error('Invalid ' + modelName + ' id to update the token for')
|
|
436
516
|
}
|
|
437
517
|
|
|
438
518
|
// Send email
|
|
439
519
|
const options = {
|
|
440
520
|
config: authConfig,
|
|
441
|
-
template: type === 'reset' ? 'reset-
|
|
442
|
-
to: `${ucFirst(
|
|
443
|
-
data: { token: token },
|
|
521
|
+
template: type === 'reset' ? 'reset-instructions' : 'invite-instructions',
|
|
522
|
+
to: `${ucFirst(firstName)}<${email}>`,
|
|
523
|
+
data: { token: token },
|
|
444
524
|
}
|
|
445
525
|
const mailgunPromise = sendEmail(beforeSendEmail ? beforeSendEmail(options, token) : options).catch(err => {
|
|
446
526
|
console.error('sendEmail(..) mailgun error', err)
|