nitro-web 0.1.3 → 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.
@@ -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/invite-confirm': [inviteConfirm], // was invite-accept
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
- const configKeys = ['baseUrl', 'emailFrom', 'env', 'name', 'mailgunDomain', 'mailgunKey', 'masterPassword', 'isNotMultiTenant',
43
- 'confirmInvites']
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 ['baseUrl', 'emailFrom', 'env', 'name']) {
49
- if (!authConfig[key]) throw new Error(`Missing config value for: config.${key}`)
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
- // const desktop = req.query.hasOwnProperty('desktop') ? '?desktop' : '' // see sendToken for future usage
175
- let email = (req.body.email || '').trim().toLowerCase()
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
- // Single-tenant:
191
- //. - no user found: error (not supported, must be pre-created)
192
- // - user pre-created: update user with token, and send them the token
193
- // Multi-tenant:
194
- // - user exists and confirmInvites=false: auto-add user to company.users
195
- // - user exists and confirmInvites=true: add user to company.invites, and send them the token
196
- //. - no user found: add user to company.invites and send them the token
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({ type: 'invite', _id: user._id, email: user.email, firstName: user.firstName, baseUrl: getBaseUrl(req) })
202
- res.json({})
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.companyId
206
- if (!req.user.isAdmin && (!companyId || req.user?.company?._id?.toString() !== companyId.toString())) {
207
- throw new Error('You do not have permission to invite users to this company.')
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
- const existingUser = await db.user.findOne({ query: { email: req.body.email } })
210
- if (existingUser && !authConfig.confirmInvites) {
211
- await db.company.update({
212
- query: db.id(companyId),
213
- $push: { users: { _id: existingUser._id, role: req.body.role, status: 'active' } },
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', _id: companyId,
218
- email: req.body.email,
219
- firstName: existingUser?.firstName || req.body.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
- res.json({})
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 confirmInvites=false: no-op (i.e no token, user already added)
244
- // - user exists and confirmInvites=true: update company (invite.tsx should display 'Invite Accepted' and redirect to home)
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.loginPopulate(),
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.loginPopulate(),
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('The current company is no longer associated with this user')
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('This user is not associated with an active company')
292
- } else if (isMultiTenant && !user.company.users?.find(u => u._id.toString() === user._id.toString())?.status === 'active') {
293
- throw new Error('This user is not associated with an active company')
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 companyIsId = !authConfig.isNotMultiTenant && isId(company)
343
-
344
- // Define new company data if applicable
345
- const companyData = !authConfig.isNotMultiTenant && !companyIsId && {
346
- _id: db.id(),
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
- ...(company ? company : {}), // removed
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
- ...(companyIsId ? { company: company } : (companyData ? { company: companyData._id } : {})), // AKA "active company"
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
- ...(companyData ? [db.company.validate(companyData, options)] : []),
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
- // Insert company & user
493
+ // todo: start: add this in transaction, handy for tokenConfirmForMultiTenant -------
494
+ // Insert user
375
495
  await db.user.insert({ data: userData, ...options })
376
- if (companyData) await db.company.insert({ data: companyData, ...options })
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
- const { token, password, password2, ...userData } = req.body
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,92 +610,126 @@ export async function tokenConfirmForSingleTenant(req, isReset) {
464
610
  }
465
611
 
466
612
  export async function tokenConfirmForMultiTenant(req) {
467
- const { token, ...userData } = req.body
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 from the token (company.invites[] entry), but bypass hooks because inviteToken may = true in
472
- // a potential afterFind hook.
473
- const company = await db.company._findOne(
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
- const invite = company.invites.find(inv => inv.inviteToken === token)
621
+ if (!invite.email) throw new Error('Internal error: Email address missing from the invite.')
479
622
 
480
- // Has the user already been added to the company (company.users[] entry)?
481
- const existingUser = await db.user.findOne({ query: { email: userData.email }, _privateData: true })
482
- if (existingUser && company.users.some(u => u._id.toString() === existingUser._id.toString())) {
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
- // Find or create new user
487
- const user = existingUser || await auth.userCreate({ ...userData, company: companyId }, true) // AKA "active company"
488
-
489
- // Add the user to the company
490
- await db.company.update({
491
- query: companyId,
492
- $push: { users: { _id: user._id, role: invite.role, status: 'active' } }, // add user to company
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
- // Signin
497
- return await auth.userSigninGetStore(user, desktop)
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 (default: 'reset')
504
- * @param {string} options._id - user or company id
505
- * @param {string} options.email - recipient email
506
- * @param {string} options.firstName - recipient first name
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 = 'reset', _id, email, firstName, beforeUpdate, beforeSendEmail, baseUrl }) {
513
- if (!_id) throw new Error(`${type === 'companyInvite' ? 'company' : 'user'} id is required`)
514
- if (!email) throw new Error('email is required')
515
- if (!firstName) throw new Error('firstName is required')
516
-
517
- const tokenName = type === 'companyInvite' ? 'inviteToken' : type + 'Token'
518
- const modelName = type === 'companyInvite' ? 'company' : 'user'
519
- const token = await auth.tokenCreate(modelName, _id)
520
- const _beforeUpdate = beforeUpdate || (o => o)
521
-
522
- if (modelName === 'company') {
523
- // For companies, add the token to company.invites[].inviteToken
524
- await db.company.update({ query: db.id(_id), $pull: { invites: { email } } })
525
- const result = await db.company.update({ query: db.id(_id), $push: { invites: _beforeUpdate({ email: email, inviteToken: token }) }})
526
- if (!result._output.matchedCount) throw new Error('Invalid company id to update the token for')
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
- // For users, add the token to user.inviteToken|resetToken
529
- const result = await db[modelName].update({
530
- query: db.id(_id),
531
- $set: _beforeUpdate({ [tokenName]: token, isInvited: type === 'invite' ? true : undefined }),
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 ' + modelName + ' id to update the token for')
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 options = {
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: { token: token },
720
+ to: `${ucFirst(payload.firstName)}<${payload.email}>`,
721
+ data: emailData,
722
+ // test: true,
542
723
  }
543
- const mailgunPromise = sendEmail(beforeSendEmail ? beforeSendEmail(options, token) : options).catch(err => {
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
- // Return the token and mailgun promise
548
- return { token, mailgunPromise }
728
+ return { token: emailData.token, mailgunPromise: mailgunPromise }
549
729
  }
550
730
 
551
- function getBaseUrl(req) {
552
- return resolveBaseUrl(req?.baseUrl, authConfig.baseUrl)
731
+ export function getBaseUrl(req) {
732
+ return resolveBaseUrl(req?.nitroBaseUrl, authConfig.baseUrl)
553
733
  }
554
734
 
555
735
  export function resolveBaseUrl(reqUrl, cfgUrl) {
@@ -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
+ }