From e7098f51a987c3c8f9a0ff72215bea60384663af Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Wed, 12 Nov 2025 15:14:41 -0300 Subject: [PATCH] Introducing the new Apps page (#3091) --- lib/livebook/apps/teams_app_spec.ex | 2 +- lib/livebook/hubs/team.ex | 3 +- lib/livebook/hubs/team_client.ex | 15 + lib/livebook/live_markdown/import.ex | 18 +- lib/livebook_web/live/apps_live.ex | 321 +++++++++++++++--- .../session_live/app_settings_component.ex | 6 +- .../live/session_live/app_teams_live.ex | 4 +- lib/livebook_web/live/session_live/render.ex | 1 + .../live_markdown/import_test.exs | 8 +- test/livebook_teams/web/admin_live_test.exs | 14 +- .../web/app_session_live_test.exs | 123 ++++++- test/livebook_teams/web/apps_live_test.exs | 319 ++++++++++++++++- test/livebook_web/live/session_live_test.exs | 3 + test/support/app_helpers.ex | 17 +- test/support/integration/teams_rpc.ex | 8 +- 15 files changed, 757 insertions(+), 105 deletions(-) diff --git a/lib/livebook/apps/teams_app_spec.ex b/lib/livebook/apps/teams_app_spec.ex index b440929db..27ded7c77 100644 --- a/lib/livebook/apps/teams_app_spec.ex +++ b/lib/livebook/apps/teams_app_spec.ex @@ -3,7 +3,7 @@ defmodule Livebook.Apps.TeamsAppSpec do @enforce_keys [:slug, :version, :hub_id, :app_deployment_id] - defstruct [:slug, :version, :hub_id, :app_deployment_id] + defstruct [:slug, :version, :hub_id, :app_deployment_id, :app_folder_id] end defimpl Livebook.Apps.AppSpec, for: Livebook.Apps.TeamsAppSpec do diff --git a/lib/livebook/hubs/team.ex b/lib/livebook/hubs/team.ex index b7bc8c566..5c9196f10 100644 --- a/lib/livebook/hubs/team.ex +++ b/lib/livebook/hubs/team.ex @@ -254,7 +254,8 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do slug: app_deployment.slug, version: app_deployment.version, hub_id: app_deployment.hub_id, - app_deployment_id: app_deployment.id + app_deployment_id: app_deployment.id, + app_folder_id: app_deployment.app_folder_id } end end diff --git a/lib/livebook/hubs/team_client.ex b/lib/livebook/hubs/team_client.ex index 9e67335b8..7874624ef 100644 --- a/lib/livebook/hubs/team_client.ex +++ b/lib/livebook/hubs/team_client.ex @@ -179,6 +179,8 @@ defmodule Livebook.Hubs.TeamClient do @spec get_app_folders(String.t()) :: list(Teams.AppFolder.t()) def get_app_folders(id) do GenServer.call(registry_name(id), :get_app_folders) + catch + :exit, _ -> [] end @doc """ @@ -865,6 +867,19 @@ defmodule Livebook.Hubs.TeamClient do defp handle_event(:app_deployment_updated, %Teams.AppDeployment{} = app_deployment, state) do manager_sync(app_deployment, state) Teams.Broadcasts.app_deployment_updated(app_deployment) + + with {:ok, current_app_deployment} <- fetch_app_deployment(app_deployment.id, state) do + if state.deployment_group_id && + (current_app_deployment.app_folder_id != + app_deployment.app_folder_id or + current_app_deployment.authorization_groups != app_deployment.authorization_groups) do + {:ok, deployment_group} = + fetch_deployment_group(app_deployment.deployment_group_id, state) + + Teams.Broadcasts.server_authorization_updated(deployment_group) + end + end + put_app_deployment(state, app_deployment) end diff --git a/lib/livebook/live_markdown/import.ex b/lib/livebook/live_markdown/import.ex index 09473c727..017a9604b 100644 --- a/lib/livebook/live_markdown/import.ex +++ b/lib/livebook/live_markdown/import.ex @@ -667,25 +667,23 @@ defmodule Livebook.LiveMarkdown.Import do # validate it against the public key). teams_enabled = is_struct(hub, Livebook.Hubs.Team) and (hub.offline == nil or stamp_verified?) - {app_settings, messages} = + messages = if app_folder_id = notebook.app_settings.app_folder_id do app_folders = Hubs.Provider.get_app_folders(hub) if Enum.any?(app_folders, &(&1.id == app_folder_id)) do - {notebook.app_settings, messages} + messages else - {Map.replace!(notebook.app_settings, :app_folder_id, nil), - messages ++ - [ - "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" - ]} + messages ++ + [ + "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" + ] end else - {notebook.app_settings, messages} + messages end - {%{notebook | app_settings: app_settings, teams_enabled: teams_enabled}, stamp_verified?, - messages} + {%{notebook | teams_enabled: teams_enabled}, stamp_verified?, messages} end defp safe_binary_split(binary, offset) diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex index 80a15520c..285d407c2 100644 --- a/lib/livebook_web/live/apps_live.ex +++ b/lib/livebook_web/live/apps_live.ex @@ -1,30 +1,40 @@ defmodule LivebookWeb.AppsLive do use LivebookWeb, :live_view + @events [ + :app_folder_created, + :app_folder_updated, + :app_folder_deleted + ] + @impl true def mount(_params, _session, socket) do if connected?(socket) do - Livebook.Teams.Broadcasts.subscribe(:app_server) + Livebook.Teams.Broadcasts.subscribe([:app_server, :app_folders]) Livebook.Apps.subscribe() end - apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user) empty_apps_path? = Livebook.Apps.empty_apps_path?() {:ok, - assign(socket, - apps: apps, + socket + |> assign( + search_term: "", + selected_app_folder: "", + apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user), empty_apps_path?: empty_apps_path?, logout_enabled?: Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil - )} + ) + |> load_app_folders() + |> apply_filters()} end @impl true def render(assigns) do ~H"""
-
+
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left"> <:toggle> @@ -42,58 +52,158 @@ defmodule LivebookWeb.AppsLive do
- <.link navigate={~p"/apps-dashboard"} class="flex items-center text-blue-600"> + <.link + navigate={~p"/apps-dashboard"} + class="flex items-center text-blue-600 hover:text-blue-700 transition-colors" + > Dashboard <.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
-
-
-

- Apps -

-
-
- <.link - :for={app <- apps_listing(@apps)} - navigate={~p"/apps/#{app.slug}"} - class="px-4 py-3 border border-gray-200 rounded-xl text-gray-800 pointer hover:bg-gray-50 flex items-center justify-between" - > - {app.notebook_name} - <.remix_icon :if={not app.public?} icon="lock-password-line" /> - +
+
+
+
+

Apps

+

Find your applications

-
-
- <.no_entries :if={@apps == []}> - No apps running. - -
-
-
- No app notebooks found. Follow these steps to list your apps here: -
-
    -
  1. - Open a notebook -
  2. -
  3. - Click <.remix_icon icon="rocket-line" class="align-baseline text-lg" /> - in the sidebar and configure the app as public -
  4. -
  5. - Save the notebook to the - {Livebook.Config.apps_path()} - folder -
  6. -
  7. - Relaunch your Livebook app -
  8. -
+ + <%= if @apps != [] do %> +
+
+
+
+ <.remix_icon + icon="search-line" + class="absolute left-3 bottom-[8px] text-gray-400" + /> + <.text_field + id="search-app" + name="search_term" + placeholder="Search apps..." + value={@search_term} + phx-keyup="search" + phx-debounce="300" + class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + /> +
+
+
+
+ <.select_field + id="select-app-folder" + name="app_folder" + prompt="Select a folder..." + value={@selected_app_folder} + options={@app_folder_options} + /> +
+
+
+ +
+ <.remix_icon icon="windy-line" class="text-gray-400 text-2xl" /> +

No apps found

+

Try adjusting your search or filter criteria

+
+
+
+

+ <.remix_icon icon={icon} /> + {app_folder} + ({length(apps)}) +

+
+ <.link + :for={app <- apps_listing(apps)} + id={"app-#{app.slug}"} + navigate={~p"/apps/#{app.slug}"} + class="border bg-gray-50 border-gray-300 rounded-lg p-4 hover:shadow-md hover:border-blue-300 transition-all duration-200" + > +
+

+ {app.notebook_name} +

+
+ <.remix_icon + :if={not app.public?} + icon="lock-password-line" + class="h-4 w-4 text-gray-400" + /> + <.remix_icon + icon="arrow-right-line" + class="h-4 w-4 text-gray-400 group-hover:text-blue-600 transition-colors" + /> +
+
+ +
+
+
+
+ <% else %> +
+
+
+ <.remix_icon icon="windy-line" class="size-16 text-gray-400 text-2xl" /> +

No apps found

+

Follow these steps to list your apps here:

+
+
+
    +
  1. + + 1 + + Open a notebook +
  2. +
  3. + + 2 + +
    + Click + <.remix_icon icon="rocket-line" class="inline align-baseline text-base" /> + in the sidebar and configure the app as public +
    +
  4. +
  5. + + 3 + +
    + Save the notebook to the + + {Livebook.Config.apps_path()} + + folder +
    +
  6. +
  7. + + 4 + + Relaunch your Livebook app +
  8. +
+
+
+
+ <.remix_icon icon="windy-line" class="size-16 text-gray-400 text-2xl" /> +

No apps running

+

Start some apps to see them listed here

+
+
+ <% end %>
@@ -101,15 +211,46 @@ defmodule LivebookWeb.AppsLive do """ end + @impl true + def handle_event("search", %{"value" => search_term}, socket) do + {:noreply, + socket + |> assign(search_term: search_term) + |> apply_filters()} + end + + def handle_event("select_app_folder", %{"app_folder" => app_folder_id}, socket) do + {:noreply, + socket + |> assign(selected_app_folder: app_folder_id) + |> apply_filters()} + end + @impl true def handle_info({type, _app} = event, socket) when type in [:app_created, :app_updated, :app_closed] do - {:noreply, update(socket, :apps, &LivebookWeb.AppComponents.update_app_list(&1, event))} + {:noreply, + socket + |> assign(apps: LivebookWeb.AppComponents.update_app_list(socket.assigns.apps, event)) + |> apply_filters()} end def handle_info({:server_authorization_updated, _}, socket) do - apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user) - {:noreply, assign(socket, :apps, apps)} + {:noreply, + socket + |> assign( + apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user), + logout_enabled?: + Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil + ) + |> apply_filters()} + end + + def handle_info({type, _app_folder}, socket) when type in @events do + {:noreply, + socket + |> load_app_folders() + |> apply_filters()} end def handle_info(_message, socket), do: {:noreply, socket} @@ -117,4 +258,74 @@ defmodule LivebookWeb.AppsLive do defp apps_listing(apps) do Enum.sort_by(apps, & &1.notebook_name) end + + def load_app_folders(socket) do + app_folders = + Enum.flat_map(Livebook.Hubs.get_hubs(), &Livebook.Hubs.Provider.get_app_folders/1) + + app_folder_options = + for app_folder <- app_folders do + {app_folder.name, app_folder.id} + end + + assign(socket, app_folders: app_folders, app_folder_options: app_folder_options) + end + + defp apply_filters(socket) do + apps = socket.assigns.apps + app_folders = socket.assigns.app_folders + + filtered_apps = + filter_apps(apps, socket.assigns.search_term, socket.assigns.selected_app_folder) + + grouped_apps = + filtered_apps + |> Enum.group_by(fn + %{app_spec: %{app_folder_id: id}} -> Enum.find_value(app_folders, &(&1.id == id && id)) + _ -> nil + end) + |> Enum.map(fn + {nil, apps} -> + {"Ungrouped apps", "ungrouped-apps", "asterisk", apps} + + {id, apps} -> + app_folder_name = Enum.find_value(app_folders, &(&1.id == id && &1.name)) + {app_folder_name, "app-folder-#{id}", "folder-line", apps} + end) + |> Enum.sort_by(&elem(&1, 0)) + + show_app_folders? = Enum.any?(apps, &is_struct(&1.app_spec, Livebook.Apps.TeamsAppSpec)) + + assign(socket, + grouped_apps: grouped_apps, + filtered_apps: filtered_apps, + show_app_folders?: show_app_folders? + ) + end + + defp filter_apps(apps, term, app_folder_id) do + apps + |> search_apps(term) + |> filter_by_app_folder(app_folder_id) + end + + defp search_apps(apps, ""), do: apps + + defp search_apps(apps, term) do + term = String.downcase(term) + + Enum.filter(apps, fn app -> + String.contains?(String.downcase(app.notebook_name), term) or + String.contains?(app.slug, term) + end) + end + + defp filter_by_app_folder(apps, ""), do: apps + + defp filter_by_app_folder(apps, app_folder_id) do + Enum.filter(apps, fn + %{app_spec: %{app_folder_id: id}} -> id == app_folder_id + _otherwise -> false + end) + end end diff --git a/lib/livebook_web/live/session_live/app_settings_component.ex b/lib/livebook_web/live/session_live/app_settings_component.ex index 037e567f4..060f4331a 100644 --- a/lib/livebook_web/live/session_live/app_settings_component.ex +++ b/lib/livebook_web/live/session_live/app_settings_component.ex @@ -20,7 +20,10 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do {:ok, socket |> assign(assigns) - |> assign(app_folder_options: app_folder_options, changeset: changeset)} + |> assign( + app_folder_options: app_folder_options, + changeset: changeset + )} end @impl true @@ -48,6 +51,7 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce /> <.select_field + :if={@hub_id != Livebook.Hubs.Personal.id()} field={f[:app_folder_id]} label="Folder" prompt="Select a folder..." diff --git a/lib/livebook_web/live/session_live/app_teams_live.ex b/lib/livebook_web/live/session_live/app_teams_live.ex index 816167c1e..d5709db48 100644 --- a/lib/livebook_web/live/session_live/app_teams_live.ex +++ b/lib/livebook_web/live/session_live/app_teams_live.ex @@ -602,11 +602,9 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do end) end - defp app_folder_name(_hub, id) when id in [nil, ""], do: "Ungrouped apps" - defp app_folder_name(hub, id) do hub |> Teams.get_app_folders() - |> Enum.find_value(&(&1.id == id && &1.name)) + |> Enum.find_value("Ungrouped apps", &(&1.id == id && &1.name)) end end diff --git a/lib/livebook_web/live/session_live/render.ex b/lib/livebook_web/live/session_live/render.ex index b11f11f14..b847a829e 100644 --- a/lib/livebook_web/live/session_live/render.ex +++ b/lib/livebook_web/live/session_live/render.ex @@ -104,6 +104,7 @@ defmodule LivebookWeb.SessionLive.Render do context={@action_assigns.context} deployed_app_slug={@data_view.deployed_app_slug} app_folders={@data_view.hub_app_folders} + hub_id={@data_view.hub.id} /> diff --git a/test/livebook_teams/live_markdown/import_test.exs b/test/livebook_teams/live_markdown/import_test.exs index d14c443d3..873bcfba1 100644 --- a/test/livebook_teams/live_markdown/import_test.exs +++ b/test/livebook_teams/live_markdown/import_test.exs @@ -11,7 +11,7 @@ defmodule Livebook.Integration.LiveMarkdown.ImportTest do @moduletag subscribe_to_teams_topics: [:clients, :app_folders] describe "app settings" do - test "don't import app folder if does not exists anymore", + test "keep the app folder id even if it does not exist anymore", %{node: node, team: team, org: org} do app_folder = TeamsRPC.create_app_folder(node, name: "delete me", org: org) @@ -39,8 +39,10 @@ defmodule Livebook.Integration.LiveMarkdown.ImportTest do TeamsRPC.delete_app_folder(node, app_folder) assert_receive {:app_folder_deleted, %{id: ^app_folder_id, hub_id: ^hub_id}} - assert {%Notebook{name: "Deleted from folder", app_settings: %{app_folder_id: nil}}, - %{warnings: warnings}} = LiveMarkdown.Import.notebook_from_livemd(markdown) + assert {%Notebook{ + name: "Deleted from folder", + app_settings: %{app_folder_id: ^app_folder_id} + }, %{warnings: warnings}} = LiveMarkdown.Import.notebook_from_livemd(markdown) assert "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" in warnings end diff --git a/test/livebook_teams/web/admin_live_test.exs b/test/livebook_teams/web/admin_live_test.exs index 1aecdcfed..26f41978d 100644 --- a/test/livebook_teams/web/admin_live_test.exs +++ b/test/livebook_teams/web/admin_live_test.exs @@ -16,12 +16,13 @@ defmodule LivebookWeb.Integration.AdminLiveTest do %{conn: conn, node: node, code: code} = context do TeamsRPC.toggle_groups_authorization(node, context.deployment_group) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, group_name: "marketing", access_type: :apps, - prefixes: ["dev-"], + app_folders: [app_folder], oidc_provider: oidc_provider, deployment_group: context.deployment_group ) @@ -76,6 +77,7 @@ defmodule LivebookWeb.Integration.AdminLiveTest do TeamsRPC.toggle_groups_authorization(node, context.deployment_group) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, @@ -99,10 +101,9 @@ defmodule LivebookWeb.Integration.AdminLiveTest do {:ok, view, _html} = live(conn, ~p"/settings") assert render(view) =~ "System settings" - TeamsRPC.update_authorization_group(node, authorization_group, %{ - access_type: :apps, - prefixes: ["ops-"] - }) + TeamsRPC.update_authorization_group(node, authorization_group, %{access_type: :apps}, [ + app_folder + ]) id = to_string(deployment_group.id) assert_receive {:server_authorization_updated, %{id: ^id}} @@ -121,12 +122,13 @@ defmodule LivebookWeb.Integration.AdminLiveTest do TeamsRPC.toggle_groups_authorization(node, context.deployment_group) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, group_name: "marketing", access_type: :apps, - prefixes: ["ops-"], + app_folders: [app_folder], oidc_provider: oidc_provider, deployment_group: deployment_group ) diff --git a/test/livebook_teams/web/app_session_live_test.exs b/test/livebook_teams/web/app_session_live_test.exs index edd97ce57..a3a2765bb 100644 --- a/test/livebook_teams/web/app_session_live_test.exs +++ b/test/livebook_teams/web/app_session_live_test.exs @@ -8,7 +8,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do setup :teams @moduletag subscribe_to_hubs_topics: [:connection] - @moduletag subscribe_to_teams_topics: [:clients, :agents, :app_deployments, :app_server] + @moduletag subscribe_to_teams_topics: [ + :clients, + :agents, + :app_deployments, + :app_server, + :app_folders + ] setup do Livebook.Apps.subscribe() @@ -23,12 +29,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do TeamsRPC.toggle_groups_authorization(node, context.deployment_group) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, group_name: "marketing", access_type: :apps, - prefixes: ["dev-"], + app_folders: [app_folder], oidc_provider: oidc_provider, deployment_group: context.deployment_group ) @@ -46,7 +53,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do slug = "dev-oban-app" context = change_to_user_session(context) - deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_folder + ) change_to_agent_session(context) pid = wait_livebook_app_start(slug) @@ -111,12 +127,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do TeamsRPC.toggle_groups_authorization(node, context.deployment_group) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, group_name: "marketing", access_type: :apps, - prefixes: ["mkt-"], + app_folders: [app_folder], oidc_provider: oidc_provider, deployment_group: deployment_group ) @@ -134,7 +151,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do slug = "mkt-analytics-#{Livebook.Utils.random_short_id()}" context = change_to_user_session(context) - deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_folder + ) change_to_agent_session(context) pid = wait_livebook_app_start(slug) @@ -144,8 +170,11 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do {:ok, view, _html} = live(conn, path) assert render(view) =~ "LivebookApp:#{slug}" - {:ok, %{prefixes: ["ops-"]}} = - TeamsRPC.update_authorization_group(node, authorization_group, %{prefixes: ["ops-"]}) + app_folder2 = TeamsRPC.create_app_folder(node, org: context.org) + app_folder_id = app_folder2.id + + {:ok, %{app_folders: [%{id: ^app_folder_id}]}} = + TeamsRPC.update_authorization_group(node, authorization_group, %{}, [app_folder2]) id = to_string(deployment_group.id) @@ -164,12 +193,14 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do TeamsRPC.toggle_groups_authorization(node, context.deployment_group) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) + app_folder2 = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, group_name: "marketing", access_type: :apps, - prefixes: ["mkt-"], + app_folders: [app_folder], oidc_provider: oidc_provider, deployment_group: deployment_group ) @@ -187,7 +218,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do slug = "analytics-app-#{Livebook.Utils.random_short_id()}" context = change_to_user_session(context) - deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_folder2 + ) change_to_agent_session(context) pid = wait_livebook_app_start(slug) @@ -207,5 +247,70 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do {:ok, view, _html} = live(conn, path) assert render(view) =~ "LivebookApp:#{slug}" end + + @tag :tmp_dir + test "renders unauthorized if app's folder is deleted in real-time", + %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do + {:ok, deployment_group} = + TeamsRPC.toggle_groups_authorization(node, context.deployment_group) + + oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) + + authorization_group = + TeamsRPC.create_authorization_group(node, + group_name: "marketing", + access_type: :apps, + app_folders: [app_folder], + oidc_provider: oidc_provider, + deployment_group: deployment_group + ) + + TeamsRPC.update_user_info_groups( + node, + code, + [ + %{ + "provider_id" => to_string(oidc_provider.id), + "group_name" => authorization_group.group_name + } + ] + ) + + slug = "mkt-analytics-#{Livebook.Utils.random_short_id()}" + context = change_to_user_session(context) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_folder + ) + + change_to_agent_session(context) + pid = wait_livebook_app_start(slug) + session_id = Livebook.App.get_session_id(pid, user: Livebook.Users.User.new()) + path = ~p"/apps/#{slug}/sessions/#{session_id}" + + {:ok, view, _html} = live(conn, path) + assert render(view) =~ "LivebookApp:#{slug}" + + app_folder_id = to_string(app_folder.id) + + TeamsRPC.delete_app_folder(node, app_folder) + assert_receive {:app_folder_deleted, %{id: ^app_folder_id}} + + id = to_string(deployment_group.id) + + assert_receive {:server_authorization_updated, %{id: ^id}} + assert_receive {:app_deployment_updated, %{slug: ^slug, app_folder_id: nil}} + assert_redirect view, path + + {:ok, view, _html} = live(conn, path) + assert render(view) =~ "Not authorized" + end end end diff --git a/test/livebook_teams/web/apps_live_test.exs b/test/livebook_teams/web/apps_live_test.exs index eaffce17e..b9b98b26b 100644 --- a/test/livebook_teams/web/apps_live_test.exs +++ b/test/livebook_teams/web/apps_live_test.exs @@ -13,9 +13,12 @@ defmodule LivebookWeb.Integration.AppsLiveTest do :agents, :deployment_groups, :app_deployments, - :app_server + :app_server, + :app_folders ] + @moduletag :tmp_dir + setup do Livebook.Apps.subscribe() :ok @@ -24,17 +27,17 @@ defmodule LivebookWeb.Integration.AppsLiveTest do describe "authorized apps" do setup :livebook_teams_auth - @tag :tmp_dir test "shows one app if user doesn't have full access", %{conn: conn, code: code, node: node, tmp_dir: tmp_dir} = context do TeamsRPC.toggle_groups_authorization(node, context.deployment_group) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, group_name: "marketing", access_type: :apps, - prefixes: ["dev-"], + app_folders: [app_folder], oidc_provider: oidc_provider, deployment_group: context.deployment_group ) @@ -52,7 +55,16 @@ defmodule LivebookWeb.Integration.AppsLiveTest do slug = "dev-app-#{Livebook.Utils.random_short_id()}" context = change_to_user_session(context) - deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_folder + ) change_to_agent_session(context) wait_livebook_app_start(slug) @@ -66,7 +78,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do assert html =~ slug end - @tag :tmp_dir test "shows all apps if user have full access", %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do TeamsRPC.toggle_groups_authorization(node, context.deployment_group) @@ -121,7 +132,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do end end - @tag :tmp_dir test "updates the apps list in real-time", %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do {:ok, %{groups_auth: true} = deployment_group} = @@ -131,12 +141,13 @@ defmodule LivebookWeb.Integration.AppsLiveTest do assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}} oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, group_name: "marketing", access_type: :apps, - prefixes: ["mkt-"], + app_folders: [app_folder], oidc_provider: oidc_provider, deployment_group: deployment_group ) @@ -154,7 +165,15 @@ defmodule LivebookWeb.Integration.AppsLiveTest do slug = "marketing-report-#{Livebook.Utils.random_short_id()}" context = change_to_user_session(context) - deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node + ) change_to_agent_session(context) wait_livebook_app_start(slug) @@ -169,7 +188,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do assert render(view) =~ slug end - @tag :tmp_dir test "shows all apps if disable the authentication in real-time", %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do {:ok, %{groups_auth: true} = deployment_group} = @@ -179,12 +197,14 @@ defmodule LivebookWeb.Integration.AppsLiveTest do assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}} oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) + app_folder2 = TeamsRPC.create_app_folder(node, org: context.org) authorization_group = TeamsRPC.create_authorization_group(node, group_name: "marketing", access_type: :apps, - prefixes: ["mkt-"], + app_folders: [app_folder], oidc_provider: oidc_provider, deployment_group: deployment_group ) @@ -202,7 +222,16 @@ defmodule LivebookWeb.Integration.AppsLiveTest do slug = "marketing-app-#{Livebook.Utils.random_short_id()}" context = change_to_user_session(context) - deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_folder2 + ) change_to_agent_session(context) wait_livebook_app_start(slug) @@ -216,5 +245,273 @@ defmodule LivebookWeb.Integration.AppsLiveTest do {:ok, view, _} = live(conn, ~p"/apps") assert render(view) =~ slug end + + test "updates the folder name in real-time", + %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do + {:ok, %{groups_auth: true} = deployment_group} = + TeamsRPC.toggle_groups_authorization(node, context.deployment_group) + + id = to_string(deployment_group.id) + assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}} + + oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) + + authorization_group = + TeamsRPC.create_authorization_group(node, + group_name: "marketing", + access_type: :apps, + app_folders: [app_folder], + oidc_provider: oidc_provider, + deployment_group: deployment_group + ) + + TeamsRPC.update_user_info_groups( + node, + code, + [ + %{ + "provider_id" => to_string(oidc_provider.id), + "group_name" => authorization_group.group_name + } + ] + ) + + slug = Livebook.Utils.random_short_id() + context = change_to_user_session(context) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_folder + ) + + change_to_agent_session(context) + wait_livebook_app_start(slug) + + {:ok, view, _} = live(conn, ~p"/apps") + assert render(view) =~ app_folder.name + assert render(view) =~ slug + + new_name = "NewAppFolderName" + app_folder_id = to_string(app_folder.id) + + {:ok, _app_folder} = TeamsRPC.update_app_folder(node, app_folder, name: new_name) + assert_receive {:app_folder_updated, %{id: ^app_folder_id, name: ^new_name}} + + refute render(view) =~ app_folder.name + assert render(view) =~ new_name + assert render(view) =~ slug + end + + test "deletes the folder and move the app to ungrouped apps folder in real-time", + %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do + {:ok, %{groups_auth: true} = deployment_group} = + TeamsRPC.toggle_groups_authorization(node, context.deployment_group) + + id = to_string(deployment_group.id) + assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}} + + oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) + + authorization_group = + TeamsRPC.create_authorization_group(node, + group_name: "marketing", + access_type: :apps, + app_folders: [app_folder], + oidc_provider: oidc_provider, + deployment_group: deployment_group + ) + + TeamsRPC.update_user_info_groups( + node, + code, + [ + %{ + "provider_id" => to_string(oidc_provider.id), + "group_name" => authorization_group.group_name + } + ] + ) + + slug = Livebook.Utils.random_short_id() + context = change_to_user_session(context) + + deploy_app( + slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_folder + ) + + change_to_agent_session(context) + wait_livebook_app_start(slug) + + {:ok, view, _} = live(conn, ~p"/apps") + assert render(view) =~ app_folder.name + assert render(view) =~ slug + + id = to_string(deployment_group.id) + app_folder_id = to_string(app_folder.id) + + TeamsRPC.delete_app_folder(node, app_folder) + assert_receive {:app_folder_deleted, %{id: ^app_folder_id}} + assert_receive {:app_deployment_updated, %{slug: ^slug, app_folder_id: nil}} + assert_receive {:app_updated, %{slug: ^slug, app_spec: %{app_folder_id: ^app_folder_id}}} + + # Once the folder is deleted, all apps are moved to a "Ungrouped apps" folder, + # which only users with full access will be able to see and access them. + refute render(view) =~ app_folder.name + refute render(view) =~ slug + + # To validate this behaivour, updates the authorization group to be full access + {:ok, %{access_type: :app_server}} = + TeamsRPC.update_authorization_group(node, authorization_group, %{access_type: :app_server}) + + # Since we're updating the authorization group access type, the app deployment must receive the updated version + assert_receive {:server_authorization_updated, %{id: ^id}}, 3_000 + assert_receive {:app_deployment_updated, %{slug: ^slug, app_folder_id: nil}} + + refute render(view) =~ app_folder.name + assert render(view) =~ "Ungrouped apps" + assert render(view) =~ slug + end + + test "filter the apps based on slug, name and app folder", + %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do + {:ok, %{groups_auth: true} = deployment_group} = + TeamsRPC.toggle_groups_authorization(node, context.deployment_group) + + id = to_string(deployment_group.id) + assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}} + + oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) + app_folder = TeamsRPC.create_app_folder(node, org: context.org) + app_folder2 = TeamsRPC.create_app_folder(node, org: context.org) + + authorization_group = + TeamsRPC.create_authorization_group(node, + access_type: :app_server, + oidc_provider: oidc_provider, + deployment_group: deployment_group + ) + + TeamsRPC.update_user_info_groups( + node, + code, + [ + %{ + "provider_id" => to_string(oidc_provider.id), + "group_name" => authorization_group.group_name + } + ] + ) + + apps_to_deploy = [ + app_to_deploy1 = %{ + slug: "app-from-folder1", + title: "Super Admin Tools", + app_folder: app_folder, + folder_id: "app-folder-#{app_folder.id}", + folder_name: app_folder.name + }, + app_to_deploy2 = %{ + slug: "app-from-folder2", + title: "Accounting daily report", + app_folder: app_folder2, + folder_id: "app-folder-#{app_folder2.id}", + folder_name: app_folder2.name + }, + app_to_deploy3 = %{ + slug: "app-from-ungrouped-folder", + title: "List of the chonkiest cats", + app_folder: nil, + folder_id: "ungrouped-apps", + folder_name: "Ungrouped apps" + } + ] + + context = change_to_user_session(context) + + for app_to_deploy <- apps_to_deploy do + deploy_app( + app_to_deploy.slug, + context.team, + context.org, + context.deployment_group, + tmp_dir, + node, + app_to_deploy.app_folder, + app_to_deploy.title + ) + end + + change_to_agent_session(context) + + for %{slug: slug} <- apps_to_deploy do + wait_livebook_app_start(slug) + end + + {:ok, view, _} = live(conn, ~p"/apps") + Enum.each(apps_to_deploy, &assert_app(view, &1)) + + # filter by slug + render_keyup(view, "search", %{value: app_to_deploy1.slug}) + assert_app(view, app_to_deploy1) + + apps_to_deploy + |> Enum.reject(&(&1 == app_to_deploy1)) + |> Enum.each(&refute_app(view, &1)) + + # filter by title + render_keyup(view, "search", %{value: app_to_deploy3.title}) + assert_app(view, app_to_deploy3) + + apps_to_deploy + |> Enum.reject(&(&1 == app_to_deploy3)) + |> Enum.each(&refute_app(view, &1)) + + # reset filter + render_keyup(view, "search", %{value: ""}) + + # filter by app folder + view + |> element("#select-app-folder-form") + |> render_change(%{app_folder: app_to_deploy2.app_folder.id}) + + assert_app(view, app_to_deploy2) + + apps_to_deploy + |> Enum.reject(&(&1 == app_to_deploy2)) + |> Enum.each(&refute_app(view, &1)) + end + end + + defp assert_app(view, app_to_deploy) do + assert view + |> element("##{app_to_deploy.folder_id}", app_to_deploy.folder_name) + |> has_element?() + + assert view + |> element("#app-#{app_to_deploy.slug}", app_to_deploy.title) + |> has_element?() + end + + defp refute_app(view, app_to_deploy) do + refute view + |> element("##{app_to_deploy.folder_id}", app_to_deploy.folder_name) + |> has_element?() + + refute view + |> element("#app-#{app_to_deploy.slug}", app_to_deploy.title) + |> has_element?() end end diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index d97530aef..ee4d1c4a7 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -2786,6 +2786,9 @@ defmodule LivebookWeb.SessionLiveTest do |> element(~s/[data-el-app-info] a/, "Configure") |> render_click() + # doesn't show the app folder select + refute has_element?(view, ~s/#app-settings-modal input[name="app_folder_id"]/) + view |> element(~s/#app-settings-modal form/) |> render_change(%{"app_settings" => %{"slug" => slug}}) diff --git a/test/support/app_helpers.ex b/test/support/app_helpers.ex index 33946d470..aa812b8a5 100644 --- a/test/support/app_helpers.ex +++ b/test/support/app_helpers.ex @@ -26,14 +26,25 @@ defmodule Livebook.AppHelpers do end end - def deploy_app(slug, team, org, deployment_group, tmp_dir, node) do + def deploy_app( + slug, + team, + org, + deployment_group, + tmp_dir, + node, + app_folder \\ nil, + title \\ nil + ) do app_path = Path.join(tmp_dir, "#{slug}.livemd") + app_folder = if app_folder, do: ~s(,"app_folder_id":"#{app_folder.id}") + title = if title, do: title, else: "LivebookApp:#{slug}" source = stamp_notebook(app_path, """ - + - # LivebookApp:#{slug} + # #{title} ```elixir IO.puts("Hi") diff --git a/test/support/integration/teams_rpc.ex b/test/support/integration/teams_rpc.ex index 4e292e713..a3e1c36f7 100644 --- a/test/support/integration/teams_rpc.ex +++ b/test/support/integration/teams_rpc.ex @@ -169,8 +169,12 @@ defmodule Livebook.TeamsRPC do # Update resource - def update_authorization_group(node, authorization_group, attrs) do - :erpc.call(node, TeamsRPC, :update_authorization_group, [authorization_group, attrs]) + def update_authorization_group(node, authorization_group, attrs, app_folders \\ []) do + :erpc.call(node, TeamsRPC, :update_authorization_group, [ + authorization_group, + attrs, + app_folders + ]) end def update_user_info_groups(node, code, groups) do