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 +4 -4
- data/CHANGELOG.md +30 -0
- data/app/controllers/studio/profiles_controller.rb +101 -14
- data/app/mailers/studio/profile_mailer.rb +55 -0
- data/app/services/studio/email_catalog.rb +21 -0
- data/app/views/studio/profile_mailer/email_change_notification.html.erb +30 -0
- data/app/views/studio/profile_mailer/email_change_notification.text.erb +11 -0
- data/app/views/studio/profiles/_email_section.html.erb +55 -0
- data/app/views/studio/profiles/_first_name_section.html.erb +26 -10
- data/app/views/studio/profiles/show.html.erb +20 -12
- data/lib/studio/profile_sections.rb +26 -3
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5cfb0ff6698fde0520d0d6e081ffd49d99801473831f92c18e86a9cb9dc177a1
|
|
4
|
+
data.tar.gz: 2a96a7e242ff9211d0307326e77258136b725a99d5d749a9664e5194336af72c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
146
|
-
#
|
|
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
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
245
|
+
def unsupported(label)
|
|
159
246
|
redirect_to profile_path,
|
|
160
|
-
alert: "This app has no #{
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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 },
|
|
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 "
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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="
|
|
50
|
-
<% @profile_sections.
|
|
51
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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
|