diff --git a/lib/livebook/hubs.ex b/lib/livebook/hubs.ex index 234ac5047..abeedcb4a 100644 --- a/lib/livebook/hubs.ex +++ b/lib/livebook/hubs.ex @@ -2,7 +2,7 @@ defmodule Livebook.Hubs do @moduledoc false alias Livebook.Storage - alias Livebook.Hubs.{Broadcasts, Enterprise, Fly, Metadata, Personal, Provider} + alias Livebook.Hubs.{Broadcasts, Enterprise, Fly, Metadata, Personal, Provider, Team} alias Livebook.Secrets.Secret @namespace :hubs @@ -169,6 +169,10 @@ defmodule Livebook.Hubs do Provider.load(%Personal{}, fields) end + defp to_struct(%{id: "team-" <> _} = fields) do + Provider.load(%Team{}, fields) + end + @doc """ Connects to the all available and connectable hubs. diff --git a/lib/livebook/hubs/enterprise.ex b/lib/livebook/hubs/enterprise.ex index 619cd4bdc..a96bb8bb3 100644 --- a/lib/livebook/hubs/enterprise.ex +++ b/lib/livebook/hubs/enterprise.ex @@ -8,25 +8,31 @@ defmodule Livebook.Hubs.Enterprise do @type t :: %__MODULE__{ id: String.t() | nil, - url: String.t() | nil, - token: String.t() | nil, - external_id: String.t() | nil, + org_id: pos_integer() | nil, + user_id: pos_integer() | nil, + org_key_id: pos_integer() | nil, + teams_key: String.t() | nil, + session_token: String.t() | nil, hub_name: String.t() | nil, hub_emoji: String.t() | nil } embedded_schema do - field :url, :string - field :token, :string - field :external_id, :string + field :org_id, :integer + field :user_id, :integer + field :org_key_id, :integer + field :teams_key, :string + field :session_token, :string field :hub_name, :string field :hub_emoji, :string end @fields ~w( - url - token - external_id + org_id + user_id + org_key_id + teams_key + session_token hub_name hub_emoji )a @@ -63,7 +69,7 @@ defmodule Livebook.Hubs.Enterprise do if Hubs.hub_exists?(id) do {:error, changeset - |> add_error(:external_id, "already exists") + |> add_error(:hub_name, "already exists") |> Map.replace!(:action, :validate)} else with {:ok, struct} <- apply_action(changeset, :insert) do @@ -92,7 +98,7 @@ defmodule Livebook.Hubs.Enterprise do else {:error, changeset - |> add_error(:external_id, "does not exists") + |> add_error(:hub_name, "does not exists") |> Map.replace!(:action, :validate)} end end @@ -105,9 +111,9 @@ defmodule Livebook.Hubs.Enterprise do end defp add_id(changeset) do - case get_field(changeset, :external_id) do + case get_field(changeset, :hub_name) do nil -> changeset - external_id -> put_change(changeset, :id, "enterprise-#{external_id}") + hub_name -> put_change(changeset, :id, "enterprise-#{hub_name}") end end end @@ -119,9 +125,11 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Enterprise do %{ enterprise | id: fields.id, - url: fields.url, - token: fields.token, - external_id: fields.external_id, + session_token: fields.session_token, + teams_key: fields.teams_key, + org_id: fields.org_id, + user_id: fields.user_id, + org_key_id: fields.org_key_id, hub_name: fields.hub_name, hub_emoji: fields.hub_emoji } diff --git a/lib/livebook/hubs/enterprise_client.ex b/lib/livebook/hubs/enterprise_client.ex index 692ae02e5..c16747a3d 100644 --- a/lib/livebook/hubs/enterprise_client.ex +++ b/lib/livebook/hubs/enterprise_client.ex @@ -82,9 +82,9 @@ defmodule Livebook.Hubs.EnterpriseClient do ## GenServer callbacks @impl true - def init(%Enterprise{url: url, token: token} = enterprise) do - headers = [{"X-Auth-Token", token}] - {:ok, pid} = ClientConnection.start_link(self(), url, headers) + def init(%Enterprise{} = enterprise) do + # TODO: Make it work with new struct and `Livebook.Teams` + {:ok, pid} = ClientConnection.start_link(self(), Livebook.Config.teams_url()) {:ok, %__MODULE__{hub: enterprise, server: pid}} end diff --git a/lib/livebook/hubs/team.ex b/lib/livebook/hubs/team.ex new file mode 100644 index 000000000..3fa2668ce --- /dev/null +++ b/lib/livebook/hubs/team.ex @@ -0,0 +1,110 @@ +defmodule Livebook.Hubs.Team do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{ + id: String.t() | nil, + org_id: pos_integer() | nil, + user_id: pos_integer() | nil, + org_key_id: pos_integer() | nil, + teams_key: String.t() | nil, + session_token: String.t() | nil, + hub_name: String.t() | nil, + hub_emoji: String.t() | nil + } + + embedded_schema do + field :org_id, :integer + field :user_id, :integer + field :org_key_id, :integer + field :teams_key, :string + field :session_token, :string + field :hub_name, :string + field :hub_emoji, :string + end + + @fields ~w( + org_id + user_id + org_key_id + teams_key + session_token + hub_name + hub_emoji + )a + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking hub changes. + """ + @spec change_hub(t(), map()) :: Ecto.Changeset.t() + def change_hub(%__MODULE__{} = team, attrs \\ %{}) do + changeset(team, attrs) + end + + defp changeset(team, attrs) do + team + |> cast(attrs, @fields) + |> validate_required(@fields) + |> add_id() + end + + defp add_id(changeset) do + if name = get_field(changeset, :hub_name) do + change(changeset, %{id: "team-#{name}"}) + else + changeset + end + end +end + +defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do + def load(team, fields) do + %{ + team + | id: fields.id, + session_token: fields.session_token, + teams_key: fields.teams_key, + org_id: fields.org_id, + user_id: fields.user_id, + org_key_id: fields.org_key_id, + hub_name: fields.hub_name, + hub_emoji: fields.hub_emoji + } + end + + def to_metadata(team) do + %Livebook.Hubs.Metadata{ + id: team.id, + name: team.hub_name, + provider: team, + emoji: team.hub_emoji, + connected?: false + } + end + + def type(_team), do: "team" + + def connection_spec(_team), do: nil + + def disconnect(_team), do: raise("not implemented") + + def capabilities(_team), do: [] + + def get_secrets(_team), do: [] + + def create_secret(_team, _secret), do: :ok + + def update_secret(_team, _secret), do: :ok + + def delete_secret(_team, _secret), do: :ok + + def connection_error(_team), do: raise("not implemented") + + def notebook_stamp(_hub, _notebook_source, _metadata) do + :skip + end + + def verify_notebook_stamp(_hub, _notebook_source, _stamp), do: raise("not implemented") +end diff --git a/lib/livebook/teams.ex b/lib/livebook/teams.ex index 9a15b45c7..1cbb452bf 100644 --- a/lib/livebook/teams.ex +++ b/lib/livebook/teams.ex @@ -1,8 +1,12 @@ defmodule Livebook.Teams do @moduledoc false + alias Livebook.Hubs + alias Livebook.Hubs.Team alias Livebook.Teams.{Client, Org} + import Ecto.Changeset, only: [add_error: 3, apply_action: 2, apply_action!: 2, get_field: 2] + @doc """ Creates an Org. @@ -16,7 +20,7 @@ defmodule Livebook.Teams do def create_org(%Org{} = org, attrs) do changeset = Org.changeset(org, attrs) - with {:ok, %Org{} = org} <- Ecto.Changeset.apply_action(changeset, :insert), + with {:ok, %Org{} = org} <- apply_action(changeset, :insert), {:ok, response} <- Client.create_org(org) do {:ok, response} else @@ -60,12 +64,45 @@ defmodule Livebook.Teams do end end + @doc """ + Creates a Hub. + + It notifies interested processes about hub metadatas data change. + """ + @spec create_hub!(map()) :: Team.t() + def create_hub!(attrs) do + changeset = Team.change_hub(%Team{}, attrs) + team = apply_action!(changeset, :insert) + + Hubs.save_hub(team) + end + + @doc """ + Updates a Hub. + + With success, notifies interested processes about hub metadatas data change. + Otherwise, it will return an error tuple with changeset. + """ + @spec update_hub(Team.t(), map()) :: {:ok, Team.t()} | {:error, Ecto.Changeset.t()} + def update_hub(%Team{} = team, attrs) do + changeset = Team.change_hub(team, attrs) + id = get_field(changeset, :id) + + if Hubs.hub_exists?(id) do + with {:ok, struct} <- apply_action(changeset, :update) do + {:ok, Hubs.save_hub(struct)} + end + else + {:error, add_error(changeset, :hub_name, "does not exists")} + end + end + defp add_org_errors(%Ecto.Changeset{} = changeset, errors_map) do for {key, errors} <- errors_map, field <- String.to_atom(key), field in Org.__schema__(:fields), error <- errors, reduce: changeset, - do: (acc -> Ecto.Changeset.add_error(acc, field, error)) + do: (acc -> add_error(acc, field, error)) end end diff --git a/lib/livebook/teams/org.ex b/lib/livebook/teams/org.ex index 83ee98dec..e62cfa4a7 100644 --- a/lib/livebook/teams/org.ex +++ b/lib/livebook/teams/org.ex @@ -6,18 +6,22 @@ defmodule Livebook.Teams.Org do @type t :: %__MODULE__{ id: pos_integer() | nil, + emoji: String.t() | nil, name: String.t() | nil, teams_key: String.t() | nil, user_code: String.t() | nil } + @primary_key {:id, :id, autogenerate: false} embedded_schema do + field :emoji, :string field :name, :string field :teams_key, :string field :user_code, :string end - @fields ~w(id name teams_key user_code)a + @fields ~w(id emoji name teams_key user_code)a + @required_fields @fields -- ~w(id user_code)a @doc """ Generates a new teams key. @@ -30,7 +34,7 @@ defmodule Livebook.Teams.Org do org |> cast(attrs, @fields) |> generate_teams_key() - |> validate_required(@fields -- [:id]) + |> validate_required(@required_fields) end defp generate_teams_key(changeset) do diff --git a/lib/livebook_web/live/hub/edit/team_component.ex b/lib/livebook_web/live/hub/edit/team_component.ex new file mode 100644 index 000000000..149a0068c --- /dev/null +++ b/lib/livebook_web/live/hub/edit/team_component.ex @@ -0,0 +1,88 @@ +defmodule LivebookWeb.Hub.Edit.TeamComponent do + use LivebookWeb, :live_component + + alias Livebook.Hubs.Team + alias Livebook.Teams + alias LivebookWeb.LayoutHelpers + + @impl true + def update(assigns, socket) do + changeset = Team.change_hub(assigns.hub) + + {:ok, + socket + |> assign(assigns) + |> assign_form(changeset)} + end + + @impl true + def render(assigns) do + ~H""" +
+
+ + + +
+ +
+
+

+ General +

+ + <.form + :let={f} + id={@id} + class="flex flex-col mt-4 space-y-4" + for={@form} + phx-submit="save" + phx-change="validate" + phx-target={@myself} + > +
+ <.emoji_field field={f[:hub_emoji]} label="Emoji" /> +
+ + + +
+
+
+ """ + end + + @impl true + def handle_event("save", %{"team" => params}, socket) do + case Teams.update_hub(socket.assigns.hub, params) do + {:ok, hub} -> + {:noreply, + socket + |> put_flash(:success, "Hub updated successfully") + |> push_navigate(to: ~p"/hub/#{hub.id}")} + + {:error, changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + def handle_event("validate", %{"team" => attrs}, socket) do + changeset = + socket.assigns.hub + |> Team.change_hub(attrs) + |> Map.replace!(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, form: to_form(changeset)) + end +end diff --git a/lib/livebook_web/live/hub/edit_live.ex b/lib/livebook_web/live/hub/edit_live.ex index 9f4b94766..44b162a82 100644 --- a/lib/livebook_web/live/hub/edit_live.ex +++ b/lib/livebook_web/live/hub/edit_live.ex @@ -70,6 +70,8 @@ defmodule LivebookWeb.Hub.EditLive do hub={@hub} id="enterprise-form" /> + <% "team" -> %> + <.live_component module={LivebookWeb.Hub.Edit.TeamComponent} hub={@hub} id="team-form" /> <% end %> diff --git a/lib/livebook_web/live/hub/new/enterprise_component.ex b/lib/livebook_web/live/hub/new/enterprise_component.ex deleted file mode 100644 index f52006ec8..000000000 --- a/lib/livebook_web/live/hub/new/enterprise_component.ex +++ /dev/null @@ -1,164 +0,0 @@ -defmodule LivebookWeb.Hub.New.EnterpriseComponent do - use LivebookWeb, :live_component - - import Ecto.Changeset, only: [get_field: 2] - - alias Livebook.Hubs.{Enterprise, EnterpriseClient} - - @impl true - def update(assigns, socket) do - if connected?(socket) do - Livebook.Hubs.subscribe(:connection) - end - - {:ok, - socket - |> assign(assigns) - |> assign( - base: %Enterprise{}, - changeset: Enterprise.change_hub(%Enterprise{}), - pid: nil - )} - end - - @impl true - def render(assigns) do - ~H""" -
- <.form - :let={f} - id={@id} - class="flex flex-col space-y-4" - for={@changeset} - phx-submit="save" - phx-change="validate" - phx-target={@myself} - > -
- <.text_field - field={f[:url]} - label="URL" - autofocus - spellcheck="false" - autocomplete="off" - phx-debounce="blur" - /> - <.password_field - type="password" - field={f[:token]} - label="Token" - spellcheck="false" - autocomplete="off" - phx-debounce="blur" - /> -
- -
- -
- - <%= if @pid do %> -
- <.password_field type="password" field={f[:external_id]} label="ID" disabled /> -
- -
- <.text_field field={f[:hub_name]} label="Name" readonly /> - <.emoji_field field={f[:hub_emoji]} label="Emoji" /> -
- -
- -
- <% end %> - -
- """ - end - - @impl true - def handle_event("connect", _params, socket) do - url = get_field(socket.assigns.changeset, :url) - token = get_field(socket.assigns.changeset, :token) - - base = %Enterprise{ - id: "enterprise-placeholder", - token: token, - external_id: "placeholder", - url: url, - hub_name: "Enterprise", - hub_emoji: "🏭" - } - - {:ok, pid} = EnterpriseClient.start_link(base) - - receive do - {:hub_connection_failed, reason} -> - EnterpriseClient.stop(base.id) - - {:noreply, - socket - |> put_flash(:error, "Failed to connect with Enterprise: " <> reason) - |> push_patch(to: ~p"/hub")} - - :hub_connected -> - data = LivebookProto.build_handshake_request(app_version: Livebook.Config.app_version()) - - case EnterpriseClient.send_request(pid, data) do - {:handshake, handshake_response} -> - base = %{base | external_id: handshake_response.id} - changeset = Enterprise.validate_hub(base) - - {:noreply, assign(socket, pid: pid, changeset: changeset, base: base)} - - {:transport_error, reason} -> - EnterpriseClient.stop(base.id) - - {:noreply, - socket - |> put_flash(:error, "Failed to connect with Enterprise: " <> reason) - |> push_patch(to: ~p"/hub")} - end - end - end - - def handle_event("save", %{"enterprise" => params}, socket) do - if socket.assigns.changeset.valid? do - case Enterprise.create_hub(socket.assigns.base, params) do - {:ok, hub} -> - if pid = socket.assigns.pid do - GenServer.stop(pid) - end - - {:noreply, - socket - |> put_flash(:success, "Hub added successfully") - |> push_navigate(to: ~p"/hub/#{hub.id}")} - - {:error, changeset} -> - {:noreply, assign(socket, changeset: changeset)} - end - else - {:noreply, socket} - end - end - - def handle_event("validate", %{"enterprise" => attrs}, socket) do - {:noreply, assign(socket, changeset: Enterprise.validate_hub(socket.assigns.base, attrs))} - end -end diff --git a/lib/livebook_web/live/hub/new/fly_component.ex b/lib/livebook_web/live/hub/new/fly_component.ex deleted file mode 100644 index b1dadefbf..000000000 --- a/lib/livebook_web/live/hub/new/fly_component.ex +++ /dev/null @@ -1,131 +0,0 @@ -defmodule LivebookWeb.Hub.New.FlyComponent do - use LivebookWeb, :live_component - - import Ecto.Changeset, only: [add_error: 3] - - alias Livebook.Hubs.{Fly, FlyClient} - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> assign( - base: %Fly{}, - changeset: Fly.change_hub(%Fly{}), - selected_app: nil, - select_options: [], - apps: [] - )} - end - - @impl true - def render(assigns) do - ~H""" -
- <.form - :let={f} - id={@id} - class="flex flex-col space-y-4" - for={@changeset} - phx-submit="save" - phx-change="validate" - phx-target={@myself} - > - <.password_field - type="password" - field={f[:access_token]} - label="Access Token" - phx-change="fetch_data" - phx-debounce="blur" - phx-target={@myself} - autofocus - spellcheck="false" - autocomplete="off" - /> - - <%= if length(@apps) > 0 do %> - <.select_field - field={f[:application_id]} - label="Application" - options={@select_options} - prompt="Select one application" - /> - -
- <.text_field field={f[:hub_name]} label="Name" /> - <.emoji_field field={f[:hub_emoji]} label="Emoji" /> -
- -
- -
- <% end %> - -
- """ - end - - @impl true - def handle_event("fetch_data", %{"fly" => %{"access_token" => token}}, socket) do - case FlyClient.fetch_apps(token) do - {:ok, apps} -> - opts = select_options(apps) - base = %Fly{access_token: token, hub_emoji: "🚀"} - changeset = Fly.validate_hub(base) - - {:noreply, - assign(socket, changeset: changeset, base: base, select_options: opts, apps: apps)} - - {:error, _} -> - changeset = - %Fly{} - |> Fly.validate_hub(%{access_token: token}) - |> add_error(:access_token, "is invalid") - - {:noreply, - assign(socket, changeset: changeset, base: %Fly{}, select_options: [], apps: [])} - end - end - - def handle_event("save", %{"fly" => params}, socket) do - if socket.assigns.changeset.valid? do - case Fly.create_hub(socket.assigns.selected_app, params) do - {:ok, hub} -> - {:noreply, - socket - |> put_flash(:success, "Hub added successfully") - |> push_navigate(to: ~p"/hub/#{hub.id}")} - - {:error, changeset} -> - {:noreply, assign(socket, changeset: changeset)} - end - else - {:noreply, socket} - end - end - - def handle_event("validate", %{"fly" => attrs}, socket) do - params = Map.merge(socket.assigns.changeset.params, attrs) - - application_id = params["application_id"] - selected_app = Enum.find(socket.assigns.apps, &(&1.application_id == application_id)) - opts = select_options(socket.assigns.apps) - changeset = Fly.validate_hub(selected_app || socket.assigns.base, params) - - {:noreply, - assign(socket, changeset: changeset, selected_app: selected_app, select_options: opts)} - end - - defp select_options(hubs) do - for fly <- hubs do - [key: "#{fly.organization_name} - #{fly.application_id}", value: fly.application_id] - end - end -end diff --git a/lib/livebook_web/live/hub/new_live.ex b/lib/livebook_web/live/hub/new_live.ex index 1d8dc7b54..e80cda8e6 100644 --- a/lib/livebook_web/live/hub/new_live.ex +++ b/lib/livebook_web/live/hub/new_live.ex @@ -1,19 +1,30 @@ defmodule LivebookWeb.Hub.NewLive do use LivebookWeb, :live_view + alias Livebook.Teams + alias Livebook.Teams.Org alias LivebookWeb.LayoutHelpers alias Phoenix.LiveView.JS on_mount LivebookWeb.SidebarHook + @check_completion_data_internal 3000 + @impl true def mount(_params, _session, socket) do enabled? = Livebook.Config.feature_flag_enabled?(:create_hub) - {:ok, assign(socket, selected_type: nil, page_title: "Hub - Livebook", enabled?: enabled?)} - end - @impl true - def handle_params(_params, _url, socket), do: {:noreply, socket} + {:ok, + assign(socket, + selected_option: nil, + page_title: "Hub - Livebook", + enabled?: enabled?, + requested_code: false, + org: nil, + verification_uri: nil, + org_form: nil + )} + end @impl true def render(%{enabled?: false} = assigns) do @@ -74,71 +85,94 @@ defmodule LivebookWeb.Hub.NewLive do

-
-

- 1. Select your Hub service -

- -
- <.card_item id="fly" selected={@selected_type} title="Fly"> - <:logo> - <%= Phoenix.HTML.raw(File.read!("static/images/fly.svg")) %> - - <:headline> - Deploy notebooks to your Fly account. - - - - <.card_item id="enterprise" selected={@selected_type} title="Livebook Teams"> - <:logo> - Livebook Teams logo - - <:headline> - Control access, manage secrets, and deploy notebooks within your team. - - -
-
- -
-

- 2. Configure your Hub -

- - <.live_component - :if={@selected_type == "fly"} - module={LivebookWeb.Hub.New.FlyComponent} - id="fly-form" - /> - - <.live_component - :if={@selected_type == "enterprise"} - module={LivebookWeb.Hub.New.EnterpriseComponent} - id="enterprise-form" - /> -
+ <.org_form + form={@org_form} + org={@org} + requested_code={@requested_code} + selected={@selected_option} + verification_uri={@verification_uri} + /> """ end + defp org_form(assigns) do + ~H""" +
+

+ 1. Select your option +

+ +
+ <.card_item id="new-org" selected={@selected} title="Create a new organization"> + <:logo><.remix_icon icon="add-circle-fill" class="text-black text-3xl" /> + <:headline>Create a new organization and invite your team members. + + + <.card_item id="join-org" selected={@selected} disabled title="Join an organization"> + <:logo><.remix_icon icon="user-add-fill" class="text-black text-3xl" /> + <:headline>Coming soon... + +
+
+ +
+

+ 2. Create your Organization +

+ + <.form + :let={f} + id="new-org-form" + class="flex flex-col space-y-4" + for={@form} + phx-submit="save" + phx-change="validate" + > +
+ <.text_field field={f[:name]} label="Name" /> + <.emoji_field field={f[:emoji]} label="Emoji" /> +
+ + <.password_field readonly field={f[:teams_key]} label="Livebook Teams Key" /> + +
+ + Access the following URL and input the User Code below to confirm the Organization creation. + + + <.link navigate={@verification_uri} target="_blank" class="font-bold text-blue-500"> + <%= @verification_uri %> + + + <%= @org.user_code %> +
+ + + +
+ """ + end + defp card_item(assigns) do + assigns = assign_new(assigns, :disabled, fn -> false end) + ~H"""
<%= render_slot(@logo) %>
-
+

<%= @title %>

@@ -151,8 +185,108 @@ defmodule LivebookWeb.Hub.NewLive do """ end + defp disabled_class(true), do: "opacity-30 pointer-events-none" + defp disabled_class(false), do: "" + + defp card_item_border_class(id, id), do: "border-gray-200" + defp card_item_border_class(_, _), do: "border-gray-100" + + defp card_item_class(id, id), do: "bg-gray-200" + defp card_item_class(_, _), do: "bg-gray-100" + @impl true - def handle_event("select_type", %{"value" => service}, socket) do - {:noreply, assign(socket, selected_type: service)} + def handle_event("select_option", %{"value" => option}, socket) do + {:noreply, + socket + |> assign(selected_option: option) + |> assign_form(option)} + end + + def handle_event("validate", %{"new_org" => attrs}, socket) do + changeset = + socket.assigns.org + |> Teams.change_org(attrs) + |> Map.replace!(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"new_org" => attrs}, socket) do + case Teams.create_org(socket.assigns.org, attrs) do + {:ok, response} -> + attrs = Map.merge(attrs, response) + changeset = Teams.change_org(socket.assigns.org, attrs) + org = Ecto.Changeset.apply_action!(changeset, :insert) + + Process.send_after(self(), :check_completion_data, @check_completion_data_internal) + + {:noreply, + socket + |> assign(requested_code: true, org: org, verification_uri: response["verification_uri"]) + |> assign_form(changeset)} + + {:error, changeset} -> + {:noreply, assign_form(socket, changeset)} + + {:transport_error, message} -> + {:noreply, put_flash(socket, :error, message)} + end + end + + @impl true + def handle_info(:check_completion_data, %{assigns: %{org: org}} = socket) do + case Teams.get_org_request_completion_data(org) do + {:ok, :awaiting_confirmation} -> + Process.send_after(self(), :check_completion_data, @check_completion_data_internal) + + {:noreply, socket} + + {:ok, %{"id" => _id, "session_token" => _session_token} = response} -> + hub = + Teams.create_hub!(%{ + org_id: response["id"], + user_id: response["user_id"], + org_key_id: response["org_key_id"], + session_token: response["session_token"], + teams_key: org.teams_key, + hub_name: org.name, + hub_emoji: org.emoji + }) + + {:noreply, + socket + |> put_flash(:success, "Hub added successfully") + |> push_navigate(to: ~p"/hub/#{hub.id}")} + + {:error, :expired} -> + changeset = Teams.change_org(org, %{user_code: nil}) + + {:noreply, + socket + |> assign(requested_code: false, org: org, verification_uri: nil) + |> put_flash( + :error, + "Oh no! Your org creation request expired, could you please try again?" + ) + |> assign_form(changeset)} + + {:transport_error, message} -> + {:noreply, put_flash(socket, :error, message)} + end + end + + def handle_info(_any, socket), do: {:noreply, socket} + + defp assign_form(socket, "new-org") do + org = %Org{emoji: "💡"} + changeset = Teams.change_org(org) + + socket + |> assign(org: org) + |> assign_form(changeset) + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, org_form: to_form(changeset, as: :new_org)) end end diff --git a/test/livebook_web/live/hub/new_live_test.exs b/test/livebook_web/live/hub/new_live_test.exs index f4ea3c8e8..c4307b6ef 100644 --- a/test/livebook_web/live/hub/new_live_test.exs +++ b/test/livebook_web/live/hub/new_live_test.exs @@ -1,166 +1,62 @@ defmodule LivebookWeb.Hub.NewLiveTest do - use LivebookWeb.ConnCase + use Livebook.TeamsIntegrationCase, async: true import Phoenix.LiveViewTest - alias Livebook.Hubs - test "render hub selection cards", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/hub") + {:ok, view, _html} = live(conn, ~p"/hub") - assert html =~ "Fly" - assert html =~ "Livebook Teams" + # shows the new options + assert has_element?(view, "#new-org") + assert has_element?(view, "#join-org") end - describe "fly" do - test "persists new hub", %{conn: conn} do - fly_bypass("123456789") + describe "new-org" do + test "persist a new hub", %{conn: conn, node: node, user: user} do + name = "New Org Test #{System.unique_integer([:positive])}" + teams_key = Livebook.Teams.Org.teams_key() {:ok, view, _html} = live(conn, ~p"/hub") + path = ~p"/hub/team-#{name}" + # select the new org option assert view - |> element("#fly") - |> render_click() =~ "2. Configure your Hub" + |> element("#new-org") + |> render_click() =~ "2. Create your Organization" - assert view - |> element(~s/input[name="fly[access_token]"]/) - |> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~ - ~s() + # builds the form data + org_attrs = %{"new_org" => %{"name" => name, "teams_key" => teams_key, "emoji" => "🐈"}} - attrs = %{ - "access_token" => "dummy access token", - "application_id" => "123456789", - "hub_name" => "My Foo Hub", - "hub_emoji" => "🐈" - } + # finds the form and change data + new_org_form = element(view, "#new-org-form") + render_change(new_org_form, org_attrs) - view - |> element("#fly-form") - |> render_change(%{"fly" => attrs}) + # submits the form + render_submit(new_org_form, org_attrs) - refute view - |> element("#fly-form .invalid-feedback") - |> has_element?() + # check if the form has the url to confirm + link_element = element(view, "#new-org-form a") + html = render(link_element) + parsed_html = Floki.parse_document!(html) + assert [url] = Floki.attribute(parsed_html, "href") + assert [_port, [org_request_id]] = Regex.scan(~r/(?<=\D|^)\d{1,4}(?=\D|$)/, url) + id = String.to_integer(org_request_id) - assert {:ok, view, _html} = - view - |> element("#fly-form") - |> render_submit(%{"fly" => attrs}) - |> follow_redirect(conn) + # force org request confirmation + org_request = :erpc.call(node, Hub.Integration, :get_org_request!, [id]) + :erpc.call(node, Hub.Integration, :confirm_org_request, [org_request, user]) - assert render(view) =~ "Hub added successfully" + # wait for the c:handle_info/2 cycle + # check if the page redirected to edit hub page + # and check the flash message + %{"success" => "Hub added successfully"} = assert_redirect(view, path, 1200) + # checks if the hub is in the sidebar + {:ok, view, _html} = live(conn, path) hubs_html = view |> element("#hubs") |> render() - assert hubs_html =~ "🐈" - assert hubs_html =~ "/hub/fly-123456789" - assert hubs_html =~ "My Foo Hub" + assert hubs_html =~ path + assert hubs_html =~ name end - - test "fails to create existing hub", %{conn: conn} do - hub = insert_hub(:fly, id: "fly-foo", application_id: "foo") - fly_bypass(hub.application_id) - - {:ok, view, _html} = live(conn, ~p"/hub") - - assert view - |> element("#fly") - |> render_click() =~ "2. Configure your Hub" - - assert view - |> element(~s/input[name="fly[access_token]"]/) - |> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~ - ~s() - - attrs = %{ - "access_token" => "dummy access token", - "application_id" => "foo", - "hub_name" => "My Foo Hub", - "hub_emoji" => "🐈" - } - - view - |> element("#fly-form") - |> render_change(%{"fly" => attrs}) - - refute view - |> element("#fly-form .invalid-feedback") - |> has_element?() - - assert view - |> element("#fly-form") - |> render_submit(%{"fly" => attrs}) =~ "already exists" - - assert_hub(view, hub) - assert Hubs.fetch_hub!(hub.id) == hub - end - end - - defp fly_bypass(app_id) do - bypass = Bypass.open() - Application.put_env(:livebook, :fly_graphql_endpoint, "http://localhost:#{bypass.port}") - - Bypass.expect(bypass, "POST", "/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - body = Jason.decode!(body) - - response = - cond do - body["query"] =~ "apps" -> fetch_apps_response(app_id) - body["query"] =~ "app" -> fetch_app_response(app_id) - end - - conn - |> Plug.Conn.put_resp_content_type("application/json") - |> Plug.Conn.resp(200, Jason.encode!(response)) - end) - end - - defp fetch_apps_response(app_id) do - app = %{ - "id" => app_id, - "organization" => %{ - "id" => "l3soyvjmvtmwtl6l2drnbfuvltipprge", - "name" => "Foo Bar", - "type" => "PERSONAL" - } - } - - %{"data" => %{"apps" => %{"nodes" => [app]}}} - end - - defp fetch_app_response(app_id) do - app = %{ - "id" => app_id, - "name" => app_id, - "hostname" => app_id <> ".fly.dev", - "platformVersion" => "nomad", - "deployed" => true, - "status" => "running", - "secrets" => [ - %{ - "createdAt" => to_string(DateTime.utc_now()), - "digest" => to_string(Livebook.Utils.random_cookie()), - "id" => Livebook.Utils.random_short_id(), - "name" => "LIVEBOOK_PASSWORD" - }, - %{ - "createdAt" => to_string(DateTime.utc_now()), - "digest" => to_string(Livebook.Utils.random_cookie()), - "id" => Livebook.Utils.random_short_id(), - "name" => "LIVEBOOK_SECRET_KEY_BASE" - } - ] - } - - %{"data" => %{"app" => app}} - end - - defp assert_hub(view, hub) do - hubs_html = view |> element("#hubs") |> render() - - assert hubs_html =~ hub.hub_emoji - assert hubs_html =~ ~p"/hub/#{hub.id}" - assert hubs_html =~ hub.hub_name end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 12a4f0a55..e4a3d233c 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -31,15 +31,17 @@ defmodule Livebook.Factory do end def build(:enterprise) do - id = Livebook.Utils.random_id() + name = "Enteprise #{Livebook.Utils.random_short_id()}" %Livebook.Hubs.Enterprise{ - id: "enterprise-#{id}", - hub_name: "Enterprise", + id: "enterprise-#{name}", + hub_name: name, hub_emoji: "🏭", - external_id: id, - token: Livebook.Utils.random_cookie(), - url: "http://localhost" + org_id: 1, + user_id: 1, + org_key_id: 1, + teams_key: Livebook.Utils.random_id(), + session_token: Livebook.Utils.random_cookie() } end @@ -74,6 +76,7 @@ defmodule Livebook.Factory do def build(:org) do %Livebook.Teams.Org{ id: nil, + emoji: "🏭", name: "Org Name #{System.unique_integer([:positive])}", teams_key: Livebook.Teams.Org.teams_key(), user_code: "request"