mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-10 15:04:25 +08:00
Implement new Add Hub page (#1909)
This commit is contained in:
parent
08199336b3
commit
3a63e30e89
13 changed files with 514 additions and 523 deletions
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
110
lib/livebook/hubs/team.ex
Normal file
110
lib/livebook/hubs/team.ex
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
88
lib/livebook_web/live/hub/edit/team_component.ex
Normal file
88
lib/livebook_web/live/hub/edit/team_component.ex
Normal file
|
@ -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"""
|
||||
<div id={"#{@id}-component"} class="space-y-8">
|
||||
<div class="flex relative">
|
||||
<LayoutHelpers.title text={"#{@hub.hub_emoji} #{@hub.hub_name}"} />
|
||||
|
||||
<button
|
||||
phx-click={JS.push("delete_hub", value: %{id: @hub.id})}
|
||||
class="absolute right-0 button-base button-red"
|
||||
>
|
||||
Delete hub
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-10">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
||||
General
|
||||
</h2>
|
||||
|
||||
<.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}
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-1 gap-3">
|
||||
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
|
||||
</div>
|
||||
|
||||
<button class="button-base button-blue" type="submit" phx-disable-with="Updating...">
|
||||
Update Hub
|
||||
</button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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
|
|
@ -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 %>
|
||||
</div>
|
||||
</LayoutHelpers.layout>
|
||||
|
|
|
@ -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"""
|
||||
<div>
|
||||
<.form
|
||||
:let={f}
|
||||
id={@id}
|
||||
class="flex flex-col space-y-4"
|
||||
for={@changeset}
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<.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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="connect"
|
||||
type="button"
|
||||
phx-click="connect"
|
||||
phx-target={@myself}
|
||||
class="button-base button-blue"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @pid do %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-1">
|
||||
<.password_field type="password" field={f[:external_id]} label="ID" disabled />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<.text_field field={f[:hub_name]} label="Name" readonly />
|
||||
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="button-base button-blue"
|
||||
type="submit"
|
||||
phx-disable-with="Add..."
|
||||
disabled={not @changeset.valid?}
|
||||
>
|
||||
Add Hub
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
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
|
|
@ -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"""
|
||||
<div>
|
||||
<.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"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<.text_field field={f[:hub_name]} label="Name" />
|
||||
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="button-base button-blue"
|
||||
phx-disable-with="Add..."
|
||||
disabled={not @changeset.valid?}
|
||||
>
|
||||
Add Hub
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
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
|
|
@ -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
|
|||
</p>
|
||||
</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">
|
||||
1. Select your Hub service
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<.card_item id="fly" selected={@selected_type} title="Fly">
|
||||
<:logo>
|
||||
<%= Phoenix.HTML.raw(File.read!("static/images/fly.svg")) %>
|
||||
</:logo>
|
||||
<:headline>
|
||||
Deploy notebooks to your Fly account.
|
||||
</:headline>
|
||||
</.card_item>
|
||||
|
||||
<.card_item id="enterprise" selected={@selected_type} title="Livebook Teams">
|
||||
<:logo>
|
||||
<img src="/images/teams.png" class="max-h-full max-w-[75%]" alt="Livebook Teams logo" />
|
||||
</:logo>
|
||||
<:headline>
|
||||
Control access, manage secrets, and deploy notebooks within your team.
|
||||
</:headline>
|
||||
</.card_item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@selected_type} class="flex flex-col space-y-4">
|
||||
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
||||
2. Configure your Hub
|
||||
</h2>
|
||||
|
||||
<.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"
|
||||
/>
|
||||
</div>
|
||||
<.org_form
|
||||
form={@org_form}
|
||||
org={@org}
|
||||
requested_code={@requested_code}
|
||||
selected={@selected_option}
|
||||
verification_uri={@verification_uri}
|
||||
/>
|
||||
</div>
|
||||
</LayoutHelpers.layout>
|
||||
"""
|
||||
end
|
||||
|
||||
defp org_form(assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-col space-y-4">
|
||||
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
||||
1. Select your option
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<.card_item id="new-org" selected={@selected} title="Create a new organization">
|
||||
<:logo><.remix_icon icon="add-circle-fill" class="text-black text-3xl" /></:logo>
|
||||
<:headline>Create a new organization and invite your team members.</:headline>
|
||||
</.card_item>
|
||||
|
||||
<.card_item id="join-org" selected={@selected} disabled title="Join an organization">
|
||||
<:logo><.remix_icon icon="user-add-fill" class="text-black text-3xl" /></:logo>
|
||||
<:headline>Coming soon...</:headline>
|
||||
</.card_item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@selected == "new-org"} class="flex flex-col space-y-4">
|
||||
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
||||
2. Create your Organization
|
||||
</h2>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
id="new-org-form"
|
||||
class="flex flex-col space-y-4"
|
||||
for={@form}
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<.text_field field={f[:name]} label="Name" />
|
||||
<.emoji_field field={f[:emoji]} label="Emoji" />
|
||||
</div>
|
||||
|
||||
<.password_field readonly field={f[:teams_key]} label="Livebook Teams Key" />
|
||||
|
||||
<div :if={@requested_code} class="grid grid-cols-1 gap-3">
|
||||
<span>
|
||||
Access the following URL and input the User Code below to confirm the Organization creation.
|
||||
</span>
|
||||
|
||||
<.link navigate={@verification_uri} target="_blank" class="font-bold text-blue-500">
|
||||
<%= @verification_uri %>
|
||||
</.link>
|
||||
|
||||
<span><%= @org.user_code %></span>
|
||||
</div>
|
||||
|
||||
<button :if={!@requested_code} class="button-base button-blue" phx-disable-with="Creating...">
|
||||
Create Org
|
||||
</button>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp card_item(assigns) do
|
||||
assigns = assign_new(assigns, :disabled, fn -> false end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class="flex flex-col cursor-pointer"
|
||||
phx-click={JS.push("select_type", value: %{value: @id})}
|
||||
class={["flex flex-col cursor-pointer", disabled_class(@disabled)]}
|
||||
phx-click={JS.push("select_option", value: %{value: @id})}
|
||||
>
|
||||
<div class={[
|
||||
"flex items-center justify-center p-6 border-2 rounded-t-2xl h-[150px]",
|
||||
if(@id == @selected, do: "border-gray-200", else: "border-gray-100")
|
||||
card_item_border_class(@id, @selected)
|
||||
]}>
|
||||
<%= render_slot(@logo) %>
|
||||
</div>
|
||||
<div class={[
|
||||
"px-6 py-4 rounded-b-2xl grow",
|
||||
if(@id == @selected, do: "bg-gray-200", else: "bg-gray-100")
|
||||
]}>
|
||||
<div class={["px-6 py-4 rounded-b-2xl grow", card_item_class(@id, @selected)]}>
|
||||
<p class="text-gray-800 font-semibold cursor-pointer">
|
||||
<%= @title %>
|
||||
</p>
|
||||
|
@ -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
|
||||
|
|
|
@ -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(<option value="123456789">Foo Bar - 123456789</option>)
|
||||
# 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(<option value="foo">Foo Bar - foo</option>)
|
||||
|
||||
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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue