Skip to content

Commit add3636

Browse files
authored
Merge pull request #94 from jhadvig/secret-form
Create/Edit page for Webhook secrets
2 parents 6ba2c2d + 33d1cba commit add3636

File tree

8 files changed

+251
-7
lines changed

8 files changed

+251
-7
lines changed

frontend/integration-tests/tests/crud.scenario.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,13 @@ describe('Kubernetes resource CRUD operations', () => {
8585
}
8686

8787
it('displays a YAML editor for creating a new resource instance', async() => {
88-
await crudView.createYAMLButton.click();
88+
const exists = await crudView.createItemButton.isPresent();
89+
if (exists) {
90+
await crudView.createItemButton.click();
91+
await crudView.createYAMLLink.click();
92+
} else {
93+
await crudView.createYAMLButton.click();
94+
}
8995
await yamlView.isLoaded();
9096

9197
const content = await yamlView.editorContent.getText();

frontend/integration-tests/views/crud.view.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { $, $$, browser, ExpectedConditions as until } from 'protractor';
22

33
export const createYAMLButton = $('#yaml-create');
4+
export const createItemButton = $('#item-create');
5+
export const createYAMLLink = $('#yaml-link');
46

57
/**
68
* Returns a promise that resolves after the loading spinner is not present.

frontend/public/components/app.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { Nav } from './nav';
2424
import { ProfilePage } from './profile';
2525
import { ResourceDetailsPage, ResourceListPage } from './resource-list';
2626
import { CopyRoleBinding, CreateRoleBinding, EditRoleBinding, EditRulePage } from './RBAC';
27+
import { CreateSecret, EditSecret } from './secrets/create-secret';
2728
import { StartGuidePage } from './start-guide';
2829
import { SearchPage } from './search';
2930
import { history, AsyncComponent, Loading } from './utils';
@@ -175,6 +176,10 @@ class App extends React.PureComponent {
175176
<Route path="/k8s/ns/:ns/roles/:name/:rule/edit" exact component={EditRulePage} />
176177
<Route path="/k8s/ns/:ns/roles" exact component={rolesListPage} />
177178

179+
<Route path="/k8s/ns/:ns/secrets/new/:type" exact component={props => <CreateSecret {...props} kind="Secret" />} />
180+
<Route path="/k8s/ns/:ns/secrets/:name/edit" exact component={props => <EditSecret {...props} kind="Secret" />} />
181+
<Route path="/k8s/ns/:ns/secrets/:name/edit-yaml" exact component={props => <EditYAMLPage {...props} kind="Secret" />} />
182+
178183
<Route path="/k8s/cluster/rolebindings/new" exact component={props => <CreateRoleBinding {...props} kind="RoleBinding" />} />
179184
<Route path="/k8s/ns/:ns/rolebindings/new" exact component={props => <CreateRoleBinding {...props} kind="RoleBinding" />} />
180185
<Route path="/k8s/ns/:ns/rolebindings/:name/copy" exact component={props => <CopyRoleBinding {...props} kind="RoleBinding" />} />

frontend/public/components/factory/list-page.jsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export const FireMan_ = connect(null, {filterList: k8sActions.filterList})(
162162
</Link>;
163163
} else if (createProps.items) {
164164
createLink = <div className="co-m-primary-action">
165-
<Dropdown noButton={true} className="btn btn-primary" id="yaml-create" title={createButtonText} items={createProps.items} onChange={(name) => history.push(createProps.createLink(name))} />
165+
<Dropdown noButton={true} className="btn btn-primary" id="item-create" title={createButtonText} items={createProps.items} onChange={(name) => history.push(createProps.createLink(name))} />
166166
</div>;
167167
} else {
168168
createLink = <div className="co-m-primary-action">
@@ -236,6 +236,7 @@ FireMan_.propTypes = {
236236
/** @type {React.SFC<{ListComponent: React.ComponentType<any>, kind: string, namespace?: string, filterLabel?: string, title?: string, showTitle?: boolean, dropdownFilters?: any[], rowFilters?: any[], selector?: string, fieldSelector?: string, canCreate?: boolean, createButtonText?: string, createProps?: any, fake?: boolean}>} */
237237
export const ListPage = props => {
238238
const {createButtonText, createHandler, filterLabel, kind, namespace, selector, name, fieldSelector, filters, limit, showTitle = true, fake} = props;
239+
let { createProps } = props;
239240
const ko = kindObj(kind);
240241
const {labelPlural, plural, namespaced, label} = ko;
241242
const title = props.title || labelPlural;
@@ -246,7 +247,8 @@ export const ListPage = props => {
246247
href = namespaced ? `/k8s/ns/${namespace || 'default'}/${ref}/new` : `/k8s/cluster/${ref}/new`;
247248
} catch (unused) { /**/ }
248249
}
249-
const createProps = createHandler ? {onClick: createHandler} : {to: href};
250+
251+
createProps = createProps || (createHandler ? {onClick: createHandler} : {to: href});
250252
const resources = [{ kind, name, namespaced, selector, fieldSelector, filters, limit }];
251253

252254
if (!namespaced && namespace) {

frontend/public/components/secret.jsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as React from 'react';
33

44
import { ColHead, DetailsPage, List, ListHeader, ListPage, ResourceRow } from './factory';
55
import { SecretData } from './configmap-and-secret-data';
6-
import { Cog, ResourceCog, ResourceLink, ResourceSummary, detailsPage, navFactory } from './utils';
6+
import { Cog, ResourceCog, ResourceLink, ResourceSummary, detailsPage, navFactory, resourceObjPath } from './utils';
77
import { fromNow } from './utils/datetime';
88
import { registerTemplate } from '../yaml-templates';
99

@@ -16,7 +16,20 @@ data:
1616
username: YWRtaW4=
1717
password: MWYyZDFlMmU2N2Rm`);
1818

19-
const menuActions = Cog.factory.common;
19+
export const WebHookSecretKey = 'WebHookSecretKey';
20+
21+
// Edit in YAML if not editing a webhook secret with one key.
22+
const editInYaml = obj => !_.has(obj, ['data', WebHookSecretKey]) || _.size(obj.data) !== 1;
23+
24+
const menuActions = [
25+
Cog.factory.ModifyLabels,
26+
Cog.factory.ModifyAnnotations,
27+
(kind, obj) => ({
28+
label: `Edit ${kind.label}...`,
29+
href: editInYaml(obj) ? `${resourceObjPath(obj, kind.kind)}/edit-yaml` : `${resourceObjPath(obj, kind.kind)}/edit`,
30+
}),
31+
Cog.factory.Delete,
32+
];
2033

2134
const SecretHeader = props => <ListHeader>
2235
<ColHead {...props} className="col-md-3 col-sm-4 col-xs-6" sortField="metadata.name">Name</ColHead>
@@ -75,7 +88,22 @@ const filters = [{
7588
],
7689
}];
7790

78-
const SecretsPage = props => <ListPage ListComponent={SecretsList} rowFilters={filters} canCreate={true} {...props} />;
91+
const SecretsPage = props => {
92+
const createItems = {
93+
// source: 'Create Source Secret',
94+
// image: 'Create Image Pull Secret',
95+
// generic: 'Create Key/Value Secret',
96+
webhook: 'Webhook Secret',
97+
yaml: 'Secret from YAML',
98+
};
99+
100+
const createProps = {
101+
items: createItems,
102+
createLink: (type) => `/k8s/ns/${props.namespace}/secrets/new/${type !== 'yaml' ? type : ''}`
103+
};
104+
105+
return <ListPage ListComponent={SecretsList} canCreate={true} rowFilters={filters} createButtonText="Create" createProps={createProps} {...props} />;
106+
};
79107

80108
const SecretsDetailsPage = props => <DetailsPage
81109
{...props}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/* eslint-disable no-undef */
2+
import * as _ from 'lodash-es';
3+
import * as React from 'react';
4+
import { Helmet } from 'react-helmet';
5+
import { connect } from 'react-redux';
6+
import { Link } from 'react-router-dom';
7+
8+
import { k8sCreate, k8sUpdate, K8sResourceKind } from '../../module/k8s';
9+
import { ButtonBar, Firehose, history, kindObj, StatusBox } from '../utils';
10+
import { getActiveNamespace, formatNamespacedRouteForResource, UIActions } from '../../ui/ui-actions';
11+
import { SafetyFirst } from '../safety-first';
12+
import { WebHookSecretKey } from '../secret';
13+
14+
export enum SecretTypes {
15+
webhook = 'webhook',
16+
generic = 'generic',
17+
}
18+
19+
const determineSecretTypeAbstraction = (data) => {
20+
return _.has(data, WebHookSecretKey) ? SecretTypes.webhook : SecretTypes.generic;
21+
};
22+
23+
class BaseEditSecret_ extends SafetyFirst<BaseEditSecretProps_, BaseEditSecretState_> {
24+
constructor (props) {
25+
super(props);
26+
const existingObj = _.pick(props.obj, ['metadata', 'type']);
27+
const existingData = _.get(props.obj, 'data');
28+
const secret = _.defaultsDeep({}, props.fixed, existingObj, {
29+
apiVersion: 'v1',
30+
data: {},
31+
kind: 'Secret',
32+
metadata: {
33+
name: '',
34+
},
35+
type: 'Opaque',
36+
});
37+
38+
this.state = {
39+
secretType: this.props.secretType || determineSecretTypeAbstraction(existingData),
40+
secret: secret,
41+
inProgress: false,
42+
type: secret.type,
43+
stringData: _.mapValues(existingData, window.atob),
44+
};
45+
this.onDataChanged = this.onDataChanged.bind(this);
46+
this.onNameChanged = this.onNameChanged.bind(this);
47+
this.save = this.save.bind(this);
48+
}
49+
onDataChanged (secretsData) {
50+
this.setState({stringData: {...secretsData}});
51+
}
52+
onNameChanged (event) {
53+
let secret = {...this.state.secret};
54+
secret.metadata.name = event.target.value;
55+
this.setState({secret});
56+
}
57+
save (e) {
58+
e.preventDefault();
59+
const { kind, metadata } = this.state.secret;
60+
this.setState({ inProgress: true });
61+
62+
const newSecret = _.assign({}, this.state.secret, {stringData: this.state.stringData});
63+
const ko = kindObj(kind);
64+
(this.props.isCreate
65+
? k8sCreate(ko, newSecret)
66+
: k8sUpdate(ko, newSecret, metadata.namespace, newSecret.metadata.name)
67+
).then(() => {
68+
this.setState({inProgress: false});
69+
history.push(formatNamespacedRouteForResource('secrets'));
70+
},
71+
err => this.setState({error: err.message, inProgress: false})
72+
);
73+
}
74+
render () {
75+
const title = `${this.props.titleVerb} ${_.upperFirst(this.state.secretType)} Secret`;
76+
const { saveButtonText } = this.props;
77+
78+
const explanation = 'Webhook secrets allow you to authenticate a webhook trigger.';
79+
const subform = <WebHookSecretSubform onChange={this.onDataChanged.bind(this)} stringData={this.state.stringData} />;
80+
81+
return <div className="co-m-pane__body">
82+
<Helmet>
83+
<title>{title}</title>
84+
</Helmet>
85+
<form className="co-m-pane__body-group" onSubmit={this.save}>
86+
<h1 className="co-m-pane__heading">{title}</h1>
87+
<p className="co-m-pane__explanation">{explanation}</p>
88+
89+
<fieldset disabled={!this.props.isCreate}>
90+
<div className="form-group">
91+
<label className="control-label">Secret Name</label>
92+
<div>
93+
<input className="form-control" type="text" onChange={this.onNameChanged} value={this.state.secret.metadata.name} required id="test--subject-name" />
94+
<p className="help-block">Unique name of the new secret.</p>
95+
</div>
96+
</div>
97+
</fieldset>
98+
{subform}
99+
<ButtonBar errorMessage={this.state.error} inProgress={this.state.inProgress} >
100+
<button type="submit" className="btn btn-primary" id="create-secret">{saveButtonText || 'Create'}</button>
101+
<Link to={formatNamespacedRouteForResource('secrets')} className="btn btn-default">Cancel</Link>
102+
</ButtonBar>
103+
</form>
104+
</div>;
105+
}
106+
}
107+
108+
const BaseEditSecret = connect(null, {setActiveNamespace: UIActions.setActiveNamespace})(
109+
(props: BaseEditSecretProps_) => <BaseEditSecret_ {...props} />
110+
);
111+
112+
const BindingLoadingWrapper = props => {
113+
const fixed = _.reduce(props.fixedKeys, (acc, k) => ({...acc, k: _.get(props.obj.data, k)}), {});
114+
return <StatusBox {...props.obj}>
115+
<BaseEditSecret {...props} obj={props.obj.data} fixed={fixed} />
116+
</StatusBox>;
117+
};
118+
119+
export const CreateSecret = ({match: {params}}) => {
120+
return <BaseEditSecret
121+
fixed={{ metadata: {namespace: params.ns} }}
122+
metadata={{ namespace: getActiveNamespace() }}
123+
secretType={params.type}
124+
titleVerb="Create"
125+
isCreate={true}
126+
/>;
127+
};
128+
129+
export const EditSecret = ({match: {params}, kind}) => <Firehose resources={[{kind: kind, name: params.name, namespace: params.ns, isList: false, prop: 'obj'}]}>
130+
<BindingLoadingWrapper fixedKeys={['kind', 'metadata']} titleVerb="Edit" saveButtonText="Save Changes" />
131+
</Firehose>;
132+
133+
const generateSecret = () => {
134+
// http://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
135+
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
136+
return s4() + s4() + s4() + s4();
137+
};
138+
139+
class WebHookSecretSubform extends React.Component<WebHookSecretSubformProps, WebHookSecretSubformState> {
140+
constructor(props) {
141+
super(props);
142+
this.state = {WebHookSecretKey: this.props.stringData.WebHookSecretKey || ''};
143+
this.changeWebHookSecretkey = this.changeWebHookSecretkey.bind(this);
144+
this.generateWebHookSecret = this.generateWebHookSecret.bind(this);
145+
}
146+
changeWebHookSecretkey(event) {
147+
this.setState({
148+
WebHookSecretKey: event.target.value
149+
}, () => this.props.onChange(this.state));
150+
}
151+
generateWebHookSecret() {
152+
this.setState({
153+
WebHookSecretKey: generateSecret()
154+
}, () => this.props.onChange(this.state));
155+
}
156+
render () {
157+
return <div className="form-group">
158+
<label className="control-label" htmlFor="webhook-secret-key">Webhook Secret Key</label>
159+
<div className="input-group">
160+
<input className="form-control" id="webhook-secret-key" type="text" name="webhookSecretKey" onChange={this.changeWebHookSecretkey} value={this.state.WebHookSecretKey} required/>
161+
<span className="input-group-btn">
162+
<button type="button" onClick={this.generateWebHookSecret} className="btn btn-default">Generate</button>
163+
</span>
164+
</div>
165+
<p className="help-block">Value of the secret will be supplied when invoking the webhook. </p>
166+
</div>;
167+
}
168+
}
169+
170+
export type BaseEditSecretState_ = {
171+
secretType?: string,
172+
secret: K8sResourceKind,
173+
inProgress: boolean,
174+
type: string,
175+
stringData: {[key: string]: string},
176+
error?: any,
177+
};
178+
179+
export type BaseEditSecretProps_ = {
180+
obj?: K8sResourceKind,
181+
fixed: any,
182+
kind?: string,
183+
isCreate: boolean,
184+
titleVerb: string,
185+
setActiveNamespace: Function,
186+
secretType?: string,
187+
saveButtonText?: string,
188+
metadata: any,
189+
};
190+
191+
export type WebHookSecretSubformState = {
192+
WebHookSecretKey: string;
193+
};
194+
195+
export type WebHookSecretSubformProps = {
196+
onChange: Function;
197+
stringData: {[WebHookSecretKey: string]: string};
198+
};
199+
/* eslint-enable no-undef */

frontend/public/components/utils/button-bar.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const InfoMessage = ({message}) => <div className="alert alert-info"><span class
2121
// NOTE: DO NOT use <a> elements within a ButtonBar.
2222
// They don't support the disabled attribute, and therefore
2323
// can't be disabled during a pending promise/request.
24+
/** @type {React.SFC<{children: any, className?: string, errorMessage?: string, infoMessage?: string, inProgress: boolean}}>} */
2425
export const ButtonBar = ({children, className, errorMessage, infoMessage, inProgress}) => {
2526
return <div className={classNames(className, 'co-m-btn-bar')}>
2627
{errorMessage && <ErrorMessage message={errorMessage} />}
@@ -35,4 +36,5 @@ ButtonBar.propTypes = {
3536
errorMessage: PropTypes.string,
3637
infoMessage: PropTypes.string,
3738
inProgress: PropTypes.bool.isRequired,
39+
className: PropTypes.string,
3840
};

frontend/public/components/utils/dropdown.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ class DropDownRow extends React.PureComponent {
103103
}
104104
return <li className={className} key={itemKey}>
105105
{prefix}
106-
<a ref={ref => this.ref=ref} className={classNames({'next-to-bookmark': !!prefix, focus: selected, hover})} onClick={e => onclick(itemKey, e)}>{content}</a>
106+
<a ref={ref => this.ref=ref} id={`${itemKey}-link`} className={classNames({'next-to-bookmark': !!prefix, focus: selected, hover})} onClick={e => onclick(itemKey, e)}>{content}</a>
107107
{suffix}
108108
</li>;
109109
}

0 commit comments

Comments
 (0)