@mindful-web/marko-web-identity-x 1.0.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/.eslintignore +1 -0
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/api/fragment-types.json +1 -0
- package/api/fragments/active-user.js +108 -0
- package/api/mutations/add-external-user-id.js +24 -0
- package/api/mutations/create-app-user.js +11 -0
- package/api/mutations/delete-app-user-for-current-application.js +7 -0
- package/api/mutations/impersonate-app-user.js +12 -0
- package/api/mutations/logout-app-user.js +11 -0
- package/api/mutations/send-change-email-link.js +7 -0
- package/api/mutations/send-login-link.js +7 -0
- package/api/mutations/set-custom-attributes.js +15 -0
- package/api/queries/check-content-access.js +19 -0
- package/api/queries/get-active-context.js +45 -0
- package/api/queries/load-user-by-external-id.js +14 -0
- package/api/queries/load-user-by-id.js +11 -0
- package/api/queries/load-user.js +11 -0
- package/browser/.eslintrc.js +11 -0
- package/browser/access.vue +377 -0
- package/browser/authenticate.vue +278 -0
- package/browser/change-email-confirm.vue +120 -0
- package/browser/change-email-init.vue +179 -0
- package/browser/comments/create.vue +107 -0
- package/browser/comments/post.vue +151 -0
- package/browser/comments/stream.vue +319 -0
- package/browser/custom-column.vue +314 -0
- package/browser/dayjs.js +6 -0
- package/browser/download-related.vue +120 -0
- package/browser/download.vue +380 -0
- package/browser/errors/authentication.js +7 -0
- package/browser/errors/feature.js +7 -0
- package/browser/errors/form.js +7 -0
- package/browser/errors/logout.js +7 -0
- package/browser/form/address-block.vue +131 -0
- package/browser/form/common/checkbox-group.vue +140 -0
- package/browser/form/common/form-group.vue +24 -0
- package/browser/form/common/form-label.vue +17 -0
- package/browser/form/consent.vue +121 -0
- package/browser/form/fields/address-extra.vue +68 -0
- package/browser/form/fields/city.vue +68 -0
- package/browser/form/fields/comment-body.vue +63 -0
- package/browser/form/fields/country.vue +94 -0
- package/browser/form/fields/custom-boolean.vue +58 -0
- package/browser/form/fields/custom-select-group.vue +25 -0
- package/browser/form/fields/custom-select-multiple.vue +97 -0
- package/browser/form/fields/custom-select-write-in-inline.vue +39 -0
- package/browser/form/fields/custom-select-write-in.vue +66 -0
- package/browser/form/fields/custom-select.vue +191 -0
- package/browser/form/fields/custom-text.vue +62 -0
- package/browser/form/fields/display-name.vue +64 -0
- package/browser/form/fields/email.vue +62 -0
- package/browser/form/fields/family-name.vue +68 -0
- package/browser/form/fields/given-name.vue +68 -0
- package/browser/form/fields/organization-title.vue +68 -0
- package/browser/form/fields/organization.vue +68 -0
- package/browser/form/fields/phone-number.vue +68 -0
- package/browser/form/fields/postal-code.vue +68 -0
- package/browser/form/fields/receive-email.vue +58 -0
- package/browser/form/fields/region.vue +106 -0
- package/browser/form/fields/regional-policy.vue +59 -0
- package/browser/form/fields/street.vue +68 -0
- package/browser/graphql/create-client.js +65 -0
- package/browser/graphql/parse.js +1 -0
- package/browser/graphql/queries/download-related-content.js +38 -0
- package/browser/index.js +110 -0
- package/browser/login-fields.vue +214 -0
- package/browser/login.vue +331 -0
- package/browser/logout.vue +68 -0
- package/browser/mixins/global-auto-signup-event-emitter.js +18 -0
- package/browser/mixins/global-event-emitter.js +28 -0
- package/browser/profile.vue +688 -0
- package/browser/service.js +32 -0
- package/browser/utils/clean-path.js +1 -0
- package/browser/utils/cookies-enabled.js +10 -0
- package/browser/utils/get-as-array.js +6 -0
- package/browser/utils/get-referring-page.js +7 -0
- package/browser/utils/get.js +8 -0
- package/browser/utils/post.js +10 -0
- package/browser/utils/redirect.js +7 -0
- package/browser/utils/region-country-codes.js +1 -0
- package/components/access.marko +24 -0
- package/components/access.marko.js +61 -0
- package/components/comment-stream.marko +31 -0
- package/components/comment-stream.marko.js +71 -0
- package/components/context.marko +15 -0
- package/components/context.marko.js +52 -0
- package/components/form-access.marko +60 -0
- package/components/form-access.marko.js +111 -0
- package/components/form-authenticate.marko +32 -0
- package/components/form-authenticate.marko.js +73 -0
- package/components/form-change-email.marko +27 -0
- package/components/form-change-email.marko.js +68 -0
- package/components/form-login.marko +32 -0
- package/components/form-login.marko.js +72 -0
- package/components/form-logout.marko +7 -0
- package/components/form-logout.marko.js +36 -0
- package/components/form-profile.marko +38 -0
- package/components/form-profile.marko.js +78 -0
- package/components/form-register.marko +2 -0
- package/components/form-register.marko.js +32 -0
- package/components/identify.marko +17 -0
- package/components/identify.marko.js +59 -0
- package/components/marko.json +90 -0
- package/components/non-auth-identify.marko +15 -0
- package/components/non-auth-identify.marko.js +53 -0
- package/config.js +161 -0
- package/hooks.js +20 -0
- package/index.js +30 -0
- package/marko.json +6 -0
- package/middleware/content-access-state.js +31 -0
- package/middleware/content-download-state.js +27 -0
- package/middleware.js +32 -0
- package/package.json +40 -0
- package/routes/access.js +29 -0
- package/routes/authenticate.js +58 -0
- package/routes/change-email-confirm.js +60 -0
- package/routes/change-email-init.js +16 -0
- package/routes/comment-count.js +19 -0
- package/routes/comments.js +43 -0
- package/routes/countries.js +19 -0
- package/routes/create-comment.js +25 -0
- package/routes/download.js +29 -0
- package/routes/flag-comment.js +21 -0
- package/routes/index.js +40 -0
- package/routes/login-fields.js +35 -0
- package/routes/login.js +95 -0
- package/routes/logout.js +19 -0
- package/routes/profile.js +183 -0
- package/routes/regions.js +22 -0
- package/service.js +483 -0
- package/utils/call-hooks-for.js +28 -0
- package/utils/context-cookie.js +30 -0
- package/utils/create-client.js +48 -0
- package/utils/create-identity-builder.js +33 -0
- package/utils/get-as-array.js +6 -0
- package/utils/json-error-handler.js +41 -0
- package/utils/token-cookie.js +30 -0
package/service.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
const { get, getAsObject } = require('@mindful-web/object-path');
|
|
2
|
+
const { getResponseCookies } = require('@mindful-web/utils');
|
|
3
|
+
const createClient = require('./utils/create-client');
|
|
4
|
+
const getActiveContext = require('./api/queries/get-active-context');
|
|
5
|
+
const checkContentAccess = require('./api/queries/check-content-access');
|
|
6
|
+
const loadUser = require('./api/queries/load-user');
|
|
7
|
+
const appUserByExternalIdQuery = require('./api/queries/load-user-by-external-id');
|
|
8
|
+
const appUserByIdQuery = require('./api/queries/load-user-by-id');
|
|
9
|
+
const addExternalUserId = require('./api/mutations/add-external-user-id');
|
|
10
|
+
const setCustomAttributes = require('./api/mutations/set-custom-attributes');
|
|
11
|
+
const impersonateAppUser = require('./api/mutations/impersonate-app-user');
|
|
12
|
+
const sendChangeEmailLinkMutation = require('./api/mutations/send-change-email-link');
|
|
13
|
+
const sendLoginLinkMutation = require('./api/mutations/send-login-link');
|
|
14
|
+
const createAppUser = require('./api/mutations/create-app-user');
|
|
15
|
+
const logoutAppUser = require('./api/mutations/logout-app-user');
|
|
16
|
+
const deleteAppUserForCurrentApplicationMutation = require('./api/mutations/delete-app-user-for-current-application');
|
|
17
|
+
const tokenCookie = require('./utils/token-cookie');
|
|
18
|
+
const callHooksFor = require('./utils/call-hooks-for');
|
|
19
|
+
|
|
20
|
+
const isEmpty = (v) => v == null || v === '';
|
|
21
|
+
const isFn = (v) => typeof v === 'function';
|
|
22
|
+
const IDENTITY_COOKIE_NAME = '__idx_idt';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef ActiveContext
|
|
26
|
+
* @prop {Application} application
|
|
27
|
+
* @prop {Boolean} hasUser
|
|
28
|
+
* @prop {User} user
|
|
29
|
+
* @prop {Boolean} hasTeams
|
|
30
|
+
* @prop {Team[]} mergedTeams
|
|
31
|
+
* @prop {AccessLevel[]} mergedAccessLevels
|
|
32
|
+
*
|
|
33
|
+
* @typedef Application
|
|
34
|
+
* @prop {String} id
|
|
35
|
+
* @prop {String} name
|
|
36
|
+
* @prop {Object} organization
|
|
37
|
+
*
|
|
38
|
+
* @typedef User
|
|
39
|
+
* @prop {String} id
|
|
40
|
+
* @prop {String} email
|
|
41
|
+
* @prop {Boolean} verified
|
|
42
|
+
* @prop {Number} verifiedCount
|
|
43
|
+
* @prop {String} givenName
|
|
44
|
+
* @prop {String} familyName
|
|
45
|
+
* @prop {String} displayName
|
|
46
|
+
* @prop {String} organization
|
|
47
|
+
* @prop {String} organizationTitle
|
|
48
|
+
* @prop {String} countryCode
|
|
49
|
+
* @prop {String} regionCode
|
|
50
|
+
* @prop {String} postalCode
|
|
51
|
+
* @prop {String} city
|
|
52
|
+
* @prop {String} street
|
|
53
|
+
* @prop {String} addressExtra
|
|
54
|
+
* @prop {String} mobileNumber
|
|
55
|
+
* @prop {String} phoneNumber
|
|
56
|
+
* @prop {Boolean} receiveEmail
|
|
57
|
+
* @prop {Boolean} mustReVerifyProfile
|
|
58
|
+
* @prop {ExternalId[]} externalIds
|
|
59
|
+
* @prop {Object} customAttributes
|
|
60
|
+
* @prop {RegionalConsentAnswer[]} regionalConsentAnswers
|
|
61
|
+
* @prop {CustomBooleanFieldAnswer[]} customBooleanFieldAnswers
|
|
62
|
+
* @prop {CustomSelectFieldAnswer[]} customSelectFieldAnswers
|
|
63
|
+
* @prop {CustomTextFieldAnswer[]} customTextFieldAnswers
|
|
64
|
+
*
|
|
65
|
+
* @typedef ExternalId
|
|
66
|
+
* @prop {String} id
|
|
67
|
+
* @prop {ExternalIdIdentifier} identifier
|
|
68
|
+
* @prop {ExternalIdNamespace} namespace
|
|
69
|
+
*
|
|
70
|
+
* @typedef ExternalIdIdentifier
|
|
71
|
+
* @prop {String} value
|
|
72
|
+
* @prop {String} type
|
|
73
|
+
*
|
|
74
|
+
* @typedef ExternalIdNamespace
|
|
75
|
+
* @prop {String} provider
|
|
76
|
+
* @prop {String} tenant
|
|
77
|
+
* @prop {String} typedef
|
|
78
|
+
*
|
|
79
|
+
* @typedef RegionalConsentAnswer
|
|
80
|
+
* @prop {String} id
|
|
81
|
+
* @prop {Boolean} given
|
|
82
|
+
* @prop {Date} date
|
|
83
|
+
*
|
|
84
|
+
* @typedef CustomBooleanFieldAnswer
|
|
85
|
+
* @prop {String} id
|
|
86
|
+
* @prop {Boolean} hasAnswered
|
|
87
|
+
* @prop {BooleanFieldAnswer} answer
|
|
88
|
+
* @prop {BooleanField} field
|
|
89
|
+
*
|
|
90
|
+
* @typedef CustomSelectFieldAnswer
|
|
91
|
+
* @prop {String} id
|
|
92
|
+
* @prop {Boolean} hasAnswered
|
|
93
|
+
* @prop {SelectFieldAnswer[]} answers
|
|
94
|
+
* @prop {SelectField} field
|
|
95
|
+
*
|
|
96
|
+
* @typedef SelectField
|
|
97
|
+
* @typedef SelectFieldAnswer
|
|
98
|
+
* @typedef BooleanField
|
|
99
|
+
* @typedef BooleanFieldAnswer
|
|
100
|
+
*
|
|
101
|
+
* @typedef Team
|
|
102
|
+
* @prop {String} id
|
|
103
|
+
* @prop {String} name
|
|
104
|
+
* @prop {String} photoURL
|
|
105
|
+
*
|
|
106
|
+
* @typedef AccessLevel
|
|
107
|
+
* @prop {String} id
|
|
108
|
+
* @prop {String} name
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
class IdentityX {
|
|
112
|
+
/**
|
|
113
|
+
* @param {Object} o
|
|
114
|
+
* @param {import('express').Request} o.req
|
|
115
|
+
* @param {import('express').Response} o.res
|
|
116
|
+
* @param {import('./config')} o.config
|
|
117
|
+
*/
|
|
118
|
+
constructor({
|
|
119
|
+
req,
|
|
120
|
+
res,
|
|
121
|
+
config,
|
|
122
|
+
} = {}) {
|
|
123
|
+
this.req = req;
|
|
124
|
+
this.res = res;
|
|
125
|
+
this.config = config;
|
|
126
|
+
this.setToken(tokenCookie.getFrom(req));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validates the email address with a user-supplied function, if present.
|
|
131
|
+
* @param {String} email
|
|
132
|
+
* @returns {[Boolean, String]} The validation status and error message (if present)
|
|
133
|
+
*/
|
|
134
|
+
async validateEmail(email) {
|
|
135
|
+
const targetFn = get(this, 'config.options.emailValidator');
|
|
136
|
+
const fn = isFn(targetFn) ? targetFn : () => [Boolean(email)];
|
|
137
|
+
return fn({ email, req: this.req });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Loads the current application, user, and team context.
|
|
142
|
+
*
|
|
143
|
+
* @returns {Promise<ActiveContext>}
|
|
144
|
+
*/
|
|
145
|
+
async loadActiveContext({ forceQuery = false } = {}) {
|
|
146
|
+
// Only run the active context query once
|
|
147
|
+
if (!this.activeContextQuery || forceQuery) {
|
|
148
|
+
this.activeContextQuery = this.client.query({ query: getActiveContext });
|
|
149
|
+
}
|
|
150
|
+
const { data = {} } = await this.activeContextQuery;
|
|
151
|
+
const activeContext = data.activeAppContext || {};
|
|
152
|
+
await callHooksFor(this, 'onLoadActiveContext', {
|
|
153
|
+
req: this.req,
|
|
154
|
+
res: this.res,
|
|
155
|
+
activeContext,
|
|
156
|
+
});
|
|
157
|
+
return activeContext;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns an IdentityX user record by email address
|
|
162
|
+
*/
|
|
163
|
+
async loadAppUserByEmail(email) {
|
|
164
|
+
const { data } = await this.client.query({
|
|
165
|
+
query: loadUser,
|
|
166
|
+
variables: { email },
|
|
167
|
+
fetchPolicy: 'no-cache',
|
|
168
|
+
});
|
|
169
|
+
return data.appUser;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Checks whether the current user can access the given content.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} input
|
|
176
|
+
* @returns {Promise<object>}
|
|
177
|
+
*/
|
|
178
|
+
async checkContentAccess(input) {
|
|
179
|
+
const variables = { input };
|
|
180
|
+
const { data = {} } = await this.client.query({ query: checkContentAccess, variables });
|
|
181
|
+
const access = data.checkContentAccess || {};
|
|
182
|
+
access.requiresUserInput = false;
|
|
183
|
+
|
|
184
|
+
const requiredFields = this.config.getRequiredServerFields();
|
|
185
|
+
if (access.isLoggedIn && requiredFields.length) {
|
|
186
|
+
// Check if the user requires additonal input.
|
|
187
|
+
const { user, application } = await this.loadActiveContext();
|
|
188
|
+
|
|
189
|
+
access.requiresUserInput = user ? requiredFields.some((key) => isEmpty(user[key])) : false;
|
|
190
|
+
if (user && !access.requiresUserInput) {
|
|
191
|
+
access.requiresUserInput = Boolean(user.mustReVerifyProfile);
|
|
192
|
+
}
|
|
193
|
+
if (user && !access.requiresUserInput) {
|
|
194
|
+
// Check if user needs to answer any globally required custom fields.
|
|
195
|
+
const ids = this.config.getActiveCustomFieldIds();
|
|
196
|
+
access.requiresUserInput = user.customSelectFieldAnswers
|
|
197
|
+
.filter(!ids.length ? ({ field }) => ids.includes(field.id) : () => true)
|
|
198
|
+
.some(({ hasAnswered, field }) => field.required && !hasAnswered);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (user && !access.requiresUserInput) {
|
|
202
|
+
const { regionalConsentPolicies } = application.organization;
|
|
203
|
+
const matchingPolicies = regionalConsentPolicies.filter((policy) => {
|
|
204
|
+
const countryCodes = policy.countries.map((country) => country.id);
|
|
205
|
+
return countryCodes.includes(user.countryCode);
|
|
206
|
+
});
|
|
207
|
+
const policiesAnswered = user.regionalConsentAnswers
|
|
208
|
+
.reduce((o, answer) => ({ [answer.id]: true }), {});
|
|
209
|
+
const hasRequiredAnswers = matchingPolicies.length
|
|
210
|
+
? matchingPolicies.every((policy) => policiesAnswered[policy.id])
|
|
211
|
+
: true;
|
|
212
|
+
access.requiresUserInput = !hasRequiredAnswers;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return access;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Returns the identity id from the request or supplied response.
|
|
220
|
+
*
|
|
221
|
+
* @param {import('express').Response} res
|
|
222
|
+
* @returns {String}
|
|
223
|
+
*/
|
|
224
|
+
getIdentity(res) {
|
|
225
|
+
const id = get(this.req, `cookies.${IDENTITY_COOKIE_NAME}`);
|
|
226
|
+
if (id) return id;
|
|
227
|
+
const { [IDENTITY_COOKIE_NAME]: resid } = getResponseCookies(res || this.res);
|
|
228
|
+
return resid;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Returns GTM formatted data for the current identity/user
|
|
233
|
+
* @returns {Object}
|
|
234
|
+
*/
|
|
235
|
+
async getIdentityData() {
|
|
236
|
+
const context = await this.loadActiveContext();
|
|
237
|
+
if (context.user) return this.getGTMUserData(context.user, 'authenticated');
|
|
238
|
+
const identity = this.getIdentity(this.res);
|
|
239
|
+
if (identity) {
|
|
240
|
+
const user = await this.findUserById(identity);
|
|
241
|
+
if (user) return this.getGTMUserData(user, 'identified');
|
|
242
|
+
}
|
|
243
|
+
return this.getGTMUserData({}, 'anonymous');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Formats user data for use with GTM
|
|
248
|
+
*
|
|
249
|
+
* @param {User} user
|
|
250
|
+
* @returns {Object}
|
|
251
|
+
*/
|
|
252
|
+
getGTMUserData(user, state = 'anonymous') {
|
|
253
|
+
return {
|
|
254
|
+
...this.config.getGTMUserData(user),
|
|
255
|
+
state,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Sets the IdentityX Identity cookie to the response
|
|
261
|
+
*/
|
|
262
|
+
setIdentityCookie(id) {
|
|
263
|
+
const options = {
|
|
264
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
265
|
+
httpOnly: false,
|
|
266
|
+
};
|
|
267
|
+
this.res.cookie(IDENTITY_COOKIE_NAME, id, options);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param {Object} o
|
|
272
|
+
* @param {String} o.identifier
|
|
273
|
+
* @param {ExternalIdNamespace} o.namespace
|
|
274
|
+
*
|
|
275
|
+
* @returns {Promise<User|null>}
|
|
276
|
+
*/
|
|
277
|
+
async findUserByExternalId({ identifier, namespace }) {
|
|
278
|
+
const apiToken = this.config.getApiToken();
|
|
279
|
+
if (!apiToken) throw new Error('Unable to look up user: No API token has been configured.');
|
|
280
|
+
const { data } = await this.client.query({
|
|
281
|
+
query: appUserByExternalIdQuery,
|
|
282
|
+
variables: {
|
|
283
|
+
identifier: { value: identifier },
|
|
284
|
+
namespace,
|
|
285
|
+
},
|
|
286
|
+
context: { apiToken },
|
|
287
|
+
});
|
|
288
|
+
return data.appUserByExternalId;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {String} id The user id
|
|
293
|
+
*
|
|
294
|
+
* @returns {Promise<User|null>}
|
|
295
|
+
*/
|
|
296
|
+
async findUserById(id) {
|
|
297
|
+
const apiToken = this.config.getApiToken();
|
|
298
|
+
if (!apiToken) throw new Error('Unable to retrieve identity: No API token has been configured.');
|
|
299
|
+
const { data } = await this.client.query({
|
|
300
|
+
query: appUserByIdQuery,
|
|
301
|
+
variables: { id },
|
|
302
|
+
context: { apiToken },
|
|
303
|
+
});
|
|
304
|
+
return data.appUserById;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
*
|
|
309
|
+
* @param {object} params
|
|
310
|
+
* @returns {Promise<object>}
|
|
311
|
+
*/
|
|
312
|
+
async addExternalUserId({
|
|
313
|
+
userId,
|
|
314
|
+
identifier = {},
|
|
315
|
+
namespace = {},
|
|
316
|
+
} = {}) {
|
|
317
|
+
const input = { userId, identifier, namespace };
|
|
318
|
+
const apiToken = this.config.getApiToken();
|
|
319
|
+
if (!apiToken) throw new Error('Unable to add external ID: No API token has been configured.');
|
|
320
|
+
const variables = { input };
|
|
321
|
+
const { data } = await this.client.mutate({
|
|
322
|
+
mutation: addExternalUserId,
|
|
323
|
+
variables,
|
|
324
|
+
context: { apiToken },
|
|
325
|
+
});
|
|
326
|
+
return data.addAppUserExternalId;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
*
|
|
331
|
+
* @param {*} param0
|
|
332
|
+
*/
|
|
333
|
+
setToken(token) {
|
|
334
|
+
this.token = token;
|
|
335
|
+
|
|
336
|
+
// Re-init the client if the token changed
|
|
337
|
+
this.client = createClient({
|
|
338
|
+
req: this.req,
|
|
339
|
+
token,
|
|
340
|
+
appId: this.config.getAppId(),
|
|
341
|
+
config: this.config,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async impersonateAppUser({ userId }) {
|
|
346
|
+
const apiToken = this.config.getApiToken();
|
|
347
|
+
if (!apiToken) throw new Error('Unable to add external ID: No API token has been configured.');
|
|
348
|
+
const variables = { input: { id: userId, verify: true } };
|
|
349
|
+
const { data } = await this.client.mutate({
|
|
350
|
+
mutation: impersonateAppUser,
|
|
351
|
+
variables,
|
|
352
|
+
context: { apiToken },
|
|
353
|
+
});
|
|
354
|
+
const { token } = data.impersonateAppUser;
|
|
355
|
+
tokenCookie.setTo(this.res, token.value);
|
|
356
|
+
this.setToken(token);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async logoutAppUser() {
|
|
360
|
+
const { token } = this;
|
|
361
|
+
const input = { token };
|
|
362
|
+
const variables = { input };
|
|
363
|
+
const user = getAsObject({
|
|
364
|
+
...(token && await this.client.mutate({ mutation: logoutAppUser, variables })),
|
|
365
|
+
}, 'data.logoutAppUserWithData');
|
|
366
|
+
tokenCookie.removeFrom(this.res);
|
|
367
|
+
await callHooksFor(this, 'onLogout', {
|
|
368
|
+
req: this.req,
|
|
369
|
+
res: this.res,
|
|
370
|
+
user,
|
|
371
|
+
});
|
|
372
|
+
this.token = null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Creates an AppUser from an email address
|
|
377
|
+
*/
|
|
378
|
+
async createAppUser({ email }) {
|
|
379
|
+
const [valid, msg] = await this.validateEmail(email);
|
|
380
|
+
if (!valid) throw new Error(msg || 'The supplied email address cannot be used.');
|
|
381
|
+
const { data } = await this.client.mutate({
|
|
382
|
+
mutation: createAppUser,
|
|
383
|
+
variables: { email },
|
|
384
|
+
});
|
|
385
|
+
return data.createAppUser;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Sets custom key-value data on the user.
|
|
390
|
+
*/
|
|
391
|
+
async setAppUserCustomAttributes({ userId, attributes = {}, dispatch = true } = {}) {
|
|
392
|
+
const { data: { updateOwnAppUserCustomAttributes: user } } = await this.client.mutate({
|
|
393
|
+
mutation: setCustomAttributes,
|
|
394
|
+
variables: { input: { id: userId, attributes } },
|
|
395
|
+
});
|
|
396
|
+
if (dispatch) await callHooksFor(this, 'onUserProfileUpdate', { req: this.req, user });
|
|
397
|
+
return user;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Sends a change email link to an existing user
|
|
402
|
+
*/
|
|
403
|
+
async sendChangeEmailLink({ email }) {
|
|
404
|
+
const authUrl = `${this.req.protocol}://${this.req.get('host')}${this.config.getEndpointFor('changeEmail')}`;
|
|
405
|
+
await this.client.mutate({
|
|
406
|
+
mutation: sendChangeEmailLinkMutation,
|
|
407
|
+
variables: { input: { email, authUrl, appContextId: this.config.get('appContextId') } },
|
|
408
|
+
});
|
|
409
|
+
await callHooksFor(this, 'onChangeEmailLinkSent', { email });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* @typedef GenerateEntityIdArgs
|
|
414
|
+
* @prop {?string} appId The application id to generate for
|
|
415
|
+
* @prop {?string} userId The user id to genereate for
|
|
416
|
+
*
|
|
417
|
+
* @param {GenerateEntityIdArgs} args
|
|
418
|
+
* @returns {?string} The entityId of the active user/identity, if present.
|
|
419
|
+
*/
|
|
420
|
+
async generateEntityId({ appId, userId } = {}) {
|
|
421
|
+
const activeContext = await this.loadActiveContext();
|
|
422
|
+
const applicationId = appId || activeContext.application.id;
|
|
423
|
+
const activeUserId = (activeContext && activeContext.user)
|
|
424
|
+
? activeContext.user.id : await this.getIdentity();
|
|
425
|
+
const uid = userId || activeUserId;
|
|
426
|
+
return `identity-x.${applicationId}.app-user*${uid}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Sends a login link to an existing user
|
|
431
|
+
*/
|
|
432
|
+
async sendLoginLink({
|
|
433
|
+
appUser,
|
|
434
|
+
authUrl,
|
|
435
|
+
source,
|
|
436
|
+
redirectTo,
|
|
437
|
+
additionalEventData,
|
|
438
|
+
}) {
|
|
439
|
+
await this.client.mutate({
|
|
440
|
+
mutation: sendLoginLinkMutation,
|
|
441
|
+
variables: {
|
|
442
|
+
input: {
|
|
443
|
+
email: appUser.email,
|
|
444
|
+
authUrl: authUrl || `${this.req.protocol}://${this.req.get('host')}${this.config.getEndpointFor('authenticate')}`,
|
|
445
|
+
appContextId: this.config.get('appContextId'),
|
|
446
|
+
source,
|
|
447
|
+
redirectTo,
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
await callHooksFor(this, 'onLoginLinkSent', {
|
|
452
|
+
...(additionalEventData || {}),
|
|
453
|
+
additionalEventData,
|
|
454
|
+
req: this.req,
|
|
455
|
+
source,
|
|
456
|
+
user: appUser,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Delete the user record for the current application using the provided email or id
|
|
462
|
+
*/
|
|
463
|
+
async deleteAppUserForCurrentApplication({
|
|
464
|
+
email,
|
|
465
|
+
userId,
|
|
466
|
+
}) {
|
|
467
|
+
const apiToken = this.config.getApiToken();
|
|
468
|
+
if (!apiToken) throw new Error('Unable to delete user: No API token has been configured.');
|
|
469
|
+
const { data } = await this.client.mutate({
|
|
470
|
+
mutation: deleteAppUserForCurrentApplicationMutation,
|
|
471
|
+
variables: {
|
|
472
|
+
input: {
|
|
473
|
+
...(email && { email }),
|
|
474
|
+
...(userId && { userId }),
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
context: { apiToken },
|
|
478
|
+
});
|
|
479
|
+
return data.deleteAppUserForCurrentApplication;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
module.exports = IdentityX;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { getAsArray } = require('@mindful-web/object-path');
|
|
2
|
+
const { isFunction: isFn } = require('@mindful-web/utils');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calls all registered hooks for the provided hook queue name.
|
|
6
|
+
*
|
|
7
|
+
* @param {IdentityX} service The IdentityX service instance.
|
|
8
|
+
* @param {string} name The hook queue name
|
|
9
|
+
* @param {object} params Parameters to send to the hook function
|
|
10
|
+
*/
|
|
11
|
+
module.exports = async (service, name, params = {}) => {
|
|
12
|
+
const { error } = console;
|
|
13
|
+
const onError = service.config.get('onHookError');
|
|
14
|
+
const onHookError = isFn(onError) ? onError : (e) => error(e);
|
|
15
|
+
const promises = { wait: [], skip: [] };
|
|
16
|
+
getAsArray(service, `config.hooks.${name}`).forEach(({ fn, shouldAwait }) => {
|
|
17
|
+
const key = shouldAwait ? 'wait' : 'skip';
|
|
18
|
+
const additionalEventData = params.additionalEventData || {};
|
|
19
|
+
const maybePromise = fn({
|
|
20
|
+
...params,
|
|
21
|
+
additionalEventData,
|
|
22
|
+
service,
|
|
23
|
+
});
|
|
24
|
+
if (maybePromise.catch) maybePromise.catch(onHookError);
|
|
25
|
+
promises[key].push(maybePromise);
|
|
26
|
+
});
|
|
27
|
+
if (promises.wait.length) await Promise.all(promises.wait).catch(onHookError);
|
|
28
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const cookie = require('cookie');
|
|
2
|
+
|
|
3
|
+
const COOKIE_NAME = '__idx_ctx';
|
|
4
|
+
const COOKIE_MAX_AGE = 1000 * 60 * 60 * 24 * 365 * 2;
|
|
5
|
+
|
|
6
|
+
const setTo = (res, context) => {
|
|
7
|
+
res.cookie(COOKIE_NAME, JSON.stringify(context), { maxAge: COOKIE_MAX_AGE, httpOnly: false });
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const getFrom = (req) => {
|
|
11
|
+
try {
|
|
12
|
+
const cookies = cookie.parse(req.get('cookie') || '');
|
|
13
|
+
const { [COOKIE_NAME]: value } = cookies;
|
|
14
|
+
return value;
|
|
15
|
+
} catch (e) {
|
|
16
|
+
// @todo log this error.
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const removeFrom = (res) => {
|
|
22
|
+
res.clearCookie(COOKIE_NAME);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
exists: (req) => Boolean(getFrom(req)),
|
|
27
|
+
setTo,
|
|
28
|
+
getFrom,
|
|
29
|
+
removeFrom,
|
|
30
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const fetch = require('node-fetch');
|
|
2
|
+
const { ApolloClient } = require('apollo-client');
|
|
3
|
+
const { InMemoryCache, IntrospectionFragmentMatcher } = require('apollo-cache-inmemory');
|
|
4
|
+
const { createHttpLink } = require('apollo-link-http');
|
|
5
|
+
const { setContext } = require('apollo-link-context');
|
|
6
|
+
const introspectionQueryResultData = require('../api/fragment-types.json');
|
|
7
|
+
|
|
8
|
+
const rootConfig = {
|
|
9
|
+
connectToDevTools: false,
|
|
10
|
+
ssrMode: true,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
module.exports = ({
|
|
14
|
+
req,
|
|
15
|
+
token,
|
|
16
|
+
appId,
|
|
17
|
+
config,
|
|
18
|
+
linkConfig = {},
|
|
19
|
+
} = {}) => {
|
|
20
|
+
if (!appId) throw new Error('An IdentityX application ID is required.');
|
|
21
|
+
|
|
22
|
+
const headers = {
|
|
23
|
+
...linkConfig.headers,
|
|
24
|
+
'x-app-id': appId,
|
|
25
|
+
'x-forwarded-for': req.ip,
|
|
26
|
+
'user-agent': req.get('user-agent'),
|
|
27
|
+
};
|
|
28
|
+
if (token) headers.authorization = `AppUser ${token}`;
|
|
29
|
+
|
|
30
|
+
const httpLink = createHttpLink({
|
|
31
|
+
...linkConfig,
|
|
32
|
+
uri: process.env.IDENTITYX_GRAPHQL_URI || 'https://identity-x.parameter1.com/graphql',
|
|
33
|
+
fetch,
|
|
34
|
+
headers,
|
|
35
|
+
});
|
|
36
|
+
const contextLink = setContext((_, { apiToken }) => ({
|
|
37
|
+
headers: { ...(apiToken && { authorization: `OrgUserApiToken ${apiToken}` }) },
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
return new ApolloClient({
|
|
41
|
+
...config,
|
|
42
|
+
...rootConfig,
|
|
43
|
+
link: contextLink.concat(httpLink),
|
|
44
|
+
cache: new InMemoryCache({
|
|
45
|
+
fragmentMatcher: new IntrospectionFragmentMatcher({ introspectionQueryResultData }),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef IdentityXContext
|
|
3
|
+
* @prop {IdentityXApp} application
|
|
4
|
+
* @prop {IdentityXAppUser|null} user
|
|
5
|
+
* @prop {Object[]} mergedTeams
|
|
6
|
+
* @prop {Object[]} mergedAccessLevels
|
|
7
|
+
* @prop {Boolean} hasTeams
|
|
8
|
+
* @prop {Boolean} hasUser
|
|
9
|
+
* @prop {Boolean} isEnabled
|
|
10
|
+
*
|
|
11
|
+
* @typedef IdentityXApp
|
|
12
|
+
* @prop {String} id
|
|
13
|
+
* @prop {String} name
|
|
14
|
+
* @prop {String} consentPolicy
|
|
15
|
+
* @prop {String} emailConsentRequest
|
|
16
|
+
* @prop {Object[]} regionalConsentPolicies
|
|
17
|
+
*
|
|
18
|
+
* @typedef IdentityXAppUser
|
|
19
|
+
* @prop {String} id
|
|
20
|
+
* @prop {String} email
|
|
21
|
+
*
|
|
22
|
+
* @param {IdentityXContext} context
|
|
23
|
+
* @param {String|null} identity
|
|
24
|
+
*/
|
|
25
|
+
module.exports = (context = {}, identity = null) => {
|
|
26
|
+
if (context.hasUser) {
|
|
27
|
+
return `return 'identity-x.${context.application.id}.app-user*' + '${context.user.id}';`;
|
|
28
|
+
}
|
|
29
|
+
if (identity) {
|
|
30
|
+
return `return 'identity-x.${context.application.id}.app-user*' + '${identity}';`;
|
|
31
|
+
}
|
|
32
|
+
return '';
|
|
33
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { get, getAsArray } = require('@mindful-web/object-path');
|
|
2
|
+
|
|
3
|
+
const dev = process.env.NODE_ENV === 'development';
|
|
4
|
+
const { isArray } = Array;
|
|
5
|
+
|
|
6
|
+
const render = (res, { status, err }) => {
|
|
7
|
+
const { message, stack } = err;
|
|
8
|
+
const obj = { error: true, status, message };
|
|
9
|
+
if (dev && stack) obj.stack = stack.split('\n');
|
|
10
|
+
res.status(status).json(obj);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const renderNetworkErrorResult = (res, { status, result }) => {
|
|
14
|
+
const message = result.errors && isArray(result.errors) ? result.errors[0].message : 'Unknown fatal.';
|
|
15
|
+
render(res, { status, err: new Error(message) });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const renderGraphqlError = (res, graphQLError) => {
|
|
19
|
+
const { message } = graphQLError;
|
|
20
|
+
const err = new Error(message);
|
|
21
|
+
err.stack = getAsArray(graphQLError, 'extensions.exception.stacktrace').join('\n');
|
|
22
|
+
const status = get(graphQLError, 'extensions.exception.statusCode', 500);
|
|
23
|
+
render(res, { status, err });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line no-unused-vars
|
|
27
|
+
module.exports = () => (err, req, res, next) => {
|
|
28
|
+
const status = err.statusCode || err.status || 500;
|
|
29
|
+
const { networkError, graphQLErrors } = err;
|
|
30
|
+
if (networkError) {
|
|
31
|
+
if (networkError.result) {
|
|
32
|
+
renderNetworkErrorResult(res, { status, result: networkError.result });
|
|
33
|
+
} else {
|
|
34
|
+
render(res, { status, err: networkError });
|
|
35
|
+
}
|
|
36
|
+
} else if (graphQLErrors && graphQLErrors.length) {
|
|
37
|
+
renderGraphqlError(res, graphQLErrors[0]);
|
|
38
|
+
} else {
|
|
39
|
+
render(res, { status, err });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const cookie = require('cookie');
|
|
2
|
+
|
|
3
|
+
const COOKIE_NAME = '__idx';
|
|
4
|
+
const COOKIE_MAX_AGE = 1000 * 60 * 60 * 24 * 365 * 2;
|
|
5
|
+
|
|
6
|
+
const setTo = (res, token) => {
|
|
7
|
+
res.cookie(COOKIE_NAME, token, { maxAge: COOKIE_MAX_AGE, httpOnly: false });
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const getFrom = (req) => {
|
|
11
|
+
try {
|
|
12
|
+
const cookies = cookie.parse(req.get('cookie') || '');
|
|
13
|
+
const { [COOKIE_NAME]: token } = cookies;
|
|
14
|
+
return token;
|
|
15
|
+
} catch (e) {
|
|
16
|
+
// @todo log this error.
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const removeFrom = (res) => {
|
|
22
|
+
res.clearCookie(COOKIE_NAME);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
exists: (req) => Boolean(getFrom(req)),
|
|
27
|
+
setTo,
|
|
28
|
+
getFrom,
|
|
29
|
+
removeFrom,
|
|
30
|
+
};
|