From adff5977c54316fb195ded899987e576b52401df Mon Sep 17 00:00:00 2001 From: Anemy Date: Fri, 21 Jan 2022 17:08:19 -0500 Subject: [PATCH 1/7] Add username password fields and handlers --- .../advanced-options-tabs.tsx | 2 +- .../authentication-default.tsx | 191 +++++++++++++++++- .../authentication-tab.spec.tsx | 0 .../authentication-tab/authentication-tab.tsx | 102 ++++++++-- .../direct-connection-input.spec.tsx | 14 +- .../general-tab/direct-connection-input.tsx | 13 +- .../general-tab/host-input.tsx | 1 - .../ssh-tunnel-tab/ssh-tunnel-tab.tsx | 5 +- .../components/connection-string-input.tsx | 9 +- .../src/components/save-connection-modal.tsx | 1 - .../src/hooks/use-connect-form.spec.ts | 146 ------------- .../src/hooks/use-connect-form.ts | 54 +++-- .../src/utils/authentication-handler.ts | 141 +++++++++++++ 13 files changed, 479 insertions(+), 200 deletions(-) create mode 100644 packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.spec.tsx create mode 100644 packages/connect-form/src/utils/authentication-handler.ts diff --git a/packages/connect-form/src/components/advanced-options-tabs/advanced-options-tabs.tsx b/packages/connect-form/src/components/advanced-options-tabs/advanced-options-tabs.tsx index 959337dfccb..248bdc0b25b 100644 --- a/packages/connect-form/src/components/advanced-options-tabs/advanced-options-tabs.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/advanced-options-tabs.tsx @@ -21,7 +21,7 @@ interface TabObject { errors: ConnectionFormError[]; connectionStringUrl: ConnectionStringUrl; updateConnectionFormField: UpdateConnectionFormField; - connectionOptions?: ConnectionOptions; + connectionOptions: ConnectionOptions; }>; } diff --git a/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-default.tsx b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-default.tsx index 9fd9cba565a..88ce397690e 100644 --- a/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-default.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-default.tsx @@ -1,9 +1,194 @@ -import React from 'react'; +import React, { useCallback, useEffect } from 'react'; +import { + Icon, + IconButton, + Label, + RadioBox, + RadioBoxGroup, + TextInput, + css, + spacing, +} from '@mongodb-js/compass-components'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; +import { AuthMechanism } from 'mongodb'; + +import { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; +import FormFieldContainer from '../../form-field-container'; +import { ConnectionFormError } from '../../../utils/validation'; + +const authSourceLabelStyles = css({ + padding: 0, + margin: 0, + flexGrow: 1, +}); + +const infoButtonStyles = css({ + verticalAlign: 'middle', + marginTop: -spacing[1], +}); + +const defaultAuthMechanismOptions: { + title: string; + value: AuthMechanism; +}[] = [ + { + title: 'Default', + value: AuthMechanism.MONGODB_DEFAULT, + }, + { + title: 'SCRAM-SHA-1', + value: AuthMechanism.MONGODB_SCRAM_SHA1, + }, + { + title: 'SCRAM-SHA-1', + value: AuthMechanism.MONGODB_SCRAM_SHA256, + }, +]; + +function AuthenticationDefault({ + errors, + connectionStringUrl, + updateConnectionFormField, +}: { + connectionStringUrl: ConnectionStringUrl; + errors: ConnectionFormError[]; + updateConnectionFormField: UpdateConnectionFormField; +}): React.ReactElement { + const password = connectionStringUrl.password; + const username = connectionStringUrl.username; + // const [username, setUsername] = useState(connectionStringUrl.username); + // const [usernameErrorMessage, usernameErrorMessage] + + const selectedAuthMechanism = + connectionStringUrl.searchParams.get('authMechanism') ?? ''; + const selectedAuthTab = + defaultAuthMechanismOptions.find( + ({ value }) => value === selectedAuthMechanism + ) ?? defaultAuthMechanismOptions[0]; + + // useEffect(() => { + // // Update the username in the state when the underlying connection username + // // changes. This can be when a user changes connections, pastes in a new + // // connection string, or changes a setting which also updates the username. + // setUsername(connectionStringUrl.username); + // }, [connectionStringUrl]); + + const optionSelected = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + updateConnectionFormField({ + type: 'update-search-param', + currentKey: 'authMechanism', + value: event.target.value, + }); + }, + [updateConnectionFormField] + ); -function AuthenticationDefault(): React.ReactElement { return ( <> -

Username and Password

+ + ) => { + updateConnectionFormField({ + type: 'update-username', + username: value, + }); + }} + // disabled={disabled} + label="Username" + errorMessage={ + errors?.find((error) => error.fieldName === 'username')?.message + } + value={username || ''} + /> + + + ) => { + updateConnectionFormField({ + type: 'update-password', + password: value, + }); + }} + // disabled={disabled} + label="Password" + type="password" + value={password || ''} + /> + + + + + ) => { + if (value === '') { + updateConnectionFormField({ + type: 'delete-search-param', + key: 'authSource', + }); + return; + } + updateConnectionFormField({ + type: 'update-search-param', + currentKey: 'authSource', + value, + }); + }} + // disabled={disabled} + id="authSourceInput" + aria-labelledby="authSourceLabel" + // label="Authentication Database" + value={connectionStringUrl.searchParams.get('authSource') ?? ''} + optional + /> + + + + + {defaultAuthMechanismOptions.map(({ title, value }) => { + return ( + + {title} + + ); + })} + + ); } diff --git a/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.spec.tsx b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.spec.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.tsx b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.tsx index 0ae62eebc8a..d64de4ff649 100644 --- a/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.tsx @@ -19,16 +19,44 @@ import AuthenticationGSSAPI from './authentication-gssapi'; import AuthenticationPlain from './authentication-plain'; import AuthenticationAWS from './authentication-aws'; +// type authMechType = typeof AuthMechanism[keyof typeof AuthMechanism]; + +// type ourAuthMechs = Pick< +// keyof typeof AuthMechanism, +// 'MONGODB_DEFAULT' +// >; +// type AUTH_TABS = 'AUTH_NONE' | Pick +// AuthMechanism; +// | 'AUTH_NONE' +// | AuthMechanism.MONGODB_DEFAULT +// | AuthMechanism.MONGODB_X509 +// | AuthMechanism.MONGODB_GSSAPI +// | AuthMechanism.MONGODB_PLAIN +// | AuthMechanism.MONGODB_AWS; + +type AUTH_TABS = + | 'AUTH_NONE' + | 'DEFAULT' // Username/Password (scram-sha 1 + 256) + | 'MONGODB-X509' + | 'GSSAPI' // Kerberos + | 'PLAIN' // LDAP + | 'MONGODB-AWS'; // AWS IAM + interface TabOption { - id: string; + id: AUTH_TABS; title: string; - component: React.FC; + component: React.FC<{ + errors: ConnectionFormError[]; + connectionStringUrl: ConnectionStringUrl; + updateConnectionFormField: UpdateConnectionFormField; + // connectionOptions?: ConnectionOptions; + }>; } const options: TabOption[] = [ { title: 'None', - id: '', + id: 'AUTH_NONE', component: function None() { return <>; }, @@ -69,27 +97,71 @@ const contentStyles = css({ width: '50%', }); +function getSelectedAuthTabForConnectionString( + connectionStringUrl: ConnectionStringUrl +): AUTH_TABS { + const authMechanismString = + connectionStringUrl.searchParams.get('authMechanism'); + + const hasPasswordOrUsername = + connectionStringUrl.password || connectionStringUrl.username; + if (!authMechanismString && hasPasswordOrUsername) { + // Default (Username/Password) auth when there is no + // `authMechanism` and there's a username or password. + return AuthMechanism.MONGODB_DEFAULT; + } + + const matchingTab = options.find(({ id }) => id === authMechanismString); + if (matchingTab) { + return matchingTab.id; + } + + switch (authMechanismString) { + case AuthMechanism.MONGODB_DEFAULT: + case AuthMechanism.MONGODB_SCRAM_SHA1: + case AuthMechanism.MONGODB_SCRAM_SHA256: + case AuthMechanism.MONGODB_CR: + // We bundle SCRAM-SHA-1 and SCRAM-SHA-256 into the Username/Password bucket. + return AuthMechanism.MONGODB_DEFAULT; + default: + return 'AUTH_NONE'; + } +} + function AuthenticationTab({ + errors, updateConnectionFormField, connectionStringUrl, }: { errors: ConnectionFormError[]; connectionStringUrl: ConnectionStringUrl; updateConnectionFormField: UpdateConnectionFormField; - connectionOptions?: ConnectionOptions; + connectionOptions: ConnectionOptions; }): React.ReactElement { - const selectedAuthMechanism = - connectionStringUrl.searchParams.get('authMechanism') ?? ''; + const selectedAuthTabId = + getSelectedAuthTabForConnectionString(connectionStringUrl); const selectedAuthTab = - options.find(({ id }) => id === selectedAuthMechanism) ?? options[0]; + options.find(({ id }) => id === selectedAuthTabId) || options[0]; const optionSelected = useCallback( (event: ChangeEvent) => { + // TODO: We'll want to wipe the current auth. + event.preventDefault(); - updateConnectionFormField({ - type: 'update-search-param', - currentKey: 'authMechanism', - value: event.target.value, + + if (event.target.value === 'AUTH_NONE') { + // updateConnectionFormField({ + // type: 'delete-search-param', + // key: 'authMechanism', + // }); + return updateConnectionFormField({ + type: 'update-auth-mechanism', + authMechanism: null, + }); + } + return updateConnectionFormField({ + type: 'update-auth-mechanism', + authMechanism: event.target.value as AuthMechanism, }); }, [updateConnectionFormField] @@ -105,6 +177,7 @@ function AuthenticationTab({ {options.map(({ title, id }) => { return ( @@ -123,7 +196,12 @@ function AuthenticationTab({ className={contentStyles} data-testid={`${selectedAuthTab.id}-tab-content`} > - + ); diff --git a/packages/connect-form/src/components/advanced-options-tabs/general-tab/direct-connection-input.spec.tsx b/packages/connect-form/src/components/advanced-options-tabs/general-tab/direct-connection-input.spec.tsx index 24f18f4ad73..0a0b0cc8949 100644 --- a/packages/connect-form/src/components/advanced-options-tabs/general-tab/direct-connection-input.spec.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/general-tab/direct-connection-input.spec.tsx @@ -42,8 +42,8 @@ describe('DirectConnectionInput', function () { it('should call to update with direct connection = false', function () { expect(updateConnectionFormFieldSpy.callCount).to.equal(1); expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ - type: 'update-direct-connection', - isDirectConnection: false, + type: 'delete-search-param', + key: 'directConnection', }); }); }); @@ -76,8 +76,9 @@ describe('DirectConnectionInput', function () { it('should call to update with direct connection = true', function () { expect(updateConnectionFormFieldSpy.callCount).to.equal(1); expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ - type: 'update-direct-connection', - isDirectConnection: true, + type: 'update-search-param', + currentKey: 'directConnection', + value: true, }); }); }); @@ -110,8 +111,9 @@ describe('DirectConnectionInput', function () { it('should call to update with direct connection = true', function () { expect(updateConnectionFormFieldSpy.callCount).to.equal(1); expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ - type: 'update-direct-connection', - isDirectConnection: true, + type: 'update-search-param', + currentKey: 'directConnection', + value: true, }); }); }); diff --git a/packages/connect-form/src/components/advanced-options-tabs/general-tab/direct-connection-input.tsx b/packages/connect-form/src/components/advanced-options-tabs/general-tab/direct-connection-input.tsx index d260391bde9..cf65e85b27c 100644 --- a/packages/connect-form/src/components/advanced-options-tabs/general-tab/direct-connection-input.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/general-tab/direct-connection-input.tsx @@ -19,9 +19,16 @@ function DirectConnectionInput({ const updateDirectConnection = useCallback( (event: React.ChangeEvent) => { - updateConnectionFormField({ - type: 'update-direct-connection', - isDirectConnection: event.target.checked, + if (!event.target.checked) { + return updateConnectionFormField({ + type: 'delete-search-param', + key: 'directConnection', + }); + } + return updateConnectionFormField({ + type: 'update-search-param', + currentKey: 'directConnection', + value: event.target.checked, }); }, [updateConnectionFormField] diff --git a/packages/connect-form/src/components/advanced-options-tabs/general-tab/host-input.tsx b/packages/connect-form/src/components/advanced-options-tabs/general-tab/host-input.tsx index 849eaaf41e7..956dcf167ca 100644 --- a/packages/connect-form/src/components/advanced-options-tabs/general-tab/host-input.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/general-tab/host-input.tsx @@ -93,7 +93,6 @@ function HostInput({ id="connection-host-input" aria-labelledby="connection-host-input-label" state={fieldNameHasError(errors, 'hosts') ? 'error' : undefined} - // Only show the error message on the last host. errorMessage={errorMessageByFieldNameAndIndex( errors, 'hosts', diff --git a/packages/connect-form/src/components/advanced-options-tabs/ssh-tunnel-tab/ssh-tunnel-tab.tsx b/packages/connect-form/src/components/advanced-options-tabs/ssh-tunnel-tab/ssh-tunnel-tab.tsx index ccbe7efae46..761e39f3054 100644 --- a/packages/connect-form/src/components/advanced-options-tabs/ssh-tunnel-tab/ssh-tunnel-tab.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/ssh-tunnel-tab/ssh-tunnel-tab.tsx @@ -105,10 +105,7 @@ function SSHTunnel({ - + {options.map(({ title, id, type }) => { return (
-
diff --git a/packages/connect-form/src/utils/authentication-handler.ts b/packages/connect-form/src/utils/authentication-handler.ts index 163662439a1..2625678ac79 100644 --- a/packages/connect-form/src/utils/authentication-handler.ts +++ b/packages/connect-form/src/utils/authentication-handler.ts @@ -38,7 +38,7 @@ export function handleUpdateAuthMechanism({ if (!action.authMechanism) { updatedSearchParams.delete('authMechanism'); - // Wipe the auth of the connection. + // Wipe any existing auth options on the connection. updatedConnectionString.password = ''; updatedConnectionString.username = ''; } else { From 316fb816b41874c80913f30ac69d47e57e2de0f5 Mon Sep 17 00:00:00 2001 From: Anemy Date: Mon, 24 Jan 2022 14:26:51 -0500 Subject: [PATCH 7/7] fixup: share parse helper, cleanup with tri --- .../authentication-tab/authentication-tab.tsx | 11 ++-- .../src/hooks/use-connect-form.ts | 39 ++++++------ .../src/utils/authentication-handler.ts | 60 ++++++++++--------- .../connect-form/src/utils/validation.spec.ts | 34 +++++++++++ packages/connect-form/src/utils/validation.ts | 11 ++++ 5 files changed, 100 insertions(+), 55 deletions(-) diff --git a/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.tsx b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.tsx index e37d06f1beb..72b76aedc26 100644 --- a/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab/authentication-tab.tsx @@ -131,15 +131,12 @@ function AuthenticationTab({ (event: ChangeEvent) => { event.preventDefault(); - if (event.target.value === 'AUTH_NONE') { - return updateConnectionFormField({ - type: 'update-auth-mechanism', - authMechanism: null, - }); - } return updateConnectionFormField({ type: 'update-auth-mechanism', - authMechanism: event.target.value as AuthMechanism, + authMechanism: + event.target.value === 'AUTH_NONE' + ? null + : (event.target.value as AuthMechanism), }); }, [updateConnectionFormField] diff --git a/packages/connect-form/src/hooks/use-connect-form.ts b/packages/connect-form/src/hooks/use-connect-form.ts index e55de9d9d81..c937679eb7b 100644 --- a/packages/connect-form/src/hooks/use-connect-form.ts +++ b/packages/connect-form/src/hooks/use-connect-form.ts @@ -8,6 +8,7 @@ import ConnectionString from 'mongodb-connection-string-url'; import { ConnectionFormError, ConnectionFormWarning, + tryToParseConnectionString, validateConnectionOptionsWarnings, } from '../utils/validation'; import { getNextHost } from '../utils/get-next-host'; @@ -137,6 +138,25 @@ export type UpdateConnectionFormField = ( action: ConnectionFormFieldActions ) => void; +function parseConnectionString( + connectionString: string +): [ConnectionString | undefined, ConnectionFormError[]] { + const [parsedConnectionString, parsingError] = + tryToParseConnectionString(connectionString); + + return [ + parsedConnectionString, + parsingError + ? [ + { + fieldName: 'connectionString', + message: parsingError.message, + }, + ] + : [], + ]; +} + function buildStateFromConnectionInfo( initialConnectionInfo: ConnectionInfo ): ConnectFormState { @@ -217,25 +237,6 @@ function handleUpdateHost({ } } -function parseConnectionString( - connectionString: string -): [ConnectionString | undefined, ConnectionFormError[]] { - try { - const connectionStringUrl = new ConnectionString(connectionString); - return [connectionStringUrl, []]; - } catch (err) { - return [ - undefined, - [ - { - fieldName: 'connectionString', - message: (err as Error)?.message, - }, - ], - ]; - } -} - // This function handles field updates from the connection form. // It performs validity checks and downstream effects. Exported for testing. export function handleConnectionFormFieldUpdate( diff --git a/packages/connect-form/src/utils/authentication-handler.ts b/packages/connect-form/src/utils/authentication-handler.ts index e1ba6b55980..400e2921e55 100644 --- a/packages/connect-form/src/utils/authentication-handler.ts +++ b/packages/connect-form/src/utils/authentication-handler.ts @@ -3,7 +3,7 @@ import ConnectionStringUrl from 'mongodb-connection-string-url'; import { ConnectionOptions } from 'mongodb-data-service'; import type { MongoClientOptions } from 'mongodb'; -import { ConnectionFormError } from './validation'; +import { ConnectionFormError, tryToParseConnectionString } from './validation'; export type UpdateAuthMechanismAction = { type: 'update-auth-mechanism'; @@ -82,33 +82,34 @@ export function handleUpdateUsername({ connectionOptions: ConnectionOptions; errors?: ConnectionFormError[]; } { - try { - const updatedConnectionString = connectionStringUrl.clone(); + const updatedConnectionString = connectionStringUrl.clone(); - updatedConnectionString.username = action.username; + updatedConnectionString.username = action.username; - // This will throw if the connection string is invalid - new ConnectionStringUrl(updatedConnectionString.toString()); + const [, parsingError] = tryToParseConnectionString( + updatedConnectionString.toString() + ); - return { - connectionOptions: { - ...connectionOptions, - connectionString: updatedConnectionString.toString(), - }, - }; - } catch (err) { + if (parsingError) { return { connectionOptions, errors: [ { fieldName: 'username', message: action.username - ? (err as Error).message - : `Username cannot be empty: "${(err as Error).message}"`, + ? parsingError.message + : `Username cannot be empty: "${parsingError.message}"`, }, ], }; } + + return { + connectionOptions: { + ...connectionOptions, + connectionString: updatedConnectionString.toString(), + }, + }; } export function handleUpdatePassword({ @@ -123,34 +124,28 @@ export function handleUpdatePassword({ connectionOptions: ConnectionOptions; errors?: ConnectionFormError[]; } { - try { - const updatedConnectionString = connectionStringUrl.clone(); + const updatedConnectionString = connectionStringUrl.clone(); - updatedConnectionString.password = action.password; + updatedConnectionString.password = action.password; - // This will throw if the connection string is invalid - new ConnectionStringUrl(updatedConnectionString.toString()); + const [, parsingError] = tryToParseConnectionString( + updatedConnectionString.toString() + ); - return { - connectionOptions: { - ...connectionOptions, - connectionString: updatedConnectionString.toString(), - }, - }; - } catch (err) { + if (parsingError) { return { connectionOptions, errors: connectionStringUrl.username ? [ { fieldName: 'password', - message: (err as Error).message, + message: parsingError.message, }, ] : [ { fieldName: 'username', - message: `Username cannot be empty: "${(err as Error).message}"`, + message: `Username cannot be empty: "${parsingError.message}"`, }, { fieldName: 'password', @@ -159,4 +154,11 @@ export function handleUpdatePassword({ ], }; } + + return { + connectionOptions: { + ...connectionOptions, + connectionString: updatedConnectionString.toString(), + }, + }; } diff --git a/packages/connect-form/src/utils/validation.spec.ts b/packages/connect-form/src/utils/validation.spec.ts index 7cb20ea4857..5c243347102 100644 --- a/packages/connect-form/src/utils/validation.spec.ts +++ b/packages/connect-form/src/utils/validation.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { + tryToParseConnectionString, validateConnectionOptionsErrors, validateConnectionOptionsWarnings, } from './validation'; @@ -495,4 +496,37 @@ describe('validation', function () { }); }); }); + + describe('#tryToParseConnectionString', function () { + it('should return the connection string when successfully parsed', function () { + const [connectionString] = tryToParseConnectionString( + 'mongodb://outerspace:27099?directConnection=true' + ); + + expect(connectionString.toString()).to.equal( + 'mongodb://outerspace:27099/?directConnection=true' + ); + expect(connectionString.hosts[0]).to.equal('outerspace:27099'); + }); + + it('should return without an error when successfully parsed', function () { + const [connectionString, error] = tryToParseConnectionString( + 'mongodb://outerspace:27099/?directConnection=true' + ); + + expect(connectionString).to.not.equal(undefined); + expect(error).to.equal(undefined); + }); + + it('should return an error when it cannot be parsed', function () { + const [connectionString, error] = tryToParseConnectionString( + 'mangos://pineapple:27099/?directConnection=true' + ); + + expect(connectionString).to.equal(undefined); + expect(error.message).to.equal( + 'Invalid connection string "mangos://pineapple:27099/?directConnection=true"' + ); + }); + }); }); diff --git a/packages/connect-form/src/utils/validation.ts b/packages/connect-form/src/utils/validation.ts index 19d918233d1..95452348a93 100644 --- a/packages/connect-form/src/utils/validation.ts +++ b/packages/connect-form/src/utils/validation.ts @@ -30,6 +30,17 @@ export interface ConnectionFormWarning { message: string; } +export function tryToParseConnectionString( + connectionString: string +): [ConnectionString | undefined, Error | undefined] { + try { + const connectionStringUrl = new ConnectionString(connectionString); + return [connectionStringUrl, undefined]; + } catch (err) { + return [undefined, err as Error]; + } +} + export function errorMessageByFieldName( errors: ConnectionFormError[], fieldName: FieldName