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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa8ab90a0e1627e7150ed1e96a348bae677c3dc35891ccd2a46f99d857cd74d1
4
- data.tar.gz: e2a354a9d1d0533a78c63cb19233ae387a4c71f747cd5bf4570fe8d2478979de
3
+ metadata.gz: e29528d33a9e256697398f86592a44caa7631acee8034148bd1eb3d5c5a877ec
4
+ data.tar.gz: 62d6f242a0d5f147d99ef0fa378d0f79ec0b11b84af51928ae3a4fddbc5d4862
5
5
  SHA512:
6
- metadata.gz: d7def604f1a4c19da8241953cee4bfa8e8047af2a2ae0e0113628aeed9f5d59ab5fa16bfe667e89687144f0f0afb5117e1bcd1f4747ab69db9c5b892e142c820
7
- data.tar.gz: 7424031597c4168ac6a8172360b7dbc2ee584f0ddc5ac205a155fd11343f7e8f91002a40d3b0865eef0218096234aeaf1617ff2133677aa1e51c9b73d91a8cf3
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
- # 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(:first_name) unless serves?(: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(:avatar) unless serves?(: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."
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(:google_account) unless serves?(:provider) && serves?(:uid)
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
- # Can this host's user model serve this field?
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 the host
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. That is not hypothetical: three of the five consumers
146
- # (mcritchie-industries, moms-app, acquisition-studio) have an avatar
147
- # attachment and no first_name column right now.
153
+ # row. Three of the five consumers have an avatar attachment and no
154
+ # first_name column right now.
148
155
  #
149
- # The endpoint asks the SAME question the row does, through the SAME method,
150
- # so the two cannot drift into disagreeing about what this app supports.
151
- def serves?(attribute)
152
- Studio::ProfileSections.served_by?(current_user, attribute)
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(attribute)
185
+ def unsupported(label)
159
186
  redirect_to profile_path,
160
- alert: "This app has no #{attribute.to_s.tr("_", " ")} to change.",
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 normalized_first_name
167
- 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
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 %>