studio-engine 0.47.2 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 918309978020f15861f65e0a861325bb7ba93046b4d9039aa1aaa1ae6da22c95
4
- data.tar.gz: 682e1529aeca6541d08ab5173028bd407b3538fed1e1b9dd3fb88b70b11e0145
3
+ metadata.gz: 5cfb0ff6698fde0520d0d6e081ffd49d99801473831f92c18e86a9cb9dc177a1
4
+ data.tar.gz: 2a96a7e242ff9211d0307326e77258136b725a99d5d749a9664e5194336af72c
5
5
  SHA512:
6
- metadata.gz: 3da6d45e75a8adcbb72b39117e416381b6c6962fe4b831ee2a90d4feddd9b60a4c63b969cc85d442780685d0a0c2916fe423e7dd11c1df392d2fa06a3f9267e4
7
- data.tar.gz: 40e41ee896e9375ebeb9522afb78a9ed481c57c6f15f7a2681a9a51211cf861875a82125bdc52cc6a3b5b9d9820d46e72bd6b2b961822f46ee39367300564755
6
+ metadata.gz: d142923e12b84afc01d96afda2cdaf578ef422a6e34f480cad0917671140ad99bf5eb73d70b0283eed6ba4a5eece21f98ea356662a963c504fdef758ccf3b857
7
+ data.tar.gz: 657e2a9742b8e92317a3128593fe50663cb2fd09ca004a1700d0dc0e0a4c6916d7f0d024c28b6098a93e6424cfacb174bdf46878cadcf9dab953b5532725e520
data/CHANGELOG.md CHANGED
@@ -4,8 +4,242 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Fixed
8
+
9
+ - **`Studio.profile_sections`' `if:` gate no longer fails open on a Symbol.**
10
+ `if: :some_predicate` — Rails' own `before_action ..., if: :method_name`
11
+ spelling, and so the most natural thing a host will write — was coerced
12
+ straight to `true`, because a Symbol does not answer `call`. The gate silently
13
+ did nothing, with no signal to the host, in the same permissive direction as
14
+ the bug `if:` was added to fix. Symbols and Strings now name a method on the
15
+ view; a name the view does not answer drops the row rather than rendering it.
16
+ Lambdas and plain booleans are unchanged.
17
+
18
+ ### Added
19
+
20
+ - **`/profile` gains the Email row — changeable from any signed-in session.**
21
+ `PATCH /profile/email` (`profile_email_path`) applies the change directly.
22
+
23
+ **THE GOOGLE EXCEPTION.** An account with a linked Google identity **cannot**
24
+ change its email: Google is the authoritative source for that address, and
25
+ letting the two drift means the next OAuth sign-in either re-links to a
26
+ stranger's row or cannot find its own. The row shows the address with the
27
+ reason instead of a field, and the endpoint refuses independently — a disabled
28
+ input is a courtesy, and anyone can POST.
29
+
30
+ **What a direct change trades away, stated so nobody rediscovers it:** a
31
+ hijacked session can move the account to another inbox, and the old address has
32
+ no veto. Two protections are kept precisely BECAUSE the veto is gone —
33
+
34
+ - the **old address is mailed** after every change (OPSEC-046), so a change
35
+ nobody made is visible to the person losing the account, and
36
+ - **every other session is invalidated** (OPSEC-045), so a hijacker holding a
37
+ second cookie loses it the moment the address moves. The session that made
38
+ the change is re-established, so the person doing it is not signed out.
39
+
40
+ - **`Studio::ProfileMailer`** with `email_change_notification`, plus a new
41
+ standard `Studio::EmailCatalog` entry so its copy, subject and banner are
42
+ operator-editable on `/admin/emails` like every other Studio email. **Namespaced deliberately:**
43
+ `app/mailers` is an autoload path and this engine is not isolated, so a host's
44
+ top-level `UserMailer` shadows the engine's — mcritchie-studio's fork defines no
45
+ `email_change_notification` at all (a bare call raises `NoMethodError`), and
46
+ turf-monster's defines one with the SAME signature — so there the call would
47
+ not raise, it would quietly send TURF's copy, banner and `account_url` instead
48
+ of the engine's. The silent case is the dangerous one.
49
+
50
+ - **`/profile` gains the Google account row.** Shows the linked identity with an
51
+ Unlink control, or a branded Connect button that POSTs to OmniAuth's own
52
+ `/auth/google_oauth2` (the engine does not draw a link route — the middleware
53
+ owns that path). Lifted from turf-monster's `/account` Identities card.
54
+
55
+ New route: `DELETE /profile/google` → `profile_unlink_google_path`.
56
+
57
+ **`Studio::OauthIdentity`** carries the rules, pure and duck-typed like
58
+ `Studio::ProfileImage`: `google_linked?`, `remaining_sign_ins`,
59
+ `unlink_orphans_account?`. It matches **both** provider spellings in the wild —
60
+ `google_oauth2` (the OmniAuth strategy name that lands in `users.provider`) and
61
+ `google` (what `Studio.auth_methods` calls it).
62
+
63
+ **THE ORPHAN GUARD — the reason this is not a straight copy.** turf-monster's
64
+ unlink is an unconditional `update!(provider: nil, uid: nil)`. For an account
65
+ whose only sign-in is Google — blank email so no magic link, no wallet, no
66
+ password — that silently locks someone out of their own account behind a button
67
+ labelled "Unlink". It is safe in turf today only because turf's users happen to
68
+ carry an email, which is a property of that app's **data**, not of its code.
69
+
70
+ The engine refuses instead, and gates on **`Studio.auth_methods`, not merely on
71
+ the column**: an app that has an `email` column but does not offer magic-link
72
+ sign-in cannot use it to get back in, so counting it would be exactly the wrong
73
+ answer. The row disables the button with the reason beside it; the endpoint
74
+ refuses the request independently, because a disabled button is a courtesy and
75
+ anyone can send the `DELETE`.
76
+
77
+ **What counts as a way back in is deliberately narrow**, because a false
78
+ positive here permits an unlink that orphans an account. A password counts only
79
+ via `Studio.password_login_available?` (`auth_method?(:password)` **and** the
80
+ User answering `authenticate`) — turf-monster removed `has_secure_password` and
81
+ kept the column, so its rows carry fossil digests no code can authenticate
82
+ against. A wallet counts only when the host has **explicitly** named its
83
+ signing-wallet column via `Studio.wallet_address_method`; the engine does not
84
+ guess a conventional reader, because turf's `User#solana_address` returns
85
+ `web3 || web2` and only the web3 address can sign in — the web2 one is
86
+ custodial, with no signer. An unconfigured app is treated as having no wallet
87
+ sign-in, which errs toward refusing: the cost is a refusal the operator fixes
88
+ with one config line, against someone locked out of their account.
89
+
90
+ The row declares `requires: %i[provider uid]` **and**
91
+ `if: -> { Studio.auth_method?(:google) }` — the model gate and the app gate are
92
+ different questions, and only the pair is correct. **Every** consumer's users
93
+ table carries `provider` and `uid`, so the model gate alone selected the whole
94
+ fleet; mcritchie-industries has both columns, `auth_methods = %i[magic_link]`,
95
+ and no omniauth gem at all, and would have been handed a "Link Google Account"
96
+ button leading nowhere. The engine's login page already asks the app question
97
+ before drawing this identical button (`app/views/sessions/new.html.erb`).
98
+
99
+ - **`Studio.profile_sections` rows accept `if:`** — an optional callable gating a
100
+ row on an app capability, evaluated at resolve time, called with the view when
101
+ it takes an argument and without when it does not. Distinct from `requires:`,
102
+ which asks whether the user MODEL can serve the row.
103
+
104
+ - **The shared profile page — `/profile`.** The engine now ships the account page
105
+ itself, not just the parts. `Studio::ProfilesController` renders a page of
106
+ declared rows; iteration one ships two, **change your photo** and **change your
107
+ first name**. Every consumer gets it on upgrade with no configuration.
108
+
109
+ **Why this page exists at all.** `components/_user_nav` has always linked the
110
+ username and the avatar to `defined?(account_path) ? account_path : "#"`, and
111
+ only turf-monster draws an account route — so every app that renders the
112
+ ENGINE's copy of that partial shipped a navbar whose avatar and username are
113
+ `href="#"`. A dead link looks exactly like a working one until it is clicked,
114
+ which is why it survived in production. The engine assumed a page every consumer
115
+ was expected to write for itself; now it ships the page.
116
+
117
+ **Which apps that actually is** (host views shadow engine views, so a fork does
118
+ not receive this): **mcritchie-industries and acquisition-studio** render the
119
+ engine partial and had the dead link. **mcritchie-studio** and **turf-monster**
120
+ each ship their own `app/views/components/_user_nav.html.erb` and are unaffected
121
+ either way; **moms-app** forks `layouts/_navbar` and renders no user nav at all.
122
+
123
+ **Why `/profile` and not `/account`.** turf-monster owns `AccountsController`
124
+ and the `account_path` helper. A shared `/account` route would raise
125
+ `Invalid route name, already in use` while turf's own `routes.rb` loads, which
126
+ takes down **every** route in that app — the same collision that forced
127
+ `draw_admin_emails_routes` and `draw_onboarding_routes` to be opt-in. `profile`
128
+ is claimed by none of the five consumers (mcritchie-studio,
129
+ mcritchie-industries, turf-monster, moms-app, acquisition-studio — each checked
130
+ 2026-08-14), so **`Studio.draw_profile_routes` defaults to `true`** and a
131
+ brand-new app is correct on day one. It also buys turf a migration path: its
132
+ `/account` keeps working untouched while its rows move to `/profile` one at a
133
+ time, and `/account` is deleted only once it is empty.
134
+
135
+ | Route | Helper | What it does |
136
+ |---|---|---|
137
+ | `GET /profile` | `profile_path` | The page |
138
+ | `PATCH /profile` | `profile_path` | Scalar fields (today: `first_name`) |
139
+ | `PATCH /profile/avatar` | `profile_avatar_path` | The picture |
140
+
141
+ The avatar has a route of its own **deliberately**: an attachment param
142
+ submitted empty PURGES the attachment, so one form carrying both the name and
143
+ the file would delete someone's photo every time they edited their name.
144
+
145
+ **The photo row is turf-monster's avatar interaction, standardized.** Click the
146
+ picture, a hover cap reads "Update", pick a file, crop it square, and it saves
147
+ immediately — no Save button and no visible file field. It runs on the engine's
148
+ existing `imageUploadHost` factory and shared crop-photo modal, the same
149
+ primitives `/admin/emails` uses.
150
+
151
+ Two changes from turf's copy, both deliberate:
152
+
153
+ - **The click target is a `<button>`, not a `<div>`.** turf's is a `div @click`,
154
+ which no keyboard or screen-reader user can reach — for them the only way to
155
+ change a photo would be a mouse. Same visual; the hover cap also reveals on
156
+ focus.
157
+ - **The crop confirm goes through `onCropConfirmed()`, not `applyCrop()`.** Every
158
+ `imageUploadHost` on a page hears the same window event, so binding
159
+ `applyCrop` directly means a page that later grows a second uploader has both
160
+ hosts save the same crop. `onCropConfirmed` checks the owner token first.
161
+
162
+ **The page brings its own modals.** A row declares `modals: true` and the page
163
+ mounts `studio/modals/_scoped_host` on a `profileModals` store, so the row works
164
+ in an app that renders no shared modal host at all (mcritchie-industries,
165
+ moms-app, acquisition-studio) and is not shadowed in the apps that ship a fork of
166
+ `studio/modals/_host` (mcritchie-studio, turf-monster). **No host app has to
167
+ touch its layout to get a working avatar cropper.** cropper.js loads only when
168
+ a resolved row actually needs it.
169
+
170
+ - **`Studio.profile_sections` — declare the rows, per app.** Same shape as
171
+ `Studio.sidebar_sections`: a static Array or a callable receiving the view, keys
172
+ symbolized, `admin: true` rows gated. Compose against
173
+ `Studio.default_profile_sections` so a later release that adds a standard row
174
+ delivers it to you:
175
+
176
+ ```ruby
177
+ Studio.configure do |config|
178
+ config.profile_sections = ->(view) {
179
+ Studio.default_profile_sections +
180
+ [ { key: :wallet, title: "Identities", partial: "profiles/wallet" } ]
181
+ }
182
+ end
183
+ ```
184
+
185
+ Drop a standard row by key instead of restating the list:
186
+ `config.profile_sections = Studio.default_profile_sections.reject { |s| s[:key] == :avatar }`.
187
+
188
+ **`nil` — the default — means the standard page, NOT a blank one.** That
189
+ distinction is what makes an empty initializer produce a working page.
190
+
191
+ A row may declare `requires:` — an attribute the current user must respond to —
192
+ and is **dropped when this host's model does not have it**. The consuming apps
193
+ genuinely disagree about `users` (mcritchie-industries has eight columns and no
194
+ `first_name`; turf-monster has forty), so a shared page that assumed a column
195
+ would raise `NoMethodError` on every signed-in request there. MI gets a page
196
+ with the photo row and no name row until it installs the standard profile
197
+ columns, at which point the row appears with no code change.
198
+
199
+ - **`Studio::UserProfile` — the display helpers, written once.** A model concern
200
+ supplying `display_name`, `avatar_initials`, `avatar_color` and
201
+ `AVATAR_COLORS`. `components/_avatar` has always called all three and the engine
202
+ has never provided any of them, so all three apps wrote their own — and they had
203
+ drifted (`"anon"` vs `"User"` for the same empty state; two different email-prefix
204
+ casings). `include Studio::UserProfile` and delete your copy. Every method is
205
+ overridable by defining it in the class body.
206
+
207
+ **Adopting changes behavior in mcritchie-industries**, deliberately: a user with
208
+ no name now falls back to `"anon"` rather than `"User"`, and an email-derived
209
+ name is capitalized. The merged chain is
210
+ `username → name → first_name → email prefix → truncated wallet → "anon"`,
211
+ respond_to?-guarded at every link.
212
+
213
+ - **`Studio::ProfileImage` — one allowlist for profile uploads.** `ALLOWED_CONTENT_TYPES`
214
+ (PNG, JPEG, WebP), `MAX_BYTES` (8 MB) and `acceptable?(file)`, lifted from
215
+ turf-monster's `ApplicationController#valid_image?`. It is an allowlist rather
216
+ than a `start_with?("image/")` check because an avatar is attacker-supplied
217
+ bytes served back to other people, and an SVG is a script host wearing an
218
+ image's content type.
219
+
220
+ - **`Studio::FIRST_NAME_MAX_LENGTH`** (40) — one constant for a column now written
221
+ from two surfaces (the onboarding step and `/profile`) and rendered by a third
222
+ (the form's `maxlength`). `Studio::OnboardingController::MAX_FIRST_NAME` now
223
+ reads it instead of defining a second copy.
224
+
7
225
  ### Changed
8
226
 
227
+ - **`components/_user_nav` no longer renders a dead link.** The username and
228
+ avatar point at **a host's own `account_path` where it exists**, at
229
+ `profile_path` otherwise, and render as **plain text** when neither exists.
230
+ The name and the picture still render in that last case — only the link is
231
+ dropped. Previously both were `href="#"`.
232
+
233
+ **The host's page wins deliberately** — the engine does not repoint an app that
234
+ already has an account page of its own. No consumer exercises that branch today
235
+ (turf-monster is the only app with an `account_path`, and it forks this partial
236
+ anyway), so it is a rule for the apps that adopt `/profile` later rather than a
237
+ behavior change now.
238
+
239
+ So: **no consumer's navbar destination changes on this upgrade.**
240
+ mcritchie-industries and acquisition-studio gain a working link where they had
241
+ `href="#"`; every other consumer forks the partial and is untouched.
242
+
9
243
  - **No pre-registered email seeds a logo — `magic_link` was the last one, and it
10
244
  seeded the Studio wordmark onto the SIGN-IN email.** `STANDARD`'s `magic_link`
11
245
  entry carried `logo: "emails/logo-horizontal.png"`, and
@@ -23,7 +23,11 @@ module Studio
23
23
  # Both actions are for the freshly signed-in user, so authentication is the
24
24
  # host's default require_authentication — no skip_before_action here.
25
25
 
26
- MAX_FIRST_NAME = 40
26
+ # The shared cap (Studio::FIRST_NAME_MAX_LENGTH). Kept under the old name
27
+ # here because it is referenced from outside this class; what changed is that
28
+ # it is no longer a SECOND definition of the same number. /profile writes the
29
+ # same column and now reads the same constant.
30
+ MAX_FIRST_NAME = Studio::FIRST_NAME_MAX_LENGTH
27
31
 
28
32
  # POST /onboarding/first_name
29
33
  #
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /profile — the shared account page every Studio app gets.
5
+ #
6
+ # A plain host-inherited controller in the same shape as StyleController and
7
+ # Studio::EmailsController: its view is a bare content wrapper, so it renders
8
+ # inside each host's application layout and picks up that app's navbar, theme
9
+ # and flash. The engine supplies the page; the app supplies the frame.
10
+ #
11
+ # WHY /profile AND NOT /account (2026-08-14, operator's call). turf-monster owns
12
+ # `AccountsController` and the `account_path` helper today. Drawing a shared
13
+ # /account route would raise `Invalid route name, already in use` while turf's
14
+ # own routes.rb loads, which takes down EVERY route in that app — the exact
15
+ # failure that forced draw_admin_emails_routes and draw_onboarding_routes to be
16
+ # opt-in. `profile` is unclaimed in all five consumers, so this page can be
17
+ # drawn by default, which is what makes a brand-new app correct on day one.
18
+ #
19
+ # It also buys the migration path: turf keeps /account working untouched while
20
+ # its rows move to /profile one at a time, and /account is deleted only once it
21
+ # is empty. Two pages briefly coexisting is the point, not an accident.
22
+ #
23
+ # WHAT ROWS RENDER is Studio.profile_sections — see lib/studio/profile_sections.rb.
24
+ # Iteration one ships two: the avatar and the first name.
25
+ class ProfilesController < ::ApplicationController
26
+ # The host's own guard, supplied by Studio::ErrorHandling and format-aware
27
+ # (HTML redirects to login; JSON gets a clean 401 rather than a 406).
28
+ before_action :require_authentication
29
+
30
+ # THE shared cap, not a copy of it — see Studio::FIRST_NAME_MAX_LENGTH. The
31
+ # onboarding step writes this same column and reads this same constant, so
32
+ # the two surfaces cannot drift apart.
33
+ MAX_FIRST_NAME = Studio::FIRST_NAME_MAX_LENGTH
34
+
35
+ def show
36
+ @profile_sections = Studio.profile_sections_for(view_context)
37
+ end
38
+
39
+ # PATCH /profile — the scalar fields. Today that is the first name.
40
+ def update
41
+ return unsupported("name") unless row_rendered?(:first_name)
42
+
43
+ value = normalized_first_name
44
+
45
+ if value.blank?
46
+ return redirect_to profile_path, alert: "Enter your first name.", status: :see_other
47
+ end
48
+
49
+ 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
76
+ end
77
+ end
78
+ end
79
+
80
+ # PATCH /profile/avatar — the picture, on its own route.
81
+ #
82
+ # 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
+ def avatar
88
+ return unsupported("profile photo") unless row_rendered?(:avatar)
89
+
90
+ file = params.dig(:profile, :avatar)
91
+
92
+ if file.blank?
93
+ return redirect_to profile_path, alert: "Choose an image first.", status: :see_other
94
+ end
95
+
96
+ unless Studio::ProfileImage.acceptable?(file)
97
+ return redirect_to profile_path, alert: Studio::ProfileImage::MESSAGE, status: :see_other
98
+ end
99
+
100
+ rescue_and_log(target: current_user) do
101
+ 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}."
176
+ end
177
+ end
178
+
179
+ # DELETE /profile/google — drop the linked Google identity.
180
+ #
181
+ # REFUSES WHEN IT WOULD ORPHAN THE ACCOUNT. turf-monster's version is an
182
+ # unconditional `update!(provider: nil, uid: nil)`; for an account whose only
183
+ # sign-in is Google (no email, so no magic link; no wallet; no password) that
184
+ # locks someone out of their own account behind a button labelled "Unlink".
185
+ # It is safe in turf only because turf's users happen to carry an email —
186
+ # a property of that app's data, not of the code.
187
+ #
188
+ # Studio::OauthIdentity gates on Studio.auth_methods, not merely on the
189
+ # column: an app with an email column that does not offer magic-link sign-in
190
+ # cannot use it to get back in.
191
+ def unlink_google
192
+ return unsupported("Google account") unless row_rendered?(:google)
193
+
194
+ unless Studio::OauthIdentity.google_linked?(current_user)
195
+ return redirect_to profile_path, alert: "No Google account is linked.", status: :see_other
196
+ end
197
+
198
+ if Studio::OauthIdentity.unlink_orphans_account?(current_user)
199
+ return redirect_to profile_path, status: :see_other,
200
+ alert: "Google is the only way to sign in to this account. " \
201
+ "Add an email address first, then unlink."
202
+ end
203
+
204
+ rescue_and_log(target: current_user) do
205
+ current_user.update!(provider: nil, uid: nil)
206
+ redirect_to profile_path, notice: "Google account unlinked."
207
+ end
208
+ end
209
+
210
+ private
211
+
212
+ # Would the page render this row for this viewer?
213
+ #
214
+ # A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row this host
215
+ # cannot serve, so nobody SEES a first-name form in an app without the
216
+ # column — but the endpoint stays open to anyone who posts to it, and
217
+ # rescue_and_log RE-RAISES, so an unguarded write is a 500 plus an ErrorLog
218
+ # row. Three of the five consumers have an avatar attachment and no
219
+ # first_name column right now.
220
+ #
221
+ # ASKED OF THE RESOLVER, not of one of its rules — and that distinction was a
222
+ # real bug, caught in review. This used to call served_by?, which answers only
223
+ # the MODEL gate (`requires:`). Once rows also carried an APP gate (`if:`),
224
+ # the two questions came apart: mcritchie-industries drops the Google row
225
+ # because it offers no Google sign-in, while the endpoint — asking only about
226
+ # columns it does have — stayed open. The comment here claimed they "cannot
227
+ # drift" while they were already drifting.
228
+ #
229
+ # Asking the resolver makes that true rather than asserted: the page and the
230
+ # endpoint run the same code and get the same answer, whatever gates a row
231
+ # grows next.
232
+ def row_rendered?(key)
233
+ profile_rows.any? { |section| section[:key] == key.to_sym }
234
+ end
235
+
236
+ # Memoized per request: a host's `if:` may be a lambda doing real work, and a
237
+ # write should not pay for it more than once.
238
+ def profile_rows
239
+ @profile_rows ||= Studio.profile_sections_for(view_context)
240
+ end
241
+
242
+ # Land the person back on a page that works rather than on a bare 404. This
243
+ # is unreachable through the UI — the row that posts here is not rendered —
244
+ # so the wording is for whoever is poking at the endpoint directly.
245
+ def unsupported(label)
246
+ redirect_to profile_path,
247
+ alert: "This app has no #{label} to change.",
248
+ status: :see_other
249
+ end
250
+
251
+ # Collapse runs of whitespace and cap the length. Done here rather than in a
252
+ # 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
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The two emails an email change sends.
5
+ #
6
+ # NAMESPACED, and that is load-bearing rather than tidiness. `app/mailers` is an
7
+ # autoload path and this engine is not isolated, so a host that defines a
8
+ # top-level `UserMailer` SHADOWS the engine's outright. Two consumers do:
9
+ # mcritchie-studio (which defines no email-change actions, so a bare call would
10
+ # raise NoMethodError) and turf-monster (which defines `email_change_confirmation`
11
+ # and `email_change_notification` ALREADY, with its own signature and its own
12
+ # `confirm_email_change_url` pointing at /account). That second case is the
13
+ # dangerous one: no exception, no warning, and the engine's flow quietly mails a
14
+ # link to turf's route instead of its own.
15
+ #
16
+ # Under `Studio::` nothing shadows it, following Studio::NewsletterMailer.
17
+ #
18
+ # ONE EMAIL, and it goes to the address being LEFT:
19
+ #
20
+ # email_change_notification → the OLD address, after the change lands. With
21
+ # the change applying directly from any signed-in session, this mail is the
22
+ # only thing that makes a change nobody made VISIBLE to the person losing
23
+ # the account. That is why it survives and why it is sent even though
24
+ # nothing about it is required for the change to work.
25
+ class ProfileMailer < ApplicationMailer
26
+ layout "branded_mailer"
27
+
28
+ # To the OLD address, after the swap landed.
29
+ def email_change_notification(user, old_email, new_email)
30
+ @user = user
31
+ @app_name = Studio.app_name
32
+ @old_email = old_email
33
+ @new_email = new_email
34
+
35
+ @banner = Studio::Banner.for(:email_change_notification, name: display_name_for(user))
36
+ @banner_url = Studio::EmailCatalog.resolved_url(:email_change_notification)
37
+ @banner_alt = [@banner&.header, "your #{@app_name} email was changed"].compact.join(" — ")
38
+
39
+ mail(to: old_email, subject: Studio::EmailCatalog.subject_for(:email_change_notification, name: display_name_for(user)))
40
+ end
41
+
42
+ private
43
+
44
+ # The engine's User contract guarantees display_name, but this mailer is also
45
+ # reachable from a host whose model is mid-migration; a name is a nicety and
46
+ # must never be the reason an email fails to send.
47
+ def display_name_for(user)
48
+ return nil unless user.respond_to?(:display_name)
49
+
50
+ user.display_name
51
+ rescue StandardError
52
+ nil
53
+ end
54
+ end
55
+ end