studio-engine 0.47.2 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ <%# Profile photo row — lifted from turf-monster's /account, which is the
2
+ interaction the operator wants standardized: click the avatar, hover reveals
3
+ an "Update" cap, pick a file, crop it square, and it saves immediately. No
4
+ Save button, no visible file field.
5
+
6
+ Locals: user (required).
7
+
8
+ HOW IT HANGS TOGETHER
9
+ imageUploadHost() the engine's crop-then-immediate-save x-data factory
10
+ (studio/modals/_image_upload). onFileSelected() reads
11
+ the picked file and opens the crop modal; the modal
12
+ dispatches the cropped Blob back on
13
+ 'crop-photo-confirmed'; applyCrop() drops it into the
14
+ hidden form's file input and submits with a saving
15
+ card.
16
+ profileModals the PAGE-SCOPED Alpine store mounted by show.html.erb.
17
+ Not the shared "modals" store — see below.
18
+ profile_avatar_path its own endpoint, because an attachment param
19
+ submitted empty PURGES the attachment.
20
+
21
+ WHY THE PAGE-SCOPED STORE AND NOT `modals`. turf's copy opens on
22
+ Alpine.store('modals') — the app's shared modal host — which works there
23
+ because turf's layout mounts one and registers crop-photo in it. Two of the
24
+ five consumers (mcritchie-industries, moms-app) render NO shared host at all,
25
+ and the two that do would each have to add a crop-photo registration to their
26
+ layout before this row worked. An engine page cannot ask that. show.html.erb
27
+ mounts studio/modals/_scoped_host instead, so the page brings its own modals
28
+ and this row behaves identically in every app. /admin/emails already does
29
+ exactly this.
30
+
31
+ WHY onCropConfirmed AND NOT applyCrop DIRECTLY. turf's call site binds
32
+ `applyCrop($event.detail.blob)`, which predates the owner guard. Every
33
+ imageUploadHost on a page hears the same window event, so a page that later
34
+ grows a second uploader (a host row with its own image) would have both
35
+ hosts save the same crop. onCropConfirmed() checks the owner token first and
36
+ is a strict improvement at the same call cost.
37
+
38
+ ON THE INLINE PIXEL SIZES. Same reason turf gives: the exact circle geometry
39
+ is expressed inline rather than through w-24/h-24, because a size utility
40
+ only exists if something already emitted it into the compiled bundle. The
41
+ classes used here (group-hover:opacity-100, rounded-full, object-cover,
42
+ absolute inset-0) already ship in other engine partials.
43
+ %>
44
+ <div x-data="imageUploadHost({
45
+ store: 'profileModals',
46
+ aspectRatio: 1,
47
+ filename: 'avatar.png',
48
+ saving: 'Saving photo…',
49
+ toast: false
50
+ })"
51
+ @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
52
+ class="flex flex-col items-center">
53
+
54
+ <%# Auto-submitted once a crop is confirmed. Hidden because the person never
55
+ touches it — applyCrop() writes the cropped File onto fileInput and calls
56
+ submitFormWithProgress on form. %>
57
+ <%= form_with url: profile_avatar_path, method: :patch, scope: :profile,
58
+ html: { multipart: true, "x-ref": "form", class: "hidden" } do %>
59
+ <input type="file" name="profile[avatar]" x-ref="fileInput">
60
+ <% end %>
61
+
62
+ <%# The real picker. Opened by the avatar button; never shown. %>
63
+ <input type="file" x-ref="filePicker" @change="onFileSelected($event)"
64
+ accept="<%= Studio::ProfileImage::ALLOWED_CONTENT_TYPES.join(",") %>" class="hidden">
65
+
66
+ <%# The clickable avatar. A BUTTON rather than turf's <div @click>: a div is
67
+ unreachable by keyboard and invisible to a screen reader, so the only way
68
+ to change your photo would be a mouse. Same visual, and it focuses. %>
69
+ <button type="button"
70
+ @click="$refs.filePicker.click()"
71
+ aria-label="Change your profile photo"
72
+ style="width: 96px; height: 96px;"
73
+ class="relative rounded-full cursor-pointer group overflow-hidden mb-3 flex-shrink-0 p-0 border-0 bg-transparent">
74
+ <% if user.avatar.attached? %>
75
+ <%= image_tag user.avatar, class: "rounded-full object-cover",
76
+ style: "width: 96px; height: 96px;", alt: user.display_name %>
77
+ <% else %>
78
+ <div class="rounded-full flex items-center justify-center font-bold text-white text-3xl"
79
+ style="width: 96px; height: 96px; background-color: <%= user.avatar_color %>">
80
+ <%= user.avatar_initials %>
81
+ </div>
82
+ <% end %>
83
+
84
+ <%# The hover cap. group-hover reveals it on pointer; focus-within is the
85
+ keyboard equivalent, so a tabbing user sees the same affordance. %>
86
+ <span class="absolute inset-0 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity duration-200"
87
+ style="background: rgba(0,0,0,0.5)">
88
+ <span class="text-white text-xs font-bold">Update</span>
89
+ </span>
90
+ </button>
91
+
92
+ <div class="text-xl font-bold text-heading"><%= user.display_name %></div>
93
+ </div>
@@ -0,0 +1,26 @@
1
+ <%# First-name row.
2
+
3
+ Locals: user (required).
4
+
5
+ This row only renders when the host's user model actually answers
6
+ `first_name` — Studio::ProfileSections drops it otherwise, so an app that has
7
+ not installed the standard profile columns gets a page without this row
8
+ rather than a NoMethodError on every visit. Nothing here needs to re-check.
9
+
10
+ The same column is written by the onboarding step
11
+ (Studio::OnboardingController#first_name); this is the surface for changing it
12
+ later.
13
+ %>
14
+ <%= form_with url: profile_path, method: :patch, scope: :profile,
15
+ data: { turbo: false }, class: "flex flex-wrap items-end gap-3" do |form| %>
16
+ <div class="flex-1 min-w-0" style="min-width: 12rem;">
17
+ <label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
18
+ <%= form.text_field :first_name,
19
+ value: user.first_name,
20
+ maxlength: Studio::FIRST_NAME_MAX_LENGTH,
21
+ autocomplete: "given-name",
22
+ placeholder: "What should we call you?",
23
+ class: "input-field" %>
24
+ </div>
25
+ <%= form.submit "Save", class: "btn btn-primary" %>
26
+ <% end %>
@@ -0,0 +1,56 @@
1
+ <%# Google account row — lifted from turf-monster's /account Identities card.
2
+
3
+ Locals: user (required).
4
+
5
+ Two states. LINKED: a tick, "Connected via Google", and an Unlink button
6
+ behind a confirm. NOT LINKED: the branded Connect button, which POSTs to
7
+ OmniAuth's own /auth/google_oauth2 — the engine does not draw that path, the
8
+ middleware owns it, which is why this is a literal string and not a helper.
9
+
10
+ The row only renders when the host's user model answers provider AND uid
11
+ (Studio::ProfileSections drops it otherwise), so nothing here re-checks.
12
+
13
+ ON THE PREDICATE: turf's view asks `@user.google_connected?`, a method only
14
+ turf defines. The engine cannot call that — it reads provider/uid itself
15
+ through Studio::OauthIdentity, which also matches BOTH spellings that appear
16
+ in the wild (`google_oauth2` from the OmniAuth strategy, `google` from
17
+ Studio.auth_methods).
18
+ %>
19
+ <% linked = Studio::OauthIdentity.google_linked?(user) %>
20
+
21
+ <% if linked %>
22
+ <div class="flex flex-wrap items-center justify-between gap-3">
23
+ <div class="flex items-center gap-3">
24
+ <svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
25
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
26
+ </svg>
27
+ <span class="text-body">Connected via Google</span>
28
+ </div>
29
+
30
+ <%# The button is rendered either way; when unlinking would orphan the
31
+ account it is DISABLED with the reason beside it, rather than hidden.
32
+ A control that vanishes teaches nothing — the person cannot tell whether
33
+ the feature is missing or their account is special. The server refuses
34
+ this case regardless (ProfilesController#unlink_google); this is the
35
+ explanation, not the enforcement. %>
36
+ <% orphans = Studio::OauthIdentity.unlink_orphans_account?(user) %>
37
+ <% if orphans %>
38
+ <div class="text-right">
39
+ <button type="button" disabled
40
+ class="btn btn-danger btn-sm opacity-50 cursor-not-allowed"
41
+ title="Google is currently the only way to sign in">Unlink</button>
42
+ <p class="text-muted text-xs mt-1">Add an email address first — Google is your only sign-in.</p>
43
+ </div>
44
+ <% else %>
45
+ <%= button_to "Unlink", profile_unlink_google_path, method: :delete,
46
+ class: "btn btn-danger btn-sm",
47
+ data: { turbo_confirm: "Unlink your Google account?" } %>
48
+ <% end %>
49
+ </div>
50
+ <% else %>
51
+ <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
52
+ class: "btn btn-google gap-3 w-full" do %>
53
+ <%= render "components/google_logo" %>
54
+ Link Google Account
55
+ <% end %>
56
+ <% end %>
@@ -0,0 +1,73 @@
1
+ <%# The shared /profile page.
2
+
3
+ A bare content wrapper, not a layout: it renders inside the host's
4
+ application layout and inherits that app's navbar, theme and flash. Same
5
+ shape as /admin/style and /admin/emails.
6
+
7
+ The page is nothing but its sections. @profile_sections is already resolved
8
+ and filtered by the controller (Studio.profile_sections_for) — rows the host
9
+ cannot serve, and admin rows for non-admins, are gone before we get here — so
10
+ this template makes no decisions of its own beyond an empty state.
11
+ %>
12
+ <% content_for(:title, "Profile") %>
13
+
14
+ <%# Does any resolved row need the crop/saving modals? A row declares
15
+ `modals: true` and the page mounts them once, here.
16
+
17
+ The page owns its modal host DELIBERATELY. The obvious alternative — render
18
+ "studio/modals/host" and let the app supply it — fails in both directions:
19
+ mcritchie-industries and moms-app render no shared host at all, so there
20
+ would be nowhere to open a modal from; and mcritchie-studio and turf-monster
21
+ each ship their OWN app/views/studio/modals/_host.html.erb, which shadows the
22
+ engine's in this non-isolated engine, so the page would silently get their
23
+ fork and its registrations. studio/modals/_scoped_host is unforked in every
24
+ app. /admin/emails reached the same conclusion first.
25
+
26
+ Mounted once per page rather than once per row: two hosts sharing a store
27
+ name would register the same modal ids twice and render duplicate cards. %>
28
+ <% profile_needs_modals = Array(@profile_sections).any? { |section| section[:modals] } %>
29
+
30
+ <% if profile_needs_modals %>
31
+ <%# cropper.js + the imageUploadHost / cropPhotoModal / submitFormWithProgress
32
+ factories. Loaded only when a row can actually open the cropper, which is
33
+ the whole reason this is a partial and not a global include. %>
34
+ <%= render "studio/cropper_assets" %>
35
+ <% end %>
36
+
37
+ <div class="max-w-2xl mx-auto py-8">
38
+ <h1 class="text-2xl font-extrabold text-heading mb-8">Profile</h1>
39
+
40
+ <% if @profile_sections.blank? %>
41
+ <%# Reachable, and worth rendering honestly rather than as a blank page: an
42
+ app whose user model serves none of the declared rows (no avatar
43
+ attachment, no first_name column) lands here until it installs the
44
+ standard profile columns. %>
45
+ <%= render "components/empty_state",
46
+ message: "Nothing to edit yet",
47
+ detail: "This app has not enabled any profile fields." %>
48
+ <% else %>
49
+ <div class="space-y-6">
50
+ <% @profile_sections.each do |section| %>
51
+ <section class="card p-6" data-profile-section="<%= section[:key] %>">
52
+ <% if section[:title].present? %>
53
+ <h2 class="text-lg font-bold text-heading mb-4"><%= section[:title] %></h2>
54
+ <% end %>
55
+ <%= render section[:partial], **section.fetch(:locals, {}).merge(user: current_user) %>
56
+ </section>
57
+ <% end %>
58
+ </div>
59
+ <% end %>
60
+ </div>
61
+
62
+ <% if profile_needs_modals %>
63
+ <%# Optional chaining on current() is load-bearing: the outer template unmounts
64
+ one tick AFTER the stack empties, so a bare .id throws on every close. %>
65
+ <%= render "studio/modals/scoped_host", store: "profileModals" do %>
66
+ <template x-if="$store.profileModals.current()?.id === 'crop-photo'">
67
+ <div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
68
+ </template>
69
+ <template x-if="$store.profileModals.current()?.id === 'saving'">
70
+ <div><%= render "studio/modals/saving", store: "profileModals" %></div>
71
+ </template>
72
+ <% end %>
73
+ <% end %>
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The OAuth identity bound to an account — is Google linked, and is it safe to
4
+ # unlink it?
5
+ #
6
+ # Pure Ruby and duck-typed, like Studio::ProfileImage: it takes anything that
7
+ # answers `provider` / `uid` / `email`, so the rules are unit-testable without a
8
+ # users table, and a host whose model has none of those columns is simply
9
+ # reported as "not linked" rather than raising.
10
+ #
11
+ # THE ORPHAN GUARD IS THE REASON THIS FILE EXISTS. turf-monster's
12
+ # AccountsController#unlink_google is one line — `update!(provider: nil, uid: nil)`
13
+ # — with no check at all. For an account whose ONLY sign-in is Google (blank
14
+ # email, so no magic link; no wallet; no password) that button silently locks
15
+ # someone out of their own account, and the label just says "Unlink". It is safe
16
+ # in turf today only because turf's users happen to carry an email; that is a
17
+ # property of turf's data, not of the code, and the engine ships to apps whose
18
+ # data it has never seen.
19
+ #
20
+ # So the engine asks first: after this unlink, is there still a way back in?
21
+ module Studio
22
+ module OauthIdentity
23
+ # Both spellings appear across the ecosystem: `google_oauth2` is the OmniAuth
24
+ # strategy name that lands in users.provider, and `google` is what
25
+ # Studio.auth_methods calls the same thing. Matching both means a host that
26
+ # stored either is read correctly rather than reported as unlinked.
27
+ GOOGLE_PROVIDERS = %w[google google_oauth2].freeze
28
+
29
+ module_function
30
+
31
+ def google_linked?(user)
32
+ return false unless user.respond_to?(:provider) && user.respond_to?(:uid)
33
+
34
+ GOOGLE_PROVIDERS.include?(user.provider.to_s) && user.uid.present?
35
+ end
36
+
37
+ # Every way this account could sign in if Google were gone. Returns symbols
38
+ # so a caller can name what is left in a message rather than just refusing.
39
+ #
40
+ # Gated on Studio.auth_methods, not just on the column: an app that has an
41
+ # email column but does not offer magic-link sign-in cannot use it to get
42
+ # back in, and counting it would be exactly the wrong answer.
43
+ def remaining_sign_ins(user, auth_methods: Studio.auth_methods)
44
+ methods = Array(auth_methods).map(&:to_sym)
45
+ remaining = []
46
+
47
+ remaining << :magic_link if methods.include?(:magic_link) && present?(user, :email)
48
+ remaining << :wallet if methods.include?(:wallet) && wallet_present?(user)
49
+ # `Studio.password_login_available?` — NOT a bare password_digest check.
50
+ # A digest can be a FOSSIL: turf-monster removed `has_secure_password` and
51
+ # kept the column, so rows still carry digests no code can authenticate
52
+ # against. Counting one as a way back in is a false positive in the
53
+ # dangerous direction — it would permit an unlink that orphans the account.
54
+ # The engine already ships the correct composite predicate
55
+ # (auth_method?(:password) && the User answering `authenticate`).
56
+ remaining << :password if methods.include?(:password) &&
57
+ Studio.password_login_available? &&
58
+ present?(user, :password_digest)
59
+
60
+ remaining
61
+ end
62
+
63
+ # The question the controller actually asks before unlinking.
64
+ def unlink_orphans_account?(user, auth_methods: Studio.auth_methods)
65
+ remaining_sign_ins(user, auth_methods: auth_methods).empty?
66
+ end
67
+
68
+ def present?(user, attribute)
69
+ user.respond_to?(attribute) && user.public_send(attribute).present?
70
+ end
71
+
72
+ # ONLY the explicitly configured wallet column — no fallback to a
73
+ # conventional name, deliberately.
74
+ #
75
+ # A convention-guessed reader is a false positive waiting to happen, and
76
+ # turf-monster is the live example: its `User#solana_address` returns
77
+ # `web3_solana_address || web2_solana_address`, and only the WEB3 address can
78
+ # actually sign in (SolanaSessionsController verifies a wallet signature; the
79
+ # web2 address is a custodial account with no signer). Guessing that reader
80
+ # would count a custodial address as a way back in and permit an unlink that
81
+ # orphans the account.
82
+ #
83
+ # So an app that has not named its signing-wallet column is treated as having
84
+ # no wallet sign-in. That errs toward REFUSING an unlink, which is the safe
85
+ # direction: the cost is an occasional refusal the operator can resolve by
86
+ # configuring `Studio.wallet_address_method`; the cost of the other direction
87
+ # is someone locked out of their account.
88
+ def wallet_present?(user)
89
+ configured = Studio.wallet_address_method
90
+ return false if configured.blank?
91
+
92
+ present?(user, configured)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The house rule for "is this upload an acceptable profile picture?" — one
4
+ # allowlist, one size cap, in one place.
5
+ #
6
+ # Pure Ruby and duck-typed on purpose: it takes anything answering
7
+ # `content_type` and `size`, which is what an ActionDispatch::Http::UploadedFile
8
+ # answers, so the rule is unit-testable without Rails, a request, or a users
9
+ # table.
10
+ #
11
+ # LIFTED FROM turf-monster, where this lived as ApplicationController#valid_image?
12
+ # with an IMAGE_UPLOAD_TYPES constant beside it. Same allowlist, same 8 MB cap —
13
+ # the values are turf's, deliberately, because they are the ones that have been
14
+ # in front of real uploads. What changes is that the second and third app no
15
+ # longer have to re-derive them.
16
+ #
17
+ # ON THE ALLOWLIST BEING AN ALLOWLIST: an avatar is attacker-supplied bytes that
18
+ # the app then serves back to other people. Naming the three formats we accept is
19
+ # what keeps an SVG — which is a script host, not a picture — from becoming a
20
+ # profile photo. Do not widen this to a `start_with?("image/")` check.
21
+ module Studio
22
+ module ProfileImage
23
+ ALLOWED_CONTENT_TYPES = %w[image/png image/jpeg image/webp].freeze
24
+
25
+ # 8 MB. Phone cameras clear this comfortably; it is a bound on abuse, not on
26
+ # the user's actual photo.
27
+ MAX_BYTES = 8 * 1024 * 1024
28
+
29
+ # A human sentence for the rejection path. Kept next to the rule so the two
30
+ # cannot drift — a message naming the wrong limit is worse than none.
31
+ MESSAGE = "Use a PNG, JPG, or WebP under 8 MB."
32
+
33
+ module_function
34
+
35
+ def acceptable?(file)
36
+ return false unless file.respond_to?(:content_type) && file.respond_to?(:size)
37
+ return false unless ALLOWED_CONTENT_TYPES.include?(file.content_type)
38
+
39
+ size = file.size
40
+ size.is_a?(Numeric) && size.positive? && size <= MAX_BYTES
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves Studio.profile_sections — the rows that make up the shared /profile
4
+ # page — for a given view context. Pure Ruby (no Rails dependency) so the unit
5
+ # suite exercises the resolution rules without booting the dummy app.
6
+ #
7
+ # THE SHAPE IS DELIBERATELY THE SIDEBAR'S. Studio.sidebar_sections already taught
8
+ # this codebase one way to let a host declare page furniture: a static Array or a
9
+ # callable that receives the view, symbolized on the way out, with sections the
10
+ # viewer may not see dropped before render. Reusing that shape means a host that
11
+ # has declared a sidebar already knows how to declare a profile.
12
+ #
13
+ # What a section looks like:
14
+ #
15
+ # { key: :avatar, title: "Profile photo", partial: "studio/profiles/avatar_section",
16
+ # locals: { ... }, requires: :avatar, admin: false }
17
+ #
18
+ # key — stable identifier. A host removes or replaces a default by key,
19
+ # so reordering the defaults never breaks a host's override.
20
+ # title — the row's heading.
21
+ # partial — the partial to render. Host partials are ordinary app views.
22
+ # locals — optional Hash passed to the partial.
23
+ # requires — optional attribute (or Array of them) the CURRENT USER must
24
+ # respond to for the row to render. See below.
25
+ # admin — admin-only row, same rule as the sidebar's.
26
+ # if — optional callable. The row renders only when it returns truthy.
27
+ # Called with the view when it takes an argument, without when it
28
+ # does not. This is the APP-CAPABILITY gate, distinct from
29
+ # `requires:` which is the MODEL gate — and the two really are
30
+ # different questions. Every consumer's users table carries
31
+ # `provider` and `uid`, so `requires:` alone selected the whole
32
+ # fleet for the Google row; mcritchie-industries has those columns
33
+ # AND `auth_methods = %i[magic_link]` with no omniauth gem at all,
34
+ # so it would have rendered a "Link Google Account" button leading
35
+ # nowhere. Having a column is not the same as offering the feature.
36
+ # modals — the row opens the shared crop/saving modals. The PAGE mounts them
37
+ # once (studio/modals/_scoped_host on a "profileModals" store) when
38
+ # any resolved row asks for them, so a row never has to know whether
39
+ # the host app renders a modal host of its own. Two of the five
40
+ # consumers render none at all, and the two that do ship a FORK of
41
+ # the engine's shared host that would shadow it.
42
+ #
43
+ # ON `requires` — this is the whole reason the registry exists rather than a
44
+ # hardcoded page. The consuming apps do NOT agree on their users table:
45
+ # mcritchie-industries has eight columns and no `first_name`; turf-monster has
46
+ # forty. A shared page that assumed a column would raise NoMethodError on every
47
+ # signed-in request in the app that lacked it — the same failure mode
48
+ # Studio.first_name_outstanding? already guards against with respond_to?.
49
+ #
50
+ # So a row DECLARES what it needs and is dropped when the host cannot serve it.
51
+ # mcritchie-industries gets a working profile page with an avatar row and no name
52
+ # row until it installs the standard-profile-columns migration, at which point
53
+ # the row appears with no code change. Silence, not a 500.
54
+ module Studio
55
+ module ProfileSections
56
+ # The page every consumer gets for free. Iteration one is deliberately two
57
+ # rows: the picture and the name. Everything else the account-standardization
58
+ # program lifts (email change, identities, preferences) arrives as further
59
+ # defaults, and a host that has declared its own list keeps working because
60
+ # it composes against `Studio.default_profile_sections` rather than a literal.
61
+ DEFAULTS = [
62
+ { key: :avatar, title: "Profile photo",
63
+ partial: "studio/profiles/avatar_section", requires: :avatar, modals: true },
64
+ { key: :first_name, title: "Your name",
65
+ partial: "studio/profiles/first_name_section", requires: :first_name },
66
+ # Gated on the app OFFERING Google, not merely on having the columns —
67
+ # the same question app/views/sessions/new.html.erb:79 already asks before
68
+ # drawing this identical button on the login page.
69
+ { key: :google, title: "Google account",
70
+ partial: "studio/profiles/google_section", requires: %i[provider uid],
71
+ if: -> { Studio.auth_method?(:google) } }
72
+ ].freeze
73
+
74
+ module_function
75
+
76
+ # A fresh, mutable copy every call. Hosts compose with `+` and sometimes
77
+ # `reject`, and handing out the frozen literal would let one host's edit
78
+ # leak into the next request's page.
79
+ def defaults
80
+ DEFAULTS.map { |section| section.dup }
81
+ end
82
+
83
+ # Declared may be nil (the host has said nothing — it gets the defaults), an
84
+ # Array, or a callable receiving the view context.
85
+ #
86
+ # `nil` meaning "the defaults" rather than "no sections" is the load-bearing
87
+ # choice here: it is what makes a brand-new app's profile page work with an
88
+ # empty initializer, which is the entire point of standardizing this.
89
+ def resolve(declared, view)
90
+ sections = declared.respond_to?(:call) ? declared.call(view) : declared
91
+ sections = defaults if sections.nil?
92
+
93
+ admin = view.respond_to?(:admin?) && view.admin?
94
+ user = view.respond_to?(:current_user) ? view.current_user : nil
95
+
96
+ Array(sections)
97
+ .map { |section| symbolize(section) }
98
+ .reject { |section| section[:admin] && !admin }
99
+ .select { |section| enabled?(section[:if], view) }
100
+ .select { |section| served_by?(user, section[:requires]) }
101
+ end
102
+
103
+ # The app-capability gate. No `if:` means always enabled. The callable takes
104
+ # the view when it wants one and nothing when it does not, so a host can
105
+ # write either `-> { Studio.feature?(:x) }` or `->(view) { view.admin? }`.
106
+ def enabled?(condition, view)
107
+ return true if condition.nil?
108
+
109
+ # A Symbol/String names a method on the VIEW — Rails' own
110
+ # `before_action ..., if: :method_name` convention, and therefore the most
111
+ # natural thing a host will write here.
112
+ #
113
+ # It is handled explicitly because the alternative FAILS OPEN: a Symbol
114
+ # does not answer `call`, so the earlier `return !!condition` coerced
115
+ # `:some_predicate` to true and rendered the row unconditionally — a gate
116
+ # that silently does nothing, in the same permissive direction as the bug
117
+ # this whole `if:` key was added to fix. A host would have had no signal.
118
+ if condition.is_a?(Symbol) || condition.is_a?(String)
119
+ return false unless view.respond_to?(condition)
120
+
121
+ return !!view.public_send(condition)
122
+ end
123
+
124
+ return !!condition unless condition.respond_to?(:call)
125
+
126
+ !!(condition.arity.zero? ? condition.call : condition.call(view))
127
+ end
128
+
129
+ # Can this host's user model serve the row? No requirement means yes — a row
130
+ # that reads nothing off the user (a static explainer, a link out) is always
131
+ # served. A nil user means we are rendering for nobody, and nothing is served.
132
+ def served_by?(user, requires)
133
+ needed = Array(requires).compact
134
+ return true if needed.empty?
135
+ return false if user.nil?
136
+
137
+ needed.all? { |attribute| user.respond_to?(attribute) }
138
+ end
139
+
140
+ def symbolize(hash)
141
+ out = hash.to_h.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
142
+
143
+ # `key` is an IDENTIFIER, so its VALUE symbolizes too — unlike every other
144
+ # field, whose value is data. A host composing by key writes
145
+ # `reject { |s| s[:key] == :avatar }`, and that silently matches nothing if
146
+ # a section declared with string keys kept `"avatar"` as a String. Removing
147
+ # a standard row is the documented seam; it must not depend on which
148
+ # spelling the host happened to use.
149
+ out[:key] = out[:key].to_sym if out[:key].respond_to?(:to_sym)
150
+ out
151
+ end
152
+ end
153
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.47.2"
2
+ VERSION = "0.48.0"
3
3
  end