Repository: livebook-dev/kino
Pull request #332 (2023-09-05 08:38:37)

Remote cell - Cookie as a secret


Patch:
diff --git a/lib/assets/remote_execution_cell/main.css b/lib/assets/remote_execution_cell/main.css
index 56542b1..984a225 100644
--- a/lib/assets/remote_execution_cell/main.css
+++ b/lib/assets/remote_execution_cell/main.css
@@ -127,3 +127,69 @@ select.input {
   background-size: 10px;
   padding-right: 28px;
 }
+
+.hidden-checkbox-input {
+  display: none;
+}
+
+.input-icon {
+  border: 1px solid var(--gray-200);
+  outline: none;
+  padding: 8px 12px 8px 42px;
+}
+
+.input-icon:hover {
+  cursor: pointer;
+}
+
+.input-icon.unavailable {
+  border-color: var(--red-300);
+}
+
+.input-icon-container {
+  position: relative;
+}
+
+.icon-container {
+  position: absolute;
+  top: 20px;
+  left: 4px;
+  padding: 5px 6px;
+  border-right: 1px solid var(--gray-200);
+  width: 22px;
+  height: 23px;
+  background-color: var(--gray-200);
+  border-radius: 0.5rem;
+  border-top-right-radius: 0;
+  border-bottom-right-radius: 0;
+  margin-left: 1px;
+}
+
+.icon-container.inline {
+  position: absolute;
+  top: 1px;
+  left: 59px;
+}
+
+.icon-container:hover {
+  cursor: pointer;
+  background-color: var(--gray-300);
+}
+
+.input-icon-text {
+  border: 1px solid var(--gray-200);
+  outline: none;
+  padding: 8px 12px 8px 42px;
+}
+
+.input-icon.required {
+  border: 1px solid var(--red--300);
+}
+
+.input-icon-text.required {
+  border: 1px solid var(--red--300);
+}
+
+.hidden-checkbox:hover {
+  cursor: pointer;
+}
diff --git a/lib/assets/remote_execution_cell/main.js b/lib/assets/remote_execution_cell/main.js
index 61ca64b..7b47081 100644
--- a/lib/assets/remote_execution_cell/main.js
+++ b/lib/assets/remote_execution_cell/main.js
@@ -33,8 +33,8 @@ export function init(ctx, payload) {
     },
 
     template: `
-    <div v-bind:class="[inline ? 'inline-field' : 'field']">
-      <label v-bind:class="inline ? 'inline-input-label' : 'input-label'">
+    <div :class="[inline ? 'inline-field' : 'field']">
+      <label :class="inline ? 'inline-input-label' : 'input-label'">
         {{ label }}
       </label>
       <input
@@ -47,11 +47,131 @@ export function init(ctx, payload) {
     `,
   };
 
-  const app = Vue.createApp({
+  const BaseSecret = {
+    name: "BaseSecret",
+
     components: {
       BaseInput: BaseInput,
     },
 
+    props: {
+      textInputName: {
+        type: String,
+        default: "",
+      },
+      secretInputName: {
+        type: String,
+        default: "",
+      },
+      toggleInputName: {
+        type: String,
+        default: "",
+      },
+      label: {
+        type: String,
+        default: "",
+      },
+      toggleInputValue: {
+        type: [String, Number],
+        default: "",
+      },
+      secretInputValue: {
+        type: [String, Number],
+        default: "",
+      },
+      textInputValue: {
+        type: [String, Number],
+        default: "",
+      },
+      modalTitle: {
+        type: String,
+        default: "Select secret",
+      },
+      inline: {
+        type: Boolean,
+        default: false,
+      },
+      required: {
+        type: Boolean,
+        default: false,
+      },
+    },
+
+    methods: {
+      selectSecret() {
+        const preselectName = this.secretInputValue || "";
+        ctx.selectSecret(
+          (secretName) => {
+            ctx.pushEvent("update_field", {
+              field: this.secretInputName,
+              value: secretName,
+            });
+          },
+          preselectName,
+          { title: this.modalTitle }
+        );
+      },
+    },
+
+    template: `
+      <div class="input-icon-container grow">
+        <BaseInput
+          v-if="toggleInputValue"
+          :name="secretInputName"
+          :label="label"
+          :value="secretInputValue"
+          inputClass="input input-icon"
+          :grow
+          readonly
+          @click="selectSecret"
+          @input="$emit('update:secretInputValue', $event.target.value)"
+          :required="!secretInputValue && required"
+          :inline="inline"
+        />
+        <BaseInput
+          v-else
+          :name="textInputName"
+          :label="label"
+          type="text"
+          :value="textInputValue"
+          inputClass="input input-icon-text"
+          :grow
+          @input="$emit('update:textInputValue', $event.target.value)"
+          :required="!textInputValue && required"
+          :inline="inline"
+        />
+        <div :class="['icon-container', { inline: inline }]">
+          <label class="hidden-checkbox">
+            <input
+              type="checkbox"
+              :name="toggleInputName"
+              :checked="toggleInputValue"
+              @input="$emit('update:toggleInputValue', $event.target.checked)"
+              class="hidden-checkbox-input"
+            />
+            <svg v-if="toggleInputValue" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
+                  width="22" height="22">
+              <path fill="none" d="M0 0h24v24H0z"/>
+              <path d="M18 8h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h2V7a6 6 0 1 1 12 0v1zM5
+                10v10h14V10H5zm6 4h2v2h-2v-2zm-4 0h2v2H7v-2zm8 0h2v2h-2v-2zm1-6V7a4 4 0 1 0-8 0v1h8z" fill="#000"/>
+            </svg>
+            <svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
+              <path fill="none" d="M0 0h24v24H0z"/>
+              <path d="M21 3v18H3V3h18zm-8.001 3h-2L6.6 17h2.154l1.199-3h4.09l1.201 3h2.155l-4.4-11zm-1 2.885L13.244
+                12h-2.492l1.247-3.115z" fill="#445668"/>
+            </svg>
+          </label>
+        </div>
+      </div>
+    `,
+  };
+
+  const app = Vue.createApp({
+    components: {
+      BaseInput,
+      BaseSecret,
+    },
+
     template: `
     <div class="app">
       <form @change="handleFieldChange">
@@ -64,11 +184,15 @@ export function init(ctx, payload) {
             :inline
             :required
           />
-          <BaseInput
-            name="cookie"
+          <BaseSecret
+            textInputName="cookie"
+            secretInputName="cookie_secret"
+            toggleInputName="use_cookie_secret"
             label="Cookie"
-            v-model="fields.cookie"
-            inputClass="input"
+            v-model:textInputValue="fields.cookie"
+            v-model:secretInputValue="fields.cookie_secret"
+            v-model:toggleInputValue="fields.use_cookie_secret"
+            modalTitle="Set cookie value"
             :inline
             :required
           />
@@ -92,8 +216,11 @@ export function init(ctx, payload) {
 
     methods: {
       handleFieldChange(event) {
-        const { name, value } = event.target;
-        ctx.pushEvent("update_field", { field: name, value });
+        const field = event.target.name;
+        const value = field.includes("cookie")
+          ? this.fields[field]
+          : event.target.value;
+        ctx.pushEvent("update_field", { field, value });
       },
     },
   }).mount(ctx.root);
diff --git a/lib/kino/remote_ execution_cell.ex b/lib/kino/remote_ execution_cell.ex
index a0b6382..26705b7 100644
--- a/lib/kino/remote_ execution_cell.ex
+++ b/lib/kino/remote_ execution_cell.ex
@@ -9,10 +9,15 @@ defmodule Kino.RemoteExecutionCell do
 
   @impl true
   def init(attrs, ctx) do
+    use_cookie_secret =
+      if Map.has_key?(attrs, "use_cookie_secret"), do: attrs["use_cookie_secret"], else: true
+
     fields = %{
       "assign_to" => attrs["assign_to"] || "",
-      "cookie" => attrs["cookie"] || "",
-      "node" => attrs["node"] || ""
+      "node" => attrs["node"] || "",
+      "cookie" => attrs["cookie"],
+      "use_cookie_secret" => use_cookie_secret,
+      "cookie_secret" => attrs["cookie_secret"]
     }
 
     ctx = assign(ctx, fields: fields)
@@ -40,22 +45,24 @@ defmodule Kino.RemoteExecutionCell do
   end
 
   @impl true
-  def to_source(%{"node" => ""}), do: ""
-  def to_source(%{"cookie" => ""}), do: ""
   def to_source(%{"code" => ""}), do: ""
+  def to_source(%{"node" => ""}), do: ""
+  def to_source(%{"use_cookie_secret" => false, "cookie" => ""}), do: ""
+  def to_source(%{"use_cookie_secret" => true, "cookie_secret" => ""}), do: ""
 
   def to_source(%{"code" => code} = attrs) do
     code = Code.string_to_quoted(code)
     to_source(attrs, code)
   end
 
-  defp to_source(%{"node" => node, "cookie" => cookie, "assign_to" => var}, {:ok, code}) do
+  defp to_source(%{"node" => node, "assign_to" => var} = attrs, {:ok, code}) do
     var = if Kino.SmartCell.valid_variable_name?(var), do: var
     call = build_call(code) |> build_var(var)
+    cookie = build_set_cookie(attrs)
 
     quote do
       node = unquote(String.to_atom(node))
-      Node.set_cookie(node, unquote(String.to_atom(cookie)))
+      Node.set_cookie(node, unquote(cookie))
       unquote(call)
     end
     |> Kino.SmartCell.quoted_to_string()
@@ -83,4 +90,12 @@ defmodule Kino.RemoteExecutionCell do
       unquote({String.to_atom(var), [], nil}) = unquote(call)
     end
   end
+
+  defp build_set_cookie(%{"use_cookie_secret" => true, "cookie_secret" => secret}) do
+    quote do
+      String.to_atom(System.fetch_env!(unquote("LB_#{secret}")))
+    end
+  end
+
+  defp build_set_cookie(%{"cookie" => cookie}), do: String.to_atom(cookie)
 end


Test patch:
diff --git a/test/kino/remote_execution_cell_test.exs b/test/kino/remote_execution_cell_test.exs
index 808e8ea..f840a04 100644
--- a/test/kino/remote_execution_cell_test.exs
+++ b/test/kino/remote_execution_cell_test.exs
@@ -8,10 +8,12 @@ defmodule Kino.RemoteExecutionCellTest do
   setup :configure_livebook_bridge
 
   @fields %{
+    "assign_to" => "",
+    "code" => ":ok",
     "node" => "name@node",
     "cookie" => "node-cookie",
-    "assign_to" => "",
-    "code" => ":ok"
+    "use_cookie_secret" => false,
+    "cookie_secret" => ""
   }
 
   test "returns the defaults when starting fresh with no data" do
@@ -119,5 +121,20 @@ defmodule Kino.RemoteExecutionCellTest do
              :erpc.call(node, fn -> :ok end)\
              """
     end
+
+    test "cookie value from secret" do
+      attrs = %{@fields | "use_cookie_secret" => true, "cookie_secret" => "COOKIE_SECRET"}
+
+      assert RemoteExecutionCell.to_source(attrs) == """
+             node = :name@node
+             Node.set_cookie(node, String.to_atom(System.fetch_env!("LB_COOKIE_SECRET")))
+             :erpc.call(node, fn -> :ok end)\
+             """
+    end
+
+    test "do not generate code for an invalid secret" do
+      attrs = %{@fields | "use_cookie_secret" => true, "cookie_secret" => ""}
+      assert RemoteExecutionCell.to_source(attrs) == ""
+    end
   end
 end