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 +4 -4
- data/CHANGELOG.md +234 -0
- data/app/controllers/studio/onboarding_controller.rb +5 -1
- data/app/controllers/studio/profiles_controller.rb +257 -0
- data/app/mailers/studio/profile_mailer.rb +55 -0
- data/app/models/concerns/studio/user_profile.rb +111 -0
- data/app/services/studio/email_catalog.rb +21 -0
- data/app/views/components/_user_nav.html.erb +40 -6
- data/app/views/studio/profile_mailer/email_change_notification.html.erb +30 -0
- data/app/views/studio/profile_mailer/email_change_notification.text.erb +11 -0
- data/app/views/studio/profiles/_avatar_section.html.erb +93 -0
- data/app/views/studio/profiles/_email_section.html.erb +55 -0
- data/app/views/studio/profiles/_first_name_section.html.erb +42 -0
- data/app/views/studio/profiles/_google_section.html.erb +56 -0
- data/app/views/studio/profiles/show.html.erb +81 -0
- data/lib/studio/oauth_identity.rb +95 -0
- data/lib/studio/profile_image.rb +43 -0
- data/lib/studio/profile_sections.rb +176 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +91 -0
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5cfb0ff6698fde0520d0d6e081ffd49d99801473831f92c18e86a9cb9dc177a1
|
|
4
|
+
data.tar.gz: 2a96a7e242ff9211d0307326e77258136b725a99d5d749a9664e5194336af72c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|