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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5cfb0ff6698fde0520d0d6e081ffd49d99801473831f92c18e86a9cb9dc177a1
4
- data.tar.gz: 2a96a7e242ff9211d0307326e77258136b725a99d5d749a9664e5194336af72c
3
+ metadata.gz: e29528d33a9e256697398f86592a44caa7631acee8034148bd1eb3d5c5a877ec
4
+ data.tar.gz: 62d6f242a0d5f147d99ef0fa378d0f79ec0b11b84af51928ae3a4fddbc5d4862
5
5
  SHA512:
6
- metadata.gz: d142923e12b84afc01d96afda2cdaf578ef422a6e34f480cad0917671140ad99bf5eb73d70b0283eed6ba4a5eece21f98ea356662a963c504fdef758ccf3b857
7
- data.tar.gz: 657e2a9742b8e92317a3128593fe50663cb2fd09ca004a1700d0dc0e0a4c6916d7f0d024c28b6098a93e6424cfacb174bdf46878cadcf9dab953b5532725e520
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` gains the Email row changeable from any signed-in session.**
21
- `PATCH /profile/email` (`profile_email_path`) applies the change directly.
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 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.
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
- # PATCH /profile — the scalar fields. Today that is the first name.
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
- return unsupported("name") unless row_rendered?(:first_name)
57
+ attrs = {}
58
+ attrs.merge!(name_attributes) if row_rendered?(:name)
59
+ attrs.merge!(birthday_attributes) if row_rendered?(:birthday)
42
60
 
43
- value = normalized_first_name
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 value.blank?
46
- return redirect_to profile_path, alert: "Enter your first name.", status: :see_other
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 = { first_name: value }
51
- # Backfill `name` when it is blank so the display-name chain has
52
- # something better than an email prefix to show. Same rule as the
53
- # onboarding step, which writes this column from the other direction.
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 that carried both would delete
84
- # someone's avatar every time they edited their name. turf-monster learned
85
- # this and branched inside its own #update; a separate route is the same
86
- # lesson expressed so the trap cannot be reintroduced.
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 row_rendered?(:avatar)
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 profile_path, alert: "Choose an image first.", status: :see_other
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 profile_path, alert: Studio::ProfileImage::MESSAGE, status: :see_other
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 profile_path, notice: "Photo updated."
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 normalized_first_name
254
- params.dig(:profile, :first_name).to_s.strip.gsub(/\s+/, " ")[0, MAX_FIRST_NAME].to_s
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 two emails an email change sends.
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 %>