Skip to content

Commit ff449ad

Browse files
feat: OAuth2 database field (#30126)
1 parent 6009023 commit ff449ad

File tree

12 files changed

+366
-17
lines changed

12 files changed

+366
-17
lines changed

superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -161,23 +161,20 @@ export const httpPathField = ({
161161
getValidation,
162162
validationErrors,
163163
db,
164-
}: FieldPropTypes) => {
165-
console.error(db);
166-
return (
167-
<ValidatedInput
168-
id="http_path_field"
169-
name="http_path_field"
170-
required={required}
171-
value={db?.parameters?.http_path_field}
172-
validationMethods={{ onBlur: getValidation }}
173-
errorMessage={validationErrors?.http_path}
174-
placeholder={t('e.g. sql/protocolv1/o/12345')}
175-
label="HTTP Path"
176-
onChange={changeMethods.onParametersChange}
177-
helpText={t('Copy the name of the HTTP Path of your cluster.')}
178-
/>
179-
);
180-
};
164+
}: FieldPropTypes) => (
165+
<ValidatedInput
166+
id="http_path_field"
167+
name="http_path_field"
168+
required={required}
169+
value={db?.parameters?.http_path_field}
170+
validationMethods={{ onBlur: getValidation }}
171+
errorMessage={validationErrors?.http_path}
172+
placeholder={t('e.g. sql/protocolv1/o/12345')}
173+
label="HTTP Path"
174+
onChange={changeMethods.onParametersChange}
175+
helpText={t('Copy the name of the HTTP Path of your cluster.')}
176+
/>
177+
);
181178
export const usernameField = ({
182179
required,
183180
changeMethods,
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { render, fireEvent } from '@testing-library/react';
21+
import '@testing-library/jest-dom/extend-expect';
22+
import { ThemeProvider, supersetTheme } from '@superset-ui/core';
23+
import { DatabaseObject } from 'src/features/databases/types';
24+
import { OAuth2ClientField } from './OAuth2ClientField';
25+
26+
const renderWithTheme = (component: JSX.Element) =>
27+
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
28+
29+
describe('OAuth2ClientField', () => {
30+
const mockChangeMethods = {
31+
onEncryptedExtraInputChange: jest.fn(),
32+
onParametersChange: jest.fn(),
33+
onChange: jest.fn(),
34+
onQueryChange: jest.fn(),
35+
onParametersUploadFileChange: jest.fn(),
36+
onAddTableCatalog: jest.fn(),
37+
onRemoveTableCatalog: jest.fn(),
38+
onExtraInputChange: jest.fn(),
39+
onSSHTunnelParametersChange: jest.fn(),
40+
};
41+
42+
const defaultProps = {
43+
required: false,
44+
onParametersChange: jest.fn(),
45+
onParametersUploadFileChange: jest.fn(),
46+
changeMethods: mockChangeMethods,
47+
validationErrors: null,
48+
getValidation: jest.fn(),
49+
clearValidationErrors: jest.fn(),
50+
field: 'test',
51+
db: {
52+
configuration_method: 'dynamic_form',
53+
database_name: 'test',
54+
driver: 'test',
55+
id: 1,
56+
name: 'test',
57+
is_managed_externally: false,
58+
engine_information: {
59+
supports_oauth2: true,
60+
},
61+
masked_encrypted_extra: JSON.stringify({
62+
oauth2_client_info: {
63+
id: 'test-id',
64+
secret: 'test-secret',
65+
authorization_request_uri: 'https://auth-uri',
66+
token_request_uri: 'https://token-uri',
67+
scope: 'test-scope',
68+
},
69+
}),
70+
} as DatabaseObject,
71+
};
72+
73+
afterEach(() => {
74+
jest.clearAllMocks();
75+
});
76+
77+
it('does not show input fields until the collapse trigger is clicked', () => {
78+
const { getByText, getByTestId, queryByTestId } = renderWithTheme(
79+
<OAuth2ClientField {...defaultProps} />,
80+
);
81+
82+
expect(queryByTestId('client-id')).not.toBeInTheDocument();
83+
expect(queryByTestId('client-secret')).not.toBeInTheDocument();
84+
expect(
85+
queryByTestId('client-authorization-request-uri'),
86+
).not.toBeInTheDocument();
87+
expect(queryByTestId('client-token-request-uri')).not.toBeInTheDocument();
88+
expect(queryByTestId('client-scope')).not.toBeInTheDocument();
89+
90+
const collapseTrigger = getByText('OAuth2 client information');
91+
fireEvent.click(collapseTrigger);
92+
93+
expect(getByTestId('client-id')).toBeInTheDocument();
94+
expect(getByTestId('client-secret')).toBeInTheDocument();
95+
expect(getByTestId('client-authorization-request-uri')).toBeInTheDocument();
96+
expect(getByTestId('client-token-request-uri')).toBeInTheDocument();
97+
expect(getByTestId('client-scope')).toBeInTheDocument();
98+
});
99+
100+
it('renders the OAuth2ClientField component with initial values', () => {
101+
const { getByTestId, getByText } = renderWithTheme(
102+
<OAuth2ClientField {...defaultProps} />,
103+
);
104+
105+
const collapseTrigger = getByText('OAuth2 client information');
106+
fireEvent.click(collapseTrigger);
107+
108+
expect(getByTestId('client-id')).toHaveValue('test-id');
109+
expect(getByTestId('client-secret')).toHaveValue('test-secret');
110+
expect(getByTestId('client-authorization-request-uri')).toHaveValue(
111+
'https://auth-uri',
112+
);
113+
expect(getByTestId('client-token-request-uri')).toHaveValue(
114+
'https://token-uri',
115+
);
116+
expect(getByTestId('client-scope')).toHaveValue('test-scope');
117+
});
118+
119+
it('handles input changes and triggers onEncryptedExtraInputChange', () => {
120+
const { getByTestId, getByText } = renderWithTheme(
121+
<OAuth2ClientField {...defaultProps} />,
122+
);
123+
124+
const collapseTrigger = getByText('OAuth2 client information');
125+
fireEvent.click(collapseTrigger);
126+
127+
const clientIdInput = getByTestId('client-id');
128+
fireEvent.change(clientIdInput, { target: { value: 'new-id' } });
129+
130+
expect(mockChangeMethods.onEncryptedExtraInputChange).toHaveBeenCalledWith(
131+
expect.objectContaining({
132+
target: {
133+
name: 'oauth2_client_info',
134+
value: expect.objectContaining({ id: 'new-id' }),
135+
},
136+
}),
137+
);
138+
});
139+
140+
it('does not render when supports_oauth2 is false', () => {
141+
const props = {
142+
...defaultProps,
143+
db: {
144+
...defaultProps.db,
145+
engine_information: {
146+
supports_oauth2: false,
147+
},
148+
},
149+
};
150+
151+
const { queryByTestId } = renderWithTheme(<OAuth2ClientField {...props} />);
152+
153+
expect(queryByTestId('client-id')).not.toBeInTheDocument();
154+
});
155+
156+
it('renders empty fields when masked_encrypted_extra is empty', () => {
157+
const props = {
158+
...defaultProps,
159+
db: {
160+
...defaultProps.db,
161+
engine_information: {
162+
supports_oauth2: true,
163+
},
164+
masked_encrypted_extra: '{}',
165+
},
166+
};
167+
168+
const { getByTestId, getByText } = renderWithTheme(
169+
<OAuth2ClientField {...props} />,
170+
);
171+
172+
const collapseTrigger = getByText('OAuth2 client information');
173+
fireEvent.click(collapseTrigger);
174+
175+
expect(getByTestId('client-id')).toHaveValue('');
176+
expect(getByTestId('client-secret')).toHaveValue('');
177+
expect(getByTestId('client-authorization-request-uri')).toHaveValue('');
178+
expect(getByTestId('client-token-request-uri')).toHaveValue('');
179+
expect(getByTestId('client-scope')).toHaveValue('');
180+
});
181+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { useState } from 'react';
21+
22+
import Collapse from 'src/components/Collapse';
23+
import { Input } from 'src/components/Input';
24+
import { FormItem } from 'src/components/Form';
25+
import { FieldPropTypes } from '../../types';
26+
27+
interface OAuth2ClientInfo {
28+
id: string;
29+
secret: string;
30+
authorization_request_uri: string;
31+
token_request_uri: string;
32+
scope: string;
33+
}
34+
35+
export const OAuth2ClientField = ({ changeMethods, db }: FieldPropTypes) => {
36+
const encryptedExtra = JSON.parse(db?.masked_encrypted_extra || '{}');
37+
const [oauth2ClientInfo, setOauth2ClientInfo] = useState<OAuth2ClientInfo>({
38+
id: encryptedExtra.oauth2_client_info?.id || '',
39+
secret: encryptedExtra.oauth2_client_info?.secret || '',
40+
authorization_request_uri:
41+
encryptedExtra.oauth2_client_info?.authorization_request_uri || '',
42+
token_request_uri:
43+
encryptedExtra.oauth2_client_info?.token_request_uri || '',
44+
scope: encryptedExtra.oauth2_client_info?.scope || '',
45+
});
46+
47+
if (db?.engine_information?.supports_oauth2 !== true) {
48+
return null;
49+
}
50+
51+
const handleChange = (key: any) => (e: any) => {
52+
const updatedInfo = {
53+
...oauth2ClientInfo,
54+
[key]: e.target.value,
55+
};
56+
57+
setOauth2ClientInfo(updatedInfo);
58+
59+
const event = {
60+
target: {
61+
name: 'oauth2_client_info',
62+
value: updatedInfo,
63+
},
64+
};
65+
changeMethods.onEncryptedExtraInputChange(event);
66+
};
67+
68+
return (
69+
<Collapse>
70+
<Collapse.Panel header="OAuth2 client information" key="1">
71+
<FormItem label="Client ID">
72+
<Input
73+
data-test="client-id"
74+
value={oauth2ClientInfo.id}
75+
onChange={handleChange('id')}
76+
/>
77+
</FormItem>
78+
<FormItem label="Client Secret">
79+
<Input
80+
data-test="client-secret"
81+
type="password"
82+
value={oauth2ClientInfo.secret}
83+
onChange={handleChange('secret')}
84+
/>
85+
</FormItem>
86+
<FormItem label="Authorization Request URI">
87+
<Input
88+
data-test="client-authorization-request-uri"
89+
placeholder="https://"
90+
value={oauth2ClientInfo.authorization_request_uri}
91+
onChange={handleChange('authorization_request_uri')}
92+
/>
93+
</FormItem>
94+
<FormItem label="Token Request URI">
95+
<Input
96+
data-test="client-token-request-uri"
97+
placeholder="https://"
98+
value={oauth2ClientInfo.token_request_uri}
99+
onChange={handleChange('token_request_uri')}
100+
/>
101+
</FormItem>
102+
<FormItem label="Scope">
103+
<Input
104+
data-test="client-scope"
105+
value={oauth2ClientInfo.scope}
106+
onChange={handleChange('scope')}
107+
/>
108+
</FormItem>
109+
</Collapse.Panel>
110+
</Collapse>
111+
);
112+
};

superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
queryField,
3333
usernameField,
3434
} from './CommonParameters';
35+
import { OAuth2ClientField } from './OAuth2ClientField';
3536
import { validatedInputField } from './ValidatedInputField';
3637
import { EncryptedField } from './EncryptedField';
3738
import { TableCatalog } from './TableCatalog';
@@ -58,6 +59,7 @@ export const FormFieldOrder = [
5859
'warehouse',
5960
'role',
6061
'ssh',
62+
'oauth2_client',
6163
];
6264

6365
const extensionsRegistry = getExtensionsRegistry();
@@ -75,6 +77,7 @@ export const FORM_FIELD_MAP = {
7577
default_schema: defaultSchemaField,
7678
username: usernameField,
7779
password: passwordField,
80+
oauth2_client: OAuth2ClientField,
7881
access_token: accessTokenField,
7982
database_name: displayField,
8083
query: queryField,

superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const DatabaseConnectionForm = ({
3232
onAddTableCatalog,
3333
onChange,
3434
onExtraInputChange,
35+
onEncryptedExtraInputChange,
3536
onParametersChange,
3637
onParametersUploadFileChange,
3738
onQueryChange,
@@ -75,6 +76,7 @@ const DatabaseConnectionForm = ({
7576
onAddTableCatalog,
7677
onRemoveTableCatalog,
7778
onExtraInputChange,
79+
onEncryptedExtraInputChange,
7880
},
7981
validationErrors,
8082
getValidation,

superset-frontend/src/features/databases/DatabaseModal/index.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,20 @@ describe('dbReducer', () => {
17231723
});
17241724
});
17251725

1726+
test('it will set state to payload from encrypted extra input change', () => {
1727+
const action: DBReducerActionType = {
1728+
type: ActionType.EncryptedExtraInputChange,
1729+
payload: { name: 'foo', value: 'bar' },
1730+
};
1731+
const currentState = dbReducer(databaseFixture, action);
1732+
1733+
// extra should be serialized
1734+
expect(currentState).toEqual({
1735+
...databaseFixture,
1736+
masked_encrypted_extra: '{"foo":"bar"}',
1737+
});
1738+
});
1739+
17261740
test('it will set state to payload from extra input change when checkbox', () => {
17271741
const action: DBReducerActionType = {
17281742
type: ActionType.ExtraInputChange,

0 commit comments

Comments
 (0)