From 9c4c21f7864ab4c39a6e03bf2168d1c215624a8f Mon Sep 17 00:00:00 2001 From: Luis Rudge Date: Thu, 29 Jun 2017 17:33:20 -0300 Subject: [PATCH] Adding a custom connection resolver option --- README.md | 17 +++++ .../__snapshots__/email_pane.test.jsx.snap | 31 +++++++++ .../__snapshots__/username_pane.test.jsx.snap | 32 +++++++++ src/__tests__/field/email_pane.test.jsx | 65 +++++++++++++++++- src/__tests__/field/username_pane.test.jsx | 66 ++++++++++++++++++- src/connection/database/actions.js | 15 +++-- src/core/index.js | 28 +++++++- src/engine/classic/login.jsx | 4 ++ src/field/email/email_pane.jsx | 14 ++++ src/field/username/username_pane.jsx | 13 ++++ src/ui/input/email_input.jsx | 7 +- src/ui/input/username_input.jsx | 5 +- 12 files changed, 285 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 03f8cdc17..f48bc5990 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,23 @@ var options = { - **configurationBaseUrl {String}**: Overrides client settings base url. By default it uses Auth0's CDN url when `domain` has the format `*.auth0.com`. Otherwise, it uses the provided `domain`. - **languageBaseUrl {String}**: Overrides the language source url for Auth0's provided translations. By default it uses to Auth0's CDN url `https://cdn.auth0.com`. - **hashCleanup {Boolean}**: When enabled, it will remove the hash part of the callback url after the user authentication. Defaults to `true`. +- **connectionResolver {Function}**: When in use, provides an extensibility point to make it possible to choose which connection to use based on the username information. Has `username`, `context` and `callback` as parameters. The callback expects an object like: `{type: 'database', name: 'connection name'}`. **This only works for database connections.** Keep in mind that this resolver will run in the username/email input's `onBlur` event, so keep it simple and fast. + +```js +var options = { + connectionResolver: function (username, context, cb) { + var domain = username.includes('@') && username.split('@')[1]; + if (domain) { + // If the username is test@auth0.com, the connection used will be the `auth0.com` connection. + // Make sure you have a database connection with the name `auth0.com`. + cb({ type: 'database', name: domain }); + } else { + // Use the default approach to figure it out the connection + cb(null); + } + } +} +``` #### Language Dictionary Specification diff --git a/src/__tests__/field/__snapshots__/email_pane.test.jsx.snap b/src/__tests__/field/__snapshots__/email_pane.test.jsx.snap index 52e09ffe3..592814878 100644 --- a/src/__tests__/field/__snapshots__/email_pane.test.jsx.snap +++ b/src/__tests__/field/__snapshots__/email_pane.test.jsx.snap @@ -16,6 +16,7 @@ exports[`EmailPane renders correctly 1`] = ` data-autoComplete={false} data-invalidHint="invalidErrorHint" data-isValid={false} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value="user@example.com" @@ -28,6 +29,7 @@ exports[`EmailPane sets \`blankErrorHint\` when username is empty 1`] = ` data-autoComplete={false} data-invalidHint="blankErrorHint" data-isValid={false} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value={undefined} @@ -40,6 +42,7 @@ exports[`EmailPane sets autoComplete to true when \`allowAutocomplete\` is true data-autoComplete={true} data-invalidHint="invalidErrorHint" data-isValid={false} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value="user@example.com" @@ -52,8 +55,36 @@ exports[`EmailPane sets isValid as true when \`isFieldVisiblyInvalid\` is false data-autoComplete={false} data-invalidHint="invalidErrorHint" data-isValid={true} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value="user@example.com" /> `; + +exports[`EmailPane with a custom \`connectionResolver\` \`swap\` calls \`setResolvedConnection\` 1`] = ` +Array [ + "model", + "resolvedConnection", +] +`; + +exports[`EmailPane with a custom \`connectionResolver\` calls \`connectionResolver\` onBlur 1`] = ` +Array [ + "user@example.com", + Object { + "connections": "connections", + "id": "id", + }, + [Function], +] +`; + +exports[`EmailPane with a custom \`connectionResolver\` calls \`swap\` in the \`connectionResolver\` callback 1`] = ` +Array [ + "updateEntity", + "lock", + 1, + [Function], +] +`; diff --git a/src/__tests__/field/__snapshots__/username_pane.test.jsx.snap b/src/__tests__/field/__snapshots__/username_pane.test.jsx.snap index 440212e48..4bf7b0d03 100644 --- a/src/__tests__/field/__snapshots__/username_pane.test.jsx.snap +++ b/src/__tests__/field/__snapshots__/username_pane.test.jsx.snap @@ -18,6 +18,7 @@ exports[`UsernamePane renders correctly 1`] = ` data-autoComplete={false} data-invalidHint="invalidErrorHint" data-isValid={false} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value="username" @@ -30,6 +31,7 @@ exports[`UsernamePane sets \`blankErrorHint\` when username is empty 1`] = ` data-autoComplete={false} data-invalidHint="blankErrorHint" data-isValid={false} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value={undefined} @@ -42,6 +44,7 @@ exports[`UsernamePane sets \`usernameFormatErrorHint\` when usernameLooksLikeEma data-autoComplete={false} data-invalidHint="usernameFormatErrorHint,min,max" data-isValid={false} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value="username" @@ -54,6 +57,7 @@ exports[`UsernamePane sets autoComplete to true when \`allowAutocomplete\` is tr data-autoComplete={true} data-invalidHint="invalidErrorHint" data-isValid={false} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value="username" @@ -66,8 +70,36 @@ exports[`UsernamePane sets isValid as true when \`isFieldVisiblyInvalid\` is fal data-autoComplete={false} data-invalidHint="invalidErrorHint" data-isValid={true} + data-onBlur={[Function]} data-onChange={[Function]} data-placeholder="placeholder" data-value="username" /> `; + +exports[`UsernamePane with a custom \`connectionResolver\` \`swap\` calls \`setResolvedConnection\` 1`] = ` +Array [ + "model", + "resolvedConnection", +] +`; + +exports[`UsernamePane with a custom \`connectionResolver\` calls \`connectionResolver\` onBlur 1`] = ` +Array [ + "username", + Object { + "connections": "connections", + "id": "id", + }, + [Function], +] +`; + +exports[`UsernamePane with a custom \`connectionResolver\` calls \`swap\` in the \`connectionResolver\` callback 1`] = ` +Array [ + "updateEntity", + "lock", + 1, + [Function], +] +`; diff --git a/src/__tests__/field/email_pane.test.jsx b/src/__tests__/field/email_pane.test.jsx index 85110f358..9ccd800f5 100644 --- a/src/__tests__/field/email_pane.test.jsx +++ b/src/__tests__/field/email_pane.test.jsx @@ -13,7 +13,12 @@ describe('EmailPane', () => { i18n: { str: (...keys) => keys.join(',') }, - lock: {}, + lock: Immutable.fromJS({ + client: { + connections: 'connections', + id: 'id' + } + }), placeholder: 'placeholder' }; @@ -40,7 +45,8 @@ describe('EmailPane', () => { ui: { avatar: () => false, allowAutocomplete: () => false - } + }, + connectionResolver: () => undefined })); jest.mock('avatar', () => ({ @@ -113,4 +119,59 @@ describe('EmailPane', () => { expect(mock.calls.length).toBe(1); expect(mock.calls[0]).toMatchSnapshot(); }); + it('does nothing on blur when there is no custom `connectionResolver`', () => { + let EmailPane = getComponent(); + + const wrapper = mount(); + const props = extractPropsFromWrapper(wrapper); + props.onBlur({ target: { value: 'newUser@example.com' } }); + + const { mock } = require('store/index').swap; + expect(mock.calls.length).toBe(0); + }); + describe('with a custom `connectionResolver`', () => { + let connectionResolverMock; + let setResolvedConnectionMock; + beforeEach(() => { + connectionResolverMock = jest.fn(); + setResolvedConnectionMock = jest.fn(); + require('core/index').connectionResolver = () => connectionResolverMock; + require('core/index').setResolvedConnection = setResolvedConnectionMock; + }); + it('calls `connectionResolver` onBlur', () => { + let EmailPane = getComponent(); + + const wrapper = mount(); + const props = extractPropsFromWrapper(wrapper); + props.onBlur({ target: { value: 'newUser@example.com' } }); + + const { mock } = connectionResolverMock; + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toMatchSnapshot(); + }); + it('calls `swap` in the `connectionResolver` callback', () => { + let EmailPane = getComponent(); + + const wrapper = mount(); + const props = extractPropsFromWrapper(wrapper); + props.onBlur({ target: { value: 'newUser@example.com' } }); + connectionResolverMock.mock.calls[0][2]('resolvedConnection'); + const { mock } = require('store/index').swap; + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toMatchSnapshot(); + }); + it.only('`swap` calls `setResolvedConnection`', () => { + let EmailPane = getComponent(); + + const wrapper = mount(); + const props = extractPropsFromWrapper(wrapper); + props.onBlur({ target: { value: 'newUser@example.com' } }); + connectionResolverMock.mock.calls[0][2]('resolvedConnection'); + require('store/index').swap.mock.calls[0][3]('model'); + + const { mock } = setResolvedConnectionMock; + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toMatchSnapshot(); + }); + }); }); diff --git a/src/__tests__/field/username_pane.test.jsx b/src/__tests__/field/username_pane.test.jsx index d0bd28a58..5d766bfea 100644 --- a/src/__tests__/field/username_pane.test.jsx +++ b/src/__tests__/field/username_pane.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import Immutable from 'immutable'; import { mount } from 'enzyme'; import { expectComponent, extractPropsFromWrapper, mockComponent } from 'testUtils'; @@ -12,7 +13,12 @@ describe('UsernamePane', () => { i18n: { str: (...keys) => keys.join(',') }, - lock: {}, + lock: Immutable.fromJS({ + client: { + connections: 'connections', + id: 'id' + } + }), placeholder: 'placeholder', validateFormat: false, usernameStyle: 'any', @@ -42,7 +48,8 @@ describe('UsernamePane', () => { ui: { avatar: () => false, allowAutocomplete: () => false - } + }, + connectionResolver: () => undefined })); jest.mock('avatar', () => ({ @@ -119,4 +126,59 @@ describe('UsernamePane', () => { expect(mock.calls.length).toBe(1); expect(mock.calls[0]).toMatchSnapshot(); }); + it('does nothing on blur when there is no custom `connectionResolver`', () => { + let EmailPane = getComponent(); + + const wrapper = mount(); + const props = extractPropsFromWrapper(wrapper); + props.onBlur({ target: { value: 'newUser@example.com' } }); + + const { mock } = require('store/index').swap; + expect(mock.calls.length).toBe(0); + }); + describe('with a custom `connectionResolver`', () => { + let connectionResolverMock; + let setResolvedConnectionMock; + beforeEach(() => { + connectionResolverMock = jest.fn(); + setResolvedConnectionMock = jest.fn(); + require('core/index').connectionResolver = () => connectionResolverMock; + require('core/index').setResolvedConnection = setResolvedConnectionMock; + }); + it('calls `connectionResolver` onBlur', () => { + let UsernamePane = getComponent(); + + const wrapper = mount(); + const props = extractPropsFromWrapper(wrapper); + props.onBlur({ target: { value: 'newUser@example.com' } }); + + const { mock } = connectionResolverMock; + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toMatchSnapshot(); + }); + it('calls `swap` in the `connectionResolver` callback', () => { + let UsernamePane = getComponent(); + + const wrapper = mount(); + const props = extractPropsFromWrapper(wrapper); + props.onBlur({ target: { value: 'newUser@example.com' } }); + connectionResolverMock.mock.calls[0][2]('resolvedConnection'); + const { mock } = require('store/index').swap; + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toMatchSnapshot(); + }); + it('`swap` calls `setResolvedConnection`', () => { + let UsernamePane = getComponent(); + + const wrapper = mount(); + const props = extractPropsFromWrapper(wrapper); + props.onBlur({ target: { value: 'newUser@example.com' } }); + connectionResolverMock.mock.calls[0][2]('resolvedConnection'); + require('store/index').swap.mock.calls[0][3]('model'); + + const { mock } = setResolvedConnectionMock; + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toMatchSnapshot(); + }); + }); }); diff --git a/src/connection/database/actions.js b/src/connection/database/actions.js index fc01f3909..7b1f5a71d 100644 --- a/src/connection/database/actions.js +++ b/src/connection/database/actions.js @@ -20,9 +20,13 @@ export function logIn(id, needsMFA = false) { const m = read(getEntity, 'lock', id); const usernameField = databaseLogInWithEmail(m) ? 'email' : 'username'; const username = c.getFieldValue(m, usernameField); - + const customResolvedConnection = l.resolvedConnection(m); + let connectionName = databaseConnectionName(m); + if (customResolvedConnection) { + connectionName = customResolvedConnection.name; + } const params = { - connection: databaseConnectionName(m), + connection: connectionName, username: username, password: c.getFieldValue(m, 'password') }; @@ -167,8 +171,11 @@ export function resetPassword(id) { function resetPasswordSuccess(id) { const m = read(getEntity, 'lock', id); if (hasScreen(m, 'login')) { - swap(updateEntity, 'lock', id, m => - setScreen(l.setSubmitting(m, false), 'login', ['']) // array with one empty string tells the function to not clear any field + swap( + updateEntity, + 'lock', + id, + m => setScreen(l.setSubmitting(m, false), 'login', ['']) // array with one empty string tells the function to not clear any field ); // TODO: should be handled by box diff --git a/src/core/index.js b/src/core/index.js index f38a16d16..26f0fb368 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -34,7 +34,8 @@ export function setup(id, clientID, domain, options, hookRunner, emitEventFn) { defaultADUsernameFromEmailPrefix: options.defaultADUsernameFromEmailPrefix === false ? false : true, - prefill: options.prefill || {} + prefill: options.prefill || {}, + connectionResolver: options.connectionResolver }) ); @@ -71,6 +72,31 @@ export function oidcConformant(m) { return get(m, 'oidcConformant'); } +export function connectionResolver(m) { + return get(m, 'connectionResolver'); +} + +export function setResolvedConnection(m, resolvedConnection) { + if (!resolvedConnection) { + return set(m, 'resolvedConnection', undefined); + } + if (!resolvedConnection.type || !resolvedConnection.name) { + throw new Error( + 'Invalid connection object. The resolved connection must look like: `{ type: "database", name: "connection name" }`.' + ); + } + if (resolvedConnection.type !== 'database') { + throw new Error( + 'Invalid connection type. Only database connections can be resolved with a custom resolver.' + ); + } + return set(m, 'resolvedConnection', resolvedConnection); +} + +export function resolvedConnection(m) { + return get(m, 'resolvedConnection'); +} + export function languageBaseUrl(m) { return get(m, 'languageBaseUrl'); } diff --git a/src/engine/classic/login.jsx b/src/engine/classic/login.jsx index 08b97c8c2..d2c6d7138 100644 --- a/src/engine/classic/login.jsx +++ b/src/engine/classic/login.jsx @@ -136,6 +136,10 @@ export default class Login extends Screen { if (isHRDDomain(model, databaseUsernameValue(model)) && !l.oidcConformant(model)) { return id => startHRD(id, databaseUsernameValue(model)); } + const customResolvedConnection = l.resolvedConnection(model); + if (customResolvedConnection && customResolvedConnection.type === 'database') { + return databaseLogIn; + } const useDatabaseConnection = !isSSOEnabled(model) && diff --git a/src/field/email/email_pane.jsx b/src/field/email/email_pane.jsx index aba0ce019..87f91f72f 100644 --- a/src/field/email/email_pane.jsx +++ b/src/field/email/email_pane.jsx @@ -24,6 +24,19 @@ export default class EmailPane extends React.Component { swap(updateEntity, 'lock', l.id(lock), setEmail, e.target.value); } + handleBlur() { + const { lock } = this.props; + const connectionResolver = l.connectionResolver(lock); + if (!connectionResolver) { + return; + } + const { connections, id } = lock.get('client').toJS(); + const context = { connections, id }; + connectionResolver(c.getFieldValue(lock, 'email'), context, resolvedConnection => { + swap(updateEntity, 'lock', l.id(lock), m => l.setResolvedConnection(m, resolvedConnection)); + }); + } + render() { const { i18n, lock, placeholder, forceInvalidVisibility = false } = this.props; const allowAutocomplete = l.ui.allowAutocomplete(lock); @@ -42,6 +55,7 @@ export default class EmailPane extends React.Component { invalidHint={invalidHint} isValid={isValid} onChange={::this.handleChange} + onBlur={::this.handleBlur} placeholder={placeholder} autoComplete={allowAutocomplete} /> diff --git a/src/field/username/username_pane.jsx b/src/field/username/username_pane.jsx index ee706ab00..b4800ea18 100644 --- a/src/field/username/username_pane.jsx +++ b/src/field/username/username_pane.jsx @@ -31,6 +31,18 @@ export default class UsernamePane extends React.Component { validateFormat ); } + handleBlur() { + const { lock } = this.props; + const connectionResolver = l.connectionResolver(lock); + if (!connectionResolver) { + return; + } + const { connections, id } = lock.get('client').toJS(); + const context = { connections, id }; + connectionResolver(c.getFieldValue(lock, 'username'), context, resolvedConnection => { + swap(updateEntity, 'lock', l.id(lock), m => l.setResolvedConnection(m, resolvedConnection)); + }); + } render() { const { i18n, lock, placeholder, validateFormat } = this.props; @@ -62,6 +74,7 @@ export default class UsernamePane extends React.Component { invalidHint={invalidHint(value)} isValid={!c.isFieldVisiblyInvalid(lock, 'username')} onChange={::this.handleChange} + onBlur={::this.handleBlur} placeholder={placeholder} autoComplete={allowAutocomplete} /> diff --git a/src/ui/input/email_input.jsx b/src/ui/input/email_input.jsx index 41ed72fbd..a3a8c076e 100644 --- a/src/ui/input/email_input.jsx +++ b/src/ui/input/email_input.jsx @@ -23,7 +23,7 @@ export default class EmailInput extends React.Component { } render() { - const { invalidHint, isValid, onChange, autoComplete, ...props } = this.props; + const { invalidHint, isValid, autoComplete, ...props } = this.props; const { focused } = this.state; return ( @@ -61,8 +61,11 @@ export default class EmailInput extends React.Component { this.setState({ focused: true }); } - handleBlur() { + handleBlur(e) { this.setState({ focused: false }); + if (this.props.onBlur) { + this.props.onBlur(e); + } } } diff --git a/src/ui/input/username_input.jsx b/src/ui/input/username_input.jsx index 482fea4c9..a15f65f5c 100644 --- a/src/ui/input/username_input.jsx +++ b/src/ui/input/username_input.jsx @@ -63,8 +63,11 @@ export default class UsernameInput extends React.Component { this.setState({ focused: true }); } - handleBlur() { + handleBlur(e) { this.setState({ focused: false }); + if (this.props.onBlur) { + this.props.onBlur(e); + } } }