Skip to content

Commit 7a2cb41

Browse files
authored
feat: Invitation welcome links (#122)
1 parent d95b69b commit 7a2cb41

25 files changed

+599
-44
lines changed

imports/api/base/associations-helper.js

+9-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Meteor } from 'meteor/meteor'
2+
import _ from 'lodash'
23

34
export const makeAssociationFactory = collectionName => (publisher, associateFn) => function (unitName) {
45
const origAdded = this.added
@@ -15,10 +16,11 @@ export const makeAssociationFactory = collectionName => (publisher, associateFn)
1516
return publisher.call(this, unitName)
1617
}
1718

18-
export const withUsers = loginNamesGetter => (publishedItem, addingFn) => Meteor.users.find({
19-
'bugzillaCreds.login': {$in: loginNamesGetter(publishedItem)}
20-
}, {
21-
fields: {profile: 1, 'bugzillaCreds.login': 1}
22-
}).forEach(user => {
23-
addingFn.call(this, 'users', user._id, user)
24-
})
19+
export const withUsers = (loginNamesGetter, customQuery = _.identity, customProj = _.identity) =>
20+
(publishedItem, addingFn) => Meteor.users.find(customQuery({
21+
'bugzillaCreds.login': {$in: loginNamesGetter(publishedItem)}
22+
}, publishedItem), {
23+
fields: customProj({profile: 1, 'bugzillaCreds.login': 1}, publishedItem)
24+
}).forEach(user => {
25+
addingFn.call(this, 'users', user._id, user)
26+
})

imports/api/comments.js

+2
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@ Meteor.methods({
5353
// Creating the comment
5454
const createData = callAPI('post', `/rest/bug/${caseId}/comment`, payload, false, true)
5555
const { token } = currUser.bugzillaCreds
56+
console.log('createData.data', createData.data)
5657

5758
// Fetching the full comment object by the returned id from the creation operation
5859
const commentData = callAPI('get', `/rest/bug/comment/${createData.data.id}`, {token}, false, true)
60+
console.log('commentData.data', commentData.data)
5961

6062
// Digging the new comment object out of the response
6163
const newComment = commentData.data.comments[createData.data.id.toString()]

imports/api/custom-users.js

+65
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Meteor } from 'meteor/meteor'
2+
import { Accounts } from 'meteor/accounts-base'
3+
import randToken from 'rand-token'
24

35
if (Meteor.isServer) {
46
Meteor.publish('users.myBzLogin', function () {
@@ -14,3 +16,66 @@ if (Meteor.isServer) {
1416
})
1517
})
1618
}
19+
20+
Meteor.methods({
21+
'users.invitationLogin': function (code) {
22+
if (Meteor.user()) throw new Meteor.Error('Not allowed for an existing user')
23+
24+
// Reusable matcher object for the next mongo queries
25+
const codeMatcher = {
26+
invitedToCases: {
27+
$elemMatch: {
28+
accessToken: code
29+
}
30+
}
31+
}
32+
33+
// Finding the user for this invite code (and checking whether one-click login is still allowed for it)
34+
const invitedUser = Meteor.users.findOne(Object.assign({
35+
'profile.isLimited': true
36+
}, codeMatcher), {
37+
fields: Object.assign({
38+
emails: 1
39+
}, codeMatcher)
40+
})
41+
if (!invitedUser) throw new Meteor.Error('The code is invalid or login is required first')
42+
43+
// Keeping track of how many times the user used this invitation to access the system
44+
Meteor.users.update({
45+
_id: invitedUser._id,
46+
'invitedToCases.accessToken': code
47+
}, {
48+
$inc: {
49+
'invitedToCases.$.accessedCount': 1
50+
}
51+
})
52+
console.log(`${invitedUser.emails[0].address} is using an invitation to access the system`)
53+
54+
// Resetting the password to something new the client-side could use for an automated login
55+
const randPass = randToken.generate(12)
56+
Accounts.setPassword(invitedUser._id, randPass, {logout: true})
57+
58+
const invitedByDetails = (() => {
59+
const { emails: [{ address: email }], profile: { name } } =
60+
Meteor.users.findOne(invitedUser.invitedToCases[0].invitedBy)
61+
return {
62+
email,
63+
name
64+
}
65+
})()
66+
return {
67+
email: invitedUser.emails[0].address,
68+
pw: randPass,
69+
caseId: invitedUser.invitedToCases[0].caseId,
70+
invitedByDetails
71+
}
72+
},
73+
'users.updateMyName': function (name) {
74+
if (!Meteor.user()) return new Meteor.Error('Must be logged in')
75+
if (!name || name.length < 2) return new Meteor.Error('Name should be of minimum 2 characters')
76+
77+
Meteor.users.update(Meteor.userId(), {
78+
$set: {'profile.name': name}
79+
})
80+
}
81+
})

imports/api/hooks/on-login.js

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Accounts } from 'meteor/accounts-base'
2+
import randToken from 'rand-token'
3+
4+
// Enforces one-time usage of random generated passwords for newly invited users
5+
Accounts.onLogin(info => {
6+
if (info.type === 'password') {
7+
console.log('info', info)
8+
const { user } = info
9+
if (user.profile.isLimited) {
10+
console.log(`resetting the password for ${user.emails[0].address} after one-time usage by invitation`)
11+
const randPass = randToken.generate(12)
12+
Accounts.setPassword(user._id, randPass, {logout: false})
13+
}
14+
}
15+
})

imports/api/pending-invitations.js

+25-4
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,16 @@ if (Meteor.isServer) {
3131
}
3232

3333
return [
34-
PendingInvitations.find({caseId}),
35-
Meteor.users.find({ // TODO: filter out users whose invitation was already finalized
34+
PendingInvitations.find({
35+
caseId,
36+
done: {$ne: true}
37+
}),
38+
Meteor.users.find({
3639
invitedToCases: {
37-
$elemMatch: {caseId}
40+
$elemMatch: {
41+
caseId,
42+
done: {$ne: true}
43+
}
3844
}
3945
}, {
4046
fields: {
@@ -68,9 +74,24 @@ Meteor.methods({
6874
}
6975

7076
// Checking only the default_assigned_to field (Should default_qa_contact be added too in the future?)
71-
const isUserAlreadyInvolved = unitItem.components.filter(
77+
const userAssignedToComponent = unitItem.components.filter(
7278
({default_assigned_to: assignedTo}) => assignedTo === email
7379
).length > 0
80+
const userWasInvitedToRole = (() => {
81+
const existingInvolvedUser = Meteor.users.findOne({
82+
'emails.address': email,
83+
invitedToCases: {
84+
$elemMatch: {
85+
unitId,
86+
// This is only for if the invitation was already 'done' so the user can be added to the case directly,
87+
// and not via email
88+
done: true
89+
}
90+
}
91+
})
92+
return !!existingInvolvedUser
93+
})()
94+
const isUserAlreadyInvolved = userAssignedToComponent || userWasInvitedToRole
7495
if (isUserAlreadyInvolved) {
7596
throw new Meteor.Error('This email belongs to a user already assigned to a role in this unit')
7697
}

imports/api/rest/put-pending-invitations.js

+17-4
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,27 @@ export default (req, res) => {
3434
}
3535
}
3636
})
37-
invite(invitee, inviteby)
38-
console.log(inviteby, 'has invited', invitee)
37+
try {
38+
invite(invitee, inviteby)
39+
} catch (e) {
40+
console.log(`Sending invitation email to ${invitee.emails[0].address} has failed due to`, e)
41+
}
42+
console.log(inviteby.emails[0].address, 'has invited', invitee.emails[0].address)
3943

4044
// Also store back reference to who invited that user?
4145

4246
// Update invitee user that he/she has been invited to a particular case
43-
Meteor.users.update({ 'bugzillaCreds.id': inviteInfo.invitee, 'invitedToCases': { $elemMatch: { caseId: inviteInfo.caseId } } },
44-
{ $set: { 'invitedToCases.$.done': true } })
47+
Meteor.users.update(
48+
{
49+
'bugzillaCreds.id': inviteInfo.invitee,
50+
'invitedToCases': { $elemMatch: { caseId: inviteInfo.caseId } }
51+
},
52+
{
53+
$set: {
54+
'invitedToCases.$.done': true
55+
}
56+
}
57+
)
4558
})
4659
res.send(200, results)
4760
} catch (e) {

imports/api/units.js

+37-11
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Mongo } from 'meteor/mongo'
33
import publicationFactory from './base/rest-resource-factory'
44
import { makeAssociationFactory, withUsers } from './base/associations-helper'
55
import { callAPI } from '../util/bugzilla-api'
6+
import _ from 'lodash'
67

78
export const collectionName = 'units'
89

@@ -12,22 +13,35 @@ export const factoryOptions = {
1213
dataResolver: data => data.products
1314
}
1415

15-
export const getUnitRoles = unit =>
16-
unit.components.reduce((all, {default_assigned_to: assigned, default_qa_contact: qaContact, name}) => {
16+
export const getUnitRoles = unit => _.uniqBy(
17+
unit.components.reduce((all, {default_assigned_to: assigned, name}) => { // Getting names from the unit's components
1718
if (assigned) {
1819
all.push({
1920
login: assigned,
2021
role: name
2122
})
2223
}
23-
if (qaContact) {
24-
all.push({
25-
login: qaContact,
26-
role: name
27-
})
28-
}
2924
return all
30-
}, [])
25+
}, []).concat(Meteor.users.find({ // Getting more names of users with a finalized invitation to the unit
26+
invitedToCases: {
27+
$elemMatch: {
28+
unitId: unit.id,
29+
done: true
30+
}
31+
}
32+
}, Meteor.isServer ? { // Projection is only done on the server, as some features are not supported in Minimongo
33+
fields: {
34+
'invitedToCases.$': 1,
35+
'bugzillaCreds.login': 1
36+
}
37+
} : {}).fetch()
38+
// Mapping the users to the same interface as the first half of the array
39+
.map(({ invitedToCases: [{ role }], bugzillaCreds: { login } }) => ({
40+
login,
41+
role
42+
}))),
43+
({login}) => login // Filtering out duplicates in case a user shows up in a component and has a finalized invitation
44+
)
3145

3246
if (Meteor.isServer) {
3347
const factory = publicationFactory(factoryOptions)
@@ -40,7 +54,19 @@ if (Meteor.isServer) {
4054
params: {names: unitName}
4155
})
4256
}),
43-
withUsers(unitItem => getUnitRoles(unitItem).map(u => u.login))
57+
withUsers(
58+
unitItem => getUnitRoles(unitItem).map(u => u.login),
59+
(query, unitItem) => Object.assign({
60+
invitedToCases: {
61+
$elemMatch: {
62+
unitId: unitItem.id
63+
}
64+
}
65+
}, query),
66+
(projection, unitItem) => Object.assign({
67+
'invitedToCases.$': 1
68+
}, projection)
69+
)
4470
))
4571

4672
Meteor.publish(`${collectionName}.forReporting`, function () {
@@ -51,7 +77,7 @@ if (Meteor.isServer) {
5177
const listResponse = callAPI('get', '/rest/product_enterable', {token}, false, true)
5278
ids = listResponse.data.ids
5379
} catch (e) {
54-
console.error('API error encountered', 'unitsForReporting', this.userId)
80+
console.error('API error encountered', `${collectionName}.forReporting`, this.userId)
5581
this.ready()
5682
this.error(new Meteor.Error({message: 'REST API error', origError: e}))
5783
}

imports/state/epics/create-case.js

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { collectionName } from '../../api/cases'
1313

1414
import 'rxjs/add/operator/take'
1515
import 'rxjs/add/operator/mergeMap'
16+
import 'rxjs/add/operator/filter'
1617

1718
export const createCase = action$ => action$
1819
.ofType(CREATE_CASE)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Meteor } from 'meteor/meteor'
2+
import { Subject } from 'rxjs/Subject'
3+
import { merge } from 'rxjs/observable/merge'
4+
import { of } from 'rxjs/observable/of'
5+
import { push } from 'react-router-redux'
6+
import {
7+
FETCH_INVITATION_CREDENTIALS,
8+
LOADING_INVITATION_CREDENTIALS,
9+
ERROR_INVITATION_CREDENTIALS,
10+
LOGIN_INVITATION_CREDENTIALS,
11+
SUCCESS_INVITATION_CREDENTIALS
12+
} from '../../ui/invitation-login/invitation-login.actions'
13+
14+
import 'rxjs/add/operator/filter'
15+
import 'rxjs/add/operator/switchMap'
16+
17+
export const fetchInvitationCredentials = action$ => action$
18+
.ofType(FETCH_INVITATION_CREDENTIALS)
19+
.filter(() => !Meteor.userId()) // fail safe, but shouldn't happen
20+
.switchMap(({code}) => {
21+
const meteorResult$ = new Subject()
22+
Meteor.call('users.invitationLogin', code, (error, {email, pw, caseId, invitedByDetails}) => {
23+
if (error) {
24+
meteorResult$.next({
25+
type: ERROR_INVITATION_CREDENTIALS,
26+
error
27+
})
28+
meteorResult$.complete()
29+
return
30+
}
31+
meteorResult$.next({
32+
type: LOGIN_INVITATION_CREDENTIALS
33+
})
34+
Meteor.loginWithPassword(email, pw, error => {
35+
if (error) {
36+
meteorResult$.next({
37+
type: ERROR_INVITATION_CREDENTIALS,
38+
error
39+
})
40+
} else {
41+
meteorResult$.next({
42+
type: SUCCESS_INVITATION_CREDENTIALS,
43+
showWelcomeMessage: !Meteor.user().profile.name,
44+
invitedByDetails
45+
})
46+
meteorResult$.next(push(`/case/${caseId}`))
47+
}
48+
meteorResult$.complete()
49+
})
50+
})
51+
return merge(
52+
of({
53+
type: LOADING_INVITATION_CREDENTIALS
54+
}),
55+
meteorResult$
56+
)
57+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Meteor } from 'meteor/meteor'
2+
import { of } from 'rxjs/observable/of'
3+
import { UPDATE_INVITED_USER_NAME, clearWelcomeMessage } from '../../ui/case/case.actions'
4+
5+
import 'rxjs/add/operator/filter'
6+
import 'rxjs/add/operator/mergeMap'
7+
8+
export const updateUserName = action$ =>
9+
action$.ofType(UPDATE_INVITED_USER_NAME)
10+
.filter(() => !!Meteor.userId()) // fail safe, but shouldn't happen
11+
.mergeMap(({name}) => {
12+
Meteor.call('users.updateMyName', name)
13+
return of(clearWelcomeMessage())
14+
})

0 commit comments

Comments
 (0)