From 00cfc814ffb9d70b7aaab34dd3e1def5e096a42f Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Sat, 9 May 2026 17:25:03 +0000 Subject: [PATCH 1/2] Add usage_caps_input slot to project settings page Mirrors the existing `concurrency_input` slot pattern: read the component module from the settings route's metadata, assign it to the LiveView, render via `<.live_component :if={...}>` when set. Empty by default in OSS Lightning; downstream apps populate it by attaching `metadata: %{usage_caps_input: SomeComponent}` to the settings route. Closes #4725. --- CHANGELOG.md | 8 ++++++++ lib/lightning_web/live/project_live/settings.ex | 7 +++++-- lib/lightning_web/live/project_live/settings.html.heex | 8 ++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6864f5be5..99663cbd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,19 @@ and this project adheres to ### Added +- New `usage_caps_input` view-extension slot on the project settings page + (`/projects/:project_id/settings`). Same pattern as the existing + `concurrency_input` slot: downstream apps register a component via + `metadata: %{usage_caps_input: SomeComponent}` on the settings route and + Lightning renders it in the settings view. No-op for OSS Lightning by default. + [#4725](https://github.com/OpenFn/lightning/issues/4725) + ### Changed ### Fixed ## [2.16.3] - 2026-05-07 + ## [2.16.3-pre3] - 2026-05-07 ### Fixed diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index 33c51e3fa8..4473e73a77 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -165,14 +165,16 @@ defmodule LightningWeb.ProjectLive.Settings do project_users = Projects.get_project_users!(socket.assigns.project.id) auth_methods = WebhookAuthMethods.list_for_project(socket.assigns.project) - concurrency_input_component = + route_metadata = socket.router |> Phoenix.Router.route_info( "GET", ~p"/projects/:project_id/settings", nil ) - |> Map.get(:concurrency_input) + + concurrency_input_component = Map.get(route_metadata, :concurrency_input) + usage_caps_input_component = Map.get(route_metadata, :usage_caps_input) socket |> assign( @@ -180,6 +182,7 @@ defmodule LightningWeb.ProjectLive.Settings do project_users: project_users, webhook_auth_methods: auth_methods, concurrency_input_component: concurrency_input_component, + usage_caps_input_component: usage_caps_input_component, show_collaborators_modal: false, show_invite_collaborators_modal: false, active_modal: nil, diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex index 296e9e9a69..bdd4ba968a 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -240,6 +240,14 @@ + <.live_component + :if={assigns[:usage_caps_input_component]} + module={assigns[:usage_caps_input_component]} + id="usage-caps-input-component" + project={@project} + current_user={@current_user} + /> +
Export your Project
From 1392e2e782a9de2a75e9e590285f8ef4d3da98c1 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Tue, 12 May 2026 05:58:38 +0000 Subject: [PATCH 2/2] Derive sandbox project_users from parent; drop the :collaborators param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Sandboxes.provision/3` accepted a `:collaborators` parameter for the sandbox's project_users list. The in-app form component was the only real caller, and it always passed the same thing: the parent's users (minus the actor, with the owner demoted to admin). The parameter let callers add users not on the parent, which bypassed the seat-limit check — every in-app seat-add path goes through `ProjectLimiter.request_new_user`, but `provision/3` did not. Drop the parameter. `provision/3` now derives the sandbox's project_users from the parent project itself: every parent user is copied with their role preserved, the parent owner is demoted to admin, and the actor is added as owner (overriding any role they had on the parent). To add a user to the sandbox who is not on the parent, call `Projects.add_project_users/3` after `provision/3` returns — that path runs the seat check. The form component's `parent_users` fetch and collaborators map are now dead; the call is just `Projects.provision_sandbox(parent, actor, attrs)`. Tests: 235 in the sandbox surface, 0 failures. --- lib/lightning/projects/sandboxes.ex | 45 +++++------ .../live/sandbox_live/form_component.ex | 11 --- test/lightning/sandboxes_test.exs | 80 +++++++++++++------ 3 files changed, 77 insertions(+), 59 deletions(-) diff --git a/lib/lightning/projects/sandboxes.ex b/lib/lightning/projects/sandboxes.ex index e5b632e96f..bf2ee7fe52 100644 --- a/lib/lightning/projects/sandboxes.ex +++ b/lib/lightning/projects/sandboxes.ex @@ -70,18 +70,21 @@ defmodule Lightning.Projects.Sandboxes do ## Optional * `:color` - UI color hex string (e.g. `"#336699"`) * `:env` - Environment identifier (e.g. `"staging"`, `"dev"`) - * `:collaborators` - List of `%{user_id: UUID, role: :admin | :editor | :viewer}` - Note: `:owner` roles and duplicate users are automatically filtered out * `:dataclip_ids` - UUIDs of dataclips to copy (only copies named dataclips of types `:global`, `:saved_input`, or `:http_request`) + + The sandbox's `project_users` are derived from the parent project: every + parent user is copied across with their role preserved, except the parent + owner who is demoted to `:admin`. The `actor` is then set as the sandbox + owner (replacing any other role they may have had on the parent). To add + a user to the sandbox who is not on the parent, call + `Lightning.Projects.add_project_users/3` after provision returns — that + path goes through the seat-limit check. """ @type provision_attrs :: %{ required(:name) => String.t(), optional(:color) => String.t() | nil, optional(:env) => String.t() | nil, - optional(:collaborators) => [ - %{user_id: Ecto.UUID.t(), role: :admin | :editor | :viewer} - ], optional(:dataclip_ids) => [Ecto.UUID.t()] } @@ -111,8 +114,7 @@ defmodule Lightning.Projects.Sandboxes do ## Example {:ok, sandbox} = Sandboxes.provision(parent_project, user, %{ name: "test-environment", - color: "#336699", - collaborators: [%{user_id: other_user.id, role: :editor}] + color: "#336699" }) """ @spec provision(Project.t(), User.t(), provision_attrs) :: @@ -440,7 +442,6 @@ defmodule Lightning.Projects.Sandboxes do sandbox_name = Map.fetch!(attrs, :name) sandbox_color = Map.get(attrs, :color) sandbox_env = Map.get(attrs, :env) - collaborators = Map.get(attrs, :collaborators, []) Repo.transaction(fn -> parent_with_data = load_parent_associations(parent) @@ -451,8 +452,7 @@ defmodule Lightning.Projects.Sandboxes do actor, sandbox_name, sandbox_color, - sandbox_env, - collaborators + sandbox_env ) case create_empty_sandbox(parent_with_data, sandbox_attrs) do @@ -485,25 +485,24 @@ defmodule Lightning.Projects.Sandboxes do triggers: [:webhook_auth_methods], edges: [] ], - project_credentials: [:credential] + project_credentials: [:credential], + project_users: [] ) end - defp build_sandbox_project_attributes( - parent, - actor, - name, - color, - env, - collaborators - ) do + defp build_sandbox_project_attributes(parent, actor, name, color, env) do owner_membership = %{user_id: actor.id, role: :owner} + # Copy every parent user except the actor (who is the owner), demoting + # any parent owner to :admin. The actor's role on the parent (if any) + # is overridden to :owner on the sandbox. additional_memberships = - collaborators - |> List.wrap() - |> Enum.reject(&(&1.user_id == actor.id or &1.role == :owner)) - |> Enum.uniq_by(& &1.user_id) + parent.project_users + |> Enum.reject(&(&1.user_id == actor.id)) + |> Enum.map(fn pu -> + role = if pu.role == :owner, do: :admin, else: pu.role + %{user_id: pu.user_id, role: role} + end) parent |> Map.take(@cloned_project_fields) diff --git a/lib/lightning_web/live/sandbox_live/form_component.ex b/lib/lightning_web/live/sandbox_live/form_component.ex index 719ac80732..ffe87c666d 100644 --- a/lib/lightning_web/live/sandbox_live/form_component.ex +++ b/lib/lightning_web/live/sandbox_live/form_component.ex @@ -67,21 +67,10 @@ defmodule LightningWeb.SandboxLive.FormComponent do } } = socket ) do - parent_users = Projects.get_project_users!(parent.id) - - collaborators = - parent_users - |> Enum.reject(fn pu -> pu.user_id == actor.id end) - |> Enum.map(fn pu -> - role = if pu.role == :owner, do: :admin, else: pu.role - %{user_id: pu.user_id, role: role} - end) - attrs = params |> build_sandbox_attrs() |> Map.put(:env, "dev") - |> Map.put(:collaborators, collaborators) with :ok <- ProjectLimiter.limit_new_sandbox(parent.id), {:ok, sandbox} <- Projects.provision_sandbox(parent, actor, attrs) do diff --git a/test/lightning/sandboxes_test.exs b/test/lightning/sandboxes_test.exs index aa38013334..4cb28ecd71 100644 --- a/test/lightning/sandboxes_test.exs +++ b/test/lightning/sandboxes_test.exs @@ -310,8 +310,7 @@ defmodule Lightning.Projects.SandboxesTest do Sandboxes.provision(parent, actor, %{ name: "sandbox-x", color: "#abcdef", - env: "staging", - collaborators: [%{user_id: actor.id, role: :owner}] + env: "staging" }) sandbox = Repo.preload(sandbox, [:project_users, :project_credentials]) @@ -1178,18 +1177,6 @@ defmodule Lightning.Projects.SandboxesTest do assert %{name: [_error_msg]} = errors_on(changeset) end - test "handles foreign key constraint violations in collaborators" do - %{actor: actor, parent: parent} = build_parent_fixture!(:owner) - non_existent_user_id = Ecto.UUID.generate() - - assert_raise Ecto.ConstraintError, ~r/foreign_key_constraint/, fn -> - Sandboxes.provision(parent, actor, %{ - name: "test-fk-error", - collaborators: [%{user_id: non_existent_user_id, role: :editor}] - }) - end - end - test "rolls back transaction on keychain validation failure" do %{actor: actor, parent: parent, pc: pc} = build_parent_fixture!(:owner) @@ -1327,19 +1314,13 @@ defmodule Lightning.Projects.SandboxesTest do end end - describe "collaborators" do - test "adds non-owner collaborators" do - %{actor: actor, parent: parent} = build_parent_fixture!(:owner) - other = insert(:user) + describe "project_users derivation from parent" do + test "copies every parent user with their role preserved" do + %{actor: actor, other: other, parent: parent} = + build_parent_fixture!(:owner) {:ok, sandbox} = - Sandboxes.provision(parent, actor, %{ - name: "sb-with-collab", - collaborators: [ - %{user_id: other.id, role: :editor}, - %{user_id: actor.id, role: :owner} - ] - }) + Sandboxes.provision(parent, actor, %{name: "sb-derived"}) sandbox = Repo.preload(sandbox, :project_users) @@ -1347,9 +1328,58 @@ defmodule Lightning.Projects.SandboxesTest do sandbox.project_users, &(&1.user_id == other.id and &1.role == :editor) ) + end + + test "demotes the parent owner to :admin on the sandbox" do + # Actor is :editor on parent so the parent owner is someone else. + actor = insert(:user) + parent_owner = insert(:user) + parent = insert(:project) + + ensure_member!(parent, actor, :editor) + ensure_member!(parent, parent_owner, :owner) + + {:ok, sandbox} = + Sandboxes.provision(parent, actor, %{name: "sb-demote-owner"}) + + sandbox = Repo.preload(sandbox, :project_users) + + assert Enum.any?( + sandbox.project_users, + &(&1.user_id == parent_owner.id and &1.role == :admin) + ) + # The actor is the sandbox owner. + assert Enum.any?( + sandbox.project_users, + &(&1.user_id == actor.id and &1.role == :owner) + ) + + # Exactly one owner on the sandbox. assert Enum.count(sandbox.project_users, &(&1.role == :owner)) == 1 end + + test "actor is sandbox owner even if they had a non-owner role on parent" do + actor = insert(:user) + parent = insert(:project) + ensure_member!(parent, actor, :editor) + + {:ok, sandbox} = + Sandboxes.provision(parent, actor, %{name: "sb-actor-owner"}) + + sandbox = Repo.preload(sandbox, :project_users) + + assert Enum.any?( + sandbox.project_users, + &(&1.user_id == actor.id and &1.role == :owner) + ) + + # Actor appears exactly once. + assert Enum.count( + sandbox.project_users, + &(&1.user_id == actor.id) + ) == 1 + end end describe "dataclips guards" do