diff --git a/CHANGELOG.md b/CHANGELOG.md index 378505f141a..7b92883a8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,13 @@ and this project adheres to ### Added +- Pagination (10 items per page) for credentials, keychain credentials, and + OAuth clients on the credentials page (`/credentials`) and the project + settings credentials tab. Each table paginates independently via URL + parameters. OAuth clients are behind a collapsible section, collapsed by + default. Tables are ordered: credentials first, keychain credentials second + (project settings only), OAuth clients last. + [#4301](https://github.com/OpenFn/lightning/issues/4301) - Add support for sync v2 protocol [#4523](https://github.com/OpenFn/lightning/issues/4523) - Support collections in sandboxes. Collection names are now scoped per project, diff --git a/lib/lightning/credentials.ex b/lib/lightning/credentials.ex index ed237b5d75c..718da7f2af6 100644 --- a/lib/lightning/credentials.ex +++ b/lib/lightning/credentials.ex @@ -124,6 +124,32 @@ defmodule Lightning.Credentials do |> Repo.all() end + @spec list_credentials(Project.t(), map()) :: Scrivener.Page.t() + def list_credentials(%Project{} = project, page_params) do + query = + from c in Credential, + join: pc in assoc(c, :project_credentials), + on: pc.project_id == ^project.id, + preload: [ + :user, + :project_credentials, + :projects, + :credential_bodies, + :oauth_client + ], + order_by: [asc: fragment("lower(?)", c.name)], + group_by: c.id + + Repo.paginate(query, page_params) + end + + @spec list_credentials(User.t(), map()) :: Scrivener.Page.t() + def list_credentials(%User{id: user_id}, page_params) do + list_credentials_query(user_id) + |> order_by([c], asc: fragment("lower(?)", c.name)) + |> Repo.paginate(page_params) + end + defp list_credentials_query(user_id) do from(c in Credential, where: c.user_id == ^user_id, @@ -1778,6 +1804,18 @@ defmodule Lightning.Credentials do |> Repo.all() end + def list_keychain_credentials_for_project( + %Project{id: project_id}, + page_params + ) do + from(kc in KeychainCredential, + where: kc.project_id == ^project_id, + order_by: [asc: fragment("lower(?)", kc.name)], + preload: [:project, :created_by, :default_credential] + ) + |> Repo.paginate(page_params) + end + @doc """ Gets a single keychain credential. diff --git a/lib/lightning/oauth_clients.ex b/lib/lightning/oauth_clients.ex index b7a6b528c58..29cee7a99a9 100644 --- a/lib/lightning/oauth_clients.ex +++ b/lib/lightning/oauth_clients.ex @@ -88,6 +88,37 @@ defmodule Lightning.OauthClients do |> Repo.all() end + def list_clients(%Project{} = project, page_params) do + global_clients_subquery = + from(c in OauthClient, + where: c.global == true, + select: c.id + ) + + clients_query = + from(c in OauthClient, + left_join: poc in ProjectOauthClient, + on: poc.oauth_client_id == c.id, + where: + poc.project_id == ^project.id or + c.id in subquery(global_clients_subquery), + preload: [:user, :project_oauth_clients, :projects], + order_by: [asc: fragment("lower(?)", c.name)], + group_by: c.id + ) + + Repo.paginate(clients_query, page_params) + end + + def list_clients(%User{id: user_id}, page_params) do + from(c in OauthClient, + where: c.user_id == ^user_id or c.global, + preload: :projects, + order_by: [asc: fragment("lower(?)", c.name)] + ) + |> Repo.paginate(page_params) + end + @doc """ Retrieves a single OAuth client by its ID, raising an error if not found. diff --git a/lib/lightning/scrivener/query_paginater.ex b/lib/lightning/scrivener/query_paginater.ex index 7051f15492e..48a0d338cae 100644 --- a/lib/lightning/scrivener/query_paginater.ex +++ b/lib/lightning/scrivener/query_paginater.ex @@ -90,7 +90,7 @@ defimpl Scrivener.Paginater, for: Ecto.Query do defp aggregate( %{ group_bys: [ - %Ecto.Query.QueryExpr{ + %{ expr: [ {{:., [], [{:&, [], [source_index]}, field]}, [], []} | _ ] diff --git a/lib/lightning_web/live/components/credentials.ex b/lib/lightning_web/live/components/credentials.ex index ec1f0c2a226..02fb15078f1 100644 --- a/lib/lightning_web/live/components/credentials.ex +++ b/lib/lightning_web/live/components/credentials.ex @@ -19,6 +19,12 @@ defmodule LightningWeb.Components.Credentials do attr :can_create_project_credential, :any, required: true attr :show_owner_in_tables, :boolean, default: false attr :return_to, :string, required: true + attr :credentials_page, :map, default: nil + attr :keychain_credentials_page, :map, default: nil + attr :oauth_clients_page, :map, default: nil + attr :credentials_url, :any, default: nil + attr :keychain_url, :any, default: nil + attr :oauth_clients_url, :any, default: nil def credentials_index_live_component(assigns) do ~H""" diff --git a/lib/lightning_web/live/components/data_tables.ex b/lib/lightning_web/live/components/data_tables.ex index 777b2b6be8e..9bd5465dadc 100644 --- a/lib/lightning_web/live/components/data_tables.ex +++ b/lib/lightning_web/live/components/data_tables.ex @@ -13,6 +13,8 @@ defmodule LightningWeb.Components.DataTables do attr :title, :string, required: true attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false + attr :page, :map, default: nil + attr :url, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -29,7 +31,7 @@ defmodule LightningWeb.Components.DataTables do <%= if Enum.empty?(@credentials) do %> {render_slot(@empty_state)} <% else %> - <.table id={"#{@id}-table"}> + <.table id={"#{@id}-table"} page={@page} url={@url}> <:header> <.tr> <.th>Name @@ -124,6 +126,8 @@ defmodule LightningWeb.Components.DataTables do attr :title, :string, required: true attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false + attr :page, :map, default: nil + attr :url, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -140,7 +144,7 @@ defmodule LightningWeb.Components.DataTables do <%= if Enum.empty?(@keychain_credentials) do %> {render_slot(@empty_state)} <% else %> - <.table id={"#{@id}-table"}> + <.table id={"#{@id}-table"} page={@page} url={@url}> <:header> <.tr> <.th>Name @@ -197,7 +201,10 @@ defmodule LightningWeb.Components.DataTables do attr :id, :string, required: true attr :clients, :list, required: true attr :title, :string, required: true + attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false + attr :page, :map, default: nil + attr :url, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -208,13 +215,13 @@ defmodule LightningWeb.Components.DataTables do def oauth_clients_table(assigns) do ~H"""
-
+
{@title}
<%= if Enum.empty?(@clients) do %> {render_slot(@empty_state)} <% else %> - <.table id={"#{@id}-table"}> + <.table id={"#{@id}-table"} page={@page} url={@url}> <:header> <.tr> <.th>Name diff --git a/lib/lightning_web/live/credential_live/credential_index_component.ex b/lib/lightning_web/live/credential_live/credential_index_component.ex index 2005360b7db..872452ad1a3 100644 --- a/lib/lightning_web/live/credential_live/credential_index_component.ex +++ b/lib/lightning_web/live/credential_live/credential_index_component.ex @@ -7,6 +7,14 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do alias Lightning.OauthClients alias Lightning.Policies + @empty_page %{ + entries: [], + page_size: 0, + total_entries: 0, + page_number: 1, + total_pages: 0 + } + @impl true def mount(socket) do {:ok, @@ -15,11 +23,15 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do can_create_keychain_credential: false, can_create_project_credential: false, credential: nil, - credentials: [], + credentials_page: @empty_page, + credentials_url: nil, current_user: nil, - keychain_credentials: nil, + keychain_credentials_page: @empty_page, + keychain_url: nil, oauth_client: nil, - oauth_clients: [], + oauth_clients_page: @empty_page, + oauth_clients_expanded: false, + oauth_clients_url: nil, project: nil, project_user: nil, projects: [], @@ -45,8 +57,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do current_user, %{project: project, project_user: project_user} ) - }) - |> load_credentials()} + })} end @impl true @@ -54,38 +65,18 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{current_user: _, projects: _, return_to: _} = assigns, socket ) do - {:ok, socket |> assign(assigns) |> load_credentials()} - end - - defp load_credentials(socket) do - %{current_user: current_user, project: project} = socket.assigns - - socket - |> assign(%{ - credentials: list_credentials(project || current_user), - oauth_clients: list_clients(project || current_user) - }) - |> then(fn socket -> - if socket.assigns.project do - socket - |> assign( - :keychain_credentials, - Lightning.Credentials.list_keychain_credentials_for_project( - socket.assigns.project - ) - ) - else - socket - end - end) + {:ok, socket |> assign(assigns)} end @impl true def handle_event("close_active_modal", _params, socket) do {:noreply, socket - |> assign(active_modal: nil, credential: nil, oauth_client: nil) - |> load_credentials()} + |> assign(active_modal: nil, credential: nil, oauth_client: nil)} + end + + def handle_event("toggle_oauth_clients", _params, socket) do + {:noreply, update(socket, :oauth_clients_expanded, &(!&1))} end def handle_event("show_modal", %{"target" => "new_credential"}, socket) do @@ -164,7 +155,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do end def handle_event("edit_oauth_client", %{"id" => client_id}, socket) do - %{oauth_clients: oauth_clients} = socket.assigns + oauth_clients = socket.assigns.oauth_clients_page.entries client = Enum.find(oauth_clients, fn client -> client.id == client_id end) if can_edit_credential(socket.assigns.current_user, client) do @@ -180,7 +171,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do end def handle_event("request_oauth_client_deletion", %{"id" => client_id}, socket) do - %{oauth_clients: oauth_clients} = socket.assigns + oauth_clients = socket.assigns.oauth_clients_page.entries client = Enum.find(oauth_clients, fn client -> client.id == client_id end) if can_edit_credential(socket.assigns.current_user, client) do @@ -216,7 +207,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do end def handle_event("edit_credential", %{"id" => credential_id}, socket) do - %{credentials: credentials} = socket.assigns + credentials = socket.assigns.credentials_page.entries credential = Enum.find(credentials, fn cred -> cred.id == credential_id end) if can_edit_credential(socket.assigns.current_user, credential) do @@ -236,7 +227,8 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => credential_id}, socket ) do - %{current_user: current_user, credentials: credentials} = socket.assigns + %{current_user: current_user} = socket.assigns + credentials = socket.assigns.credentials_page.entries credential = Enum.find(credentials, &(&1.id == credential_id)) if credential && can_delete_credential(current_user, credential) do @@ -252,7 +244,8 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => credential_id}, socket ) do - %{current_user: current_user, credentials: credentials} = socket.assigns + %{current_user: current_user} = socket.assigns + credentials = socket.assigns.credentials_page.entries credential = Enum.find(credentials, &(&1.id == credential_id)) if credential && can_edit_credential(current_user, credential) do @@ -268,8 +261,9 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => keychain_credential_id}, socket ) do - %{current_user: current_user, keychain_credentials: keychain_credentials} = - socket.assigns + %{current_user: current_user} = socket.assigns + + keychain_credentials = socket.assigns.keychain_credentials_page.entries credential = Enum.find(keychain_credentials, &(&1.id == keychain_credential_id)) @@ -291,7 +285,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => keychain_credential_id}, socket ) do - %{keychain_credentials: keychain_credentials} = socket.assigns + keychain_credentials = socket.assigns.keychain_credentials_page.entries credential = Enum.find(keychain_credentials, &(&1.id == keychain_credential_id)) @@ -324,14 +318,12 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do if credential && can_delete_credential(current_user, credential) do Lightning.Credentials.delete_keychain_credential(credential) |> case do - {:ok, %{id: id}} -> + {:ok, _deleted} -> {:noreply, socket - |> update(:keychain_credentials, fn credentials -> - credentials |> Enum.reject(&(&1.id == id)) - end) |> push_event("close_modal", %{id: modal_id}) - |> put_flash(:info, "Keychain credential deleted")} + |> put_flash(:info, "Keychain credential deleted") + |> push_patch(to: socket.assigns.return_to)} {:error, _} -> {:noreply, @@ -403,39 +395,6 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do |> push_patch(to: socket.assigns.return_to)} end - defp list_credentials(user_or_project) do - user_or_project - |> Credentials.list_credentials() - |> Enum.map(fn credential -> - project_names = - Map.get(credential, :projects, []) |> Enum.map(fn p -> p.name end) - - environment_names = - credential - |> Map.get(:credential_bodies, []) - |> Enum.map(& &1.name) - - credential - |> Map.put(:project_names, project_names) - |> Map.put(:environment_names, environment_names) - end) - end - - defp list_clients(user_or_project) do - user_or_project - |> OauthClients.list_clients() - |> Enum.map(fn c -> - project_names = - if c.global, - do: ["GLOBAL"], - else: - Map.get(c, :projects, []) - |> Enum.map(fn p -> p.name end) - - Map.put(c, :project_names, project_names) - end) - end - defp delete_action(assigns) do ~H""" <%= if @credential.scheduled_deletion do %> diff --git a/lib/lightning_web/live/credential_live/credential_index_component.html.heex b/lib/lightning_web/live/credential_live/credential_index_component.html.heex index 74815de39b0..e2620d66021 100644 --- a/lib/lightning_web/live/credential_live/credential_index_component.html.heex +++ b/lib/lightning_web/live/credential_live/credential_index_component.html.heex @@ -1,60 +1,12 @@
- - <:empty_state> - <.empty_state - icon="hero-plus-circle" - message="No OAuth clients found." - button_text="Create a new OAuth client" - button_id="open-create-oauth-client-modal-big-buttton" - phx-target={@myself} - phx-click="show_modal" - phx-value-target="new_oauth_client" - button_disabled={false} - /> - - <:actions :let={client}> -
- - <:button> - Actions - - - <:options> - <.link - id={"oauth-client-actions-#{client.id}-edit"} - phx-click="edit_oauth_client" - phx-value-id={client.id} - phx-target={@myself} - > - Edit - - <.link - id={"oauth-client-actions-#{client.id}-delete"} - phx-click="request_oauth_client_deletion" - phx-value-id={client.id} - phx-target={@myself} - > - Delete - - - -
- -
<:empty_state> <.empty_state @@ -111,12 +63,15 @@
- <%= if @keychain_credentials do %> + + <%= if @project do %> <:empty_state> <.empty_state @@ -163,6 +118,83 @@ <% end %> + +
+ + + <%= if @oauth_clients_expanded do %> +
+ + <:empty_state> + <.empty_state + icon="hero-plus-circle" + message="No OAuth clients found." + button_text="Create a new OAuth client" + button_id="open-create-oauth-client-modal-big-buttton" + phx-target={@myself} + phx-click="show_modal" + phx-value-target="new_oauth_client" + button_disabled={false} + /> + + <:actions :let={client}> +
+ + <:button> + Actions + + + <:options> + <.link + id={"oauth-client-actions-#{client.id}-edit"} + phx-click="edit_oauth_client" + phx-value-id={client.id} + phx-target={@myself} + > + Edit + + <.link + id={"oauth-client-actions-#{client.id}-delete"} + phx-click="request_oauth_client_deletion" + phx-value-id={client.id} + phx-target={@myself} + > + Delete + + + +
+ +
+
+ <% end %> +
<.live_component :if={@active_modal == :new_oauth_client} @@ -202,7 +234,7 @@ credential_type={nil} credential={@credential} oauth_client={nil} - oauth_clients={@oauth_clients} + oauth_clients={@oauth_clients_page.entries} project={@project} projects={@projects} current_user={@current_user} @@ -218,7 +250,7 @@ action={:edit} keychain_credential={@credential} project={@project} - credentials={@credentials} + credentials={@credentials_page.entries} current_user={@current_user} project_user={@project_user} return_to={@return_to} @@ -231,6 +263,7 @@ credential_type={nil} credential={@credential} oauth_client={@oauth_client} + oauth_clients={@oauth_clients_page.entries} project={@project} projects={@projects} current_user={@current_user} @@ -261,7 +294,7 @@ action={:new} keychain_credential={@credential} project={@project} - credentials={@credentials} + credentials={@credentials_page.entries} current_user={@current_user} project_user={@project_user} return_to={@return_to} diff --git a/lib/lightning_web/live/credential_live/index.ex b/lib/lightning_web/live/credential_live/index.ex index eab6e6b647f..e6cc8784101 100644 --- a/lib/lightning_web/live/credential_live/index.ex +++ b/lib/lightning_web/live/credential_live/index.ex @@ -4,6 +4,9 @@ defmodule LightningWeb.CredentialLive.Index do """ use LightningWeb, :live_view + alias Lightning.Credentials + alias Lightning.OauthClients + on_mount {LightningWeb.Hooks, :assign_projects} @impl true @@ -21,9 +24,39 @@ defmodule LightningWeb.CredentialLive.Index do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end - defp apply_action(socket, :index, _params) do + defp apply_action(socket, :index, params) do + current_user = socket.assigns.current_user + creds_params = %{"page" => params["credentials_page"] || "1"} + oauth_params = %{"page" => params["oauth_clients_page"] || "1"} + + credentials_page = + Credentials.list_credentials(current_user, creds_params) + |> map_credentials() + + oauth_clients_page = + OauthClients.list_clients(current_user, oauth_params) + |> map_oauth_clients() + socket - |> assign(credential: nil) + |> assign(:credential, nil) + |> assign(:credentials_page, credentials_page) + |> assign(:oauth_clients_page, oauth_clients_page) + |> assign( + :credentials_url, + fn opts -> + Routes.credential_index_path(socket, :index, + credentials_page: opts[:page] + ) + end + ) + |> assign( + :oauth_clients_url, + fn opts -> + Routes.credential_index_path(socket, :index, + oauth_clients_page: opts[:page] + ) + end + ) end @doc """ @@ -34,4 +67,32 @@ defmodule LightningWeb.CredentialLive.Index do send_update(mod, opts) {:noreply, socket} end + + defp map_credentials(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &add_credential_display_fields/1)} + end + + defp add_credential_display_fields(credential) do + project_names = Map.get(credential, :projects, []) |> Enum.map(& &1.name) + + environment_names = + credential |> Map.get(:credential_bodies, []) |> Enum.map(& &1.name) + + credential + |> Map.put(:project_names, project_names) + |> Map.put(:environment_names, environment_names) + end + + defp map_oauth_clients(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &add_oauth_client_display_fields/1)} + end + + defp add_oauth_client_display_fields(client) do + project_names = + if client.global, + do: ["GLOBAL"], + else: Map.get(client, :projects, []) |> Enum.map(& &1.name) + + Map.put(client, :project_names, project_names) + end end diff --git a/lib/lightning_web/live/credential_live/index.html.heex b/lib/lightning_web/live/credential_live/index.html.heex index acd3e10e8d1..3fc10ad45d7 100644 --- a/lib/lightning_web/live/credential_live/index.html.heex +++ b/lib/lightning_web/live/credential_live/index.html.heex @@ -33,6 +33,10 @@ can_create_project_credential={true} show_owner_in_tables={false} return_to={~p"/credentials"} + credentials_page={@credentials_page} + oauth_clients_page={@oauth_clients_page} + credentials_url={@credentials_url} + oauth_clients_url={@oauth_clients_url} /> diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index 33c51e3fa8e..17beb79d327 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -10,6 +10,7 @@ defmodule LightningWeb.ProjectLive.Settings do alias Lightning.Accounts.User alias Lightning.Credentials alias Lightning.Helpers + alias Lightning.OauthClients alias Lightning.Policies.Permissions alias Lightning.Projects alias Lightning.Projects.Project @@ -128,9 +129,33 @@ defmodule LightningWeb.ProjectLive.Settings do active_menu_item: :settings, can_receive_failure_alerts: can_receive_failure_alerts, collaborators_to_invite: [], + credentials_page: %{ + entries: [], + page_size: 0, + total_entries: 0, + page_number: 1, + total_pages: 0 + }, + credentials_url: nil, current_user: socket.assigns.current_user, github_enabled: VersionControl.github_enabled?(), + keychain_credentials_page: %{ + entries: [], + page_size: 0, + total_entries: 0, + page_number: 1, + total_pages: 0 + }, + keychain_url: nil, name: project.name, + oauth_clients_page: %{ + entries: [], + page_size: 0, + total_entries: 0, + page_number: 1, + total_pages: 0 + }, + oauth_clients_url: nil, parent_project: parent_project, root_project: root_project, project: project, @@ -161,9 +186,10 @@ defmodule LightningWeb.ProjectLive.Settings do |> apply_action(live_action, params)} end - defp apply_action(socket, :index, _params) do - project_users = Projects.get_project_users!(socket.assigns.project.id) - auth_methods = WebhookAuthMethods.list_for_project(socket.assigns.project) + defp apply_action(socket, :index, params) do + project = socket.assigns.project + project_users = Projects.get_project_users!(project.id) + auth_methods = WebhookAuthMethods.list_for_project(project) concurrency_input_component = socket.router @@ -174,6 +200,21 @@ defmodule LightningWeb.ProjectLive.Settings do ) |> Map.get(:concurrency_input) + creds_params = %{"page" => params["credentials_page"] || "1"} + keychain_params = %{"page" => params["keychain_page"] || "1"} + oauth_params = %{"page" => params["oauth_clients_page"] || "1"} + + credentials_page = + Credentials.list_credentials(project, creds_params) + |> map_credentials() + + keychain_credentials_page = + Credentials.list_keychain_credentials_for_project(project, keychain_params) + + oauth_clients_page = + OauthClients.list_clients(project, oauth_params) + |> map_oauth_clients() + socket |> assign( page_title: "Project settings", @@ -185,6 +226,33 @@ defmodule LightningWeb.ProjectLive.Settings do active_modal: nil, active_modal_assigns: nil ) + |> assign(:credentials_page, credentials_page) + |> assign(:keychain_credentials_page, keychain_credentials_page) + |> assign(:oauth_clients_page, oauth_clients_page) + |> assign( + :credentials_url, + fn opts -> + Routes.project_settings_path(socket, :index, project.id, + credentials_page: opts[:page] + ) + end + ) + |> assign( + :keychain_url, + fn opts -> + Routes.project_settings_path(socket, :index, project.id, + keychain_page: opts[:page] + ) + end + ) + |> assign( + :oauth_clients_url, + fn opts -> + Routes.project_settings_path(socket, :index, project.id, + oauth_clients_page: opts[:page] + ) + end + ) end defp apply_action(socket, :delete, %{"project_id" => id}) do @@ -797,6 +865,34 @@ defmodule LightningWeb.ProjectLive.Settings do end end + defp map_credentials(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &add_credential_display_fields/1)} + end + + defp add_credential_display_fields(credential) do + project_names = Map.get(credential, :projects, []) |> Enum.map(& &1.name) + + environment_names = + credential |> Map.get(:credential_bodies, []) |> Enum.map(& &1.name) + + credential + |> Map.put(:project_names, project_names) + |> Map.put(:environment_names, environment_names) + end + + defp map_oauth_clients(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &add_oauth_client_display_fields/1)} + end + + defp add_oauth_client_display_fields(client) do + project_names = + if client.global, + do: ["GLOBAL"], + else: Map.get(client, :projects, []) |> Enum.map(& &1.name) + + Map.put(client, :project_names, project_names) + end + attr :can_edit_project, :boolean, required: true attr :project, :any, required: true diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex index 296e9e9a698..872c56737ab 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -353,6 +353,12 @@ can_create_project_credential={@can_create_project_credential} show_owner_in_tables={true} return_to={~p"/projects/#{@project.id}/settings#credentials"} + credentials_page={@credentials_page} + keychain_credentials_page={@keychain_credentials_page} + oauth_clients_page={@oauth_clients_page} + credentials_url={@credentials_url} + keychain_url={@keychain_url} + oauth_clients_url={@oauth_clients_url} /> <:panel hash="collections" class="space-y-4"> diff --git a/test/lightning/credentials_test.exs b/test/lightning/credentials_test.exs index e58ad8a5127..b49ecc76993 100644 --- a/test/lightning/credentials_test.exs +++ b/test/lightning/credentials_test.exs @@ -71,6 +71,86 @@ defmodule Lightning.CredentialsTest do ] end + test "list_credentials/2 returns a paginated page for a user" do + user = insert(:user) + for i <- 1..12, do: insert(:credential, user: user, name: "cred-#{i}") + + page = + Credentials.list_credentials(user, %{"page" => "1", "page_size" => "10"}) + + assert %Scrivener.Page{} = page + assert page.total_entries == 12 + assert page.page_size == 10 + assert length(page.entries) == 10 + + page2 = + Credentials.list_credentials(user, %{"page" => "2", "page_size" => "10"}) + + assert length(page2.entries) == 2 + assert page2.page_number == 2 + end + + test "list_credentials/2 returns a paginated page for a project" do + user = insert(:user) + project = insert(:project, project_users: [%{user: user}]) + other_project = insert(:project) + + for i <- 1..12, + do: + insert(:credential, + user: user, + name: "cred-#{i}", + project_credentials: [%{project: project}] + ) + + insert(:credential, + user: user, + name: "other-cred", + project_credentials: [%{project: other_project}] + ) + + page = + Credentials.list_credentials(project, %{ + "page" => "1", + "page_size" => "10" + }) + + assert %Scrivener.Page{} = page + assert page.total_entries == 12 + assert length(page.entries) == 10 + assert Enum.all?(page.entries, fn c -> c.id != "other-cred" end) + end + + test "list_keychain_credentials_for_project/2 returns a paginated page" do + user = insert(:user) + project = insert(:project, project_users: [%{user: user}]) + other_project = insert(:project) + + for _i <- 1..12, + do: insert(:keychain_credential, project: project, created_by: user) + + insert(:keychain_credential, project: other_project, created_by: user) + + page = + Credentials.list_keychain_credentials_for_project(project, %{ + "page" => "1", + "page_size" => "10" + }) + + assert %Scrivener.Page{} = page + assert page.total_entries == 12 + assert length(page.entries) == 10 + assert Enum.all?(page.entries, fn kc -> kc.project_id == project.id end) + + page2 = + Credentials.list_keychain_credentials_for_project(project, %{ + "page" => "2", + "page_size" => "10" + }) + + assert length(page2.entries) == 2 + end + test "get_credential!/1 returns the credential with given id" do user = insert(:user) credential = insert(:credential, user_id: user.id) diff --git a/test/lightning/oauth_clients_test.exs b/test/lightning/oauth_clients_test.exs index 075ab54f8c2..f486655cec7 100644 --- a/test/lightning/oauth_clients_test.exs +++ b/test/lightning/oauth_clients_test.exs @@ -110,6 +110,57 @@ defmodule Lightning.OauthClientsTest do end end + describe "list_clients/2" do + test "returns a paginated page of oauth clients for a user" do + user = insert(:user) + for _i <- 1..12, do: insert(:oauth_client, user: user) + + page = + OauthClients.list_clients(user, %{"page" => "1", "page_size" => "10"}) + + assert %Scrivener.Page{} = page + assert page.page_size == 10 + assert page.total_entries >= 12 + assert length(page.entries) == 10 + + page2 = + OauthClients.list_clients(user, %{"page" => "2", "page_size" => "10"}) + + assert page2.page_number == 2 + assert length(page2.entries) > 0 + end + + test "returns a paginated page of oauth clients for a project, including globals" do + user = insert(:user) + project = insert(:project) + other_project = insert(:project) + + project_clients = + for _i <- 1..3, + do: + insert(:oauth_client, + user: user, + project_oauth_clients: [%{project: project}] + ) + + new_global = insert(:oauth_client, global: true, user: user) + + other_client = + insert(:oauth_client, + user: user, + project_oauth_clients: [%{project: other_project}] + ) + + page = + OauthClients.list_clients(project, %{"page" => "1", "page_size" => "100"}) + + assert %Scrivener.Page{} = page + assert client_id_in_list?(new_global, page.entries) + assert Enum.all?(project_clients, &client_id_in_list?(&1, page.entries)) + refute client_id_in_list?(other_client, page.entries) + end + end + describe "create_client/1 with project association" do test "successfully creates a client and associates with a project" do user = insert(:user) diff --git a/test/lightning_web/live/credential_live_test.exs b/test/lightning_web/live/credential_live_test.exs index b1151cebcd5..3d1c2593df4 100644 --- a/test/lightning_web/live/credential_live_test.exs +++ b/test/lightning_web/live/credential_live_test.exs @@ -508,6 +508,176 @@ defmodule LightningWeb.CredentialLiveTest do end end + describe "CredentialIndexComponent pagination and collapsible" do + test "credentials table shows pagination bar and supports page navigation when there are more than 10 credentials", + %{conn: conn, user: user} do + for _i <- 1..12, do: insert(:credential, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + table_html = index_live |> element("#credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + + render_patch(index_live, ~p"/credentials?credentials_page=2") + + table_html = index_live |> element("#credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + end + + test "credentials table does not show page navigation links when there are 10 or fewer credentials", + %{conn: conn, user: user} do + for _i <- 1..5, do: insert(:credential, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + table_html = index_live |> element("#credentials-table") |> render() + + refute table_html =~ "sr-only\">Previous" + refute table_html =~ "sr-only\">Next" + end + + test "OAuth clients section is collapsed by default and toggle button is visible", + %{conn: conn, user: user} do + oauth_client = insert(:oauth_client, user: user) + + {:ok, index_live, html} = live(conn, ~p"/credentials", on_error: :raise) + + assert has_element?(index_live, "#oauth-clients-section") + assert has_element?(index_live, "button[phx-click='toggle_oauth_clients']") + refute html =~ oauth_client.name + end + + test "toggling OAuth clients section shows and then hides the clients table", + %{conn: conn, user: user} do + oauth_client = insert(:oauth_client, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + html = + index_live + |> with_target("#credentials-index-component") + |> render_click("toggle_oauth_clients", %{}) + + assert html =~ oauth_client.name + + html = + index_live + |> with_target("#credentials-index-component") + |> render_click("toggle_oauth_clients", %{}) + + refute html =~ oauth_client.name + end + + test "OAuth clients table shows pagination bar after section is expanded and supports page navigation", + %{conn: conn, user: user} do + for _i <- 1..12, do: insert(:oauth_client, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + index_live + |> with_target("#credentials-index-component") + |> render_click("toggle_oauth_clients", %{}) + + table_html = index_live |> element("#oauth-clients-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + + render_patch(index_live, ~p"/credentials?oauth_clients_page=2") + + table_html = index_live |> element("#oauth-clients-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + end + + test "credentials pagination works on the project settings page", + %{conn: conn, user: user, project: project} do + for _i <- 1..12, + do: + insert(:credential, + user: user, + project_credentials: [%{project: project}] + ) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project}/settings#credentials", + on_error: :raise + ) + + table_html = view |> element("#credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + + render_patch(view, ~p"/projects/#{project.id}/settings?credentials_page=2") + + table_html = view |> element("#credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + end + + test "keychain credentials table shows pagination on project settings when there are more than 10", + %{conn: conn, user: user, project: project} do + for _i <- 1..12, + do: insert(:keychain_credential, project: project, created_by: user) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project}/settings#credentials", + on_error: :raise + ) + + table_html = view |> element("#keychain-credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + + render_patch(view, ~p"/projects/#{project.id}/settings?keychain_page=2") + + table_html = view |> element("#keychain-credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + end + + test "keychain credentials section is not shown on the user credentials page", + %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + refute has_element?(index_live, "#keychain-credentials-table") + end + + test "keychain credentials section is shown on the project settings page", + %{conn: conn, user: user, project: project} do + insert(:keychain_credential, project: project, created_by: user) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project}/settings#credentials", + on_error: :raise + ) + + assert has_element?(view, "#keychain-credentials-table") + end + + test "navigating to a page number beyond total pages falls back gracefully", + %{conn: conn, user: user} do + for _i <- 1..5, do: insert(:credential, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + render_patch(index_live, ~p"/credentials?credentials_page=999") + + table_html = index_live |> element("#credentials-table") |> render() + + assert table_html =~ "Showing" + end + end + describe "Clicking new from the list view" do test "allows the user to define and save a new raw credential", %{ conn: conn, diff --git a/test/lightning_web/live/oauth_clients_live_test.exs b/test/lightning_web/live/oauth_clients_live_test.exs index 8a1f7dcede6..4e734a77d7c 100644 --- a/test/lightning_web/live/oauth_clients_live_test.exs +++ b/test/lightning_web/live/oauth_clients_live_test.exs @@ -253,33 +253,44 @@ defmodule LightningWeb.OauthClientsLiveTest do {view, added_mandatory_scopes, added_optional_scopes} = perforom_scopes_management_tests(view) - {:ok, _view, html} = + {:ok, redirected_view, html} = view |> form("#oauth-client-form-new", oauth_client: valid_attrs) |> render_submit() |> follow_redirect(conn, url) - assert html =~ valid_attrs.name assert html =~ "Oauth client created successfully" + expanded_html = + redirected_view + |> with_target("#credentials-index-component") + |> render_click("toggle_oauth_clients", %{}) + + assert expanded_html =~ valid_attrs.name + saved_clients_names = Lightning.Repo.all(OauthClient) |> Enum.map(fn client -> client.name end) assert valid_attrs.name in saved_clients_names - assert Lightning.Repo.all(OauthClient) - |> Enum.map(fn client -> - MapSet.subset?( - MapSet.new(String.split(client.mandatory_scopes, ",")), - MapSet.new(added_mandatory_scopes) - ) and - MapSet.subset?( - MapSet.new(String.split(client.optional_scopes, ",")), - MapSet.new(added_optional_scopes) - ) - end) - |> Enum.all?() + new_client = + Lightning.Repo.all( + from c in OauthClient, where: c.name == ^valid_attrs.name + ) + |> List.first() + + assert new_client + + assert MapSet.subset?( + MapSet.new(String.split(new_client.mandatory_scopes, ",")), + MapSet.new(added_mandatory_scopes) + ) + + assert MapSet.subset?( + MapSet.new(String.split(new_client.optional_scopes, ",")), + MapSet.new(added_optional_scopes) + ) end) end end