Introducing the new Apps page (#3091)

This commit is contained in:
Alexandre de Souza 2025-11-12 15:14:41 -03:00 committed by GitHub
parent c7762469f2
commit e7098f51a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 757 additions and 105 deletions

View file

@ -3,7 +3,7 @@ defmodule Livebook.Apps.TeamsAppSpec do
@enforce_keys [:slug, :version, :hub_id, :app_deployment_id] @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 end
defimpl Livebook.Apps.AppSpec, for: Livebook.Apps.TeamsAppSpec do defimpl Livebook.Apps.AppSpec, for: Livebook.Apps.TeamsAppSpec do

View file

@ -254,7 +254,8 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
slug: app_deployment.slug, slug: app_deployment.slug,
version: app_deployment.version, version: app_deployment.version,
hub_id: app_deployment.hub_id, 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
end end

View file

@ -179,6 +179,8 @@ defmodule Livebook.Hubs.TeamClient do
@spec get_app_folders(String.t()) :: list(Teams.AppFolder.t()) @spec get_app_folders(String.t()) :: list(Teams.AppFolder.t())
def get_app_folders(id) do def get_app_folders(id) do
GenServer.call(registry_name(id), :get_app_folders) GenServer.call(registry_name(id), :get_app_folders)
catch
:exit, _ -> []
end end
@doc """ @doc """
@ -865,6 +867,19 @@ defmodule Livebook.Hubs.TeamClient do
defp handle_event(:app_deployment_updated, %Teams.AppDeployment{} = app_deployment, state) do defp handle_event(:app_deployment_updated, %Teams.AppDeployment{} = app_deployment, state) do
manager_sync(app_deployment, state) manager_sync(app_deployment, state)
Teams.Broadcasts.app_deployment_updated(app_deployment) 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) put_app_deployment(state, app_deployment)
end end

View file

@ -667,25 +667,23 @@ defmodule Livebook.LiveMarkdown.Import do
# validate it against the public key). # validate it against the public key).
teams_enabled = is_struct(hub, Livebook.Hubs.Team) and (hub.offline == nil or stamp_verified?) 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 if app_folder_id = notebook.app_settings.app_folder_id do
app_folders = Hubs.Provider.get_app_folders(hub) app_folders = Hubs.Provider.get_app_folders(hub)
if Enum.any?(app_folders, &(&1.id == app_folder_id)) do if Enum.any?(app_folders, &(&1.id == app_folder_id)) do
{notebook.app_settings, messages} messages
else else
{Map.replace!(notebook.app_settings, :app_folder_id, nil), messages ++
messages ++ [
[ "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder"
"notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" ]
]}
end end
else else
{notebook.app_settings, messages} messages
end end
{%{notebook | app_settings: app_settings, teams_enabled: teams_enabled}, stamp_verified?, {%{notebook | teams_enabled: teams_enabled}, stamp_verified?, messages}
messages}
end end
defp safe_binary_split(binary, offset) defp safe_binary_split(binary, offset)

View file

@ -1,30 +1,40 @@
defmodule LivebookWeb.AppsLive do defmodule LivebookWeb.AppsLive do
use LivebookWeb, :live_view use LivebookWeb, :live_view
@events [
:app_folder_created,
:app_folder_updated,
:app_folder_deleted
]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
if connected?(socket) do if connected?(socket) do
Livebook.Teams.Broadcasts.subscribe(:app_server) Livebook.Teams.Broadcasts.subscribe([:app_server, :app_folders])
Livebook.Apps.subscribe() Livebook.Apps.subscribe()
end end
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
empty_apps_path? = Livebook.Apps.empty_apps_path?() empty_apps_path? = Livebook.Apps.empty_apps_path?()
{:ok, {:ok,
assign(socket, socket
apps: apps, |> assign(
search_term: "",
selected_app_folder: "",
apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user),
empty_apps_path?: empty_apps_path?, empty_apps_path?: empty_apps_path?,
logout_enabled?: logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)} )
|> load_app_folders()
|> apply_filters()}
end end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="h-full flex flex-col overflow-y-auto"> <div class="h-full flex flex-col overflow-y-auto">
<div class="px-4 py-3 flex items-center justify-between"> <div class="px-6 py-4 bg-white border-b border-gray-200 flex items-center justify-between">
<div class="w-10 h-10"> <div class="w-10 h-10">
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left"> <.menu id="apps-menu" position="bottom-right" md_position="bottom-left">
<:toggle> <:toggle>
@ -42,58 +52,158 @@ defmodule LivebookWeb.AppsLive do
</.menu> </.menu>
</div> </div>
<div> <div>
<.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"
>
<span class="font-semibold">Dashboard</span> <span class="font-semibold">Dashboard</span>
<.remix_icon icon="arrow-right-line" class="align-middle ml-1" /> <.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
</.link> </.link>
</div> </div>
</div> </div>
<div class="w-full max-w-screen-lg px-4 md:px-20 py-4 mx-auto"> <div class="flex-1 px-6 py-6">
<div class="flex flex-col items-center"> <div class="max-w-7xl mx-auto">
<h1 class="text-2xl text-gray-800 font-medium"> <div class="flex flex-col gap-y-4 w-full">
Apps <div class="flex flex-col gap-y-2">
</h1> <h1 class="text-3xl font-bold text-gray-900">Apps</h1>
<div :if={@apps != []} class="w-full mt-5 max-w-[400px]"> <p class="text-gray-600">Find your applications</p>
<div class="w-full flex flex-col space-y-4">
<.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"
>
<span class="font-semibold">{app.notebook_name}</span>
<.remix_icon :if={not app.public?} icon="lock-password-line" />
</.link>
</div> </div>
</div>
<div <%= if @apps != [] do %>
:if={@apps == [] and not @empty_apps_path?} <div class="flex flex-col gap-y-8">
class="mt-5 flex flex-col w-full max-w-[400px]" <div class="flex flex-col md:flex-row gap-4">
> <div class="flex-1">
<.no_entries :if={@apps == []}> <div class="relative">
No apps running. <.remix_icon
</.no_entries> icon="search-line"
</div> class="absolute left-3 bottom-[8px] text-gray-400"
<div :if={@apps == [] and @empty_apps_path?} class="mt-5 text-gray-600"> />
<div> <.text_field
No app notebooks found. Follow these steps to list your apps here: id="search-app"
</div> name="search_term"
<ol class="mt-4 pl-4 flex flex-col space-y-1 list-decimal list-inside"> placeholder="Search apps..."
<li> value={@search_term}
Open a notebook phx-keyup="search"
</li> phx-debounce="300"
<li> 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"
Click <.remix_icon icon="rocket-line" class="align-baseline text-lg" /> />
in the sidebar and configure the app as public </div>
</li> </div>
<li> <div :if={@show_app_folders?} class="md:w-48">
Save the notebook to the <form id="select-app-folder-form" phx-change="select_app_folder" phx-nosubmit>
<span class="font-medium">{Livebook.Config.apps_path()}</span> <.select_field
folder id="select-app-folder"
</li> name="app_folder"
<li> prompt="Select a folder..."
Relaunch your Livebook app value={@selected_app_folder}
</li> options={@app_folder_options}
</ol> />
</form>
</div>
</div>
<div
:if={@filtered_apps == []}
class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center"
>
<.remix_icon icon="windy-line" class="text-gray-400 text-2xl" />
<h3 class="text-lg font-medium text-gray-900">No apps found</h3>
<p class="text-gray-600">Try adjusting your search or filter criteria</p>
</div>
<div class="flex flex-col h-full gap-y-8 pr-2">
<div
:for={{app_folder, id, icon, apps} <- @grouped_apps}
:if={@filtered_apps != []}
id={id}
class="flex flex-col gap-y-4"
>
<h2 class="flex items-center gap-x-3 text-xl font-semibold text-gray-900">
<.remix_icon icon={icon} />
{app_folder}
<span class="text-sm font-normal text-gray-500">({length(apps)})</span>
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<.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"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors truncate">
{app.notebook_name}
</h3>
<div class="space-x-1 ml-2">
<.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"
/>
</div>
</div>
</.link>
</div>
</div>
</div>
</div>
<% else %>
<div class="flex flex-col bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<div :if={@empty_apps_path?} class="flex flex-col gap-y-4">
<div>
<.remix_icon icon="windy-line" class="size-16 text-gray-400 text-2xl" />
<h3 class="text-lg font-medium text-gray-900">No apps found</h3>
<p class="text-gray-600">Follow these steps to list your apps here:</p>
</div>
<div class="p-6 text-left max-w-md mx-auto">
<ol class="space-y-3 text-sm text-gray-700">
<li class="flex items-center">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
1
</span>
Open a notebook
</li>
<li class="flex items-center">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
2
</span>
<div class="flex gap-x-1 items-center">
Click
<.remix_icon icon="rocket-line" class="inline align-baseline text-base" />
in the sidebar and configure the app as public
</div>
</li>
<li class="flex items-center">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
3
</span>
<div class="flex gap-x-1 items-center">
Save the notebook to the
<span class="font-medium bg-gray-100 px-1 rounded text-xs">
{Livebook.Config.apps_path()}
</span>
folder
</div>
</li>
<li class="flex items-center">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
4
</span>
Relaunch your Livebook app
</li>
</ol>
</div>
</div>
<div :if={not @empty_apps_path?}>
<.remix_icon icon="windy-line" class="size-16 text-gray-400 text-2xl" />
<h3 class="text-lg font-medium text-gray-900">No apps running</h3>
<p class="text-gray-600">Start some apps to see them listed here</p>
</div>
</div>
<% end %>
</div> </div>
</div> </div>
</div> </div>
@ -101,15 +211,46 @@ defmodule LivebookWeb.AppsLive do
""" """
end 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 @impl true
def handle_info({type, _app} = event, socket) def handle_info({type, _app} = event, socket)
when type in [:app_created, :app_updated, :app_closed] do 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 end
def handle_info({:server_authorization_updated, _}, socket) do def handle_info({:server_authorization_updated, _}, socket) do
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user) {:noreply,
{:noreply, assign(socket, :apps, apps)} 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 end
def handle_info(_message, socket), do: {:noreply, socket} def handle_info(_message, socket), do: {:noreply, socket}
@ -117,4 +258,74 @@ defmodule LivebookWeb.AppsLive do
defp apps_listing(apps) do defp apps_listing(apps) do
Enum.sort_by(apps, & &1.notebook_name) Enum.sort_by(apps, & &1.notebook_name)
end 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 end

View file

@ -20,7 +20,10 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
{:ok, {:ok,
socket socket
|> assign(assigns) |> assign(assigns)
|> assign(app_folder_options: app_folder_options, changeset: changeset)} |> assign(
app_folder_options: app_folder_options,
changeset: changeset
)}
end end
@impl true @impl true
@ -48,6 +51,7 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce /> <.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce />
<.select_field <.select_field
:if={@hub_id != Livebook.Hubs.Personal.id()}
field={f[:app_folder_id]} field={f[:app_folder_id]}
label="Folder" label="Folder"
prompt="Select a folder..." prompt="Select a folder..."

View file

@ -602,11 +602,9 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
end) end)
end end
defp app_folder_name(_hub, id) when id in [nil, ""], do: "Ungrouped apps"
defp app_folder_name(hub, id) do defp app_folder_name(hub, id) do
hub hub
|> Teams.get_app_folders() |> Teams.get_app_folders()
|> Enum.find_value(&(&1.id == id && &1.name)) |> Enum.find_value("Ungrouped apps", &(&1.id == id && &1.name))
end end
end end

View file

@ -104,6 +104,7 @@ defmodule LivebookWeb.SessionLive.Render do
context={@action_assigns.context} context={@action_assigns.context}
deployed_app_slug={@data_view.deployed_app_slug} deployed_app_slug={@data_view.deployed_app_slug}
app_folders={@data_view.hub_app_folders} app_folders={@data_view.hub_app_folders}
hub_id={@data_view.hub.id}
/> />
</.modal> </.modal>

View file

@ -11,7 +11,7 @@ defmodule Livebook.Integration.LiveMarkdown.ImportTest do
@moduletag subscribe_to_teams_topics: [:clients, :app_folders] @moduletag subscribe_to_teams_topics: [:clients, :app_folders]
describe "app settings" do 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 %{node: node, team: team, org: org} do
app_folder = TeamsRPC.create_app_folder(node, name: "delete me", org: org) 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) TeamsRPC.delete_app_folder(node, app_folder)
assert_receive {:app_folder_deleted, %{id: ^app_folder_id, hub_id: ^hub_id}} 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}}, assert {%Notebook{
%{warnings: warnings}} = LiveMarkdown.Import.notebook_from_livemd(markdown) 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 assert "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" in warnings
end end

View file

@ -16,12 +16,13 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
%{conn: conn, node: node, code: code} = context do %{conn: conn, node: node, code: code} = context do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group) TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
group_name: "marketing", group_name: "marketing",
access_type: :apps, access_type: :apps,
prefixes: ["dev-"], app_folders: [app_folder],
oidc_provider: oidc_provider, oidc_provider: oidc_provider,
deployment_group: context.deployment_group deployment_group: context.deployment_group
) )
@ -76,6 +77,7 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group) TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
@ -99,10 +101,9 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
{:ok, view, _html} = live(conn, ~p"/settings") {:ok, view, _html} = live(conn, ~p"/settings")
assert render(view) =~ "System settings" assert render(view) =~ "System settings"
TeamsRPC.update_authorization_group(node, authorization_group, %{ TeamsRPC.update_authorization_group(node, authorization_group, %{access_type: :apps}, [
access_type: :apps, app_folder
prefixes: ["ops-"] ])
})
id = to_string(deployment_group.id) id = to_string(deployment_group.id)
assert_receive {:server_authorization_updated, %{id: ^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) TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
group_name: "marketing", group_name: "marketing",
access_type: :apps, access_type: :apps,
prefixes: ["ops-"], app_folders: [app_folder],
oidc_provider: oidc_provider, oidc_provider: oidc_provider,
deployment_group: deployment_group deployment_group: deployment_group
) )

View file

@ -8,7 +8,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
setup :teams setup :teams
@moduletag subscribe_to_hubs_topics: [:connection] @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 setup do
Livebook.Apps.subscribe() Livebook.Apps.subscribe()
@ -23,12 +29,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group) TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
group_name: "marketing", group_name: "marketing",
access_type: :apps, access_type: :apps,
prefixes: ["dev-"], app_folders: [app_folder],
oidc_provider: oidc_provider, oidc_provider: oidc_provider,
deployment_group: context.deployment_group deployment_group: context.deployment_group
) )
@ -46,7 +53,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
slug = "dev-oban-app" slug = "dev-oban-app"
context = change_to_user_session(context) 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) change_to_agent_session(context)
pid = wait_livebook_app_start(slug) pid = wait_livebook_app_start(slug)
@ -111,12 +127,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group) TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
group_name: "marketing", group_name: "marketing",
access_type: :apps, access_type: :apps,
prefixes: ["mkt-"], app_folders: [app_folder],
oidc_provider: oidc_provider, oidc_provider: oidc_provider,
deployment_group: deployment_group deployment_group: deployment_group
) )
@ -134,7 +151,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
slug = "mkt-analytics-#{Livebook.Utils.random_short_id()}" slug = "mkt-analytics-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context) 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) change_to_agent_session(context)
pid = wait_livebook_app_start(slug) pid = wait_livebook_app_start(slug)
@ -144,8 +170,11 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
{:ok, view, _html} = live(conn, path) {:ok, view, _html} = live(conn, path)
assert render(view) =~ "LivebookApp:#{slug}" assert render(view) =~ "LivebookApp:#{slug}"
{:ok, %{prefixes: ["ops-"]}} = app_folder2 = TeamsRPC.create_app_folder(node, org: context.org)
TeamsRPC.update_authorization_group(node, authorization_group, %{prefixes: ["ops-"]}) 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) id = to_string(deployment_group.id)
@ -164,12 +193,14 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group) TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) 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 = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
group_name: "marketing", group_name: "marketing",
access_type: :apps, access_type: :apps,
prefixes: ["mkt-"], app_folders: [app_folder],
oidc_provider: oidc_provider, oidc_provider: oidc_provider,
deployment_group: deployment_group deployment_group: deployment_group
) )
@ -187,7 +218,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
slug = "analytics-app-#{Livebook.Utils.random_short_id()}" slug = "analytics-app-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context) 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) change_to_agent_session(context)
pid = wait_livebook_app_start(slug) pid = wait_livebook_app_start(slug)
@ -207,5 +247,70 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
{:ok, view, _html} = live(conn, path) {:ok, view, _html} = live(conn, path)
assert render(view) =~ "LivebookApp:#{slug}" assert render(view) =~ "LivebookApp:#{slug}"
end 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
end end

View file

@ -13,9 +13,12 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
:agents, :agents,
:deployment_groups, :deployment_groups,
:app_deployments, :app_deployments,
:app_server :app_server,
:app_folders
] ]
@moduletag :tmp_dir
setup do setup do
Livebook.Apps.subscribe() Livebook.Apps.subscribe()
:ok :ok
@ -24,17 +27,17 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
describe "authorized apps" do describe "authorized apps" do
setup :livebook_teams_auth setup :livebook_teams_auth
@tag :tmp_dir
test "shows one app if user doesn't have full access", test "shows one app if user doesn't have full access",
%{conn: conn, code: code, node: node, tmp_dir: tmp_dir} = context do %{conn: conn, code: code, node: node, tmp_dir: tmp_dir} = context do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group) TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
group_name: "marketing", group_name: "marketing",
access_type: :apps, access_type: :apps,
prefixes: ["dev-"], app_folders: [app_folder],
oidc_provider: oidc_provider, oidc_provider: oidc_provider,
deployment_group: context.deployment_group deployment_group: context.deployment_group
) )
@ -52,7 +55,16 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
slug = "dev-app-#{Livebook.Utils.random_short_id()}" slug = "dev-app-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context) 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) change_to_agent_session(context)
wait_livebook_app_start(slug) wait_livebook_app_start(slug)
@ -66,7 +78,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
assert html =~ slug assert html =~ slug
end end
@tag :tmp_dir
test "shows all apps if user have full access", test "shows all apps if user have full access",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group) TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
@ -121,7 +132,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
end end
end end
@tag :tmp_dir
test "updates the apps list in real-time", test "updates the apps list in real-time",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
{:ok, %{groups_auth: true} = deployment_group} = {: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}} assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
group_name: "marketing", group_name: "marketing",
access_type: :apps, access_type: :apps,
prefixes: ["mkt-"], app_folders: [app_folder],
oidc_provider: oidc_provider, oidc_provider: oidc_provider,
deployment_group: deployment_group deployment_group: deployment_group
) )
@ -154,7 +165,15 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
slug = "marketing-report-#{Livebook.Utils.random_short_id()}" slug = "marketing-report-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context) 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) change_to_agent_session(context)
wait_livebook_app_start(slug) wait_livebook_app_start(slug)
@ -169,7 +188,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
assert render(view) =~ slug assert render(view) =~ slug
end end
@tag :tmp_dir
test "shows all apps if disable the authentication in real-time", test "shows all apps if disable the authentication in real-time",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do %{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
{:ok, %{groups_auth: true} = deployment_group} = {: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}} assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org) 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 = authorization_group =
TeamsRPC.create_authorization_group(node, TeamsRPC.create_authorization_group(node,
group_name: "marketing", group_name: "marketing",
access_type: :apps, access_type: :apps,
prefixes: ["mkt-"], app_folders: [app_folder],
oidc_provider: oidc_provider, oidc_provider: oidc_provider,
deployment_group: deployment_group deployment_group: deployment_group
) )
@ -202,7 +222,16 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
slug = "marketing-app-#{Livebook.Utils.random_short_id()}" slug = "marketing-app-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context) 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) change_to_agent_session(context)
wait_livebook_app_start(slug) wait_livebook_app_start(slug)
@ -216,5 +245,273 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
{:ok, view, _} = live(conn, ~p"/apps") {:ok, view, _} = live(conn, ~p"/apps")
assert render(view) =~ slug assert render(view) =~ slug
end 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
end end

View file

@ -2786,6 +2786,9 @@ defmodule LivebookWeb.SessionLiveTest do
|> element(~s/[data-el-app-info] a/, "Configure") |> element(~s/[data-el-app-info] a/, "Configure")
|> render_click() |> render_click()
# doesn't show the app folder select
refute has_element?(view, ~s/#app-settings-modal input[name="app_folder_id"]/)
view view
|> element(~s/#app-settings-modal form/) |> element(~s/#app-settings-modal form/)
|> render_change(%{"app_settings" => %{"slug" => slug}}) |> render_change(%{"app_settings" => %{"slug" => slug}})

View file

@ -26,14 +26,25 @@ defmodule Livebook.AppHelpers do
end end
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_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 = source =
stamp_notebook(app_path, """ stamp_notebook(app_path, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{team.id}","deployment_group_id":"#{deployment_group.id}"} --> <!-- livebook:{"app_settings":{"access_type":"public"#{app_folder},"slug":"#{slug}"},"hub_id":"#{team.id}","deployment_group_id":"#{deployment_group.id}"} -->
# LivebookApp:#{slug} # #{title}
```elixir ```elixir
IO.puts("Hi") IO.puts("Hi")

View file

@ -169,8 +169,12 @@ defmodule Livebook.TeamsRPC do
# Update resource # Update resource
def update_authorization_group(node, authorization_group, attrs) do def update_authorization_group(node, authorization_group, attrs, app_folders \\ []) do
:erpc.call(node, TeamsRPC, :update_authorization_group, [authorization_group, attrs]) :erpc.call(node, TeamsRPC, :update_authorization_group, [
authorization_group,
attrs,
app_folders
])
end end
def update_user_info_groups(node, code, groups) do def update_user_info_groups(node, code, groups) do