Uh oh!
There was an error while loading. Please reload this page.
Add MediaBrowser live_component with scope_folder_id - #495
Merged
Conversation
- Drop language_switcher_dropdown from admin top bar in layout_wrapper.ex; locale selection now lives in the user avatar dropdown only - Remove dead admin_language_dropdown/1 and unused Phoenix.LiveView.JS alias from admin_nav.ex - Update Languages README: clarify that admin locale switches via user menu, and the pre-login globe on the sign-in page stays for unauthenticated visitors
- Extend table_default_with_cards with toolbar_title and toolbar_actions slots, rendered in the same row as the view-toggle buttons. Toolbar row is only shown when at least one of the three (title, actions, toggle) is present. Flex-wrap layout keeps the row adaptive on mobile; the view toggle stays desktop-only since mobile forces card view. - Apply to roles page: move 'Create New Role' button from a centered block above the table into toolbar_actions; add role count in toolbar_title. - Apply to activity page: move 'Clear filters' button from the filter card into toolbar_actions; show total count in toolbar_title and drop duplicate count from page subtitle. - Apply to integrations page: move 'Add Integration' from the page header actions into toolbar_actions; add connection count in toolbar_title. Empty-state CTA already provides its own button.
Extract Users.Media LiveView into reusable PhoenixKitWeb.Components.MediaBrowser
accepting optional scope_folder_id for hard-scoped embedding (variant A).
/admin/media behavior preserved byte-for-byte (scope=nil).
Storage:
- Add within_scope?/2 predicate and scope-aware helpers:
list_folder_tree/1, folder_breadcrumbs/2, list_folders/2,
list_files_in_scope/2 (recursive CTE), count_orphaned_files/1
- Scope guards on all mutators (create_folder, update_folder, delete_folder,
move_file_to_folder, create_folder_link) return {:error, :out_of_scope}
Component:
- Parent LiveView shrunk to wrapper (1158 LOC to 38 LOC)
- Upload via progress: callback replaces Process.send_after polling
(handle_info absent, incompatible with live_components)
- Controlled mode detected by on_navigate attr presence: parent owns
navigation state and receives {MediaBrowser, id, {:navigate, params}}
notify messages; push_patch round-trip for ?folder/?q/?page/?orphaned
- scope_invalid detection with UI banner when scope folder deleted
- Upload target falls back to scope_folder_id at virtual root
- Orphan filter UI hidden when scope is set
- scoped_fallback? flash on URL-hack to out-of-scope folder
- Integer.parse fallback for malformed ?page param
- Guard update/2 first-mount check via Map.has_key? (not raises on nil)
- assign_new(:scope_folder_id) default so template @scope_folder_id
renders safely when parent omits the attr
Tests (52 total):
- 18 URL-builder unit tests (media_url_test.exs)
- 17 LiveView integration tests for URL sync and auth (media_test.exs)
- 17 component tests for scope behaviors (media_browser_test.exs)
- Storage scope contracts (scope_test.exs, media_browser_scope_test.exs)
- Extended ConnCase with sandbox + endpoint supervision
Out of scope (Task 7 follow-up):
- attr declarations on component
- Inline <script>/<style> extraction from template
- Bulk-mutator error aggregation for partial out-of-scope failures
- Extract build_url/parse_page as public helpers (test duplication)Merge upstream/dev (commits a470bea..5f19ab8) which adds V97/V98 migrations, maintenance mode, OAuth health check, image_set component, and a new ?view=all "All Files" feature in the media browser. Conflicts in media.ex and media.html.heex resolved with our refactor (MediaBrowser live_component), then upstream's ?view=all feature ported into the new architecture: - Add :file_view assign and navigate_view_all event to MediaBrowser - Extend Storage.list_files_in_scope/2 with UUID-search via `fragment("CAST(? AS TEXT) ILIKE ?", f.uuid, search)` - URL round-trip for ?view=all (parent handle_params ↔ component navigate/apply_nav_params); search and clear_search now preserve file_view; toggle_orphan_filter explicitly resets view to nil - Template: All Files sidebar button with active-state highlight, All Files ({count}) section title, empty state, hide folder rows and new-folder controls when file_view == "all" - Preserves scope semantics: view=all under scope shows all files within scope subtree via existing recursive CTE (no leakage) Tests added: 2 URL-builder tests (view=all + q roundtrip, view=all standalone), 2 UUID-search tests in scope_test.exs, 1 deep-link test for /admin/media?view=all. Refactor bonus: resolve_folder/2 and load_nav_files/7 extracted from apply_nav_params to reduce complexity.
Runtime fixes discovered during browser smoke testing on Decor3D Print.
MediaBrowser event routing:
- Add phx-target={@Myself} to every phx-click/submit/change/keydown binding
in media_browser.html.heex (57 occurrences) and function components
folder_tree_node/move_folder_option (8 occurrences). phx-target does NOT
cascade from the root div; each event element needs its own target.
Without this, all clicks routed to the parent LiveView and crashed with
UndefinedFunctionError since the parent only defines mount/render.
- Add :myself attr to folder_tree_node and move_folder_option function
components so recursive calls propagate it correctly.
MediaBrowser KeyError on first mount:
- Replace `not socket.assigns[:uploaded_files]` with `not Map.has_key?(...)`
in update/2. Elixir's `not` is strict-boolean and raises ArgumentError
on nil or list values.
- Add assign_new(:scope_folder_id, fn -> nil end) so templates that use
@scope_folder_id don't crash with KeyError when parent omits the attr.
AssetsController — serve phoenix_kit_consent.js:
- Extend the valid-assets map to route by OTP app, not just filename.
phoenix_kit_consent.js lives in the phoenix_kit_legal package;
previously the controller returned 404 text/plain, breaking the strict
MIME check on any page that injects the consent banner script (that
then also broke other <script> tags and in effect killed LiveSocket
init on many pages).ddon
approved these changes
Apr 16, 2026
ddon
left a comment
Contributor
There was a problem hiding this comment.
Claude review — approve / merge
Large refactor, but architecturally clean. Full writeup in dev_docs/pull_requests/2026/495-media-browser-component/CLAUDE_REVIEW.md.
Verified:
- Scope enforcement is complete —
within_scope?/2guards every mutator (create_folder,update_folder,delete_folder,move_file_to_folder,create_folder_link); recursive CTE inlist_files_in_scope/2uses Ecto param binding (no SQL injection);folder_breadcrumbs/2truncates cleanly viadrop_while. - All 62 server-event bindings have
phx-target={@myself}— mechanically checked acrossmedia_browser.html.heex. Two apparent misses at:488and:752are false positives (forms carry submit + change + keydown under one target). - Lifecycle hotfixes correct —
Map.has_key?(socket.assigns, :uploaded_files)first-mount guard avoids the strict-not-on-nil trap;assign_new(:scope_folder_id, fn -> nil end)protects templates from KeyError when parent omits the attr. AssetsControllerwidening is safe —try/rescue ArgumentErroris the correct exception forApplication.app_dir/2when:phoenix_kit_legalisn't installed; parent apps without the package get a 404 (same as before), not a crash.- Backward compatibility preserved — all new
scope_folder_id \\ nildefaults;/admin/mediaunscoped path unchanged. - Tests land where they matter — 52 new tests: scope CRUD guards (
scope_test.exs, 519 LOC), component integration (media_browser_scope_test.exs, 233), URL sync + deep links (media_test.exs, 295), URL-builder units (media_url_test.exs, 167). - Two bundled admin UI changes are non-breaking — globe-switcher removal is pure deletion;
table_defaulttoolbar slots are additive, and the three migrated pages (roles/activity/integrations) are consistent.
Medium improvements (non-blocking):
- Missing
attrdeclarations on the live_component — author already listed as out-of-scope/follow-up, reasonable folder_breadcrumbs/2may N+1 on deep trees (only matters per-navigation, not per-file) — batch into a CTE if it becomes a hotspot- Verify the admin avatar dropdown exposes a language picker after the globe removal
Nitpicks:
- Silent
nilreturn fromAssetsControllerwhen:phoenix_kit_legalisn't loaded — considerLogger.debug(...)to aid "why isn't my consent banner loading?" debugging
Nice work — the controlled/uncontrolled mode separation, scope_invalid banner, orphan-filter gating, and upload fallback to scope root are all well thought out. Ready to merge.
- delete_selected: check within_scope? for each file before calling delete_file_completely. Previously scope was read but not applied, allowing crafted WebSocket events to delete files outside scope. - navigate_to_folder (uncontrolled mode): add within_scope? guard before rendering folder contents. Previously a crafted navigate_folder event with an out-of-scope UUID could expose sibling folder names. Both are defense-in-depth fixes — admin-only, require crafted events, but the scope enforcement contract should hold uniformly across all code paths. Found during independent PR review by verifier agent.
ddon
commented
Apr 16, 2026
Contributor
Follow-up on |
ddon pushed a commit
that referenced
this pull request
Apr 16, 2026
Covers scope enforcement across all mutators, event routing verification (62 bindings), AssetsController widening for Legal app, and follow-up on commit 4a7057d closing two defense-in-depth holes in delete_selected and navigate_to_folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
timujinne added a commit
to timujinne/phoenix_kit
that referenced
this pull request
May 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PhoenixKitWeb.Live.Users.Media(1158 LOC LiveView) into reusablePhoenixKitWeb.Components.MediaBrowserlive_component with optionalscope_folder_id(hard-scoped embedding — variant A)./admin/mediabehavior preserved byte-for-byte whenscope_folder_id: nil.?view=all"All Files" feature (from Added image_set for correct file pulling on the front end image calls #490) ported into the new architecture. UUID-search and scope-safety preserved.Changes
Storage (scope helpers)
within_scope?/2predicate + scope-awarelist_folder_tree/1,folder_breadcrumbs/2,list_folders/2,list_files_in_scope/2(recursive CTE),count_orphaned_files/1create_folder,update_folder,delete_folder,move_file_to_folder,create_folder_linkall return{:error, :out_of_scope}on violationscope_folder_id \\ nil→ backward-compatibleMediaBrowserlive_componentmedia.exshrunk to 75 LOC wrapperprogress: &handle_progress/3callback (replacesProcess.send_afterpolling incompatible with live_components). Reference pattern:media_selector_modal.exon_navigateattr. Parent-notify pattern: component emits{MediaBrowser, id, {:navigate, params}}→ parenthandle_info→push_patch→handle_params→send_update(nav_params: ...)round-trip for?folder/q/page/orphaned/viewscope_invaliddetection (scope folder deleted) with UI banner; orphan filter UI hidden when scope set; upload target falls back toscope_folder_idat virtual root; out-of-scope navigation falls back to scope root with flash?view=all"All Files" feature ported: hides folders in grid, showsAll Files ({count})title, scope-safe vialist_files_in_scope(scope, ...). UUID-search inlist_files_in_scopepreserved from upstream.Runtime hotfixes discovered during browser smoke testing
phx-target={@myself}added to all 65 event bindings in template + function components (doesn't cascade from root div; each element withphx-click/change/submit/...needs its own target or events route to parent LV)update/2first-mount check viaMap.has_key?(...)instead ofnot socket.assigns[...](strictnotraises on nil/list)assign_new(:scope_folder_id, fn -> nil end)so templates using@scope_folder_iddon't KeyError when parent omits attrAssetsControllerextended to routephoenix_kit_consent.jsto:phoenix_kit_legalapp — previously returned 404 text/plain, breaking strict MIME check on any page usingapp_layoutwith Legal module enabled. Safe viatry/rescue ArgumentError: parent apps without the Legal package see no impact.Tests (52 new)
ConnCasewith sandbox + endpoint supervision for LiveView testsAdditional admin UI changes
Two small admin UI refactors are also included in this PR (committed to
devbefore the MediaBrowser work started):ffda72f0Remove globe language switcher from admin header — the standalone globe/locale dropdown is removed from the top admin bar; locale switching moves to the user avatar menu to reduce header clutter. Affectscomponents/admin_nav.ex(-89 LOC),components/layout_wrapper.ex,modules/languages/README.md.1d09f49bAdd toolbar_title and toolbar_actions slots to table_default — new slots oncore/table_default.exallow pages to inject a toolbar area above the table without extra wrapping. Migrated three admin pages to use the new slots: roles, activity index, integrations.Both are non-breaking, additive/refactor changes. Called out explicitly for reviewer visibility.
Parent app impact
After merging to
BeamLabEU:devand pulling into parent apps:mix phoenix_kit.update— installsphoenix_kit.jsand adjusts root layout if needed.mix ecto.migrate— applies V97 (per-item markup override) and V98 (image_set support) migrations coming from upstream./admin/mediausage — byte-for-byte preserved.<.live_component module={PhoenixKitWeb.Components.MediaBrowser} id="..." scope_folder_id={folder_uuid} phoenix_kit_current_user={@current_user} on_navigate={:navigate}/>for scoped media browsers inside forms.Out of scope (follow-up)
attrdeclarations at top ofMediaBrowser<script>/<style>extraction from templatebuild_url/parse_pageas public helpers to remove test duplicationMediaSelector/MediaSelectorModalontoMediaBrowserTest plan
mix compile --warnings-as-errors— cleanmix format --check-formatted— cleanmix credo --strict— 0 issues on 504 filesmix dialyzer— clean (via pre-commit)path: "/app") — Mode A:/admin/mediabehaves identically to pre-refactor (folder tree, file grid, upload, navigation, search, orphan filter, URL sync for?folder/q/page/orphaned/view,?view=all, malformed?page=foofalls back to page 1). Mode B scoped data contracts verified via IEx.