diff --git a/CHANGELOG.md b/CHANGELOG.md index 762fa2afa7f..fdb7d974112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,21 @@ and this project adheres to ### Added +- Support collections in sandboxes. Collection names are now scoped per project, + empty collections are cloned into a sandbox on provision, and collection names + (not data) are synchronised when a sandbox is merged back into its parent. + Adds a v2 collections API at `/collections/:project_id/:name` selected via the + `x-api-version: 2` header. V1 continues to work and returns 409 when a name is + ambiguous across projects. + [#3548](https://github.com/OpenFn/lightning/issues/3548) +- Sandbox-aware Project Settings page. Each tab shows a banner explaining how + changes will (or will not) flow on merge: Local (sandbox-only), Editable + (syncs on merge), or Inherited (read-only, managed in the parent). The Sandbox + Identity panel links back to the parent project, the MFA toggle is read-only, + webhook authentication methods are managed from the parent project, and parent + project admins cannot be removed from a sandbox. + [#3398](https://github.com/OpenFn/lightning/issues/3398) + ### Changed - Bump `@openfn/ws-worker` from diff --git a/assets/js/hooks/OtpInput.ts b/assets/js/hooks/OtpInput.ts new file mode 100644 index 00000000000..2ec1bcd49f8 --- /dev/null +++ b/assets/js/hooks/OtpInput.ts @@ -0,0 +1,143 @@ +import type { PhoenixHook } from './PhoenixHook'; + +type OtpInput = PhoenixHook< + { + boxes: HTMLInputElement[]; + lastSubmitted: string; + _onInput(e: Event): void; + _onKeyDown(e: KeyboardEvent): void; + _onPaste(e: ClipboardEvent): void; + _onFocus(e: FocusEvent): void; + sync(): void; + clear(): void; + }, + { + hiddenTarget?: string; + autofocus?: string; + validateEvent?: string; + submitEvent?: string; + } +>; + +const OtpInput = { + mounted() { + this.boxes = Array.from( + this.el.querySelectorAll('input[data-otp-box]') + ); + this.lastSubmitted = ''; + + this.handleEvent<{ id?: string }>('otp:clear', payload => { + if (payload.id && payload.id !== this.el.id) return; + this.clear(); + }); + + this._onInput = (e: Event) => { + const input = e.target as HTMLInputElement; + const index = Number(input.dataset['index']); + const value = input.value.replace(/\D/g, '').slice(0, 1); + input.value = value; + + if (value && index < this.boxes.length - 1) { + this.boxes[index + 1]?.focus(); + } + + this.sync(); + }; + + this._onKeyDown = (e: KeyboardEvent) => { + const input = e.target as HTMLInputElement; + const index = Number(input.dataset['index']); + + if (e.key === 'Backspace' && !input.value && index > 0) { + e.preventDefault(); + const prev = this.boxes[index - 1]; + if (prev) { + prev.focus(); + prev.value = ''; + this.sync(); + } + } else if (e.key === 'ArrowLeft' && index > 0) { + e.preventDefault(); + this.boxes[index - 1]?.focus(); + } else if (e.key === 'ArrowRight' && index < this.boxes.length - 1) { + e.preventDefault(); + this.boxes[index + 1]?.focus(); + } + }; + + this._onPaste = (e: ClipboardEvent) => { + e.preventDefault(); + const text = e.clipboardData?.getData('text') ?? ''; + const digits = text.replace(/\D/g, '').slice(0, this.boxes.length); + if (digits.length === 0) return; + + this.boxes.forEach((box, i) => { + box.value = digits[i] ?? ''; + }); + + const lastFilled = Math.min(digits.length, this.boxes.length) - 1; + this.boxes[lastFilled]?.focus(); + this.sync(); + }; + + this._onFocus = (e: FocusEvent) => { + (e.target as HTMLInputElement).select(); + }; + + this.boxes.forEach(box => { + box.addEventListener('input', this._onInput); + box.addEventListener('keydown', this._onKeyDown); + box.addEventListener('paste', this._onPaste); + box.addEventListener('focus', this._onFocus); + }); + + if (this.el.dataset.autofocus === 'true') { + this.boxes[0]?.focus(); + } + }, + sync() { + const code = this.boxes.map(b => b.value).join(''); + + const selector = this.el.dataset['hiddenTarget']; + if (selector) { + const hidden = document.querySelector(selector); + if (hidden) hidden.value = code; + } + + const validateEvent = this.el.dataset['validateEvent']; + if (validateEvent) this.pushEventTo(this.el, validateEvent, { code }); + + const submitEvent = this.el.dataset['submitEvent']; + if ( + submitEvent && + code.length === this.boxes.length && + code !== this.lastSubmitted + ) { + this.lastSubmitted = code; + this.pushEventTo(this.el, submitEvent, { code }); + } + }, + clear() { + this.boxes.forEach(b => { + b.value = ''; + }); + this.lastSubmitted = ''; + this.boxes[0]?.focus(); + + const selector = this.el.dataset['hiddenTarget']; + if (selector) { + const hidden = document.querySelector(selector); + if (hidden) hidden.value = ''; + } + }, + destroyed() { + this.boxes.forEach(box => { + box.removeEventListener('input', this._onInput); + box.removeEventListener('keydown', this._onKeyDown); + box.removeEventListener('paste', this._onPaste); + box.removeEventListener('focus', this._onFocus); + }); + }, +} as OtpInput; + +export { OtpInput }; diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index 1b9e5929196..a4c34d3749e 100644 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -25,6 +25,7 @@ import { OpenProjectPickerViaCtrlP, } from './KeyHandlers'; import LogLineHighlight from './LogLineHighlight'; +import { OtpInput } from './OtpInput'; import type { PhoenixHook } from './PhoenixHook'; import { TabbedContainer, @@ -53,6 +54,7 @@ export { OpenProjectPickerViaCtrlP, FileDropzone, CredentialSelector, + OtpInput, }; export { ReactComponent, HeexReactComponent } from '#/react/hooks'; diff --git a/lib/lightning/collections.ex b/lib/lightning/collections.ex index d89671a8de5..682acec71a0 100644 --- a/lib/lightning/collections.ex +++ b/lib/lightning/collections.ex @@ -53,9 +53,19 @@ defmodule Lightning.Collections do end @spec get_collection(String.t()) :: - {:ok, Collection.t()} | {:error, :not_found} + {:ok, Collection.t()} | {:error, :not_found} | {:error, :conflict} def get_collection(name) do - case Repo.get_by(Collection, name: name) do + case Repo.all(from c in Collection, where: c.name == ^name) do + [] -> {:error, :not_found} + [collection] -> {:ok, collection} + [_ | _] -> {:error, :conflict} + end + end + + @spec get_collection(Ecto.UUID.t(), String.t()) :: + {:ok, Collection.t()} | {:error, :not_found} + def get_collection(project_id, name) do + case Repo.get_by(Collection, project_id: project_id, name: name) do nil -> {:error, :not_found} collection -> {:ok, collection} end diff --git a/lib/lightning/collections/collection.ex b/lib/lightning/collections/collection.ex index ecb4c1a0791..fc6b947b2f2 100644 --- a/lib/lightning/collections/collection.ex +++ b/lib/lightning/collections/collection.ex @@ -40,7 +40,8 @@ defmodule Lightning.Collections.Collection do |> validate_format(:name, ~r/^[a-z0-9]+([\-_.][a-z0-9]+)*$/, message: "Collection name must be URL safe" ) - |> unique_constraint([:name], + |> unique_constraint(:name, + name: :collections_project_id_name_index, message: "A collection with this name already exists" ) end @@ -50,7 +51,8 @@ defmodule Lightning.Collections.Collection do |> validate_format(:name, ~r/^[a-z0-9]+([\-_.][a-z0-9]+)*$/, message: "Collection name must be URL safe" ) - |> unique_constraint([:name], + |> unique_constraint(:name, + name: :collections_project_id_name_index, message: "A collection with this name already exists" ) end diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index 32d663ac7e5..1cde89e49f8 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -319,7 +319,10 @@ defmodule Lightning.Projects do ** (Ecto.NoResultsError) """ - def get_project_user!(id), do: Repo.get!(ProjectUser, id) + def get_project_user!(id, opts \\ []) do + include = Keyword.get(opts, :include, []) + ProjectUser |> Repo.get!(id) |> Repo.preload(include) + end @spec get_project_user(Ecto.UUID.t()) :: ProjectUser.t() | nil def get_project_user(id) when is_binary(id), do: Repo.get(ProjectUser, id) @@ -574,6 +577,15 @@ defmodule Lightning.Projects do %{user_id: user_id, project_id: project_id} = Repo.preload(project_user, [:user, :project]) + if Project.sandbox?(project_user.project) and + Lightning.Projects.Sandboxes.parent_admin?( + project_user.project, + project_user.user + ) do + raise ArgumentError, + "Cannot remove a parent project admin from a sandbox" + end + Repo.transaction(fn -> from(pc in Lightning.Projects.ProjectCredential, join: c in Lightning.Credentials.Credential, diff --git a/lib/lightning/projects/sandboxes.ex b/lib/lightning/projects/sandboxes.ex index cebcd8edfcb..bf430e536df 100644 --- a/lib/lightning/projects/sandboxes.ex +++ b/lib/lightning/projects/sandboxes.ex @@ -18,6 +18,7 @@ defmodule Lightning.Projects.Sandboxes do ## Operations * `provision/3` - Create a new sandbox from a parent project + * `merge/4` - Merge a sandbox into its target (workflows + collections) * `update_sandbox/3` - Update sandbox name, color, or environment * `delete_sandbox/2` - Delete a sandbox and all its descendants @@ -36,10 +37,14 @@ defmodule Lightning.Projects.Sandboxes do import Ecto.Query alias Lightning.Accounts.User + alias Lightning.Collections + alias Lightning.Collections.Collection alias Lightning.Credentials.KeychainCredential alias Lightning.Policies.Permissions + alias Lightning.Projects.MergeProjects alias Lightning.Projects.Project alias Lightning.Projects.ProjectCredential + alias Lightning.Projects.Provisioner alias Lightning.Projects.SandboxPromExPlugin alias Lightning.Repo alias Lightning.Workflows @@ -121,6 +126,42 @@ defmodule Lightning.Projects.Sandboxes do end end + @doc """ + Merges a sandbox into its target project. + + Applies the sandbox's workflow configuration to the target via the + provisioner, then synchronises collection names. Collection data is + never copied. + + ## Parameters + * `source` - The sandbox project being merged + * `target` - The project receiving the merge + * `actor` - The user performing the merge + * `opts` - Merge options (`:selected_workflow_ids`, `:deleted_target_workflow_ids`) + + ## Returns + * `{:ok, updated_target}` - Merge and collection sync succeeded + * `{:error, reason}` - Workflow merge or collection sync failed + """ + @spec merge(Project.t(), Project.t(), User.t(), map()) :: + {:ok, Project.t()} | {:error, term()} + def merge( + %Project{} = source, + %Project{} = target, + %User{} = actor, + opts \\ %{} + ) do + merge_doc = MergeProjects.merge_project(source, target, opts) + + with {:ok, updated_target} <- + Provisioner.import_document(target, actor, merge_doc, + allow_stale: true + ), + {:ok, _} <- sync_collections(source, target) do + {:ok, updated_target} + end + end + @doc """ Updates a sandbox project's basic attributes. @@ -168,6 +209,35 @@ defmodule Lightning.Projects.Sandboxes do end end + @doc """ + Returns `true` when `user` has an `:admin` or `:owner` role on any ancestor + of `project`, walking the parent chain. + + Used to enforce the parent-admin floor rule: a user who is admin/owner on + any ancestor project cannot be removed from, or downgraded within, a + sandbox descended from that project. + """ + @spec parent_admin?(Project.t(), User.t()) :: boolean() + def parent_admin?(%Project{} = project, %User{} = user) do + project + |> ancestors() + |> Enum.any?(fn ancestor -> + Lightning.Projects.get_project_user_role(user, ancestor) in [ + :admin, + :owner + ] + end) + end + + defp ancestors(%Project{parent_id: nil}), do: [] + + defp ancestors(%Project{parent_id: parent_id}) do + case Lightning.Projects.get_project(parent_id) do + nil -> [] + %Project{} = parent -> [parent | ancestors(parent)] + end + end + @doc """ Deletes a sandbox and all its descendant projects. @@ -566,6 +636,87 @@ defmodule Lightning.Projects.Sandboxes do |> copy_workflow_version_history(sandbox.workflow_id_mapping) |> create_initial_workflow_snapshots() |> copy_selected_dataclips(parent.id, Map.get(original_attrs, :dataclip_ids)) + |> clone_collections_from_parent(parent) + end + + defp clone_collections_from_parent(sandbox, parent) do + parent_names = parent |> Collections.list_project_collections() |> names() + insert_empty_collections(sandbox.id, parent_names) + sandbox + end + + @doc """ + Synchronises collection names from a sandbox to its merge target. + + After a successful merge, this brings the target's set of collections in + line with the sandbox's: + + * Collections present in the sandbox but missing from the target are + created (empty) in the target. + * Collections present in the target but missing from the sandbox are + deleted from the target, along with all their items. + + **Collection data is never copied or merged.** Only the set of collection + names is synchronised, mirroring the sandbox-is-for-configuration model. + + The create and delete operations run in a single transaction; a failure + leaves the target's collections unchanged. + """ + @spec sync_collections(Project.t(), Project.t()) :: + {:ok, %{created: non_neg_integer(), deleted: non_neg_integer()}} + | {:error, term()} + def sync_collections(%Project{} = source, %Project{} = target) do + source_names = source |> Collections.list_project_collections() |> names() + + target_collections = Collections.list_project_collections(target) + target_names = names(target_collections) + + to_create = MapSet.difference(source_names, target_names) + + names_to_delete = MapSet.difference(target_names, source_names) + + to_delete_ids = + for c <- target_collections, + c.name in names_to_delete, + do: c.id + + Repo.transaction(fn -> + {created, _} = insert_empty_collections(target.id, to_create) + {deleted, _} = delete_collections(to_delete_ids) + %{created: created, deleted: deleted} + end) + end + + defp names(collections), do: MapSet.new(collections, & &1.name) + + defp insert_empty_collections(project_id, names) do + if Enum.empty?(names) do + {0, nil} + else + now = DateTime.utc_now() |> DateTime.truncate(:second) + + rows = + Enum.map(names, fn name -> + %{ + id: Ecto.UUID.generate(), + name: name, + project_id: project_id, + byte_size_sum: 0, + inserted_at: now, + updated_at: now + } + end) + + # on_conflict: :nothing handles the rare case where two concurrent + # merges into the same target both try to create the same collection. + Repo.insert_all(Collection, rows, on_conflict: :nothing) + end + end + + defp delete_collections([]), do: {0, nil} + + defp delete_collections(ids) do + Repo.delete_all(from c in Collection, where: c.id in ^ids) end defp copy_workflow_version_history(sandbox, workflow_id_mapping) do diff --git a/lib/lightning_web/components/layout_components.ex b/lib/lightning_web/components/layout_components.ex index cc5c9024022..9afb8ffd968 100644 --- a/lib/lightning_web/components/layout_components.ex +++ b/lib/lightning_web/components/layout_components.ex @@ -186,7 +186,7 @@ defmodule LightningWeb.LayoutComponents do
{render_slot(@inner_block)}
@@ -388,7 +388,7 @@ defmodule LightningWeb.LayoutComponents do attr :title, :string, required: true attr :subtitle, :string, required: true - attr :permissions_message, :string, required: true + attr :permissions_message, :string, default: nil attr :can_perform_action, :boolean, default: true attr :action_button_text, :string, default: nil attr :action_button_click, :any, default: nil @@ -409,7 +409,7 @@ defmodule LightningWeb.LayoutComponents do {@subtitle} - <%= if !@can_perform_action do %> + <%= if !@can_perform_action and @permissions_message do %> <.permissions_message section={@permissions_message} /> <% end %>
diff --git a/lib/lightning_web/components/new_inputs.ex b/lib/lightning_web/components/new_inputs.ex index f54fc8b0c3d..e775c6b0ecf 100644 --- a/lib/lightning_web/components/new_inputs.ex +++ b/lib/lightning_web/components/new_inputs.ex @@ -415,7 +415,9 @@ defmodule LightningWeb.Components.NewInputs do attr :tooltip, :any, default: nil - attr :on_click, :string, default: nil + attr :on_click, :any, default: nil + + attr :on_click_target, :any, default: nil attr :value_key, :any, default: nil @@ -875,13 +877,7 @@ defmodule LightningWeb.Components.NewInputs do