Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8fd134d
Scope collection name uniqueness to project
josephjclark Apr 10, 2026
4f5d3da
Return 409 when v1 collection lookup finds a name conflict
josephjclark Apr 10, 2026
9eba1db
Test v1 collection conflict detection
josephjclark Apr 10, 2026
5271c41
Add v2 collections API with project-scoped routing
josephjclark Apr 10, 2026
b01d53a
Add v2 collections API tests
josephjclark Apr 10, 2026
f769675
Clone empty collections from parent when provisioning a sandbox
josephjclark Apr 10, 2026
cd54286
Sync collection names when merging a sandbox into its parent
josephjclark Apr 10, 2026
c479e38
Warn about collection sync behavior in merge modal
josephjclark Apr 10, 2026
067c749
Split dispatch/2 into dispatch_v1 and dispatch_v2 to satisfy credo
josephjclark Apr 10, 2026
5c7cdbe
Refactor collections API for sandboxes
elias-ba Apr 13, 2026
c8e7c58
Update changelog for sandbox collections support
elias-ba Apr 13, 2026
ce0dee9
Test dispatch fallback clauses for unsupported paths and methods
elias-ba Apr 13, 2026
17aaa1c
Test api version plug rejects multiple header values
elias-ba Apr 13, 2026
1687260
Use explicit up/down in collections migration with irreversible guard
elias-ba Apr 14, 2026
57434b1
Allow migration rollback when no duplicate collection names exist
elias-ba Apr 14, 2026
27bdb15
Handle sync_collections failure, remove dead code, fix test name
elias-ba Apr 14, 2026
e5c904b
Restore on_conflict: :nothing to guard against concurrent merges
elias-ba Apr 14, 2026
e02d9d5
Fix review findings for sandbox collections
elias-ba Apr 14, 2026
bf8a54e
Replace catch-all match with reusable VersionedRouter plug
elias-ba Apr 14, 2026
1b2e4d4
Simplify versioned routing into a single CollectionsRouter plug
elias-ba Apr 14, 2026
e0bfc46
Fix dialyzer warnings for controller action specs
elias-ba Apr 14, 2026
b15c804
Move merge pipeline into Sandboxes.merge/4
elias-ba Apr 14, 2026
83a073a
Fix alias ordering in Sandboxes module
elias-ba Apr 14, 2026
a36bad3
Test that collection sync failure shows flash error on merge
elias-ba Apr 14, 2026
d1fa4e3
Test Sandboxes.merge/4 including default opts
elias-ba Apr 15, 2026
73a92a3
Add SandboxSettingsBanner component and assign sandbox? in settings
elias-ba Apr 17, 2026
c5357a2
Apply sandbox-aware UI per settings tab
elias-ba Apr 17, 2026
39527bb
Enforce parent admin floor rule for sandbox collaborators
elias-ba Apr 17, 2026
ecf4950
Test sandbox settings page UI and merge-layer field protection
elias-ba Apr 17, 2026
0b211c8
Update changelog for sandbox settings page
elias-ba Apr 17, 2026
d860833
Fix credo: import order and nested module aliases
elias-ba Apr 17, 2026
8f083fe
Remove dead nil clauses from parent admin code paths
elias-ba Apr 17, 2026
662da08
Restore nil parent handling and preload :user in remove handler
elias-ba Apr 17, 2026
4b7d0c5
Move :user preload into Projects.get_project_user!/2
elias-ba Apr 17, 2026
6916e93
Address Joe's review + polish sandbox settings UX
elias-ba Apr 19, 2026
877f929
Polish project settings page UX
elias-ba Apr 19, 2026
a916565
Redesign profile page and bring MFA setup up to worldclass
elias-ba Apr 19, 2026
f43b9cd
Polish credentials listing
elias-ba Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 143 additions & 0 deletions assets/js/hooks/OtpInput.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>('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<HTMLInputElement>(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<HTMLInputElement>(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 };
2 changes: 2 additions & 0 deletions assets/js/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
OpenProjectPickerViaCtrlP,
} from './KeyHandlers';
import LogLineHighlight from './LogLineHighlight';
import { OtpInput } from './OtpInput';
import type { PhoenixHook } from './PhoenixHook';
import {
TabbedContainer,
Expand Down Expand Up @@ -53,6 +54,7 @@ export {
OpenProjectPickerViaCtrlP,
FileDropzone,
CredentialSelector,
OtpInput,
};

export { ReactComponent, HeexReactComponent } from '#/react/hooks';
Expand Down
14 changes: 12 additions & 2 deletions lib/lightning/collections.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions lib/lightning/collections/collection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion lib/lightning/projects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading