mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-01-09 08:27:36 +08:00
Add hub secrets list to Personal Hub page (#1763)
This commit is contained in:
parent
84fccd16ea
commit
3b41e87876
7 changed files with 413 additions and 4 deletions
|
@ -39,7 +39,7 @@ defmodule LivebookWeb.EnvVarsComponent do
|
|||
<div class="flex items-center place-content-end">
|
||||
<.menu id={"env-var-#{@env_var.name}-menu"}>
|
||||
<:toggle>
|
||||
<button class="icon-button" aria-label="open session menu" type="button">
|
||||
<button class="icon-button" aria-label="open environment variable menu" type="button">
|
||||
<.remix_icon icon="more-2-fill" class="text-xl" />
|
||||
</button>
|
||||
</:toggle>
|
||||
|
|
|
@ -8,10 +8,16 @@ defmodule LivebookWeb.Hub.Edit.PersonalComponent do
|
|||
def update(assigns, socket) do
|
||||
changeset = Personal.change_hub(assigns.hub)
|
||||
|
||||
secret_value =
|
||||
if assigns.live_action == :edit_secret do
|
||||
secret = Enum.find(assigns.secrets, &(&1.name == assigns.secret_name))
|
||||
secret.value
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(changeset: changeset)}
|
||||
|> assign(changeset: changeset, secret_value: secret_value)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
@ -57,6 +63,116 @@ defmodule LivebookWeb.Hub.Edit.PersonalComponent do
|
|||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-4">
|
||||
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
||||
Secrets
|
||||
</h2>
|
||||
|
||||
<.secrets_list
|
||||
id="hub-secrets-list"
|
||||
new_secret_path={~p"/hub/#{@hub.id}/secrets/new"}
|
||||
secrets={@secrets}
|
||||
target={@myself}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.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}"}
|
||||
/>
|
||||
</.modal>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp secrets_list(assigns) do
|
||||
~H"""
|
||||
<div id={@id} class="flex flex-col space-y-4">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div
|
||||
:for={secret <- @secrets}
|
||||
class="flex items-center justify-between border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<.secret_info secret={secret} target={@target} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<.link patch={@new_secret_path} class="button-base button-blue" id="add-secret">
|
||||
Add new secret
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp secret_info(assigns) do
|
||||
~H"""
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 w-full">
|
||||
<div class="place-content-start">
|
||||
<.labeled_text label="Name">
|
||||
<%= @secret.name %>
|
||||
</.labeled_text>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center place-content-end">
|
||||
<.menu id={"hub-secret-#{@secret.name}-menu"}>
|
||||
<:toggle>
|
||||
<button class="icon-button" aria-label="open environment variable menu" type="button">
|
||||
<.remix_icon icon="more-2-fill" class="text-xl" />
|
||||
</button>
|
||||
</:toggle>
|
||||
<.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" />
|
||||
<span>Edit</span>
|
||||
</.link>
|
||||
</.menu_item>
|
||||
<.menu_item variant={:danger}>
|
||||
<button
|
||||
id={"hub-secret-#{@secret.name}-delete"}
|
||||
type="button"
|
||||
phx-click={
|
||||
with_confirm(
|
||||
JS.push("delete_hub_secret",
|
||||
value: %{
|
||||
name: @secret.name,
|
||||
value: @secret.value,
|
||||
hub_id: @secret.hub_id
|
||||
},
|
||||
target: @target
|
||||
),
|
||||
title: "Delete hub secret - #{@secret.name}",
|
||||
description: "Are you sure you want to delete this hub secret?",
|
||||
confirm_text: "Delete",
|
||||
confirm_icon: "delete-bin-6-line"
|
||||
)
|
||||
}
|
||||
phx-target={@target}
|
||||
role="menuitem"
|
||||
>
|
||||
<.remix_icon icon="delete-bin-line" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</.menu_item>
|
||||
</.menu>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
104
lib/livebook_web/live/hub/secret_form_component.ex
Normal file
104
lib/livebook_web/live/hub/secret_form_component.ex
Normal file
|
@ -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"""
|
||||
<div class="p-6 max-w-4xl flex flex-col space-y-5">
|
||||
<h3 class="text-2xl font-semibold text-gray-800">
|
||||
<%= @title %>
|
||||
</h3>
|
||||
<div class="flex flex-columns gap-4">
|
||||
<.form
|
||||
:let={f}
|
||||
id={"#{@id}-form"}
|
||||
for={@changeset}
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
autocomplete="off"
|
||||
class="basis-1/2 grow"
|
||||
>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<.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} />
|
||||
<div class="flex space-x-2">
|
||||
<button class="button-base button-blue" type="submit" disabled={not @changeset.valid?}>
|
||||
<.remix_icon icon="add-line" class="align-middle" />
|
||||
<span class="font-normal">Add</span>
|
||||
</button>
|
||||
<.link patch={@return_to} class="button-base button-outlined-gray">
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in a new issue