nitro-web 0.1.4 → 0.2.0
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 +5 -5
- package/client/index.ts +1 -1
- package/client/store.ts +15 -5
- package/components/auth/auth.api.js +304 -117
- package/components/auth/inviteConfirm.tsx +57 -37
- package/components/auth/reset.tsx +9 -4
- package/components/auth/signin.tsx +12 -5
- package/components/auth/signup.tsx +6 -4
- package/package.json +1 -1
- package/server/email/index.js +34 -32
- package/server/index.js +1 -1
- package/server/models/company.js +3 -2
- package/server/models/user.js +2 -1
- package/server/router.js +6 -5
- package/types/components/auth/auth.api.d.ts +40 -11
- package/types/components/auth/auth.api.d.ts.map +1 -1
- package/types/server/email/index.d.ts +2 -0
- package/types/server/email/index.d.ts.map +1 -1
- package/types/server/index.d.ts +1 -1
- package/types/server/index.d.ts.map +1 -1
- package/types/server/models/company.d.ts +5 -1
- package/types/server/models/company.d.ts.map +1 -1
- package/types/server/models/user.d.ts +1 -1
- package/types/server/models/user.d.ts.map +1 -1
- package/types/util.d.ts +2 -2
- package/types/util.d.ts.map +1 -1
- package/types.ts +2 -1
- package/util.js +4 -3
|
@@ -7,17 +7,20 @@ import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'
|
|
|
7
7
|
import db, { isId } from 'monastery'
|
|
8
8
|
import jsonwebtoken from 'jsonwebtoken'
|
|
9
9
|
import { getDomain } from 'tldts'
|
|
10
|
-
import { sendEmail } from 'nitro-web/server'
|
|
11
|
-
import { isArray, pick, ucFirst, fullNameSplit } from 'nitro-web/util'
|
|
10
|
+
import { sendEmail, requiredEmailConfigKeys, optionalEmailConfigKeys } from 'nitro-web/server'
|
|
11
|
+
import { isArray, pick, ucFirst, fullNameSplit, isEmail } from 'nitro-web/util'
|
|
12
12
|
// Todo: detect if the user is already invited to the company, instead of token error
|
|
13
13
|
|
|
14
14
|
const JWT_SECRET = process.env.JWT_SECRET || 'replace_this_with_secure_env_secret'
|
|
15
15
|
let authConfig = null
|
|
16
|
+
const requiredConfigKeys = [...requiredEmailConfigKeys]
|
|
17
|
+
const optionalConfigKeys = [...optionalEmailConfigKeys, 'masterPassword', 'isNotMultiTenant', 'autoAddExistingUsers', 'limitOneTenantPerUser']
|
|
16
18
|
|
|
17
19
|
export const auth = {
|
|
18
|
-
userFindFromProvider, userSigninGetStore, getStore,
|
|
20
|
+
userFindFromProvider, userSigninGetStore, getStore, addUserToCompany,
|
|
19
21
|
userCreate, passwordValidate, tokenCreate, tokenParse, tokenSend,
|
|
20
22
|
tokenConfirmForReset, tokenConfirmForSingleTenant, tokenConfirmForMultiTenant,
|
|
23
|
+
getBaseUrl, invitePreConfirm, inviteConfirm, updateMemberRole, removeMember,
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export const routes = {
|
|
@@ -29,24 +32,30 @@ export const routes = {
|
|
|
29
32
|
'post /api/reset-instructions': [resetInstructions],
|
|
30
33
|
'post /api/reset-confirm': [resetConfirm], // was reset-password
|
|
31
34
|
'post /api/invite-instructions': [inviteInstructions],
|
|
32
|
-
'post /api/
|
|
35
|
+
'post /api/resend-instructions': [resendInstructions],
|
|
36
|
+
'get /api/invite-pre-confirm/:token': [invitePreConfirm],
|
|
37
|
+
'post /api/invite-confirm/:token': [inviteConfirm], // was invite-accept
|
|
33
38
|
'delete /api/account/:uid': [remove],
|
|
39
|
+
'put /api/company/:cid/member-role/:uidOrEmail': ['isCompanyOwner', updateMemberRole],
|
|
40
|
+
'delete /api/company/:cid/member/:uidOrEmail': ['isCompanyOwner', removeMember],
|
|
34
41
|
// Setup (called automatically when express starts)
|
|
35
42
|
setup: setup,
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
function setup(middleware, _config, helpers = {}) {
|
|
46
|
+
// todo: i think returning setup with auth context is better. We can then spread this
|
|
47
|
+
// in the project e.g. const { getUrl, signup, ... } = auth.setup(middleware, config, { getStore, ... })
|
|
48
|
+
//
|
|
39
49
|
// Tip: you can pass in your own helpers to override the default helpers, internally all functions are called
|
|
40
50
|
// with `auth` as the context, so `this` context contains all helpers.
|
|
41
51
|
// E.g. setup: (middleware, config, helpers) => authRoutes.setup(middleware, config, { getStore, ... })
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
authConfig = pick(_config, configKeys)
|
|
52
|
+
|
|
53
|
+
authConfig = pick(_config, [...requiredConfigKeys, ...optionalConfigKeys])
|
|
45
54
|
for (const key of Object.keys(helpers)) {
|
|
46
55
|
auth[key] = helpers[key]
|
|
47
56
|
}
|
|
48
|
-
for (const key of
|
|
49
|
-
if (!authConfig[key]) throw new Error(`
|
|
57
|
+
for (const key of requiredConfigKeys) {
|
|
58
|
+
if (!authConfig[key]) throw new Error(`Auth setup(): Missing config.${key} value`)
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
passport.use(
|
|
@@ -112,7 +121,7 @@ async function store(req, res) {
|
|
|
112
121
|
async function signup(req, res) {
|
|
113
122
|
try {
|
|
114
123
|
const desktop = req.query.desktop
|
|
115
|
-
const user = await auth.userCreate(req.body, getBaseUrl(req))
|
|
124
|
+
const user = await auth.userCreate(req.body, auth.getBaseUrl(req))
|
|
116
125
|
res.send(await auth.userSigninGetStore(user, desktop))
|
|
117
126
|
} catch (err) {
|
|
118
127
|
res.error(err)
|
|
@@ -171,15 +180,10 @@ async function remove(req, res) {
|
|
|
171
180
|
|
|
172
181
|
export async function resetInstructions(req, res) {
|
|
173
182
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (!email) throw { title: 'email', detail: 'The email you entered is incorrect.' }
|
|
177
|
-
|
|
178
|
-
let user = await db.user.findOne({ query: { email }, _privateData: true })
|
|
183
|
+
const email = (req.body.email || '').trim().toLowerCase()
|
|
184
|
+
const user = email && await db.user.findOne({ query: { email }, _privateData: true })
|
|
179
185
|
if (!user) throw { title: 'email', detail: 'The email you entered is incorrect.' }
|
|
180
|
-
|
|
181
|
-
// Send reset password email
|
|
182
|
-
await auth.tokenSend({ _id: user._id, email: user.email, firstName: user.firstName, baseUrl: getBaseUrl(req) })
|
|
186
|
+
await auth.tokenSend({ type: 'reset', id: user._id, payload: pick(user, ['email', 'firstName']), baseUrl: auth.getBaseUrl(req) })
|
|
183
187
|
res.json({})
|
|
184
188
|
} catch (err) {
|
|
185
189
|
res.error(err)
|
|
@@ -187,46 +191,84 @@ export async function resetInstructions(req, res) {
|
|
|
187
191
|
}
|
|
188
192
|
|
|
189
193
|
export async function inviteInstructions(req, res) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Single-tenant (requires pre-created user): res.body: isResend ? { _id } : { _id }
|
|
196
|
+
* - no user found: error
|
|
197
|
+
* - user exsits: send them the token to update their password
|
|
198
|
+
*
|
|
199
|
+
* Multi-tenant: res.body: isResend ? { companyId, email } : { companyId, email, firstName, role }
|
|
200
|
+
* - user exists and autoAddExistingUsers=true: auto-add user to company.users
|
|
201
|
+
* - user exists and autoAddExistingUsers=false: add user to company.invites, and send them the token
|
|
202
|
+
* - no user found: add user to company.invites and send them the token
|
|
203
|
+
*/
|
|
197
204
|
try {
|
|
198
205
|
if (authConfig.isNotMultiTenant) {
|
|
199
206
|
const user = await db.user.findOne({ query: { _id: req.body._id }, _privateData: true })
|
|
200
207
|
if (!user) throw new Error('Please pre-create the user first, no user id found.')
|
|
201
|
-
await auth.tokenSend({
|
|
202
|
-
|
|
208
|
+
await auth.tokenSend({
|
|
209
|
+
type: 'invite',
|
|
210
|
+
id: user._id,
|
|
211
|
+
payload: pick(user, ['email', 'firstName']),
|
|
212
|
+
baseUrl: auth.getBaseUrl(req),
|
|
213
|
+
isResend: req.isResend,
|
|
214
|
+
})
|
|
215
|
+
return res.json({})
|
|
203
216
|
|
|
204
217
|
} else {
|
|
205
|
-
const companyId = req.body
|
|
206
|
-
if (!
|
|
207
|
-
|
|
218
|
+
const { companyId, email, firstName, role } = req.body
|
|
219
|
+
if (!companyId) throw new Error('CompanyId is missing from the request body.')
|
|
220
|
+
const company = await db.company.findOne({ query: { _id: db.id(companyId) }, project: { users: 1 } })
|
|
221
|
+
|
|
222
|
+
if (!company) {
|
|
223
|
+
throw new Error('Company not found.')
|
|
224
|
+
} else if (!req.user.isAdmin && company.users?.find(u => u._id.toString() === req.user._id.toString())?.role !== 'owner') {
|
|
225
|
+
throw new Error('Only company owners can invite users to this company.')
|
|
208
226
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
227
|
+
|
|
228
|
+
// Is there an exsiting user witht his email?
|
|
229
|
+
const existingUserCheck = (authConfig.autoAddExistingUsers || authConfig.limitOneTenantPerUser) ? true : false
|
|
230
|
+
const existingUser = existingUserCheck ? await db.user.findOne({ query: { email }, project: { firstName: 1, company: 1 } }) : null
|
|
231
|
+
const autoAdd = authConfig.autoAddExistingUsers && existingUser
|
|
232
|
+
|
|
233
|
+
// Check if the user is already a member of another company (the extra condition is to handle user's missing from company.users)
|
|
234
|
+
if (authConfig.limitOneTenantPerUser && existingUser?.company && existingUser.company.toString() !== companyId) {
|
|
235
|
+
throw new Error('This user is already a member of another company.')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (autoAdd) {
|
|
239
|
+
// Make sure the user is not already a member of the company
|
|
240
|
+
if (company?.users?.some(u => u._id.toString() === existingUser._id.toString())) {
|
|
241
|
+
throw new Error('User is already a member of this company.')
|
|
242
|
+
}
|
|
243
|
+
// Add the user to the company
|
|
244
|
+
await auth.addUserToCompany(companyId, existingUser._id, role)
|
|
245
|
+
|
|
215
246
|
} else {
|
|
216
247
|
await auth.tokenSend({
|
|
217
|
-
type: 'companyInvite',
|
|
218
|
-
|
|
219
|
-
firstName:
|
|
220
|
-
baseUrl: getBaseUrl(req),
|
|
248
|
+
type: 'companyInvite',
|
|
249
|
+
id: companyId,
|
|
250
|
+
payload: { email: email, firstName: firstName, role: role },
|
|
251
|
+
baseUrl: auth.getBaseUrl(req),
|
|
252
|
+
isResend: req.isResend,
|
|
221
253
|
})
|
|
222
254
|
}
|
|
223
|
-
|
|
255
|
+
|
|
256
|
+
res.json(await db.company.findOne({
|
|
257
|
+
query: db.id(companyId),
|
|
258
|
+
populate: db.company.authPopulate(),
|
|
259
|
+
project: autoAdd ? { users: 1, usersExpanded: 1 } : { invites: 1, invitesExpanded: 1 },
|
|
260
|
+
}))
|
|
224
261
|
}
|
|
225
262
|
} catch (err) {
|
|
226
263
|
res.error(err)
|
|
227
264
|
}
|
|
228
265
|
}
|
|
229
266
|
|
|
267
|
+
export async function resendInstructions(req, res) {
|
|
268
|
+
req.isResend = true
|
|
269
|
+
return inviteInstructions(req, res)
|
|
270
|
+
}
|
|
271
|
+
|
|
230
272
|
export async function resetConfirm(req, res) {
|
|
231
273
|
try {
|
|
232
274
|
res.send(await auth.tokenConfirmForReset(req))
|
|
@@ -240,8 +282,8 @@ export async function inviteConfirm(req, res) {
|
|
|
240
282
|
// - user pre-created: update user with new password (and any other inviteConfirm.tsx form fields)
|
|
241
283
|
//. - no user found: error (not supported, must be pre-created)
|
|
242
284
|
// multi tenant:
|
|
243
|
-
// - user exists and
|
|
244
|
-
// - user exists and
|
|
285
|
+
// - user exists and autoAddExistingUsers=true: no-op (i.e no token, user already added)
|
|
286
|
+
// - user exists and autoAddExistingUsers=false: update company (invite.tsx should display 'Invite Accepted' and redirect to home)
|
|
245
287
|
// - no user found: create new user with new password (and any other inviteConfirm.tsx form fields)
|
|
246
288
|
try {
|
|
247
289
|
const result = authConfig.isNotMultiTenant
|
|
@@ -253,6 +295,77 @@ export async function inviteConfirm(req, res) {
|
|
|
253
295
|
}
|
|
254
296
|
}
|
|
255
297
|
|
|
298
|
+
export async function invitePreConfirm(req, res) {
|
|
299
|
+
req.preConfirm = true
|
|
300
|
+
return await auth.inviteConfirm(req, res)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function updateMemberRole(req, res) {
|
|
304
|
+
// req.body: { role }
|
|
305
|
+
try {
|
|
306
|
+
const { role } = req.body
|
|
307
|
+
const { cid, uidOrEmail } = req.params
|
|
308
|
+
const companyId = db.id(cid)
|
|
309
|
+
const uid = uidOrEmail.includes('@') ? null : db.id(uidOrEmail)
|
|
310
|
+
const email = uidOrEmail.includes('@') ? uidOrEmail.trim().toLowerCase() : null
|
|
311
|
+
|
|
312
|
+
const company = await db.company.findOne({ query: { _id: companyId }, blacklist: false, project: { users: 1, invites: 1 } })
|
|
313
|
+
const item = uid ? company?.users?.find((u) => u._id.toString() === uid.toString()) : company?.invites?.find((i) => i.email === email)
|
|
314
|
+
if (!company) throw { title: 'error', detail: 'Company not found.' }
|
|
315
|
+
else if (uid && !item) throw { title: 'error', detail: 'User not found.' }
|
|
316
|
+
else if (email && !item) throw { title: 'error', detail: 'Invite not found.' }
|
|
317
|
+
else if (!role) throw { title: 'role', detail: 'Role is required.' }
|
|
318
|
+
else if (role !== 'owner' && uid) ensureNotLastOwner(company?.users, uid)
|
|
319
|
+
|
|
320
|
+
// Validate the role
|
|
321
|
+
await db.company.validate(
|
|
322
|
+
{ [uid ? 'users' : 'invites']: [{ ...item, role }] },
|
|
323
|
+
{ update: true, blacklist: ['-users', '-invites'] }
|
|
324
|
+
)
|
|
325
|
+
await db.company.update({
|
|
326
|
+
query: { _id: companyId, ...(uid ? { 'users._id': uid } : { 'invites.email': email }) },
|
|
327
|
+
$set: { ...(uid ? { 'users.$.role': role } : { 'invites.$.role': role }) },
|
|
328
|
+
})
|
|
329
|
+
res.json({})
|
|
330
|
+
} catch (err) {
|
|
331
|
+
res.error(err)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function removeMember(req, res) {
|
|
336
|
+
try {
|
|
337
|
+
const { cid, uidOrEmail } = req.params
|
|
338
|
+
const companyId = db.id(cid)
|
|
339
|
+
const uid = uidOrEmail.includes('@') ? null : db.id(uidOrEmail)
|
|
340
|
+
const email = uidOrEmail.includes('@') ? uidOrEmail.trim().toLowerCase() : null
|
|
341
|
+
|
|
342
|
+
const company = await db.company.findOne({ query: { _id: companyId }, blacklist: false, project: { users: 1, invites: 1 } })
|
|
343
|
+
if (!company) throw { title: 'error', detail: 'Company not found.' }
|
|
344
|
+
if (uid && uid.toString() === req.user._id.toString()) throw { title: 'error', detail: 'You cannot remove yourself.' }
|
|
345
|
+
if (uid && !company.users.find((u) => u._id.toString() === uid.toString())) throw { title: 'error', detail: 'User not found.' }
|
|
346
|
+
if (email && !company.invites.find((i) => i.email === email)) throw { title: 'error', detail: 'Invite not found.' }
|
|
347
|
+
if (uid) ensureNotLastOwner(company?.users, uid)
|
|
348
|
+
|
|
349
|
+
// Remove the user from the company
|
|
350
|
+
await db.company.update({
|
|
351
|
+
query: { _id: companyId, ...(uid ? { 'users._id': uid } : { 'invites.email': email }) },
|
|
352
|
+
$pull: { ...(uid ? { users: { _id: uid } } : { invites: { email } }) },
|
|
353
|
+
})
|
|
354
|
+
// Not required since the company.users is checked on auth.
|
|
355
|
+
//
|
|
356
|
+
// Remove the user's active company, if it's the same
|
|
357
|
+
// if (uid) {
|
|
358
|
+
// await db.user.update({
|
|
359
|
+
// query: { _id: uid, company: cid },
|
|
360
|
+
// $set: { company: null },
|
|
361
|
+
// })
|
|
362
|
+
// }
|
|
363
|
+
res.json({})
|
|
364
|
+
} catch (err) {
|
|
365
|
+
res.error(err)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
256
369
|
/* ---- Overridable helpers ------------------ */
|
|
257
370
|
|
|
258
371
|
export async function userFindFromProvider(query, passwordToCheck) {
|
|
@@ -267,13 +380,13 @@ export async function userFindFromProvider(query, passwordToCheck) {
|
|
|
267
380
|
const user = await db.user.findOne({
|
|
268
381
|
query: query,
|
|
269
382
|
blacklist: ['-password'],
|
|
270
|
-
populate: db.user.
|
|
383
|
+
populate: db.user.authPopulate(),
|
|
271
384
|
_privateData: true,
|
|
272
385
|
})
|
|
273
386
|
if (isMultiTenant && user?.company) {
|
|
274
387
|
user.company = await db.company.findOne({
|
|
275
388
|
query: user.company,
|
|
276
|
-
populate: db.company.
|
|
389
|
+
populate: db.company.authPopulate(),
|
|
277
390
|
_privateData: true,
|
|
278
391
|
})
|
|
279
392
|
}
|
|
@@ -286,11 +399,11 @@ export async function userFindFromProvider(query, passwordToCheck) {
|
|
|
286
399
|
} else if (user.status === 'invited') {
|
|
287
400
|
throw new Error('This user account is not yet active.')
|
|
288
401
|
} else if (isMultiTenant && !user.company) {
|
|
289
|
-
throw new Error('
|
|
402
|
+
throw new Error('There is no primary company associated with this user')
|
|
290
403
|
} else if (isMultiTenant && user.company.status != 'active') {
|
|
291
|
-
throw new Error('
|
|
292
|
-
} else if (isMultiTenant &&
|
|
293
|
-
throw new Error('This user is
|
|
404
|
+
throw new Error('The company associated with this user is no longer active')
|
|
405
|
+
} else if (isMultiTenant && user.company.users?.find(u => u._id.toString() === user._id.toString())?.status !== 'active') {
|
|
406
|
+
throw new Error('This user is no longer a member of the company')
|
|
294
407
|
} else {
|
|
295
408
|
if (checkPassword) {
|
|
296
409
|
if (!user.password) {
|
|
@@ -335,32 +448,38 @@ export async function getStore(user, _req) {
|
|
|
335
448
|
* @param {boolean} [skipSendEmail=false] - whether to skip sending the welcome email
|
|
336
449
|
* @returns {Promise<object>} - the created user
|
|
337
450
|
*/
|
|
338
|
-
export async function userCreate({ password, password2, company, ...userDataProp }, baseUrl, skipSendEmail) {
|
|
451
|
+
export async function userCreate({ password, password2, company, ...userDataProp }, baseUrl, invite, skipSendEmail) {
|
|
339
452
|
try {
|
|
340
453
|
const userId = db.id()
|
|
454
|
+
const isMultiTenant = !authConfig.isNotMultiTenant
|
|
341
455
|
const options = { blacklist: ['-_id'] }
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
456
|
+
const isUpdate = isMultiTenant && isId(company)
|
|
457
|
+
const isInsert = isMultiTenant && !isId(company)
|
|
458
|
+
const companyId = isInsert ? db.id() : isUpdate ? db.id(company) : null
|
|
459
|
+
|
|
460
|
+
// Create or update data if multi tenant
|
|
461
|
+
const insertCompanyData = isInsert ? {
|
|
462
|
+
...(company || {}),
|
|
463
|
+
_id: companyId,
|
|
347
464
|
users: [{ _id: userId, role: 'owner', status: 'active' }],
|
|
348
|
-
|
|
349
|
-
}
|
|
465
|
+
} : null
|
|
350
466
|
|
|
351
467
|
// Define user data
|
|
352
468
|
const userData = {
|
|
353
469
|
...userDataProp,
|
|
354
470
|
_id: userId,
|
|
355
471
|
password: password ? await bcrypt.hash(password, 10) : undefined,
|
|
356
|
-
...(
|
|
472
|
+
...(isInsert || isUpdate ? { company: companyId } : {}), // AKA "active company"
|
|
357
473
|
...(userDataProp.name ? { firstName: fullNameSplit(userDataProp.name)[0], lastName: fullNameSplit(userDataProp.name)[1] } : {}),
|
|
358
474
|
}
|
|
359
475
|
// First validate the data so we don't have to create a transaction
|
|
360
476
|
const results = await Promise.allSettled([
|
|
361
477
|
db.user.validate(userData, options),
|
|
362
478
|
typeof password === 'undefined' ? Promise.resolve() : auth.passwordValidate(password, password2),
|
|
363
|
-
...(
|
|
479
|
+
...(isInsert
|
|
480
|
+
? [db.company.validate(insertCompanyData, options)]
|
|
481
|
+
: isUpdate ? [addUserToCompany(companyId, userId, invite.role, invite.inviteToken, true)] : []
|
|
482
|
+
),
|
|
364
483
|
])
|
|
365
484
|
|
|
366
485
|
// Throw all the errors from at once
|
|
@@ -371,9 +490,13 @@ export async function userCreate({ password, password2, company, ...userDataProp
|
|
|
371
490
|
}, [])
|
|
372
491
|
if (errors.length) throw errors
|
|
373
492
|
|
|
374
|
-
//
|
|
493
|
+
// todo: start: add this in transaction, handy for tokenConfirmForMultiTenant -------
|
|
494
|
+
// Insert user
|
|
375
495
|
await db.user.insert({ data: userData, ...options })
|
|
376
|
-
|
|
496
|
+
|
|
497
|
+
// Insert, or, update company with user
|
|
498
|
+
if (isInsert) await db.company.insert({ data: insertCompanyData, ...options })
|
|
499
|
+
else if (isUpdate) await auth.addUserToCompany(companyId, userId, invite.role, invite.inviteToken)
|
|
377
500
|
|
|
378
501
|
// Send welcome email
|
|
379
502
|
if (!skipSendEmail) {
|
|
@@ -386,6 +509,7 @@ export async function userCreate({ password, password2, company, ...userDataProp
|
|
|
386
509
|
|
|
387
510
|
// Return the user
|
|
388
511
|
return await auth.userFindFromProvider({ _id: userId })
|
|
512
|
+
// todo: end: add this in transaction, handy for tokenConfirmForMultiTenant -------
|
|
389
513
|
|
|
390
514
|
} catch (err) {
|
|
391
515
|
if (!isArray(err)) throw err
|
|
@@ -434,12 +558,33 @@ export function tokenParse(token, modelName, maxAgeMs = 1000 * 60 * 60 * 24) {
|
|
|
434
558
|
}
|
|
435
559
|
}
|
|
436
560
|
|
|
561
|
+
export async function addUserToCompany(companyId, userId, role, token, justValidate) {
|
|
562
|
+
// Validate first, if it fails its an internal error really...
|
|
563
|
+
const userRow = { _id: userId, role: role, status: 'active' }
|
|
564
|
+
const userRow2 = (await db.company.validate({ users: [userRow] }, { update: true, blacklist: ['-users'] })).users[0]
|
|
565
|
+
if (justValidate) return userRow2
|
|
566
|
+
|
|
567
|
+
// Add the user to the company ($push skips implicit validation)
|
|
568
|
+
await db.company.update({
|
|
569
|
+
query: companyId,
|
|
570
|
+
$push: { users: userRow2 },
|
|
571
|
+
...(token ? { $pull: { invites: { inviteToken: token } } } : {}), // token optional
|
|
572
|
+
})
|
|
573
|
+
// Update users with no active company yet
|
|
574
|
+
await db.user.update({
|
|
575
|
+
query: { _id: userId, company: null },
|
|
576
|
+
$set: { company: companyId },
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
|
|
437
580
|
export async function tokenConfirmForReset(req) {
|
|
438
581
|
return await auth.tokenConfirmForSingleTenant(req, true)
|
|
439
582
|
}
|
|
440
583
|
|
|
441
584
|
export async function tokenConfirmForSingleTenant(req, isReset) {
|
|
442
|
-
|
|
585
|
+
// single tenant: for both confirm and reset, this endpoint at least requires password/password2
|
|
586
|
+
const token = req.params.token
|
|
587
|
+
const { password, password2, ...userData } = req.body
|
|
443
588
|
const tokenName = isReset ? 'resetToken' : 'inviteToken'
|
|
444
589
|
const desktop = req.query.desktop
|
|
445
590
|
const _id = db.id(auth.tokenParse(token, 'user'))
|
|
@@ -448,6 +593,7 @@ export async function tokenConfirmForSingleTenant(req, isReset) {
|
|
|
448
593
|
// Find the inviteToken, but bypass hooks because inviteToken may = true in a potential afterFind hook.
|
|
449
594
|
const user1 = await db.user._findOne({ _id: _id}, { projection: { [tokenName]: 1 } })
|
|
450
595
|
if (!user1 || user1[tokenName] !== token) throw new Error('Sorry your token is invalid or has already been used.')
|
|
596
|
+
if (req.preConfirm) throw new Error('Internal error: Token pre-confirm is not supported in single tenant mode.')
|
|
451
597
|
|
|
452
598
|
await db.user.update({
|
|
453
599
|
query: _id,
|
|
@@ -464,91 +610,125 @@ export async function tokenConfirmForSingleTenant(req, isReset) {
|
|
|
464
610
|
}
|
|
465
611
|
|
|
466
612
|
export async function tokenConfirmForMultiTenant(req) {
|
|
467
|
-
const
|
|
613
|
+
const token = req.params.token
|
|
468
614
|
const desktop = req.query.desktop
|
|
469
615
|
const companyId = db.id(auth.tokenParse(token, 'company'))
|
|
470
616
|
|
|
471
|
-
// Find the invite
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
{ _id: companyId, 'invites.inviteToken': token },
|
|
475
|
-
{ projection: { 'invites.inviteToken': 1 } }
|
|
476
|
-
)
|
|
617
|
+
// Find the invite (we need to bypass hooks as inviteToken maybe true in an afterFind hook)
|
|
618
|
+
const company = await db.company._findOne({ _id: companyId, 'invites.inviteToken': token }, { projection: { invites: 1, users: 1 } })
|
|
619
|
+
const invite = company?.invites?.find(inv => inv.inviteToken === token)
|
|
477
620
|
if (!company) throw new Error('Sorry your token is invalid or has already been used.')
|
|
478
|
-
|
|
621
|
+
if (!invite.email) throw new Error('Internal error: Email address missing from the invite.')
|
|
479
622
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
throw new Error('This user has already been added to the company.')
|
|
623
|
+
const existingUser = await db.user.findOne({ query: { email: invite.email }, _privateData: true })
|
|
624
|
+
if (authConfig.limitOneTenantPerUser && existingUser?.company && existingUser.company.toString() !== companyId.toString()) {
|
|
625
|
+
throw new Error(`You (${invite.email}) are already a member of another company.`)
|
|
484
626
|
}
|
|
485
627
|
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
$pull: { invites: { inviteToken: token } }, // remove invite
|
|
494
|
-
})
|
|
628
|
+
// Existing user already added to company?
|
|
629
|
+
if (existingUser && company.users?.some(u => u._id.toString() === existingUser._id.toString())) {
|
|
630
|
+
throw new Error('User is already a member of this company.')
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Dont submit yet if the user is requesting preConfirm data (used to show the correct form fields first)
|
|
634
|
+
if (req.preConfirm) return { isExistingUser: !!existingUser, email: invite.email }
|
|
495
635
|
|
|
496
|
-
//
|
|
497
|
-
|
|
636
|
+
// Existing user: add the user to company
|
|
637
|
+
if (existingUser) {
|
|
638
|
+
await auth.addUserToCompany(companyId, existingUser._id, invite.role, invite.inviteToken)
|
|
639
|
+
return {} // no signin, security risk if they take hold of the token
|
|
640
|
+
|
|
641
|
+
// New user: create user (and add the user to the company), and signin
|
|
642
|
+
} else {
|
|
643
|
+
const baseUrl = auth.getBaseUrl(req)
|
|
644
|
+
const user = await auth.userCreate({ ...req.body, company: companyId }, baseUrl, invite, true)
|
|
645
|
+
return await auth.userSigninGetStore(user, desktop)
|
|
646
|
+
}
|
|
498
647
|
}
|
|
499
648
|
|
|
500
649
|
/**
|
|
501
650
|
* Creates and sends a reset or invite token to a user or company
|
|
502
651
|
* @param {object} options
|
|
503
|
-
* @param {'reset' | 'invite' | 'companyInvite'} options.type - token type
|
|
504
|
-
* @param {string} options.
|
|
505
|
-
* @param {
|
|
506
|
-
*
|
|
652
|
+
* @param {'reset' | 'invite' | 'companyInvite'} options.type - token type
|
|
653
|
+
* @param {string} options.id - user or company id
|
|
654
|
+
* @param {{
|
|
655
|
+
* email: string,
|
|
656
|
+
* firstName: string,
|
|
657
|
+
* [key: string]: any, // other fields to include in the invite row
|
|
658
|
+
* }} options.payload
|
|
507
659
|
* @param {function} [options.beforeUpdate] - runs before updating the model with the token, return null to skip update
|
|
508
660
|
* @param {function} [options.beforeSendEmail] - runs before sending the email, receives (options, token)
|
|
509
661
|
* @param {string} [options.baseUrl] - baseUrl to use for the email
|
|
510
662
|
* @returns {Promise<{token: string, mailgunPromise: Promise<unknown>}>}
|
|
511
663
|
*/
|
|
512
|
-
export async function tokenSend({ type
|
|
513
|
-
if (!
|
|
514
|
-
if (!email) throw
|
|
515
|
-
if (!firstName) throw
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
664
|
+
export async function tokenSend({ type, id, payload, beforeUpdate, beforeSendEmail, baseUrl, isResend }) {
|
|
665
|
+
if (!id) throw { title: 'error', detail: `${type === 'companyInvite' ? 'Company id is required' : 'User id is required'}` }
|
|
666
|
+
if (!payload?.email || !isEmail(payload.email)) throw { title: 'email', detail: 'A valid email address is required' }
|
|
667
|
+
if (!isResend && !payload?.firstName) throw { title: 'firstName', detail: 'First name is required' }
|
|
668
|
+
|
|
669
|
+
delete payload._id
|
|
670
|
+
delete payload.inviteToken
|
|
671
|
+
|
|
672
|
+
const docId = db.id(id)
|
|
673
|
+
const apply = beforeUpdate || (o => o)
|
|
674
|
+
const tokenName = type + 'Token'
|
|
675
|
+
const emailData = {}
|
|
676
|
+
const companyProjection = { invites: 1, name: 1, business: 1 }
|
|
677
|
+
|
|
678
|
+
// Company invite resend: reuse the invite token (bypass hooks, inviteToken may be `true` in an afterFind hook)
|
|
679
|
+
if (type === 'companyInvite' && isResend) {
|
|
680
|
+
const company = await db.company._findOne({ _id: docId, 'invites.email': payload.email }, { projection: companyProjection })
|
|
681
|
+
const invite = company?.invites?.find(i => i.email === payload.email)
|
|
682
|
+
if (!invite?.inviteToken) throw new Error('No pending invite found for this email.')
|
|
683
|
+
if (!payload.firstName) payload.firstName = invite.firstName
|
|
684
|
+
emailData.token = invite.inviteToken
|
|
685
|
+
emailData.companyName = company.name || company.business?.name
|
|
686
|
+
|
|
687
|
+
// Company invite new: push new invite onto company.invites
|
|
688
|
+
} else if (type === 'companyInvite') {
|
|
689
|
+
const company = await db.company._findOne({ _id: docId }, { projection: companyProjection })
|
|
690
|
+
if (!company) throw new Error('Invalid company id to send an invite for.')
|
|
691
|
+
if (company.invites?.find(i => i.email === payload.email)) throw new Error('This email has already been invited to join this company.')
|
|
692
|
+
emailData.token = await auth.tokenCreate('company', id)
|
|
693
|
+
emailData.companyName = company.name || company.business?.name
|
|
694
|
+
const invite = (await db.company.validate(
|
|
695
|
+
{ invites: [apply({ ...payload, inviteToken: emailData.token })] },
|
|
696
|
+
{ update: true, blacklist: ['-invites'] }
|
|
697
|
+
)).invites[0]
|
|
698
|
+
await db.company.update({ query: docId, $push: { invites: invite } })
|
|
699
|
+
|
|
700
|
+
// User token resend: reuse the reset/invite token stored on the user
|
|
701
|
+
} else if (isResend) {
|
|
702
|
+
const user = await db.user._findOne({ _id: docId }, { projection: { [tokenName]: 1 } }) // token maybe `true` in an afterFind hook
|
|
703
|
+
if (!user?.[tokenName]) throw new Error('No pending invite for this user.')
|
|
704
|
+
emailData.token = user[tokenName]
|
|
705
|
+
|
|
706
|
+
// User token fresh: create token, set on user.resetToken or user.inviteToken
|
|
527
707
|
} else {
|
|
528
|
-
|
|
529
|
-
const result = await db
|
|
530
|
-
query:
|
|
531
|
-
$set:
|
|
708
|
+
emailData.token = await auth.tokenCreate('user', id)
|
|
709
|
+
const result = await db.user.update({
|
|
710
|
+
query: docId,
|
|
711
|
+
$set: apply({ [tokenName]: emailData.token, isInvited: type === 'invite' ? true : undefined }),
|
|
532
712
|
})
|
|
533
|
-
if (!result._output.matchedCount) throw new Error('Invalid
|
|
713
|
+
if (!result._output.matchedCount) throw new Error('Invalid user id to update the token for')
|
|
534
714
|
}
|
|
535
715
|
|
|
536
716
|
// Send email
|
|
537
|
-
const
|
|
717
|
+
const emailOpts = {
|
|
538
718
|
config: { ...authConfig, baseUrl: baseUrl || authConfig.baseUrl },
|
|
539
719
|
template: type === 'reset' ? 'reset-instructions' : 'invite-instructions',
|
|
540
|
-
to: `${ucFirst(firstName)}<${email}>`,
|
|
541
|
-
data:
|
|
720
|
+
to: `${ucFirst(payload.firstName)}<${payload.email}>`,
|
|
721
|
+
data: emailData,
|
|
722
|
+
// test: true,
|
|
542
723
|
}
|
|
543
|
-
const mailgunPromise = sendEmail(beforeSendEmail ? beforeSendEmail(
|
|
724
|
+
const mailgunPromise = sendEmail(beforeSendEmail ? beforeSendEmail(emailOpts, emailData.token) : emailOpts).catch(err => {
|
|
544
725
|
console.error('sendEmail(..) mailgun error', err)
|
|
545
726
|
})
|
|
546
727
|
|
|
547
|
-
|
|
548
|
-
return { token, mailgunPromise }
|
|
728
|
+
return { token: emailData.token, mailgunPromise: mailgunPromise }
|
|
549
729
|
}
|
|
550
730
|
|
|
551
|
-
function getBaseUrl(req) {
|
|
731
|
+
export function getBaseUrl(req) {
|
|
552
732
|
return resolveBaseUrl(req?.nitroBaseUrl, authConfig.baseUrl)
|
|
553
733
|
}
|
|
554
734
|
|
|
@@ -571,4 +751,11 @@ export function resolveBaseUrl(reqUrl, cfgUrl) {
|
|
|
571
751
|
} catch (_) {
|
|
572
752
|
return cfgUrl
|
|
573
753
|
}
|
|
574
|
-
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function ensureNotLastOwner(companyUsers, idNowNonOwner) {
|
|
757
|
+
const remaining = (companyUsers || []).filter((u) =>
|
|
758
|
+
u.role === 'owner' && u.status !== 'deleted' && u._id.toString() !== idNowNonOwner.toString()
|
|
759
|
+
)
|
|
760
|
+
if (!remaining.length) throw { title: 'role', detail: 'There must be at least one active owner.' }
|
|
761
|
+
}
|