studio-engine 0.48.0 → 0.50.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 +94 -0
- data/app/controllers/studio/profiles_controller.rb +174 -54
- 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/_birthday_fields.html.erb +31 -0
- data/app/views/studio/profiles/_editable_identity.html.erb +35 -0
- data/app/views/studio/profiles/_email_fields.html.erb +37 -0
- data/app/views/studio/profiles/_form_script.html.erb +65 -0
- data/app/views/studio/profiles/_identity.html.erb +41 -0
- data/app/views/studio/profiles/_identity_body.html.erb +44 -0
- data/app/views/studio/profiles/_identity_mini.html.erb +106 -0
- data/app/views/studio/profiles/_identity_styles.html.erb +96 -0
- data/app/views/studio/profiles/_name_fields.html.erb +34 -0
- data/app/views/studio/profiles/_pencil_icon.html.erb +7 -0
- data/app/views/studio/profiles/_save_bar.html.erb +39 -0
- data/app/views/studio/profiles/edit.html.erb +111 -0
- data/app/views/studio/profiles/show.html.erb +13 -58
- data/lib/studio/profile_sections.rb +69 -22
- data/lib/studio/sidebar_sections.rb +47 -3
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -2
- metadata +16 -3
- data/app/views/studio/profiles/_avatar_section.html.erb +0 -93
- data/app/views/studio/profiles/_first_name_section.html.erb +0 -26
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e29528d33a9e256697398f86592a44caa7631acee8034148bd1eb3d5c5a877ec
|
|
4
|
+
data.tar.gz: 62d6f242a0d5f147d99ef0fa378d0f79ec0b11b84af51928ae3a4fddbc5d4862
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 359a84187b7b63daba90c5ee89e5dc25c52291b922e05ad6e991d1bc0da141b58e096b8a1ceb0b293c19faf0e35dd272bdd5c31b0e3dc2358dda82876e526c7d
|
|
7
|
+
data.tar.gz: d22644b76b30889cd712c02a188b63d0d7287ed368c40c27a797afc48058eca1cd4e97a60dc1e38e2321f31e4cab95acb5029112420f4a0f1953c1ae53362ed5
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **The link sidebar leads with a Profile link, shipped by the engine.** The
|
|
10
|
+
engine ships `/profile`, so it now ships the way in rather than asking five
|
|
11
|
+
apps to declare the same entry and watching them drift in wording and emoji.
|
|
12
|
+
`Studio::SidebarSections.resolve` prepends a `You` section; a host's own
|
|
13
|
+
sections follow in their declared order.
|
|
14
|
+
|
|
15
|
+
Two gates, both load-bearing:
|
|
16
|
+
|
|
17
|
+
- **`Studio.draw_profile_routes`** — an app that turned the page off must not
|
|
18
|
+
be handed a menu item pointing at a route that does not exist.
|
|
19
|
+
- **signed in** — `/profile` requires authentication, so offering it to a
|
|
20
|
+
signed-out visitor bounces them to login from something that looked like
|
|
21
|
+
navigation. Same shape as the existing `admin:` rule.
|
|
22
|
+
|
|
23
|
+
**WHO ACTUALLY RECEIVES THIS — check before assuming your app does.** The
|
|
24
|
+
trigger renders from `layouts/_navbar`, and two consumers fork that partial:
|
|
25
|
+
|
|
26
|
+
| Consumer | Effect |
|
|
27
|
+
|---|---|
|
|
28
|
+
| mcritchie-studio | Profile link added to the top of its existing sidebar |
|
|
29
|
+
| mcritchie-industries | same |
|
|
30
|
+
| acquisition-studio | **gains a sidebar it did not have** — it declared no sections, and now has one |
|
|
31
|
+
| turf-monster, moms-app | **no change** — both fork `layouts/_navbar` and render no sidebar trigger, so a bump does not deliver this |
|
|
32
|
+
|
|
33
|
+
That last row is the standing rule in this engine: a host view shadows the
|
|
34
|
+
engine's, so a partial change reaches only the apps that still render the
|
|
35
|
+
engine's copy.
|
|
36
|
+
|
|
37
|
+
**`studio_sidebar?` is now true in any signed-in app** that renders the engine
|
|
38
|
+
navbar, where it previously depended on the host declaring something. That is
|
|
39
|
+
the intended consequence — the sidebar always has at least the viewer's own
|
|
40
|
+
profile in it — but it is a visible change to a navbar every consumer inherits.
|
|
41
|
+
|
|
7
42
|
### Fixed
|
|
8
43
|
|
|
9
44
|
- **`Studio.profile_sections`' `if:` gate no longer fails open on a Symbol.**
|
|
@@ -17,6 +52,65 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
17
52
|
|
|
18
53
|
### Added
|
|
19
54
|
|
|
55
|
+
- **`/profile` splits into a read page and an edit page.** `/profile` is now "you
|
|
56
|
+
at a glance" — the identity header, and the rows you look at rather than type
|
|
57
|
+
into. `/profile/edit` (`edit_profile_path`) holds the fields, in ONE form with
|
|
58
|
+
ONE save.
|
|
59
|
+
|
|
60
|
+
A section declares which page it belongs to with **`page:`** — `:show` or
|
|
61
|
+
`:edit`. A row that says nothing defaults to `:edit`, because someone adding a
|
|
62
|
+
row is usually adding a field, and the read page is a curated surface. Existing
|
|
63
|
+
hosts need no change: `Studio.profile_sections_for(view)` without a `page:`
|
|
64
|
+
still returns every row.
|
|
65
|
+
|
|
66
|
+
**The identity header** (avatar, display name, address) renders on both pages
|
|
67
|
+
and is where the avatar now lives — it stopped being a row when it stopped
|
|
68
|
+
looking like one. On the read page the whole card is the link through to
|
|
69
|
+
editing, and its badge glyph fades in on hover; on the edit page the badge is a
|
|
70
|
+
button that opens the cropper.
|
|
71
|
+
|
|
72
|
+
**The save bar is sticky**, so it never depends on where the reader is in the
|
|
73
|
+
scroll, and it stays out of the way until a field is actually dirty. Without
|
|
74
|
+
JavaScript it renders as a plain always-visible submit — the form still saves.
|
|
75
|
+
|
|
76
|
+
**The header morphs on scroll.** Once the card leaves the viewport a compact
|
|
77
|
+
bar takes its place beneath the navbar, matched to the card's own width, so who
|
|
78
|
+
you are stays on screen down a long page. It is `aria-hidden` and
|
|
79
|
+
non-interactive: it duplicates what is already in the document rather than
|
|
80
|
+
adding a second copy for a screen reader to read out. Honors
|
|
81
|
+
`prefers-reduced-motion`.
|
|
82
|
+
|
|
83
|
+
- **`/profile` gains the Email field — changeable from any signed-in session.**
|
|
84
|
+
It saves with the rest of the edit form through `PATCH /profile`.
|
|
85
|
+
|
|
86
|
+
**THE GOOGLE EXCEPTION.** An account with a linked Google identity **cannot**
|
|
87
|
+
change its email: Google is the authoritative source for that address, and
|
|
88
|
+
letting the two drift means the next OAuth sign-in either re-links to a
|
|
89
|
+
stranger's row or cannot find its own. The locked branch renders **no input at
|
|
90
|
+
all** — it shows the address, the reason, and a link to the read page where the
|
|
91
|
+
unlink control lives. The server refuses the change independently of what the
|
|
92
|
+
page drew, because anyone can POST.
|
|
93
|
+
|
|
94
|
+
**What a direct change trades away, stated so nobody rediscovers it:** a
|
|
95
|
+
hijacked session can move the account to another inbox, and the old address has
|
|
96
|
+
no veto. Two protections are kept precisely BECAUSE the veto is gone —
|
|
97
|
+
|
|
98
|
+
- the **old address is mailed** after every change (OPSEC-046), so a change
|
|
99
|
+
nobody made is visible to the person losing the account, and
|
|
100
|
+
- **every other session is invalidated** (OPSEC-045), so a hijacker holding a
|
|
101
|
+
second cookie loses it the moment the address moves. The session that made
|
|
102
|
+
the change is re-established, so the person doing it is not signed out.
|
|
103
|
+
|
|
104
|
+
- **`Studio::ProfileMailer`** with `email_change_notification`, plus a new
|
|
105
|
+
standard `Studio::EmailCatalog` entry so its copy, subject and banner are
|
|
106
|
+
operator-editable on `/admin/emails` like every other Studio email. **Namespaced deliberately:**
|
|
107
|
+
`app/mailers` is an autoload path and this engine is not isolated, so a host's
|
|
108
|
+
top-level `UserMailer` shadows the engine's — mcritchie-studio's fork defines no
|
|
109
|
+
`email_change_notification` at all (a bare call raises `NoMethodError`), and
|
|
110
|
+
turf-monster's defines one with the SAME signature — so there the call would
|
|
111
|
+
not raise, it would quietly send TURF's copy, banner and `account_url` instead
|
|
112
|
+
of the engine's. The silent case is the dangerous one.
|
|
113
|
+
|
|
20
114
|
- **`/profile` gains the Google account row.** Shows the linked identity with an
|
|
21
115
|
Unlink control, or a branded Connect button that POSTs to OmniAuth's own
|
|
22
116
|
`/auth/google_oauth2` (the engine does not draw a link route — the middleware
|
|
@@ -33,73 +33,81 @@ module Studio
|
|
|
33
33
|
MAX_FIRST_NAME = Studio::FIRST_NAME_MAX_LENGTH
|
|
34
34
|
|
|
35
35
|
def show
|
|
36
|
-
@profile_sections = Studio.profile_sections_for(view_context)
|
|
36
|
+
@profile_sections = Studio.profile_sections_for(view_context, page: :show)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
#
|
|
39
|
+
# GET /profile/edit — the form. Read and edit are separate pages (operator's
|
|
40
|
+
# call, 2026-08-14): /profile is "you at a glance", this is where you change
|
|
41
|
+
# things.
|
|
42
|
+
def edit
|
|
43
|
+
@profile_sections = Studio.profile_sections_for(view_context, page: :edit)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# PATCH /profile — every editable field, in one request.
|
|
47
|
+
#
|
|
48
|
+
# ONE FORM, ONE SAVE (operator's call). Email used to be its own action, and
|
|
49
|
+
# needed to be only while it was out-of-band; now that it applies directly
|
|
50
|
+
# there is no reason for a second mechanism on the same page. Its side
|
|
51
|
+
# effects stay here rather than moving into the form.
|
|
52
|
+
#
|
|
53
|
+
# Each field is applied only if its ROW would render — the same resolver the
|
|
54
|
+
# page uses — so a host that cannot serve a field cannot have one posted into
|
|
55
|
+
# it either.
|
|
40
56
|
def update
|
|
41
|
-
|
|
57
|
+
attrs = {}
|
|
58
|
+
attrs.merge!(name_attributes) if row_rendered?(:name)
|
|
59
|
+
attrs.merge!(birthday_attributes) if row_rendered?(:birthday)
|
|
42
60
|
|
|
43
|
-
|
|
61
|
+
# May redirect (the Google lock); if it did, we are done.
|
|
62
|
+
prepare_email_change(attrs) if row_rendered?(:email)
|
|
63
|
+
return if performed?
|
|
44
64
|
|
|
45
|
-
if
|
|
46
|
-
return redirect_to
|
|
65
|
+
if attrs.empty?
|
|
66
|
+
return redirect_to edit_profile_path, alert: "Nothing to save.", status: :see_other
|
|
47
67
|
end
|
|
48
68
|
|
|
69
|
+
previous_email = current_user.email.to_s if current_user.respond_to?(:email)
|
|
70
|
+
|
|
49
71
|
rescue_and_log(target: current_user) do
|
|
50
|
-
attrs
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
attrs[:name] = value if current_user.respond_to?(:name) && current_user.name.blank?
|
|
55
|
-
|
|
56
|
-
if current_user.update(attrs)
|
|
57
|
-
# Read back rather than trusting the write. A host whose before_save
|
|
58
|
-
# DERIVES first_name from name (turf-monster's set_name_parts does
|
|
59
|
-
# exactly that) would silently discard the value, and a flash saying
|
|
60
|
-
# "Saved" over a discarded write is worse than a plain failure.
|
|
61
|
-
# Reporting what actually persisted keeps the page honest on a host the
|
|
62
|
-
# engine has not met yet.
|
|
63
|
-
persisted = current_user.reload.first_name.to_s
|
|
64
|
-
|
|
65
|
-
if persisted == value
|
|
66
|
-
redirect_to profile_path, notice: "Name updated."
|
|
67
|
-
else
|
|
68
|
-
redirect_to profile_path,
|
|
69
|
-
alert: "This app derives your name from another field — it saved as #{persisted.presence || "blank"}.",
|
|
70
|
-
status: :see_other
|
|
71
|
-
end
|
|
72
|
-
else
|
|
73
|
-
redirect_to profile_path,
|
|
74
|
-
alert: current_user.errors.full_messages.to_sentence.presence || "Could not save that name.",
|
|
75
|
-
status: :see_other
|
|
72
|
+
unless current_user.update(attrs)
|
|
73
|
+
next redirect_to edit_profile_path, status: :see_other,
|
|
74
|
+
alert: current_user.errors.full_messages.to_sentence.presence ||
|
|
75
|
+
"Could not save those changes."
|
|
76
76
|
end
|
|
77
|
+
|
|
78
|
+
after_email_change(previous_email) if attrs.key?(:email)
|
|
79
|
+
|
|
80
|
+
redirect_to profile_path, notice: "Profile updated."
|
|
77
81
|
end
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
# PATCH /profile/avatar — the picture, on its own route.
|
|
81
85
|
#
|
|
82
86
|
# SEPARATE FROM #update deliberately: an attachment param submitted empty
|
|
83
|
-
# PURGES the attachment, so a combined form
|
|
84
|
-
# someone's
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
+
# PURGES the attachment, so a combined form carrying both would delete
|
|
88
|
+
# someone's photo every time they saved a name. A separate route makes that
|
|
89
|
+
# unreachable rather than merely avoided.
|
|
90
|
+
#
|
|
91
|
+
# GUARDED DIFFERENTLY FROM THE FIELD ROWS, and the difference is real: the
|
|
92
|
+
# avatar stopped being a row when it moved into the identity header, so
|
|
93
|
+
# "would its row render?" has no answer for it. The MODEL gate is the right
|
|
94
|
+
# question here — can this host's user hold an attachment at all.
|
|
87
95
|
def avatar
|
|
88
|
-
return unsupported(
|
|
96
|
+
return unsupported("profile photo") unless avatar_supported?
|
|
89
97
|
|
|
90
98
|
file = params.dig(:profile, :avatar)
|
|
91
99
|
|
|
92
100
|
if file.blank?
|
|
93
|
-
return redirect_to
|
|
101
|
+
return redirect_to edit_profile_path, alert: "Choose an image first.", status: :see_other
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
unless Studio::ProfileImage.acceptable?(file)
|
|
97
|
-
return redirect_to
|
|
105
|
+
return redirect_to edit_profile_path, alert: Studio::ProfileImage::MESSAGE, status: :see_other
|
|
98
106
|
end
|
|
99
107
|
|
|
100
108
|
rescue_and_log(target: current_user) do
|
|
101
109
|
current_user.avatar.attach(file)
|
|
102
|
-
redirect_to
|
|
110
|
+
redirect_to edit_profile_path, notice: "Photo updated."
|
|
103
111
|
end
|
|
104
112
|
end
|
|
105
113
|
|
|
@@ -116,7 +124,7 @@ module Studio
|
|
|
116
124
|
# column: an app with an email column that does not offer magic-link sign-in
|
|
117
125
|
# cannot use it to get back in.
|
|
118
126
|
def unlink_google
|
|
119
|
-
return unsupported(
|
|
127
|
+
return unsupported("Google account") unless row_rendered?(:google)
|
|
120
128
|
|
|
121
129
|
unless Studio::OauthIdentity.google_linked?(current_user)
|
|
122
130
|
return redirect_to profile_path, alert: "No Google account is linked.", status: :see_other
|
|
@@ -136,35 +144,147 @@ module Studio
|
|
|
136
144
|
|
|
137
145
|
private
|
|
138
146
|
|
|
139
|
-
#
|
|
147
|
+
# Would the page render this row for this viewer?
|
|
140
148
|
#
|
|
141
|
-
# A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row
|
|
149
|
+
# A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row this host
|
|
142
150
|
# cannot serve, so nobody SEES a first-name form in an app without the
|
|
143
151
|
# column — but the endpoint stays open to anyone who posts to it, and
|
|
144
152
|
# 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.
|
|
153
|
+
# row. Three of the five consumers have an avatar attachment and no
|
|
154
|
+
# first_name column right now.
|
|
148
155
|
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
156
|
+
# ASKED OF THE RESOLVER, not of one of its rules — and that distinction was a
|
|
157
|
+
# real bug, caught in review. This used to call served_by?, which answers only
|
|
158
|
+
# the MODEL gate (`requires:`). Once rows also carried an APP gate (`if:`),
|
|
159
|
+
# the two questions came apart: mcritchie-industries drops the Google row
|
|
160
|
+
# because it offers no Google sign-in, while the endpoint — asking only about
|
|
161
|
+
# columns it does have — stayed open. The comment here claimed they "cannot
|
|
162
|
+
# drift" while they were already drifting.
|
|
163
|
+
#
|
|
164
|
+
# Asking the resolver makes that true rather than asserted: the page and the
|
|
165
|
+
# endpoint run the same code and get the same answer, whatever gates a row
|
|
166
|
+
# grows next.
|
|
167
|
+
def row_rendered?(key)
|
|
168
|
+
profile_rows.any? { |section| section[:key] == key.to_sym }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The avatar is not a row, so it asks the MODEL gate directly — see #avatar.
|
|
172
|
+
def avatar_supported?
|
|
173
|
+
Studio::ProfileSections.served_by?(current_user, :avatar)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Memoized per request: a host's `if:` may be a lambda doing real work, and a
|
|
177
|
+
# write should not pay for it more than once.
|
|
178
|
+
def profile_rows
|
|
179
|
+
@profile_rows ||= Studio.profile_sections_for(view_context)
|
|
153
180
|
end
|
|
154
181
|
|
|
155
182
|
# Land the person back on a page that works rather than on a bare 404. This
|
|
156
183
|
# is unreachable through the UI — the row that posts here is not rendered —
|
|
157
184
|
# so the wording is for whoever is poking at the endpoint directly.
|
|
158
|
-
def unsupported(
|
|
185
|
+
def unsupported(label)
|
|
159
186
|
redirect_to profile_path,
|
|
160
|
-
alert: "This app has no #{
|
|
187
|
+
alert: "This app has no #{label} to change.",
|
|
161
188
|
status: :see_other
|
|
162
189
|
end
|
|
163
190
|
|
|
191
|
+
# --- field assembly ---------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def name_attributes
|
|
194
|
+
attrs = {}
|
|
195
|
+
first = normalized_name(params.dig(:profile, :first_name))
|
|
196
|
+
attrs[:first_name] = first if first.present?
|
|
197
|
+
|
|
198
|
+
if current_user.respond_to?(:last_name)
|
|
199
|
+
last = normalized_name(params.dig(:profile, :last_name))
|
|
200
|
+
attrs[:last_name] = last if last.present?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Backfill `name` when it is blank so the display-name chain has something
|
|
204
|
+
# better than an email prefix to show. Same rule as the onboarding step.
|
|
205
|
+
if attrs[:first_name].present? && current_user.respond_to?(:name) && current_user.name.blank?
|
|
206
|
+
attrs[:name] = [attrs[:first_name], attrs[:last_name]].compact_blank.join(" ")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
attrs
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# ONE date input, THREE integer columns. The split is deliberate upstream —
|
|
213
|
+
# it keeps "how old are they" and "whose birthday is today" both cheap — so
|
|
214
|
+
# the form joins them and this takes them apart again.
|
|
215
|
+
#
|
|
216
|
+
# A blank date CLEARS all three rather than being ignored: someone deleting
|
|
217
|
+
# their birthday means it, and leaving a stale year behind would be the
|
|
218
|
+
# wrong answer to both questions above.
|
|
219
|
+
def birthday_attributes
|
|
220
|
+
raw = params.dig(:profile, :birthday)
|
|
221
|
+
return {} if raw.nil?
|
|
222
|
+
|
|
223
|
+
if raw.to_s.strip.blank?
|
|
224
|
+
return { birth_year: nil, birth_month: nil, birth_day: nil }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
date = begin
|
|
228
|
+
Date.iso8601(raw.to_s)
|
|
229
|
+
rescue ArgumentError, TypeError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
return {} if date.nil? || date > Date.current
|
|
233
|
+
|
|
234
|
+
{ birth_year: date.year, birth_month: date.month, birth_day: date.day }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Adds :email to attrs, or REDIRECTS when the change must be refused — the
|
|
238
|
+
# caller checks performed? rather than a return value.
|
|
239
|
+
def prepare_email_change(attrs)
|
|
240
|
+
value = params.dig(:profile, :email).to_s.strip
|
|
241
|
+
return nil if value.blank?
|
|
242
|
+
return nil if value.casecmp?(current_user.email.to_s)
|
|
243
|
+
|
|
244
|
+
# THE GOOGLE EXCEPTION. Google is the authoritative source for a linked
|
|
245
|
+
# account's address; letting the two drift means the next OAuth sign-in
|
|
246
|
+
# either re-links to a stranger's row or cannot find its own. The field is
|
|
247
|
+
# locked in the row and refused here — a disabled input is a courtesy, and
|
|
248
|
+
# anyone can POST.
|
|
249
|
+
if Studio::OauthIdentity.google_linked?(current_user)
|
|
250
|
+
redirect_to edit_profile_path, status: :see_other,
|
|
251
|
+
alert: "Your email comes from your linked Google account. " \
|
|
252
|
+
"Unlink Google on your profile first if you want to change it."
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
attrs[:email] = value
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The two protections kept BECAUSE a direct change leaves the old address no
|
|
261
|
+
# veto: tell it, and invalidate every other session.
|
|
262
|
+
def after_email_change(previous_email)
|
|
263
|
+
current_user.update_columns(email_verified_at: nil) if current_user.respond_to?(:email_verified_at)
|
|
264
|
+
|
|
265
|
+
# ROTATE FIRST, MAIL SECOND. Studio::Email.deliver can raise, and a mail
|
|
266
|
+
# failure between the write and the rotation would leave the address
|
|
267
|
+
# changed with every other session still live — the exact window OPSEC-045
|
|
268
|
+
# exists to close, so it closes before anything that can throw.
|
|
269
|
+
# turf-monster's own apply_email_change rotates first for the same reason.
|
|
270
|
+
if current_user.respond_to?(:regenerate_session_token!)
|
|
271
|
+
current_user.regenerate_session_token!
|
|
272
|
+
# Re-establish THIS session. Rotating without it signs out the very
|
|
273
|
+
# person who just made the change.
|
|
274
|
+
session[:session_token] = current_user.session_token if current_user.respond_to?(:session_token)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
return if previous_email.blank?
|
|
278
|
+
|
|
279
|
+
Studio::Email.deliver(Studio::ProfileMailer, :email_change_notification,
|
|
280
|
+
current_user, previous_email, current_user.email,
|
|
281
|
+
to: previous_email, user: current_user)
|
|
282
|
+
end
|
|
283
|
+
|
|
164
284
|
# Collapse runs of whitespace and cap the length. Done here rather than in a
|
|
165
285
|
# model validation because the engine does not own the host's User class.
|
|
166
|
-
def
|
|
167
|
-
|
|
286
|
+
def normalized_name(raw)
|
|
287
|
+
raw.to_s.strip.gsub(/\s+/, " ")[0, MAX_FIRST_NAME].to_s
|
|
168
288
|
end
|
|
169
289
|
end
|
|
170
290
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The one email 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,31 @@
|
|
|
1
|
+
<%# Birthday — ONE date input over THREE integer columns.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
The split is deliberate in the standard-columns migration and worth not
|
|
6
|
+
undoing: birth_day / birth_month / birth_year keep the two questions apps
|
|
7
|
+
actually ask cheap and separable — how old is this person (needs the year,
|
|
8
|
+
and the month and day for the boundary case) and whose birthday is today
|
|
9
|
+
(needs month and day, and no year at all). A single date column answers the
|
|
10
|
+
second one badly.
|
|
11
|
+
|
|
12
|
+
So the UI joins them and ProfilesController#update splits them again. A
|
|
13
|
+
native date input is used rather than three selects because a date is one
|
|
14
|
+
idea to the person entering it, and the browser already knows how to enter
|
|
15
|
+
one on every platform.
|
|
16
|
+
|
|
17
|
+
The row renders only when the host has all three columns, so nothing here
|
|
18
|
+
re-checks.
|
|
19
|
+
%>
|
|
20
|
+
<%
|
|
21
|
+
y = user.birth_year
|
|
22
|
+
m = user.birth_month
|
|
23
|
+
d = user.birth_day
|
|
24
|
+
value = (y.present? && m.present? && d.present?) ? format("%04d-%02d-%02d", y, m, d) : nil
|
|
25
|
+
%>
|
|
26
|
+
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_birthday">Date of birth</label>
|
|
27
|
+
<input type="date" name="profile[birthday]" id="profile_birthday"
|
|
28
|
+
value="<%= value %>" class="input-field"
|
|
29
|
+
autocomplete="bday" max="<%= Date.current.iso8601 %>"
|
|
30
|
+
x-model="fields.birthday">
|
|
31
|
+
<p class="text-muted text-xs mt-2">Stored as day, month and year — we use it for birthdays and age checks.</p>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%# The identity header on the EDIT page, wrapped in the uploader's Alpine scope.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
ONE COMPONENT, and it has to be: the header's badge button calls
|
|
6
|
+
`$refs.filePicker`, and Alpine resolves $refs only within the same x-data.
|
|
7
|
+
Rendering the header and the picker as siblings — which is how this was first
|
|
8
|
+
written — leaves the badge pointing at a ref that does not exist in its
|
|
9
|
+
scope, so clicking it does nothing at all. Silently.
|
|
10
|
+
|
|
11
|
+
The avatar SAVES ON ITS OWN, outside the field form. An attachment param
|
|
12
|
+
submitted empty PURGES the attachment, so an avatar input inside the bulk
|
|
13
|
+
form would delete someone's photo every time they saved a name. Nesting
|
|
14
|
+
forms is invalid HTML anyway; this sits alongside.
|
|
15
|
+
%>
|
|
16
|
+
<div x-data="imageUploadHost({
|
|
17
|
+
store: 'profileModals',
|
|
18
|
+
aspectRatio: 1,
|
|
19
|
+
filename: 'avatar.png',
|
|
20
|
+
saving: 'Saving photo…',
|
|
21
|
+
toast: false
|
|
22
|
+
})"
|
|
23
|
+
@crop-photo-confirmed.window="onCropConfirmed($event.detail)">
|
|
24
|
+
|
|
25
|
+
<%= render "studio/profiles/identity", user: user, editable: true %>
|
|
26
|
+
|
|
27
|
+
<%# Auto-submitted once a crop is confirmed; the person never touches it. %>
|
|
28
|
+
<%= form_with url: profile_avatar_path, method: :patch, scope: :profile, multipart: true,
|
|
29
|
+
html: { "x-ref": "form", class: "hidden" } do %>
|
|
30
|
+
<input type="file" name="profile[avatar]" x-ref="fileInput">
|
|
31
|
+
<% end %>
|
|
32
|
+
|
|
33
|
+
<input type="file" x-ref="filePicker" @change="onFileSelected($event)"
|
|
34
|
+
accept="<%= Studio::ProfileImage::ALLOWED_CONTENT_TYPES.join(",") %>" class="hidden">
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<%# Email field.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
LOCKED WHEN GOOGLE IS LINKED, and the message points at /profile — because
|
|
6
|
+
that is where the unlink lives. The two surfaces work in conjunction: Google
|
|
7
|
+
is read-level and visible on the read page, and unlinking it there is what
|
|
8
|
+
frees this field. A lock that does not say where the key is is just a wall.
|
|
9
|
+
|
|
10
|
+
Changing this address has consequences the form does not show: the old
|
|
11
|
+
address is told, and every other session is signed out. The line underneath
|
|
12
|
+
says so, because a field that quietly logs out your other devices is a
|
|
13
|
+
surprise worth spending two lines on.
|
|
14
|
+
%>
|
|
15
|
+
<% locked = Studio::OauthIdentity.google_linked?(user) %>
|
|
16
|
+
|
|
17
|
+
<% if locked %>
|
|
18
|
+
<p class="text-heading font-semibold mb-2 break-all"><%= user.email %></p>
|
|
19
|
+
<div class="rounded-lg p-3 flex items-start gap-2" style="background: var(--color-surface-alt);">
|
|
20
|
+
<span aria-hidden="true">🔒</span>
|
|
21
|
+
<p class="text-muted text-xs">
|
|
22
|
+
This comes from your linked Google account.
|
|
23
|
+
<%= link_to "Unlink Google on your profile", profile_path, class: "text-primary hover:underline" %>
|
|
24
|
+
to change it.
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
<% else %>
|
|
28
|
+
<input type="email" name="profile[email]" id="profile_email"
|
|
29
|
+
value="<%= user.email %>" class="input-field"
|
|
30
|
+
autocomplete="email" placeholder="you@example.com"
|
|
31
|
+
x-model="fields.email">
|
|
32
|
+
<% if user.email.present? %>
|
|
33
|
+
<p class="text-muted text-xs mt-2">
|
|
34
|
+
Changing this tells <span class="font-semibold"><%= user.email %></span> and signs out your other devices.
|
|
35
|
+
</p>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|