diff --git a/frontend/public/components/app.jsx b/frontend/public/components/app.jsx index 101edd39a58..8fbb782f0f5 100644 --- a/frontend/public/components/app.jsx +++ b/frontend/public/components/app.jsx @@ -262,7 +262,7 @@ class App extends React.PureComponent { import('./storage/create-pvc' /* webpackChunkName: "create-pvc" */).then(m => m.CreatePVC)} /> import('./monitoring' /* webpackChunkName: "monitoring" */).then(m => m.MonitoringUI)} /> - + import('./cluster-settings/openid-idp-form' /* webpackChunkName: "openid-idp-form" */).then(m => m.AddOpenIDPage)} /> import('./cluster-settings/htpasswd-idp-form' /* webpackChunkName: "htpasswd-idp-form" */).then(m => m.AddHTPasswdPage)} /> import('./cluster-settings/cluster-settings' /* webpackChunkName: "cluster-settings" */).then(m => m.ClusterSettingsPage)} /> diff --git a/frontend/public/components/cluster-settings/oauth.tsx b/frontend/public/components/cluster-settings/oauth.tsx index fdcc3d4957a..d4184251005 100644 --- a/frontend/public/components/cluster-settings/oauth.tsx +++ b/frontend/public/components/cluster-settings/oauth.tsx @@ -53,6 +53,7 @@ const OAuthDetails: React.SFC = ({obj}: {obj: K8sResourceKind const { identityProviders, tokenConfig = {} } = obj.spec; const addIDPItems = { htpasswd: 'HTPasswd', + oidconnect: 'OpenID Connect', }; return
diff --git a/frontend/public/components/cluster-settings/openid-idp-form.tsx b/frontend/public/components/cluster-settings/openid-idp-form.tsx new file mode 100644 index 00000000000..035fc3fdd47 --- /dev/null +++ b/frontend/public/components/cluster-settings/openid-idp-form.tsx @@ -0,0 +1,282 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; +import * as _ from 'lodash-es'; +import { Helmet } from 'react-helmet'; + +import { OAuthModel, SecretModel, ConfigMapModel } from '../../models'; +import { k8sCreate, k8sGet, k8sPatch, K8sResourceKind, referenceFor } from '../../module/k8s'; +import { + AsyncComponent, + ButtonBar, + Dropdown, + ListInput, + PromiseComponent, + history, + resourceObjPath, +} from '../utils'; + +// The name of the cluster-scoped OAuth configuration resource. +const oauthResourceName = 'cluster'; + +const DroppableFileInput = (props) => import('../utils/file-input').then(c => c.DroppableFileInput)} {...props} />; + +export class AddOpenIDPage extends PromiseComponent { + readonly state: AddOpenIDIDPPageState = { + name: 'openid', + mappingMethod: 'claim', + clientID: '', + clientSecretFileContent: '', + claimPreferredUsernames: [], + claimNames: [], + claimEmails: [], + issuer: '', + caFileContent: '', + extraScopes: [], + inProgress: false, + errorMessage: '', + }; + + getOAuthResource(): Promise { + return this.handlePromise(k8sGet(OAuthModel, oauthResourceName)); + } + + createClientSecret(): Promise { + const secret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + generateName: 'openid-client-secret-', + namespace: 'openshift-config', + }, + stringData: { + clientSecret: this.state.clientSecretFileContent, + }, + }; + + return this.handlePromise(k8sCreate(SecretModel, secret)); + } + + createCAConfigMap(): Promise { + const { caFileContent } = this.state; + if (!caFileContent) { + return Promise.resolve(null); + } + + const ca = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + generateName: 'openid-ca-', + namespace: 'openshift-config', + }, + stringData: { + ca: caFileContent, + }, + }; + + return this.handlePromise(k8sCreate(ConfigMapModel, ca)); + } + + addOpenIDIDP(oauth: K8sResourceKind, clientSecretName: string, caName: string): Promise { + const { name, mappingMethod, clientID, issuer, extraScopes, claimPreferredUsernames, claimNames, claimEmails } = this.state; + const openID: any = { + name, + type: 'OpenID', + mappingMethod, + openID: { + clientID, + clientSecret: { + name: clientSecretName, + }, + issuer, + extraScopes, + claims: { + preferredUsername: claimPreferredUsernames, + name: claimNames, + email: claimEmails, + }, + }, + }; + + if (caName) { + openID.ca = { + name: caName, + }; + } + + const patch = _.isEmpty(oauth.spec.identityProviders) + ? { op: 'add', path: '/spec/identityProviders', value: [openID] } + : { op: 'add', path: '/spec/identityProviders/-', value: openID }; + return this.handlePromise(k8sPatch(OAuthModel, oauth, [patch])); + } + + submit: React.FormEventHandler = (e) => { + e.preventDefault(); + if (!this.state.clientSecretFileContent) { + this.setState({errorMessage: 'You must specify a Client Secret file.'}); + return; + } + + // Clear any previous errors. + this.setState({errorMessage: ''}); + this.getOAuthResource().then((oauth: K8sResourceKind) => { + const promises = [ + this.createClientSecret(), + this.createCAConfigMap(), + ]; + + Promise.all(promises).then(([secret, configMap]) => { + const caName = configMap ? configMap.metadata.name : ''; + return this.addOpenIDIDP(oauth, secret.metadata.name, caName); + }).then(() => { + history.push(resourceObjPath(oauth, referenceFor(oauth))); + }); + }); + }; + + nameChanged: React.ReactEventHandler = (event) => { + this.setState({name: event.currentTarget.value}); + }; + + clientIDChanged: React.ReactEventHandler = (event) => { + this.setState({clientID: event.currentTarget.value}); + }; + + issuerChanged: React.ReactEventHandler = (event) => { + this.setState({issuer: event.currentTarget.value}); + }; + + claimPreferredUsernamesChanged = (claimPreferredUsernames: string[]) => { + this.setState({claimPreferredUsernames}); + }; + + claimNamesChanged = (claimNames: string[]) => { + this.setState({claimNames}); + }; + + claimEmailsChanged = (claimEmails: string[]) => { + this.setState({claimEmails}); + }; + + extraScopesChanged = (extraScopes: string[]) => { + this.setState({extraScopes}); + }; + + mappingMethodChanged = (mappingMethod: string) => { + this.setState({mappingMethod}); + }; + + clientSecretFileChanged = (clientSecretFileContent: string) => { + this.setState({clientSecretFileContent}); + }; + + caFileChanged = (caFileContent: string) => { + this.setState({caFileContent}); + }; + + render() { + const { name, mappingMethod, clientID, clientSecretFileContent, issuer, caFileContent } = this.state; + const title = 'Add Identity Provider: OpenID Connect'; + const mappingMethods = { + 'claim': 'Claim', + 'lookup': 'Lookup', + 'add': 'Add', + }; + return
+ + {title} + +
+

{title}

+

+ Integrate with an OpenID Connect identity provider using an Authorization Code Flow. +

+
+ + +

+ Unique name of the new identity provider. This cannot be changed later. +

+
+
+ + +
+ { /* TODO: Add doc link when available in 4.0 docs. */ } + Specifies how new identities are mapped to users when they log in. +
+
+
+ + +
+
+ +
+
+ + +
+
+

Claims

+

The first non-empty claim is used. At least one claim is required.

+ + + +
+

More options

+
+ +
+ + + + + + +
; + } +} + +type AddOpenIDIDPPageState = { + name: string; + mappingMethod: 'claim' | 'lookup' | 'add'; + clientID: string; + clientSecretFileContent: string; + claimPreferredUsernames: string[]; + claimNames: string[]; + claimEmails: string[]; + issuer: string; + caFileContent: string; + extraScopes: string[]; + inProgress: boolean; + errorMessage: string; +}; diff --git a/frontend/public/components/utils/_list-input.scss b/frontend/public/components/utils/_list-input.scss new file mode 100644 index 00000000000..0d70f86d680 --- /dev/null +++ b/frontend/public/components/utils/_list-input.scss @@ -0,0 +1,15 @@ +.co-list-input__add-btn { + padding-left: 0; +} + +.co-list-input__remove-btn { + margin-top: -6px; +} + +.co-list-input__row { + display: flex; +} + +.co-list-input__value { + flex-grow: 1; +} diff --git a/frontend/public/components/utils/index.tsx b/frontend/public/components/utils/index.tsx index e4fad8f9aa7..4054e11f713 100644 --- a/frontend/public/components/utils/index.tsx +++ b/frontend/public/components/utils/index.tsx @@ -47,6 +47,8 @@ export * from './k8s-watcher'; export * from './workload-pause'; export * from './list-dropdown'; export * from './status-icon'; +export * from './list-input'; + /* Add the enum for NameValueEditorPair here and not in its namesake file because the editor should always be loaded asynchronously in order not to bloat the vendor file. The enum reference into the editor diff --git a/frontend/public/components/utils/list-input.tsx b/frontend/public/components/utils/list-input.tsx new file mode 100644 index 00000000000..12bc8ebd0ac --- /dev/null +++ b/frontend/public/components/utils/list-input.tsx @@ -0,0 +1,75 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; +import * as _ from 'lodash-es'; + +export class ListInput extends React.Component { + readonly state: ListInputState = { + values: [''], + }; + + componentDidUpdate(prevProps: ListInputProps, prevState: ListInputState) { + if (prevState.values !== this.state.values) { + const values = _.compact(this.state.values); + this.props.onChange(values); + } + } + + valueChanged(i: number, v: string) { + this.setState(state => { + const values = [...state.values]; + values[i] = v; + return { values }; + }); + } + + addValue() { + this.setState(state => ({ values: [...state.values, '']})); + } + + removeValue(i: number) { + this.setState(state => { + const values = [...state.values]; + values.splice(i, 1); + return { + values: _.isEmpty(values) ? [''] : values, + }; + }); + } + + render() { + const { label } = this.props; + const { values } = this.state; + return ( +
+ + {_.map(values, (v: string, i: number) => ( +
+
+ ) => this.valueChanged(i, e.currentTarget.value)} /> +
+
+