studio-engine 0.49.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 +69 -5
- data/app/controllers/studio/profiles_controller.rb +148 -115
- data/app/mailers/studio/profile_mailer.rb +1 -1
- 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 +8 -61
- data/lib/studio/profile_sections.rb +46 -22
- data/lib/studio/sidebar_sections.rb +47 -3
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -4
- metadata +13 -4
- data/app/views/studio/profiles/_avatar_section.html.erb +0 -93
- data/app/views/studio/profiles/_email_section.html.erb +0 -55
- data/app/views/studio/profiles/_first_name_section.html.erb +0 -42
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,15 +52,44 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
17
52
|
|
|
18
53
|
### Added
|
|
19
54
|
|
|
20
|
-
- **`/profile`
|
|
21
|
-
|
|
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`.
|
|
22
85
|
|
|
23
86
|
**THE GOOGLE EXCEPTION.** An account with a linked Google identity **cannot**
|
|
24
87
|
change its email: Google is the authoritative source for that address, and
|
|
25
88
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
29
93
|
|
|
30
94
|
**What a direct change trades away, stated so nobody rediscovers it:** a
|
|
31
95
|
hijacked session can move the account to another inbox, and the old address has
|
|
@@ -33,146 +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("profile photo") unless
|
|
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
|
|
103
|
-
end
|
|
104
|
-
end
|
|
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}."
|
|
110
|
+
redirect_to edit_profile_path, notice: "Photo updated."
|
|
176
111
|
end
|
|
177
112
|
end
|
|
178
113
|
|
|
@@ -233,6 +168,11 @@ module Studio
|
|
|
233
168
|
profile_rows.any? { |section| section[:key] == key.to_sym }
|
|
234
169
|
end
|
|
235
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
|
+
|
|
236
176
|
# Memoized per request: a host's `if:` may be a lambda doing real work, and a
|
|
237
177
|
# write should not pay for it more than once.
|
|
238
178
|
def profile_rows
|
|
@@ -248,10 +188,103 @@ module Studio
|
|
|
248
188
|
status: :see_other
|
|
249
189
|
end
|
|
250
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
|
+
|
|
251
284
|
# Collapse runs of whitespace and cap the length. Done here rather than in a
|
|
252
285
|
# model validation because the engine does not own the host's User class.
|
|
253
|
-
def
|
|
254
|
-
|
|
286
|
+
def normalized_name(raw)
|
|
287
|
+
raw.to_s.strip.gsub(/\s+/, " ")[0, MAX_FIRST_NAME].to_s
|
|
255
288
|
end
|
|
256
289
|
end
|
|
257
290
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Studio
|
|
4
|
-
# The
|
|
4
|
+
# The one email an email change sends.
|
|
5
5
|
#
|
|
6
6
|
# NAMESPACED, and that is load-bearing rather than tidiness. `app/mailers` is an
|
|
7
7
|
# autoload path and this engine is not isolated, so a host that defines a
|
|
@@ -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 %>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<%# The edit form's Alpine component.
|
|
2
|
+
|
|
3
|
+
Its own partial so the page above stays readable and this can be reasoned
|
|
4
|
+
about as behaviour rather than markup.
|
|
5
|
+
%>
|
|
6
|
+
<script>
|
|
7
|
+
window.studioProfileForm = function (initial) {
|
|
8
|
+
return {
|
|
9
|
+
alpine: true,
|
|
10
|
+
fields: Object.assign({}, initial),
|
|
11
|
+
initial: Object.assign({}, initial),
|
|
12
|
+
|
|
13
|
+
// Trimmed on both sides: whitespace is not a change, and the controller
|
|
14
|
+
// trims on the way in too — so the bar agrees with what saving would do.
|
|
15
|
+
changed(key) {
|
|
16
|
+
return String(this.fields[key] || "").trim() !== String(this.initial[key] || "").trim();
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
get changeCount() {
|
|
20
|
+
var self = this;
|
|
21
|
+
return Object.keys(this.fields).filter(function (k) { return self.changed(k); }).length;
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
get dirty() { return this.changeCount > 0; },
|
|
25
|
+
|
|
26
|
+
discard() {
|
|
27
|
+
this.fields = Object.assign({}, this.initial);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
init() {
|
|
31
|
+
var self = this;
|
|
32
|
+
|
|
33
|
+
// Guard the exit BOTH ways. beforeunload covers a hard navigation or a
|
|
34
|
+
// closed tab; turbo:before-visit covers a Turbo Drive visit, which never
|
|
35
|
+
// fires beforeunload — an app with Turbo on would otherwise drop edits
|
|
36
|
+
// silently on any in-app link.
|
|
37
|
+
this._beforeUnload = function (e) {
|
|
38
|
+
if (!self.dirty) return;
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
e.returnValue = "";
|
|
41
|
+
};
|
|
42
|
+
window.addEventListener("beforeunload", this._beforeUnload);
|
|
43
|
+
|
|
44
|
+
this._beforeVisit = function (e) {
|
|
45
|
+
if (!self.dirty) return;
|
|
46
|
+
if (!window.confirm("You have unsaved changes. Leave without saving?")) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
document.addEventListener("turbo:before-visit", this._beforeVisit);
|
|
51
|
+
|
|
52
|
+
// Submitting is not leaving. Drop both guards before the form goes, or
|
|
53
|
+
// saving prompts you about the very changes you just chose to save.
|
|
54
|
+
this.$refs.form.addEventListener("submit", function () { self.teardown(); });
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
teardown() {
|
|
58
|
+
window.removeEventListener("beforeunload", this._beforeUnload);
|
|
59
|
+
document.removeEventListener("turbo:before-visit", this._beforeVisit);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
destroy() { this.teardown(); }
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
</script>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# The "you at a glance" header — avatar, name, address — at the top of BOTH
|
|
2
|
+
/profile and /profile/edit.
|
|
3
|
+
|
|
4
|
+
Locals:
|
|
5
|
+
user (required)
|
|
6
|
+
editable (Boolean) — true on the edit page, where the avatar badge opens
|
|
7
|
+
the crop modal.
|
|
8
|
+
|
|
9
|
+
ON THE READ PAGE THE WHOLE CARD IS THE LINK to /profile/edit (operator's
|
|
10
|
+
call). It began as a badge-only link, then the page heading and the Edit
|
|
11
|
+
button were removed, and a 28px target became the single route to editing —
|
|
12
|
+
so the target grew to the thing people actually aim at.
|
|
13
|
+
|
|
14
|
+
That is also why the badge is a <span> there: an <a> inside an <a> is invalid,
|
|
15
|
+
and browsers repair it by closing the outer link early, which would quietly
|
|
16
|
+
shrink the card's clickable area to whatever preceded the badge.
|
|
17
|
+
|
|
18
|
+
THE AVATAR IS OPTIONAL — the guard the registry used to carry. As a ROW the
|
|
19
|
+
avatar declared `requires: :avatar` and was dropped whole on a host whose
|
|
20
|
+
model has no attachment; moving it into this header lost that, and the first
|
|
21
|
+
render against such a model raised `undefined method 'avatar'`.
|
|
22
|
+
%>
|
|
23
|
+
<% editable = local_assigns.fetch(:editable, false) %>
|
|
24
|
+
<% attachable = user.respond_to?(:avatar) && user.avatar.respond_to?(:attached?) %>
|
|
25
|
+
|
|
26
|
+
<%= render "studio/profiles/identity_styles" %>
|
|
27
|
+
|
|
28
|
+
<%= render "studio/profiles/identity_mini", user: user %>
|
|
29
|
+
|
|
30
|
+
<% if editable %>
|
|
31
|
+
<div data-studio-identity-full class="card p-6 mb-6 text-center">
|
|
32
|
+
<%= render "studio/profiles/identity_body", user: user, editable: true, attachable: attachable %>
|
|
33
|
+
</div>
|
|
34
|
+
<% else %>
|
|
35
|
+
<%= link_to edit_profile_path,
|
|
36
|
+
class: "studio-identity-card card p-6 mb-6 text-center block no-underline",
|
|
37
|
+
data: { studio_identity_full: true },
|
|
38
|
+
aria: { label: "Edit your profile" } do %>
|
|
39
|
+
<%= render "studio/profiles/identity_body", user: user, editable: false, attachable: attachable %>
|
|
40
|
+
<% end %>
|
|
41
|
+
<% end %>
|