diff --git a/lib/livebook_web/live/env_vars_component.ex b/lib/livebook_web/live/env_vars_component.ex
index bc169b071..6638fe21c 100644
--- a/lib/livebook_web/live/env_vars_component.ex
+++ b/lib/livebook_web/live/env_vars_component.ex
@@ -39,7 +39,7 @@ defmodule LivebookWeb.EnvVarsComponent do
<.menu id={"env-var-#{@env_var.name}-menu"}>
<:toggle>
-
+
+
+
+ Secrets
+
+
+ <.secrets_list
+ id="hub-secrets-list"
+ new_secret_path={~p"/hub/#{@hub.id}/secrets/new"}
+ secrets={@secrets}
+ target={@myself}
+ />
+
+
+
+ <.modal
+ :if={@live_action in [:new_secret, :edit_secret]}
+ id="secrets-modal"
+ show
+ width={:big}
+ patch={~p"/hub/#{@hub.id}"}
+ >
+ <.live_component
+ module={LivebookWeb.Hub.SecretFormComponent}
+ id="secrets"
+ hub={@hub}
+ secret_name={@secret_name}
+ secret_value={@secret_value}
+ return_to={~p"/hub/#{@hub.id}"}
+ />
+
+
+ """
+ end
+
+ defp secrets_list(assigns) do
+ ~H"""
+
+
+
+ <.secret_info secret={secret} target={@target} />
+
+
+
+ <.link patch={@new_secret_path} class="button-base button-blue" id="add-secret">
+ Add new secret
+
+
+
+ """
+ end
+
+ defp secret_info(assigns) do
+ ~H"""
+
+
+ <.labeled_text label="Name">
+ <%= @secret.name %>
+
+
+
+
+ <.menu id={"hub-secret-#{@secret.name}-menu"}>
+ <:toggle>
+
+ <.remix_icon icon="more-2-fill" class="text-xl" />
+
+
+ <.menu_item>
+ <.link
+ id={"hub-secret-#{@secret.name}-edit"}
+ patch={~p"/hub/#{@secret.hub_id}/secrets/edit/#{@secret.name}"}
+ type="button"
+ role="menuitem"
+ >
+ <.remix_icon icon="file-edit-line" />
+ Edit
+
+
+ <.menu_item variant={:danger}>
+
+ <.remix_icon icon="delete-bin-line" />
+ Delete
+
+
+
"""
@@ -79,4 +195,11 @@ defmodule LivebookWeb.Hub.Edit.PersonalComponent do
def handle_event("validate", %{"personal" => attrs}, socket) do
{:noreply, assign(socket, changeset: Personal.validate_hub(socket.assigns.hub, attrs))}
end
+
+ def handle_event("delete_hub_secret", attrs, socket) do
+ {:ok, secret} = Livebook.Secrets.update_secret(%Livebook.Secrets.Secret{}, attrs)
+ :ok = Livebook.Hubs.delete_secret(socket.assigns.hub, secret)
+
+ {:noreply, socket}
+ end
end
diff --git a/lib/livebook_web/live/hub/edit_live.ex b/lib/livebook_web/live/hub/edit_live.ex
index 3ed287630..c1185e5f1 100644
--- a/lib/livebook_web/live/hub/edit_live.ex
+++ b/lib/livebook_web/live/hub/edit_live.ex
@@ -9,11 +9,20 @@ defmodule LivebookWeb.Hub.EditLive do
@impl true
def mount(_params, _session, socket) do
- {:ok, assign(socket, hub: nil, type: nil, page_title: "Livebook - Hub", env_var_id: nil)}
+ {:ok,
+ assign(socket,
+ hub: nil,
+ secrets: [],
+ type: nil,
+ page_title: "Livebook - Hub",
+ env_var_id: nil,
+ secret_name: nil
+ )}
end
@impl true
def handle_params(params, _url, socket) do
+ Livebook.Hubs.subscribe([:secrets])
hub = Hubs.fetch_hub!(params["id"])
type = Provider.type(hub)
@@ -21,9 +30,11 @@ defmodule LivebookWeb.Hub.EditLive do
assign(socket,
hub: hub,
type: type,
+ secrets: Hubs.get_secrets(hub),
page_title: "Livebook - Hub",
params: params,
- env_var_id: params["env_var_id"]
+ env_var_id: params["env_var_id"],
+ secret_name: params["secret_name"]
)}
end
@@ -49,6 +60,9 @@ defmodule LivebookWeb.Hub.EditLive do
<.live_component
module={LivebookWeb.Hub.Edit.PersonalComponent}
hub={@hub}
+ secrets={@secrets}
+ live_action={@live_action}
+ secret_name={@secret_name}
id="personal-form"
/>
<% "enterprise" -> %>
@@ -72,4 +86,34 @@ defmodule LivebookWeb.Hub.EditLive do
|> put_flash(:success, "Hub deleted successfully")
|> push_navigate(to: "/")}
end
+
+ @impl true
+ def handle_info({:secret_created, %{hub_id: id}}, %{assigns: %{hub: %{id: id}}} = socket) do
+ {:noreply,
+ socket
+ |> refresh_secrets()
+ |> put_flash(:success, "Secret created successfully")}
+ end
+
+ def handle_info({:secret_updated, %{hub_id: id}}, %{assigns: %{hub: %{id: id}}} = socket) do
+ {:noreply,
+ socket
+ |> refresh_secrets()
+ |> put_flash(:success, "Secret updated successfully")}
+ end
+
+ def handle_info({:secret_deleted, %{hub_id: id}}, %{assigns: %{hub: %{id: id}}} = socket) do
+ {:noreply,
+ socket
+ |> refresh_secrets()
+ |> put_flash(:success, "Secret deleted successfully")}
+ end
+
+ def handle_info(_message, socket) do
+ {:noreply, socket}
+ end
+
+ defp refresh_secrets(socket) do
+ assign(socket, secrets: Livebook.Hubs.get_secrets(socket.assigns.hub))
+ end
end
diff --git a/lib/livebook_web/live/hub/secret_form_component.ex b/lib/livebook_web/live/hub/secret_form_component.ex
new file mode 100644
index 000000000..04205e279
--- /dev/null
+++ b/lib/livebook_web/live/hub/secret_form_component.ex
@@ -0,0 +1,104 @@
+defmodule LivebookWeb.Hub.SecretFormComponent do
+ use LivebookWeb, :live_component
+
+ alias Livebook.Hubs
+ alias Livebook.Secrets
+ alias Livebook.Secrets.Secret
+
+ @impl true
+ def update(assigns, socket) do
+ changeset =
+ Secrets.change_secret(%Secret{}, %{
+ name: assigns.secret_name,
+ value: assigns.secret_value
+ })
+
+ socket = assign(socket, assigns)
+
+ {:ok, assign(socket, title: title(socket), changeset: changeset)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ <%= @title %>
+
+
+ <.form
+ :let={f}
+ id={"#{@id}-form"}
+ for={@changeset}
+ phx-target={@myself}
+ phx-change="validate"
+ phx-submit="save"
+ autocomplete="off"
+ class="basis-1/2 grow"
+ >
+
+ <.text_field
+ field={f[:name]}
+ label="Name (alphanumeric and underscore)"
+ autofocus={@secret_name == nil}
+ spellcheck="false"
+ autocomplete="off"
+ phx-debounce="blur"
+ class="uppercase"
+ />
+ <.text_field
+ field={f[:value]}
+ label="Value"
+ autofocus={@secret_name != nil}
+ spellcheck="false"
+ autocomplete="off"
+ phx-debounce="blur"
+ />
+ <.hidden_field field={f[:hub_id]} value={@hub.id} />
+
+
+ <.remix_icon icon="add-line" class="align-middle" />
+ Add
+
+ <.link patch={@return_to} class="button-base button-outlined-gray">
+ Cancel
+
+
+
+
+
+
+ """
+ end
+
+ @impl true
+ def handle_event("save", %{"secret" => attrs}, socket) do
+ with {:ok, secret} <- Secrets.update_secret(%Secret{}, attrs),
+ :ok <- set_secret(socket, secret) do
+ {:noreply, push_patch(socket, to: socket.assigns.return_to)}
+ else
+ {:error, changeset} ->
+ {:noreply, assign(socket, changeset: changeset)}
+ end
+ end
+
+ def handle_event("validate", %{"secret" => attrs}, socket) do
+ changeset =
+ %Secret{}
+ |> Secrets.change_secret(attrs)
+ |> Map.put(:action, :validate)
+
+ {:noreply, assign(socket, changeset: changeset)}
+ end
+
+ defp title(%{assigns: %{secret_name: nil}}), do: "Add secret"
+ defp title(_), do: "Edit secret"
+
+ defp set_secret(%{assigns: %{secret_name: nil}} = socket, %Secret{} = secret) do
+ Hubs.create_secret(socket.assigns.hub, secret)
+ end
+
+ defp set_secret(socket, %Secret{} = secret) do
+ Hubs.update_secret(socket.assigns.hub, secret)
+ end
+end
diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex
index b70f0c416..ba374768d 100644
--- a/lib/livebook_web/live/session_live.ex
+++ b/lib/livebook_web/live/session_live.ex
@@ -1237,6 +1237,8 @@ defmodule LivebookWeb.SessionLive do
end
def handle_info(:hub_changed, socket) do
+ Session.set_notebook_hub(socket.assigns.session.pid, socket.private.data.hub.id)
+
{:noreply, refresh_secrets(socket)}
end
diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex
index d365b580f..5cca09073 100644
--- a/lib/livebook_web/router.ex
+++ b/lib/livebook_web/router.ex
@@ -78,6 +78,8 @@ defmodule LivebookWeb.Router do
live "/hub/:id", Hub.EditLive, :edit, as: :hub
live "/hub/:id/env-var/new", Hub.EditLive, :add_env_var, as: :hub
live "/hub/:id/env-var/edit/:env_var_id", Hub.EditLive, :edit_env_var, as: :hub
+ live "/hub/:id/secrets/new", Hub.EditLive, :new_secret, as: :hub
+ live "/hub/:id/secrets/edit/:secret_name", Hub.EditLive, :edit_secret, as: :hub
live "/sessions/:id", SessionLive, :page
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
diff --git a/test/livebook_web/live/hub/edit_live_test.exs b/test/livebook_web/live/hub/edit_live_test.exs
index 5d7f52ccb..6ca890b46 100644
--- a/test/livebook_web/live/hub/edit_live_test.exs
+++ b/test/livebook_web/live/hub/edit_live_test.exs
@@ -233,6 +233,140 @@ defmodule LivebookWeb.Hub.EditLiveTest do
end
end
+ describe "personal" do
+ setup do
+ Livebook.Hubs.subscribe([:secrets])
+ {:ok, hub: Hubs.fetch_hub!(Hubs.Personal.id())}
+ end
+
+ test "updates the hub", %{conn: conn, hub: hub} do
+ {:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
+
+ attrs = %{"hub_emoji" => "🐈"}
+
+ view
+ |> element("#personal-form")
+ |> render_change(%{"personal" => attrs})
+
+ refute view
+ |> element("#enterprise-form .invalid-feedback")
+ |> has_element?()
+
+ assert {:ok, view, _html} =
+ view
+ |> element("#personal-form")
+ |> render_submit(%{"personal" => attrs})
+ |> follow_redirect(conn)
+
+ assert render(view) =~ "Hub updated successfully"
+
+ assert_hub(view, %{hub | hub_emoji: attrs["hub_emoji"]})
+ refute Hubs.fetch_hub!(hub.id) == hub
+ end
+
+ test "creates secret", %{conn: conn, hub: hub} do
+ {:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
+ secret = build(:secret, name: "PERSONAL_ADD_SECRET")
+
+ attrs = %{
+ secret: %{
+ name: secret.name,
+ value: secret.value,
+ hub_id: secret.hub_id
+ }
+ }
+
+ refute render(view) =~ secret.name
+
+ view
+ |> element("#add-secret")
+ |> render_click(%{})
+
+ assert_patch(view, ~p"/hub/#{hub.id}/secrets/new")
+ assert render(view) =~ "Add new secret"
+
+ view
+ |> element("#secrets-form")
+ |> render_change(attrs)
+
+ refute view
+ |> element("#secrets-form button[disabled]")
+ |> has_element?()
+
+ view
+ |> element("#secrets-form")
+ |> render_submit(attrs)
+
+ assert_receive {:secret_created, ^secret}
+ assert render(view) =~ "Secret created successfully"
+ assert render(view) =~ secret.name
+ assert secret in Livebook.Hubs.get_secrets(hub)
+ end
+
+ test "updates secret", %{conn: conn, hub: hub} do
+ {:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
+ secret = insert_secret(name: "PERSONAL_EDIT_SECRET", value: "GetTheBonk")
+
+ attrs = %{
+ secret: %{
+ name: secret.name,
+ value: secret.value,
+ hub_id: secret.hub_id
+ }
+ }
+
+ new_value = "new_value"
+
+ view
+ |> element("#hub-secret-#{secret.name}-edit")
+ |> render_click(%{"secret_name" => secret.name})
+
+ assert_patch(view, ~p"/hub/#{hub.id}/secrets/edit/#{secret.name}")
+ assert render(view) =~ "Edit secret"
+
+ view
+ |> element("#secrets-form")
+ |> render_change(attrs)
+
+ refute view
+ |> element("#secrets-form button[disabled]")
+ |> has_element?()
+
+ view
+ |> element("#secrets-form")
+ |> render_submit(put_in(attrs.secret.value, new_value))
+
+ updated_secret = %{secret | value: new_value}
+
+ assert_receive {:secret_updated, ^updated_secret}
+ assert render(view) =~ "Secret updated successfully"
+ assert render(view) =~ secret.name
+ assert updated_secret in Livebook.Hubs.get_secrets(hub)
+ end
+
+ test "deletes secret", %{conn: conn, hub: hub} do
+ {:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
+ secret = insert_secret(name: "PERSONAL_DELETE_SECRET", value: "GetTheBonk")
+
+ refute view
+ |> element("#secrets-form button[disabled]")
+ |> has_element?()
+
+ view
+ |> with_target("#personal-form-component")
+ |> render_click("delete_hub_secret", %{
+ name: secret.name,
+ value: secret.value,
+ hub_id: secret.hub_id
+ })
+
+ assert_receive {:secret_deleted, ^secret}
+ assert render(view) =~ "Secret deleted successfully"
+ refute render(view) =~ secret.name
+ refute secret in Livebook.Hubs.get_secrets(hub)
+ end
+ end
+
defp assert_hub(view, hub) do
hubs_html = view |> element("#hubs") |> render()