studio-engine 0.48.0 → 0.49.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa8ab90a0e1627e7150ed1e96a348bae677c3dc35891ccd2a46f99d857cd74d1
4
- data.tar.gz: e2a354a9d1d0533a78c63cb19233ae387a4c71f747cd5bf4570fe8d2478979de
3
+ metadata.gz: 5cfb0ff6698fde0520d0d6e081ffd49d99801473831f92c18e86a9cb9dc177a1
4
+ data.tar.gz: 2a96a7e242ff9211d0307326e77258136b725a99d5d749a9664e5194336af72c
5
5
  SHA512:
6
- metadata.gz: d7def604f1a4c19da8241953cee4bfa8e8047af2a2ae0e0113628aeed9f5d59ab5fa16bfe667e89687144f0f0afb5117e1bcd1f4747ab69db9c5b892e142c820
7
- data.tar.gz: 7424031597c4168ac6a8172360b7dbc2ee584f0ddc5ac205a155fd11343f7e8f91002a40d3b0865eef0218096234aeaf1617ff2133677aa1e51c9b73d91a8cf3
6
+ metadata.gz: d142923e12b84afc01d96afda2cdaf578ef422a6e34f480cad0917671140ad99bf5eb73d70b0283eed6ba4a5eece21f98ea356662a963c504fdef758ccf3b857
7
+ data.tar.gz: 657e2a9742b8e92317a3128593fe50663cb2fd09ca004a1700d0dc0e0a4c6916d7f0d024c28b6098a93e6424cfacb174bdf46878cadcf9dab953b5532725e520
data/CHANGELOG.md CHANGED
@@ -17,6 +17,36 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
17
17
 
18
18
  ### Added
19
19
 
20
+ - **`/profile` gains the Email row — changeable from any signed-in session.**
21
+ `PATCH /profile/email` (`profile_email_path`) applies the change directly.
22
+
23
+ **THE GOOGLE EXCEPTION.** An account with a linked Google identity **cannot**
24
+ change its email: Google is the authoritative source for that address, and
25
+ letting the two drift means the next OAuth sign-in either re-links to a
26
+ stranger's row or cannot find its own. The row shows the address with the
27
+ reason instead of a field, and the endpoint refuses independently — a disabled
28
+ input is a courtesy, and anyone can POST.
29
+
30
+ **What a direct change trades away, stated so nobody rediscovers it:** a
31
+ hijacked session can move the account to another inbox, and the old address has
32
+ no veto. Two protections are kept precisely BECAUSE the veto is gone —
33
+
34
+ - the **old address is mailed** after every change (OPSEC-046), so a change
35
+ nobody made is visible to the person losing the account, and
36
+ - **every other session is invalidated** (OPSEC-045), so a hijacker holding a
37
+ second cookie loses it the moment the address moves. The session that made
38
+ the change is re-established, so the person doing it is not signed out.
39
+
40
+ - **`Studio::ProfileMailer`** with `email_change_notification`, plus a new
41
+ standard `Studio::EmailCatalog` entry so its copy, subject and banner are
42
+ operator-editable on `/admin/emails` like every other Studio email. **Namespaced deliberately:**
43
+ `app/mailers` is an autoload path and this engine is not isolated, so a host's
44
+ top-level `UserMailer` shadows the engine's — mcritchie-studio's fork defines no
45
+ `email_change_notification` at all (a bare call raises `NoMethodError`), and
46
+ turf-monster's defines one with the SAME signature — so there the call would
47
+ not raise, it would quietly send TURF's copy, banner and `account_url` instead
48
+ of the engine's. The silent case is the dangerous one.
49
+
20
50
  - **`/profile` gains the Google account row.** Shows the linked identity with an
21
51
  Unlink control, or a branded Connect button that POSTs to OmniAuth's own
22
52
  `/auth/google_oauth2` (the engine does not draw a link route — the middleware
@@ -38,7 +38,7 @@ module Studio
38
38
 
39
39
  # PATCH /profile — the scalar fields. Today that is the first name.
40
40
  def update
41
- return unsupported(:first_name) unless serves?(:first_name)
41
+ return unsupported("name") unless row_rendered?(:first_name)
42
42
 
43
43
  value = normalized_first_name
44
44
 
@@ -85,7 +85,7 @@ module Studio
85
85
  # this and branched inside its own #update; a separate route is the same
86
86
  # lesson expressed so the trap cannot be reintroduced.
87
87
  def avatar
88
- return unsupported(:avatar) unless serves?(:avatar)
88
+ return unsupported("profile photo") unless row_rendered?(:avatar)
89
89
 
90
90
  file = params.dig(:profile, :avatar)
91
91
 
@@ -103,6 +103,79 @@ module Studio
103
103
  end
104
104
  end
105
105
 
106
+ # PATCH /profile/email — change the address, from any signed-in session.
107
+ #
108
+ # DIRECT, not out-of-band (operator's call, 2026-08-14). An earlier build
109
+ # mailed a confirmation link to the current address and applied the change
110
+ # only when the holder of that inbox clicked it. That is the stronger flow and
111
+ # it is gone on purpose: the session is now the authority.
112
+ #
113
+ # What that trades away, stated so nobody has to rediscover it: a hijacked
114
+ # session can move the account to another inbox, and the old address no
115
+ # longer gets a veto. Two things are kept BECAUSE the veto is gone —
116
+ #
117
+ # * the OLD address is mailed after every change (OPSEC-046), so a change
118
+ # nobody made is visible to the person losing the account, and
119
+ # * every OTHER session is invalidated (OPSEC-045), so a hijacker holding a
120
+ # second cookie loses it the moment the address moves.
121
+ #
122
+ # THE GOOGLE EXCEPTION. An account with a linked Google identity cannot
123
+ # change its email here: Google is the authoritative source for that address,
124
+ # and letting the two drift means the next OAuth sign-in either re-links to a
125
+ # stranger's row or fails to find its own. Unlink Google first, then change it.
126
+ def email
127
+ return unsupported("email") unless row_rendered?(:email)
128
+
129
+ if Studio::OauthIdentity.google_linked?(current_user)
130
+ return redirect_to profile_path, status: :see_other,
131
+ alert: "Your email comes from your linked Google account. " \
132
+ "Unlink Google first if you want to change it."
133
+ end
134
+
135
+ value = params.dig(:profile, :email).to_s.strip
136
+ current = current_user.email.to_s
137
+
138
+ return redirect_to profile_path, alert: "Enter an email address.", status: :see_other if value.blank?
139
+
140
+ if value.casecmp?(current)
141
+ return redirect_to profile_path, alert: "That is already your email address.", status: :see_other
142
+ end
143
+
144
+ rescue_and_log(target: current_user) do
145
+ unless current_user.update(email: value)
146
+ next redirect_to profile_path, status: :see_other,
147
+ alert: current_user.errors.full_messages.to_sentence.presence ||
148
+ "Could not save that address."
149
+ end
150
+
151
+ # The new address has not been proved yet; the host's own verification
152
+ # flow picks it up from here.
153
+ current_user.update_columns(email_verified_at: nil) if current_user.respond_to?(:email_verified_at)
154
+
155
+ # ROTATE FIRST, MAIL SECOND — the order matters and it was the other way
156
+ # round. Studio::Email.deliver can raise (a host's delivery record is a
157
+ # create! plus perform_later with no rescue at that level), and a mail
158
+ # failure between the write and the rotation would leave the address
159
+ # changed with every other session still live. That is the exact window
160
+ # OPSEC-045 exists to close, so it closes before anything that can throw.
161
+ # turf-monster's own apply_email_change rotates first for the same reason.
162
+ if current_user.respond_to?(:regenerate_session_token!)
163
+ current_user.regenerate_session_token!
164
+ # Re-establish THIS session. Rotating without it signs out the very
165
+ # person who just made the change — correct when the actor arrived from
166
+ # a confirmation link, wrong now that the actor IS the session.
167
+ session[:session_token] = current_user.session_token if current_user.respond_to?(:session_token)
168
+ end
169
+
170
+ if current.present?
171
+ Studio::Email.deliver(Studio::ProfileMailer, :email_change_notification,
172
+ current_user, current, value, to: current, user: current_user)
173
+ end
174
+
175
+ redirect_to profile_path, notice: "Email changed to #{value}."
176
+ end
177
+ end
178
+
106
179
  # DELETE /profile/google — drop the linked Google identity.
107
180
  #
108
181
  # REFUSES WHEN IT WOULD ORPHAN THE ACCOUNT. turf-monster's version is an
@@ -116,7 +189,7 @@ module Studio
116
189
  # column: an app with an email column that does not offer magic-link sign-in
117
190
  # cannot use it to get back in.
118
191
  def unlink_google
119
- return unsupported(:google_account) unless serves?(:provider) && serves?(:uid)
192
+ return unsupported("Google account") unless row_rendered?(:google)
120
193
 
121
194
  unless Studio::OauthIdentity.google_linked?(current_user)
122
195
  return redirect_to profile_path, alert: "No Google account is linked.", status: :see_other
@@ -136,28 +209,42 @@ module Studio
136
209
 
137
210
  private
138
211
 
139
- # Can this host's user model serve this field?
212
+ # Would the page render this row for this viewer?
140
213
  #
141
- # A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row the host
214
+ # A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row this host
142
215
  # cannot serve, so nobody SEES a first-name form in an app without the
143
216
  # column — but the endpoint stays open to anyone who posts to it, and
144
217
  # rescue_and_log RE-RAISES, so an unguarded write is a 500 plus an ErrorLog
145
- # row. That is not hypothetical: three of the five consumers
146
- # (mcritchie-industries, moms-app, acquisition-studio) have an avatar
147
- # attachment and no first_name column right now.
218
+ # row. Three of the five consumers have an avatar attachment and no
219
+ # first_name column right now.
148
220
  #
149
- # The endpoint asks the SAME question the row does, through the SAME method,
150
- # so the two cannot drift into disagreeing about what this app supports.
151
- def serves?(attribute)
152
- Studio::ProfileSections.served_by?(current_user, attribute)
221
+ # ASKED OF THE RESOLVER, not of one of its rules and that distinction was a
222
+ # real bug, caught in review. This used to call served_by?, which answers only
223
+ # the MODEL gate (`requires:`). Once rows also carried an APP gate (`if:`),
224
+ # the two questions came apart: mcritchie-industries drops the Google row
225
+ # because it offers no Google sign-in, while the endpoint — asking only about
226
+ # columns it does have — stayed open. The comment here claimed they "cannot
227
+ # drift" while they were already drifting.
228
+ #
229
+ # Asking the resolver makes that true rather than asserted: the page and the
230
+ # endpoint run the same code and get the same answer, whatever gates a row
231
+ # grows next.
232
+ def row_rendered?(key)
233
+ profile_rows.any? { |section| section[:key] == key.to_sym }
234
+ end
235
+
236
+ # Memoized per request: a host's `if:` may be a lambda doing real work, and a
237
+ # write should not pay for it more than once.
238
+ def profile_rows
239
+ @profile_rows ||= Studio.profile_sections_for(view_context)
153
240
  end
154
241
 
155
242
  # Land the person back on a page that works rather than on a bare 404. This
156
243
  # is unreachable through the UI — the row that posts here is not rendered —
157
244
  # so the wording is for whoever is poking at the endpoint directly.
158
- def unsupported(attribute)
245
+ def unsupported(label)
159
246
  redirect_to profile_path,
160
- alert: "This app has no #{attribute.to_s.tr("_", " ")} to change.",
247
+ alert: "This app has no #{label} to change.",
161
248
  status: :see_other
162
249
  end
163
250
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The two emails an email change sends.
5
+ #
6
+ # NAMESPACED, and that is load-bearing rather than tidiness. `app/mailers` is an
7
+ # autoload path and this engine is not isolated, so a host that defines a
8
+ # top-level `UserMailer` SHADOWS the engine's outright. Two consumers do:
9
+ # mcritchie-studio (which defines no email-change actions, so a bare call would
10
+ # raise NoMethodError) and turf-monster (which defines `email_change_confirmation`
11
+ # and `email_change_notification` ALREADY, with its own signature and its own
12
+ # `confirm_email_change_url` pointing at /account). That second case is the
13
+ # dangerous one: no exception, no warning, and the engine's flow quietly mails a
14
+ # link to turf's route instead of its own.
15
+ #
16
+ # Under `Studio::` nothing shadows it, following Studio::NewsletterMailer.
17
+ #
18
+ # ONE EMAIL, and it goes to the address being LEFT:
19
+ #
20
+ # email_change_notification → the OLD address, after the change lands. With
21
+ # the change applying directly from any signed-in session, this mail is the
22
+ # only thing that makes a change nobody made VISIBLE to the person losing
23
+ # the account. That is why it survives and why it is sent even though
24
+ # nothing about it is required for the change to work.
25
+ class ProfileMailer < ApplicationMailer
26
+ layout "branded_mailer"
27
+
28
+ # To the OLD address, after the swap landed.
29
+ def email_change_notification(user, old_email, new_email)
30
+ @user = user
31
+ @app_name = Studio.app_name
32
+ @old_email = old_email
33
+ @new_email = new_email
34
+
35
+ @banner = Studio::Banner.for(:email_change_notification, name: display_name_for(user))
36
+ @banner_url = Studio::EmailCatalog.resolved_url(:email_change_notification)
37
+ @banner_alt = [@banner&.header, "your #{@app_name} email was changed"].compact.join(" — ")
38
+
39
+ mail(to: old_email, subject: Studio::EmailCatalog.subject_for(:email_change_notification, name: display_name_for(user)))
40
+ end
41
+
42
+ private
43
+
44
+ # The engine's User contract guarantees display_name, but this mailer is also
45
+ # reachable from a host whose model is mid-migration; a name is a nicety and
46
+ # must never be the reason an email fails to send.
47
+ def display_name_for(user)
48
+ return nil unless user.respond_to?(:display_name)
49
+
50
+ user.display_name
51
+ rescue StandardError
52
+ nil
53
+ end
54
+ end
55
+ end
@@ -193,6 +193,27 @@ module Studio
193
193
  # inherited email with no preview is a row on every app's manager that
194
194
  # cannot be looked at.
195
195
  preview: -> { Studio::NewsletterMailer.subscribed("preview@example.com", name: "Alex") }
196
+ },
197
+ {
198
+ key: "email_change_notification",
199
+ label: "Email change — heads up",
200
+ description: "Sent to the OLD address after a change lands, so an unauthorized change is visible rather than silent.",
201
+ # Reuses the shipped email-change artwork. The asset is named for a
202
+ # confirmation email that no longer exists; the picture is right for the
203
+ # subject either way, and renaming a shipped asset is its own migration.
204
+ default_asset: "emails/email-change-confirmation.gif",
205
+ header: "Your email was changed",
206
+ header_fallback: "Your email was changed",
207
+ subtext: "a heads-up from {app}",
208
+ subject: "Your {app} email was changed",
209
+ body: "The email address on your {app} account was just changed. " \
210
+ "If you did not do this, contact us straight away — this message " \
211
+ "was sent to your previous address on purpose.",
212
+ # No button: there is nothing to click. The point is the notice itself.
213
+ supports_cta: false,
214
+ preview: lambda {
215
+ Studio::ProfileMailer.email_change_notification(nil, "old@example.com", "new@example.com")
216
+ }
196
217
  }
197
218
  ].freeze
198
219
 
@@ -0,0 +1,30 @@
1
+ <%# Body only — branded_mailer supplies the banner + card.
2
+
3
+ NO BUTTON, deliberately: there is nothing to click. This email exists so an
4
+ unauthorised change is VISIBLE to the person losing the account, and the
5
+ catalogue entry sets supports_cta: false to match. Adding a CTA here would
6
+ invite someone to click their way somewhere from a message that may itself
7
+ be the first sign of a compromise.
8
+ %>
9
+ <%
10
+ body = @body.presence || Studio::EmailCatalog.body(:email_change_notification)
11
+ %>
12
+ <h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">Your email was changed</h1>
13
+
14
+ <% if body.present? %>
15
+ <%= simple_format body, { style: "margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;" }, sanitize: true %>
16
+ <% end %>
17
+
18
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px;">
19
+ <tr>
20
+ <td style="padding:14px 16px;background:#f4f6f3;border-radius:10px;font-size:15px;line-height:1.6;color:#3f4a3c;">
21
+ <strong style="color:#2f3a2c;">Was:</strong> <%= @old_email %><br>
22
+ <strong style="color:#2f3a2c;">Now:</strong> <%= @new_email %>
23
+ </td>
24
+ </tr>
25
+ </table>
26
+
27
+ <p style="margin:28px 0 0;font-size:13px;line-height:1.5;color:#6b756a;text-align:center;">
28
+ This message was sent to <strong style="color:#2f3a2c;"><%= @old_email %></strong> — your previous
29
+ address — on purpose, so a change you did not make cannot happen quietly.
30
+ </p>
@@ -0,0 +1,11 @@
1
+ The email address on your <%= @app_name %> account was just changed.
2
+
3
+ Was: <%= @old_email %>
4
+ Now: <%= @new_email %>
5
+
6
+ If you did not do this, contact us straight away.
7
+
8
+ This message was sent to <%= @old_email %> — your previous address — on purpose,
9
+ so a change you did not make cannot happen quietly.
10
+
11
+ — <%= @app_name %>
@@ -0,0 +1,55 @@
1
+ <%# Email row.
2
+
3
+ Locals: user (required).
4
+
5
+ DIRECT CHANGE from any signed-in session (operator's call, 2026-08-14). The
6
+ earlier build mailed a confirmation link to the current address and applied
7
+ the change only when someone clicked it; the session is the authority now.
8
+ The row therefore saves what you type, and the copy says so plainly rather
9
+ than implying a pending step that no longer exists.
10
+
11
+ THE GOOGLE EXCEPTION. An account with a linked Google identity cannot change
12
+ its email here — Google is the authoritative source for that address, and
13
+ letting the two drift means the next OAuth sign-in either re-links to a
14
+ stranger's row or cannot find its own. The locked branch renders NO form at
15
+ all — there is nothing to type into and nothing to submit — and
16
+ ProfilesController#email refuses the request independently anyway, because a
17
+ missing form is a courtesy and anyone can POST.
18
+
19
+ Gated on a real delta for the same reason the name row is — an empty field,
20
+ or the address you already have, is not a change.
21
+ %>
22
+ <% locked = Studio::OauthIdentity.google_linked?(user) %>
23
+ <% current = user.email.to_s.strip %>
24
+
25
+ <% if locked %>
26
+ <p class="text-sm text-secondary mb-1">Current address</p>
27
+ <p class="text-heading font-semibold mb-3 break-all"><%= user.email %></p>
28
+ <div class="rounded-lg p-3 flex items-start gap-2" style="background: var(--color-surface-alt);">
29
+ <span aria-hidden="true">🔒</span>
30
+ <p class="text-muted text-xs">
31
+ This comes from your linked Google account. Unlink Google below if you want to change it.
32
+ </p>
33
+ </div>
34
+ <% else %>
35
+ <%= form_with url: profile_email_path, method: :patch, scope: :profile,
36
+ data: { turbo: false },
37
+ html: { "x-data": "{ current: #{current.downcase.to_json}, value: #{current.to_json} }" },
38
+ class: "flex flex-wrap items-end gap-3" do |form| %>
39
+ <div class="flex-1 min-w-0" style="min-width: 14rem;">
40
+ <label class="block text-sm text-secondary mb-2 font-medium" for="profile_email">Email</label>
41
+ <%= form.email_field :email, value: user.email, autocomplete: "email",
42
+ placeholder: "you@example.com", class: "input-field",
43
+ "x-model": "value" %>
44
+ </div>
45
+ <%= form.submit "Update",
46
+ class: "btn btn-primary",
47
+ ":disabled": "value.trim() === '' || value.trim().toLowerCase() === current",
48
+ ":class": "(value.trim() === '' || value.trim().toLowerCase() === current) && 'opacity-50 cursor-not-allowed'" %>
49
+ <% end %>
50
+ <% if current.present? %>
51
+ <p class="text-muted text-xs mt-2">
52
+ We'll let <span class="font-semibold"><%= user.email %></span> know it changed, and sign out your other devices.
53
+ </p>
54
+ <% end %>
55
+ <% end %>
@@ -2,17 +2,29 @@
2
2
 
3
3
  Locals: user (required).
4
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.
5
+ PREFILLED, with the Update button to the right of the input and DISABLED
6
+ until the value actually differs (operator's call, 2026-08-14). A blank field
7
+ made you retype a name you had already set just to see what it was, and an
8
+ always-live Save invited a no-op write that flashed "Name updated." over a
9
+ change nobody made.
9
10
 
10
- The same column is written by the onboarding step
11
- (Studio::OnboardingController#first_name); this is the surface for changing it
12
- later.
11
+ The delta is computed against the value the server rendered, and both sides
12
+ are trimmed — leading or trailing whitespace is not a change, and the
13
+ controller trims on the way in too, so the button agrees with what the server
14
+ would actually do.
15
+
16
+ NO-JS FALLBACK: the button carries no `disabled` attribute in the markup;
17
+ Alpine adds it on init. A page whose JS never ran therefore gets a working,
18
+ always-enabled Save rather than a control it can never turn on — the server
19
+ re-checks anyway.
20
+
21
+ This row only renders when the host's user model answers `first_name`
22
+ (Studio::ProfileSections drops it otherwise), so nothing here re-checks.
13
23
  %>
14
24
  <%= form_with url: profile_path, method: :patch, scope: :profile,
15
- data: { turbo: false }, class: "flex flex-wrap items-end gap-3" do |form| %>
25
+ data: { turbo: false },
26
+ html: { "x-data": "{ initial: #{user.first_name.to_s.strip.to_json}, value: #{user.first_name.to_s.strip.to_json} }" },
27
+ class: "flex flex-wrap items-end gap-3" do |form| %>
16
28
  <div class="flex-1 min-w-0" style="min-width: 12rem;">
17
29
  <label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
18
30
  <%= form.text_field :first_name,
@@ -20,7 +32,11 @@
20
32
  maxlength: Studio::FIRST_NAME_MAX_LENGTH,
21
33
  autocomplete: "given-name",
22
34
  placeholder: "What should we call you?",
23
- class: "input-field" %>
35
+ class: "input-field",
36
+ "x-model": "value" %>
24
37
  </div>
25
- <%= form.submit "Save", class: "btn btn-primary" %>
38
+ <%= form.submit "Update",
39
+ class: "btn btn-primary",
40
+ ":disabled": "value.trim() === initial.trim()",
41
+ ":class": "value.trim() === initial.trim() && 'opacity-50 cursor-not-allowed'" %>
26
42
  <% end %>
@@ -4,10 +4,15 @@
4
4
  application layout and inherits that app's navbar, theme and flash. Same
5
5
  shape as /admin/style and /admin/emails.
6
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.
7
+ ONE CARD, rows inside it (operator's call, 2026-08-14). Each row used to be
8
+ its own card, which read as four unrelated settings pages stacked up rather
9
+ than one profile. Rows are separated by a hairline instead, so the page has a
10
+ single edge and the eye runs down one column.
11
+
12
+ The page is still nothing but its sections. @profile_sections is already
13
+ resolved and filtered by the controller (Studio.profile_sections_for) — rows
14
+ the host cannot serve, and admin rows for non-admins, are gone before we get
15
+ here — so this template makes no decisions of its own beyond the empty state.
11
16
  %>
12
17
  <% content_for(:title, "Profile") %>
13
18
 
@@ -21,10 +26,10 @@
21
26
  each ship their OWN app/views/studio/modals/_host.html.erb, which shadows the
22
27
  engine's in this non-isolated engine, so the page would silently get their
23
28
  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. %>
29
+ app. /admin/emails reached the same conclusion first. %>
30
+ <%# Mounted ONCE per page, not once per row. Two hosts sharing a store name
31
+ would register the same modal ids twice and render duplicate cards, so the
32
+ page owns the host and the rows only ask for it. %>
28
33
  <% profile_needs_modals = Array(@profile_sections).any? { |section| section[:modals] } %>
29
34
 
30
35
  <% if profile_needs_modals %>
@@ -46,11 +51,14 @@
46
51
  message: "Nothing to edit yet",
47
52
  detail: "This app has not enabled any profile fields." %>
48
53
  <% else %>
49
- <div class="space-y-6">
50
- <% @profile_sections.each do |section| %>
51
- <section class="card p-6" data-profile-section="<%= section[:key] %>">
54
+ <div class="card p-0 overflow-hidden">
55
+ <% @profile_sections.each_with_index do |section, index| %>
56
+ <%# The divider is a TOP border on every row but the first, so a row can
57
+ be added or dropped anywhere without leaving a trailing rule. %>
58
+ <section class="p-6 <%= "border-t border-subtle" unless index.zero? %>"
59
+ data-profile-section="<%= section[:key] %>">
52
60
  <% if section[:title].present? %>
53
- <h2 class="text-lg font-bold text-heading mb-4"><%= section[:title] %></h2>
61
+ <h2 class="text-sm font-bold text-heading mb-4"><%= section[:title] %></h2>
54
62
  <% end %>
55
63
  <%= render section[:partial], **section.fetch(:locals, {}).merge(user: current_user) %>
56
64
  </section>
@@ -63,8 +63,10 @@ module Studio
63
63
  partial: "studio/profiles/avatar_section", requires: :avatar, modals: true },
64
64
  { key: :first_name, title: "Your name",
65
65
  partial: "studio/profiles/first_name_section", requires: :first_name },
66
+ { key: :email, title: "Email",
67
+ partial: "studio/profiles/email_section", requires: :email },
66
68
  # 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
69
+ # the same question app/views/sessions/new.html.erb already asks before
68
70
  # drawing this identical button on the login page.
69
71
  { key: :google, title: "Google account",
70
72
  partial: "studio/profiles/google_section", requires: %i[provider uid],
@@ -116,9 +118,21 @@ module Studio
116
118
  # that silently does nothing, in the same permissive direction as the bug
117
119
  # this whole `if:` key was added to fix. A host would have had no signal.
118
120
  if condition.is_a?(Symbol) || condition.is_a?(String)
119
- return false unless view.respond_to?(condition)
121
+ unless view.respond_to?(condition)
122
+ # SILENT would repeat the bug this key was added to fix. The old
123
+ # coercion gave the host no signal; dropping the row without one only
124
+ # moves the silence somewhere safer. A typo'd gate now says so.
125
+ warn_gate("profile_sections: `if: #{condition.inspect}` names a method the view " \
126
+ "does not answer — the row was dropped. Check the spelling.")
127
+ return false
128
+ end
120
129
 
121
- return !!view.public_send(condition)
130
+ # Arity-aware, matching the lambda branch below: a predicate written as
131
+ # `def visible?(view)` is as natural as one written without an argument,
132
+ # and calling it wrong raises ArgumentError — which surfaces as a 500 on
133
+ # /profile rather than as a dropped row.
134
+ method = view.method(condition)
135
+ return !!(method.arity.zero? ? view.public_send(condition) : view.public_send(condition, view))
122
136
  end
123
137
 
124
138
  return !!condition unless condition.respond_to?(:call)
@@ -137,6 +151,15 @@ module Studio
137
151
  needed.all? { |attribute| user.respond_to?(attribute) }
138
152
  end
139
153
 
154
+ # Pure Ruby: this file loads without Rails, so it cannot assume a logger.
155
+ def warn_gate(message)
156
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
157
+ Rails.logger.warn(message)
158
+ else
159
+ Kernel.warn(message)
160
+ end
161
+ end
162
+
140
163
  def symbolize(hash)
141
164
  out = hash.to_h.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
142
165
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.48.0"
2
+ VERSION = "0.49.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -687,8 +687,10 @@ module Studio
687
687
  patch "profile/avatar", to: "studio/profiles#avatar", as: :profile_avatar
688
688
  # DELETE, because unlinking removes an identity. Linking is not drawn
689
689
  # here: it is OmniAuth's own /auth/:provider, which the middleware owns.
690
+ patch "profile/email", to: "studio/profiles#email", as: :profile_email
690
691
  delete "profile/google", to: "studio/profiles#unlink_google",
691
692
  as: :profile_unlink_google
693
+
692
694
  end
693
695
 
694
696
  resources :error_logs, only: [:index, :show]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.48.0
4
+ version: 0.49.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -216,6 +216,7 @@ files:
216
216
  - app/jobs/studio/email_delivery_job.rb
217
217
  - app/mailers/application_mailer.rb
218
218
  - app/mailers/studio/newsletter_mailer.rb
219
+ - app/mailers/studio/profile_mailer.rb
219
220
  - app/mailers/user_mailer.rb
220
221
  - app/models/concerns/sluggable.rb
221
222
  - app/models/concerns/studio/board/rankable.rb
@@ -335,7 +336,10 @@ files:
335
336
  - app/views/studio/models/show.html.erb
336
337
  - app/views/studio/newsletter_mailer/subscribed.html.erb
337
338
  - app/views/studio/newsletter_mailer/subscribed.text.erb
339
+ - app/views/studio/profile_mailer/email_change_notification.html.erb
340
+ - app/views/studio/profile_mailer/email_change_notification.text.erb
338
341
  - app/views/studio/profiles/_avatar_section.html.erb
342
+ - app/views/studio/profiles/_email_section.html.erb
339
343
  - app/views/studio/profiles/_first_name_section.html.erb
340
344
  - app/views/studio/profiles/_google_section.html.erb
341
345
  - app/views/studio/profiles/show.html.erb