diff --git a/core/src/main/resources/lib/form/secretTextarea.jelly b/core/src/main/resources/lib/form/secretTextarea.jelly
new file mode 100644
index 000000000000..5d476d0c0f86
--- /dev/null
+++ b/core/src/main/resources/lib/form/secretTextarea.jelly
@@ -0,0 +1,109 @@
+
+
+
+
+ for editing multi-line secrets.
+
+Example usage:
+
+
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+ Used for databinding. Must be compatible with hudson.util.Secret for round-trip ciphertext.
+
+
+ Name to use for form input name. Calculated from @field by default.
+
+
+ Value of the secret. Calculated from instance[@field] by default.
+ This value must be of type hudson.util.Secret.
+ The value will be encrypted when sent to the client if the client has Item.CONFIGURE permissions.
+
+
+ Placeholder text for input field when displayed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/main/resources/lib/form/secretTextarea.properties b/core/src/main/resources/lib/form/secretTextarea.properties
new file mode 100644
index 000000000000..ebd0491095e4
--- /dev/null
+++ b/core/src/main/resources/lib/form/secretTextarea.properties
@@ -0,0 +1,29 @@
+#
+# The MIT License
+#
+# Copyright (c) 2019 CloudBees, Inc.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+Add=Add
+Replace=Replace
+EnterSecret=Enter New Secret Below
+Concealed=Concealed for Confidentiality
+NoStoredValue=No Stored Value
diff --git a/core/src/main/resources/lib/form/secretTextarea/secret.css b/core/src/main/resources/lib/form/secretTextarea/secret.css
new file mode 100644
index 000000000000..4960c64aed6f
--- /dev/null
+++ b/core/src/main/resources/lib/form/secretTextarea/secret.css
@@ -0,0 +1,77 @@
+/*
+ * The MIT License
+ *
+ * Copyright (c) 2019 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.secret-header {
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ background: #f9f9f9;
+ display: flex;
+ justify-content: space-around;
+}
+
+.secret-header:not(:only-child) {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.secret-header > div {
+ flex-grow: 1;
+ display: inline-flex;
+ align-items: center;
+ padding: 1.5em 1.75em;
+}
+
+.secret-legend > svg {
+ margin-right: 1em;
+}
+
+.secret-update {
+ justify-content: flex-end;
+}
+
+.secret-input {
+ border: solid 1px #ccc;
+ border-top: none;
+ border-radius: 0 0 3px 3px;
+}
+
+.secret-input textarea {
+ width: 100%;
+ font-family: monospace;
+ border: none;
+ padding: 1em;
+}
+
+.secret input[type='button'] {
+ background: #4b99d0;
+ color: #fff;
+ border-radius: 4px;
+ border: none;
+ padding: 1em 2em;
+}
+
+.secret input[type='button']:hover {
+ background: #5092be;
+ cursor: pointer;
+}
diff --git a/core/src/main/resources/lib/form/secretTextarea/secret.js b/core/src/main/resources/lib/form/secretTextarea/secret.js
new file mode 100644
index 000000000000..c2b312144075
--- /dev/null
+++ b/core/src/main/resources/lib/form/secretTextarea/secret.js
@@ -0,0 +1,77 @@
+/*
+ * The MIT License
+ *
+ * Copyright (c) 2019 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+Behaviour.specify('.secret', 'secret-button', 0, function (e) {
+ var secretUpdateBtn = e.querySelector('.secret-update-btn');
+ if (secretUpdateBtn === null) return;
+
+ var id = 'secret-' + (iota++);
+ var name = e.getAttribute('data-name');
+ var placeholder = e.getAttribute('data-placeholder');
+ var prompt = e.getAttribute('data-prompt');
+
+ var appendSecretInput = function () {
+ var textarea = document.createElement('textarea');
+ textarea.setAttribute('id', id);
+ textarea.setAttribute('name', name);
+ if (placeholder !== null && placeholder !== '') {
+ textarea.setAttribute('placeholder', placeholder);
+ }
+ var secretInput = document.createElement('div');
+ secretInput.setAttribute('class', 'secret-input');
+ secretInput.appendChild(textarea);
+ e.appendChild(secretInput);
+ }
+
+ var clearSecretValue = function () {
+ var secretValue = e.querySelector('input[type="hidden"]');
+ if (secretValue !== null) {
+ secretValue.parentNode.removeChild(secretValue);
+ }
+ }
+
+ var replaceUpdateButton = function () {
+ var secretLabel = document.createElement('label');
+ secretLabel.setAttribute('for', id);
+ secretLabel.appendChild(document.createTextNode(prompt));
+ secretUpdateBtn.parentNode.replaceChild(secretLabel, secretUpdateBtn);
+ }
+
+ var removeSecretLegendLabel = function () {
+ var secretLegend = e.querySelector('.secret-legend');
+ var secretLegendText = secretLegend.querySelector('span');
+ if (secretLegendText !== null) {
+ secretLegend.removeChild(secretLegendText);
+ }
+ }
+
+ secretUpdateBtn.onclick = function () {
+ appendSecretInput();
+ clearSecretValue();
+ replaceUpdateButton();
+ removeSecretLegendLabel();
+ // fix UI bug when DOM changes
+ Event.fire(window, 'jenkins:bottom-sticker-adjust');
+ };
+});
diff --git a/test/src/test/java/lib/form/SecretTextareaTest.java b/test/src/test/java/lib/form/SecretTextareaTest.java
new file mode 100644
index 000000000000..93cc37bda57d
--- /dev/null
+++ b/test/src/test/java/lib/form/SecretTextareaTest.java
@@ -0,0 +1,175 @@
+/*
+ * The MIT License
+ *
+ * Copyright (c) 2019 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package lib.form;
+
+import com.gargoylesoftware.htmlunit.html.HtmlForm;
+import com.gargoylesoftware.htmlunit.html.HtmlHiddenInput;
+import com.gargoylesoftware.htmlunit.html.HtmlTextInput;
+import hudson.model.AbstractProject;
+import hudson.model.Project;
+import hudson.tasks.BuildStepDescriptor;
+import hudson.tasks.Builder;
+import hudson.util.Secret;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.JenkinsRule.WebClient;
+import org.jvnet.hudson.test.TestExtension;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+import org.xml.sax.SAXException;
+
+import java.io.IOException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+
+public class SecretTextareaTest {
+
+ private Project, ?> project;
+ private WebClient wc;
+
+ @Rule public JenkinsRule j = new JenkinsRule();
+
+ @Before
+ public void setUp() throws IOException {
+ project = j.createFreeStyleProject();
+ project.getBuildersList().add(TestBuilder.newDefault());
+ wc = j.createWebClient();
+ }
+
+ @Test
+ public void addEmptySecret() throws Exception {
+ j.configRoundtrip(project);
+ assertTestBuilderDataBoundEqual(TestBuilder.newDefault());
+ }
+
+ @Test
+ public void addSecret() throws Exception {
+ setProjectSecret("testValue");
+ assertTestBuilderDataBoundEqual(TestBuilder.fromString("testValue"));
+ }
+
+ @Test
+ public void addSecretAndUpdateDescription() throws Exception {
+ setProjectSecret("Original Value");
+ assertTestBuilderDataBoundEqual(TestBuilder.fromString("Original Value"));
+ HtmlForm configForm = goToConfigForm();
+ HtmlTextInput description = configForm.getInputByName("_.description");
+ description.setText("New description");
+ j.submit(configForm);
+ assertTestBuilderDataBoundEqual(TestBuilder.fromStringWithDescription("Original Value", "New description"));
+ }
+
+ @Test
+ public void addSecretAndUpdateSecretWithEmptyValue() throws Exception {
+ setProjectSecret("First");
+ assertTestBuilderDataBoundEqual(TestBuilder.fromString("First"));
+ HtmlForm configForm = goToConfigForm();
+ String hiddenValue = getHiddenSecretValue(configForm);
+ assertNotNull(hiddenValue);
+ assertNotEquals("First", hiddenValue);
+ assertEquals("First", Secret.fromString(hiddenValue).getPlainText());
+ clickSecretUpdateButton(configForm);
+ j.submit(configForm);
+ assertTestBuilderDataBoundEqual(TestBuilder.fromString(""));
+ }
+
+ private void assertTestBuilderDataBoundEqual(TestBuilder other) throws Exception {
+ j.assertEqualDataBoundBeans(other, project.getBuildersList().get(TestBuilder.class));
+ }
+
+ private void setProjectSecret(String secret) throws Exception {
+ HtmlForm configForm = goToConfigForm();
+ clickSecretUpdateButton(configForm);
+ configForm.getTextAreaByName("_.secret").setText(secret);
+ j.submit(configForm);
+ }
+
+ private HtmlForm goToConfigForm() throws IOException, SAXException {
+ return wc.getPage(project, "configure").getFormByName("config");
+ }
+
+ private static void clickSecretUpdateButton(HtmlForm configForm) throws IOException {
+ configForm.getOneHtmlElementByAttribute("input", "class", "secret-update-btn").click();
+ }
+
+ private static String getHiddenSecretValue(HtmlForm configForm) {
+ HtmlHiddenInput hiddenSecret = configForm.getInputByName("_.secret");
+ return hiddenSecret == null ? null : hiddenSecret.getValueAttribute();
+ }
+
+ public static class TestBuilder extends Builder {
+ private final Secret secret;
+ private String description = "";
+
+ private static TestBuilder newDefault() {
+ return new TestBuilder(null);
+ }
+
+ private static TestBuilder fromString(String secret) {
+ return new TestBuilder(Secret.fromString(secret));
+ }
+
+ private static TestBuilder fromStringWithDescription(String secret, String description) {
+ TestBuilder b = fromString(secret);
+ b.setDescription(description);
+ return b;
+ }
+
+ @DataBoundConstructor
+ public TestBuilder(Secret secret) {
+ this.secret = secret;
+ }
+
+ public Secret getSecret() {
+ return secret;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ @DataBoundSetter
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ @TestExtension
+ public static class DescriptorImpl extends BuildStepDescriptor {
+ @Override
+ public String getDisplayName() {
+ return "Test Secret";
+ }
+
+ @Override
+ public boolean isApplicable(Class extends AbstractProject> jobType) {
+ return true;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/src/test/resources/lib/form/SecretTextareaTest/TestBuilder/config.jelly b/test/src/test/resources/lib/form/SecretTextareaTest/TestBuilder/config.jelly
new file mode 100644
index 000000000000..dfd396de7dda
--- /dev/null
+++ b/test/src/test/resources/lib/form/SecretTextareaTest/TestBuilder/config.jelly
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+