studio-engine 0.47.2 → 0.48.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 +204 -0
- data/app/controllers/studio/onboarding_controller.rb +5 -1
- data/app/controllers/studio/profiles_controller.rb +170 -0
- data/app/models/concerns/studio/user_profile.rb +111 -0
- data/app/views/components/_user_nav.html.erb +40 -6
- data/app/views/studio/profiles/_avatar_section.html.erb +93 -0
- data/app/views/studio/profiles/_first_name_section.html.erb +26 -0
- data/app/views/studio/profiles/_google_section.html.erb +56 -0
- data/app/views/studio/profiles/show.html.erb +73 -0
- data/lib/studio/oauth_identity.rb +95 -0
- data/lib/studio/profile_image.rb +43 -0
- data/lib/studio/profile_sections.rb +153 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +89 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa8ab90a0e1627e7150ed1e96a348bae677c3dc35891ccd2a46f99d857cd74d1
|
|
4
|
+
data.tar.gz: e2a354a9d1d0533a78c63cb19233ae387a4c71f747cd5bf4570fe8d2478979de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7def604f1a4c19da8241953cee4bfa8e8047af2a2ae0e0113628aeed9f5d59ab5fa16bfe667e89687144f0f0afb5117e1bcd1f4747ab69db9c5b892e142c820
|
|
7
|
+
data.tar.gz: 7424031597c4168ac6a8172360b7dbc2ee584f0ddc5ac205a155fd11343f7e8f91002a40d3b0865eef0218096234aeaf1617ff2133677aa1e51c9b73d91a8cf3
|
data/CHANGELOG.md
CHANGED
|
@@ -4,8 +4,212 @@ 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 Google account row.** Shows the linked identity with an
|
|
21
|
+
Unlink control, or a branded Connect button that POSTs to OmniAuth's own
|
|
22
|
+
`/auth/google_oauth2` (the engine does not draw a link route — the middleware
|
|
23
|
+
owns that path). Lifted from turf-monster's `/account` Identities card.
|
|
24
|
+
|
|
25
|
+
New route: `DELETE /profile/google` → `profile_unlink_google_path`.
|
|
26
|
+
|
|
27
|
+
**`Studio::OauthIdentity`** carries the rules, pure and duck-typed like
|
|
28
|
+
`Studio::ProfileImage`: `google_linked?`, `remaining_sign_ins`,
|
|
29
|
+
`unlink_orphans_account?`. It matches **both** provider spellings in the wild —
|
|
30
|
+
`google_oauth2` (the OmniAuth strategy name that lands in `users.provider`) and
|
|
31
|
+
`google` (what `Studio.auth_methods` calls it).
|
|
32
|
+
|
|
33
|
+
**THE ORPHAN GUARD — the reason this is not a straight copy.** turf-monster's
|
|
34
|
+
unlink is an unconditional `update!(provider: nil, uid: nil)`. For an account
|
|
35
|
+
whose only sign-in is Google — blank email so no magic link, no wallet, no
|
|
36
|
+
password — that silently locks someone out of their own account behind a button
|
|
37
|
+
labelled "Unlink". It is safe in turf today only because turf's users happen to
|
|
38
|
+
carry an email, which is a property of that app's **data**, not of its code.
|
|
39
|
+
|
|
40
|
+
The engine refuses instead, and gates on **`Studio.auth_methods`, not merely on
|
|
41
|
+
the column**: an app that has an `email` column but does not offer magic-link
|
|
42
|
+
sign-in cannot use it to get back in, so counting it would be exactly the wrong
|
|
43
|
+
answer. The row disables the button with the reason beside it; the endpoint
|
|
44
|
+
refuses the request independently, because a disabled button is a courtesy and
|
|
45
|
+
anyone can send the `DELETE`.
|
|
46
|
+
|
|
47
|
+
**What counts as a way back in is deliberately narrow**, because a false
|
|
48
|
+
positive here permits an unlink that orphans an account. A password counts only
|
|
49
|
+
via `Studio.password_login_available?` (`auth_method?(:password)` **and** the
|
|
50
|
+
User answering `authenticate`) — turf-monster removed `has_secure_password` and
|
|
51
|
+
kept the column, so its rows carry fossil digests no code can authenticate
|
|
52
|
+
against. A wallet counts only when the host has **explicitly** named its
|
|
53
|
+
signing-wallet column via `Studio.wallet_address_method`; the engine does not
|
|
54
|
+
guess a conventional reader, because turf's `User#solana_address` returns
|
|
55
|
+
`web3 || web2` and only the web3 address can sign in — the web2 one is
|
|
56
|
+
custodial, with no signer. An unconfigured app is treated as having no wallet
|
|
57
|
+
sign-in, which errs toward refusing: the cost is a refusal the operator fixes
|
|
58
|
+
with one config line, against someone locked out of their account.
|
|
59
|
+
|
|
60
|
+
The row declares `requires: %i[provider uid]` **and**
|
|
61
|
+
`if: -> { Studio.auth_method?(:google) }` — the model gate and the app gate are
|
|
62
|
+
different questions, and only the pair is correct. **Every** consumer's users
|
|
63
|
+
table carries `provider` and `uid`, so the model gate alone selected the whole
|
|
64
|
+
fleet; mcritchie-industries has both columns, `auth_methods = %i[magic_link]`,
|
|
65
|
+
and no omniauth gem at all, and would have been handed a "Link Google Account"
|
|
66
|
+
button leading nowhere. The engine's login page already asks the app question
|
|
67
|
+
before drawing this identical button (`app/views/sessions/new.html.erb`).
|
|
68
|
+
|
|
69
|
+
- **`Studio.profile_sections` rows accept `if:`** — an optional callable gating a
|
|
70
|
+
row on an app capability, evaluated at resolve time, called with the view when
|
|
71
|
+
it takes an argument and without when it does not. Distinct from `requires:`,
|
|
72
|
+
which asks whether the user MODEL can serve the row.
|
|
73
|
+
|
|
74
|
+
- **The shared profile page — `/profile`.** The engine now ships the account page
|
|
75
|
+
itself, not just the parts. `Studio::ProfilesController` renders a page of
|
|
76
|
+
declared rows; iteration one ships two, **change your photo** and **change your
|
|
77
|
+
first name**. Every consumer gets it on upgrade with no configuration.
|
|
78
|
+
|
|
79
|
+
**Why this page exists at all.** `components/_user_nav` has always linked the
|
|
80
|
+
username and the avatar to `defined?(account_path) ? account_path : "#"`, and
|
|
81
|
+
only turf-monster draws an account route — so every app that renders the
|
|
82
|
+
ENGINE's copy of that partial shipped a navbar whose avatar and username are
|
|
83
|
+
`href="#"`. A dead link looks exactly like a working one until it is clicked,
|
|
84
|
+
which is why it survived in production. The engine assumed a page every consumer
|
|
85
|
+
was expected to write for itself; now it ships the page.
|
|
86
|
+
|
|
87
|
+
**Which apps that actually is** (host views shadow engine views, so a fork does
|
|
88
|
+
not receive this): **mcritchie-industries and acquisition-studio** render the
|
|
89
|
+
engine partial and had the dead link. **mcritchie-studio** and **turf-monster**
|
|
90
|
+
each ship their own `app/views/components/_user_nav.html.erb` and are unaffected
|
|
91
|
+
either way; **moms-app** forks `layouts/_navbar` and renders no user nav at all.
|
|
92
|
+
|
|
93
|
+
**Why `/profile` and not `/account`.** turf-monster owns `AccountsController`
|
|
94
|
+
and the `account_path` helper. A shared `/account` route would raise
|
|
95
|
+
`Invalid route name, already in use` while turf's own `routes.rb` loads, which
|
|
96
|
+
takes down **every** route in that app — the same collision that forced
|
|
97
|
+
`draw_admin_emails_routes` and `draw_onboarding_routes` to be opt-in. `profile`
|
|
98
|
+
is claimed by none of the five consumers (mcritchie-studio,
|
|
99
|
+
mcritchie-industries, turf-monster, moms-app, acquisition-studio — each checked
|
|
100
|
+
2026-08-14), so **`Studio.draw_profile_routes` defaults to `true`** and a
|
|
101
|
+
brand-new app is correct on day one. It also buys turf a migration path: its
|
|
102
|
+
`/account` keeps working untouched while its rows move to `/profile` one at a
|
|
103
|
+
time, and `/account` is deleted only once it is empty.
|
|
104
|
+
|
|
105
|
+
| Route | Helper | What it does |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `GET /profile` | `profile_path` | The page |
|
|
108
|
+
| `PATCH /profile` | `profile_path` | Scalar fields (today: `first_name`) |
|
|
109
|
+
| `PATCH /profile/avatar` | `profile_avatar_path` | The picture |
|
|
110
|
+
|
|
111
|
+
The avatar has a route of its own **deliberately**: an attachment param
|
|
112
|
+
submitted empty PURGES the attachment, so one form carrying both the name and
|
|
113
|
+
the file would delete someone's photo every time they edited their name.
|
|
114
|
+
|
|
115
|
+
**The photo row is turf-monster's avatar interaction, standardized.** Click the
|
|
116
|
+
picture, a hover cap reads "Update", pick a file, crop it square, and it saves
|
|
117
|
+
immediately — no Save button and no visible file field. It runs on the engine's
|
|
118
|
+
existing `imageUploadHost` factory and shared crop-photo modal, the same
|
|
119
|
+
primitives `/admin/emails` uses.
|
|
120
|
+
|
|
121
|
+
Two changes from turf's copy, both deliberate:
|
|
122
|
+
|
|
123
|
+
- **The click target is a `<button>`, not a `<div>`.** turf's is a `div @click`,
|
|
124
|
+
which no keyboard or screen-reader user can reach — for them the only way to
|
|
125
|
+
change a photo would be a mouse. Same visual; the hover cap also reveals on
|
|
126
|
+
focus.
|
|
127
|
+
- **The crop confirm goes through `onCropConfirmed()`, not `applyCrop()`.** Every
|
|
128
|
+
`imageUploadHost` on a page hears the same window event, so binding
|
|
129
|
+
`applyCrop` directly means a page that later grows a second uploader has both
|
|
130
|
+
hosts save the same crop. `onCropConfirmed` checks the owner token first.
|
|
131
|
+
|
|
132
|
+
**The page brings its own modals.** A row declares `modals: true` and the page
|
|
133
|
+
mounts `studio/modals/_scoped_host` on a `profileModals` store, so the row works
|
|
134
|
+
in an app that renders no shared modal host at all (mcritchie-industries,
|
|
135
|
+
moms-app, acquisition-studio) and is not shadowed in the apps that ship a fork of
|
|
136
|
+
`studio/modals/_host` (mcritchie-studio, turf-monster). **No host app has to
|
|
137
|
+
touch its layout to get a working avatar cropper.** cropper.js loads only when
|
|
138
|
+
a resolved row actually needs it.
|
|
139
|
+
|
|
140
|
+
- **`Studio.profile_sections` — declare the rows, per app.** Same shape as
|
|
141
|
+
`Studio.sidebar_sections`: a static Array or a callable receiving the view, keys
|
|
142
|
+
symbolized, `admin: true` rows gated. Compose against
|
|
143
|
+
`Studio.default_profile_sections` so a later release that adds a standard row
|
|
144
|
+
delivers it to you:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
Studio.configure do |config|
|
|
148
|
+
config.profile_sections = ->(view) {
|
|
149
|
+
Studio.default_profile_sections +
|
|
150
|
+
[ { key: :wallet, title: "Identities", partial: "profiles/wallet" } ]
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Drop a standard row by key instead of restating the list:
|
|
156
|
+
`config.profile_sections = Studio.default_profile_sections.reject { |s| s[:key] == :avatar }`.
|
|
157
|
+
|
|
158
|
+
**`nil` — the default — means the standard page, NOT a blank one.** That
|
|
159
|
+
distinction is what makes an empty initializer produce a working page.
|
|
160
|
+
|
|
161
|
+
A row may declare `requires:` — an attribute the current user must respond to —
|
|
162
|
+
and is **dropped when this host's model does not have it**. The consuming apps
|
|
163
|
+
genuinely disagree about `users` (mcritchie-industries has eight columns and no
|
|
164
|
+
`first_name`; turf-monster has forty), so a shared page that assumed a column
|
|
165
|
+
would raise `NoMethodError` on every signed-in request there. MI gets a page
|
|
166
|
+
with the photo row and no name row until it installs the standard profile
|
|
167
|
+
columns, at which point the row appears with no code change.
|
|
168
|
+
|
|
169
|
+
- **`Studio::UserProfile` — the display helpers, written once.** A model concern
|
|
170
|
+
supplying `display_name`, `avatar_initials`, `avatar_color` and
|
|
171
|
+
`AVATAR_COLORS`. `components/_avatar` has always called all three and the engine
|
|
172
|
+
has never provided any of them, so all three apps wrote their own — and they had
|
|
173
|
+
drifted (`"anon"` vs `"User"` for the same empty state; two different email-prefix
|
|
174
|
+
casings). `include Studio::UserProfile` and delete your copy. Every method is
|
|
175
|
+
overridable by defining it in the class body.
|
|
176
|
+
|
|
177
|
+
**Adopting changes behavior in mcritchie-industries**, deliberately: a user with
|
|
178
|
+
no name now falls back to `"anon"` rather than `"User"`, and an email-derived
|
|
179
|
+
name is capitalized. The merged chain is
|
|
180
|
+
`username → name → first_name → email prefix → truncated wallet → "anon"`,
|
|
181
|
+
respond_to?-guarded at every link.
|
|
182
|
+
|
|
183
|
+
- **`Studio::ProfileImage` — one allowlist for profile uploads.** `ALLOWED_CONTENT_TYPES`
|
|
184
|
+
(PNG, JPEG, WebP), `MAX_BYTES` (8 MB) and `acceptable?(file)`, lifted from
|
|
185
|
+
turf-monster's `ApplicationController#valid_image?`. It is an allowlist rather
|
|
186
|
+
than a `start_with?("image/")` check because an avatar is attacker-supplied
|
|
187
|
+
bytes served back to other people, and an SVG is a script host wearing an
|
|
188
|
+
image's content type.
|
|
189
|
+
|
|
190
|
+
- **`Studio::FIRST_NAME_MAX_LENGTH`** (40) — one constant for a column now written
|
|
191
|
+
from two surfaces (the onboarding step and `/profile`) and rendered by a third
|
|
192
|
+
(the form's `maxlength`). `Studio::OnboardingController::MAX_FIRST_NAME` now
|
|
193
|
+
reads it instead of defining a second copy.
|
|
194
|
+
|
|
7
195
|
### Changed
|
|
8
196
|
|
|
197
|
+
- **`components/_user_nav` no longer renders a dead link.** The username and
|
|
198
|
+
avatar point at **a host's own `account_path` where it exists**, at
|
|
199
|
+
`profile_path` otherwise, and render as **plain text** when neither exists.
|
|
200
|
+
The name and the picture still render in that last case — only the link is
|
|
201
|
+
dropped. Previously both were `href="#"`.
|
|
202
|
+
|
|
203
|
+
**The host's page wins deliberately** — the engine does not repoint an app that
|
|
204
|
+
already has an account page of its own. No consumer exercises that branch today
|
|
205
|
+
(turf-monster is the only app with an `account_path`, and it forks this partial
|
|
206
|
+
anyway), so it is a rule for the apps that adopt `/profile` later rather than a
|
|
207
|
+
behavior change now.
|
|
208
|
+
|
|
209
|
+
So: **no consumer's navbar destination changes on this upgrade.**
|
|
210
|
+
mcritchie-industries and acquisition-studio gain a working link where they had
|
|
211
|
+
`href="#"`; every other consumer forks the partial and is untouched.
|
|
212
|
+
|
|
9
213
|
- **No pre-registered email seeds a logo — `magic_link` was the last one, and it
|
|
10
214
|
seeded the Studio wordmark onto the SIGN-IN email.** `STANDARD`'s `magic_link`
|
|
11
215
|
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,170 @@
|
|
|
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(:first_name) unless serves?(: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(:avatar) unless serves?(: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
|
+
# DELETE /profile/google — drop the linked Google identity.
|
|
107
|
+
#
|
|
108
|
+
# REFUSES WHEN IT WOULD ORPHAN THE ACCOUNT. turf-monster's version is an
|
|
109
|
+
# unconditional `update!(provider: nil, uid: nil)`; for an account whose only
|
|
110
|
+
# sign-in is Google (no email, so no magic link; no wallet; no password) that
|
|
111
|
+
# locks someone out of their own account behind a button labelled "Unlink".
|
|
112
|
+
# It is safe in turf only because turf's users happen to carry an email —
|
|
113
|
+
# a property of that app's data, not of the code.
|
|
114
|
+
#
|
|
115
|
+
# Studio::OauthIdentity gates on Studio.auth_methods, not merely on the
|
|
116
|
+
# column: an app with an email column that does not offer magic-link sign-in
|
|
117
|
+
# cannot use it to get back in.
|
|
118
|
+
def unlink_google
|
|
119
|
+
return unsupported(:google_account) unless serves?(:provider) && serves?(:uid)
|
|
120
|
+
|
|
121
|
+
unless Studio::OauthIdentity.google_linked?(current_user)
|
|
122
|
+
return redirect_to profile_path, alert: "No Google account is linked.", status: :see_other
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if Studio::OauthIdentity.unlink_orphans_account?(current_user)
|
|
126
|
+
return redirect_to profile_path, status: :see_other,
|
|
127
|
+
alert: "Google is the only way to sign in to this account. " \
|
|
128
|
+
"Add an email address first, then unlink."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
rescue_and_log(target: current_user) do
|
|
132
|
+
current_user.update!(provider: nil, uid: nil)
|
|
133
|
+
redirect_to profile_path, notice: "Google account unlinked."
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Can this host's user model serve this field?
|
|
140
|
+
#
|
|
141
|
+
# A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row the host
|
|
142
|
+
# cannot serve, so nobody SEES a first-name form in an app without the
|
|
143
|
+
# column — but the endpoint stays open to anyone who posts to it, and
|
|
144
|
+
# 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.
|
|
148
|
+
#
|
|
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)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Land the person back on a page that works rather than on a bare 404. This
|
|
156
|
+
# is unreachable through the UI — the row that posts here is not rendered —
|
|
157
|
+
# so the wording is for whoever is poking at the endpoint directly.
|
|
158
|
+
def unsupported(attribute)
|
|
159
|
+
redirect_to profile_path,
|
|
160
|
+
alert: "This app has no #{attribute.to_s.tr("_", " ")} to change.",
|
|
161
|
+
status: :see_other
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Collapse runs of whitespace and cap the length. Done here rather than in a
|
|
165
|
+
# 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
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Studio
|
|
6
|
+
# The four things every Studio app's User has had to define for itself: how to
|
|
7
|
+
# name a person on screen, and how to draw them when they have no picture.
|
|
8
|
+
#
|
|
9
|
+
# WHY THIS EXISTS. The engine's own components/_avatar partial calls
|
|
10
|
+
# `display_name`, `avatar_initials` and `avatar_color` — and the engine has
|
|
11
|
+
# never provided any of them. Every consumer wrote its own; mcritchie-industries'
|
|
12
|
+
# user.rb even carries a comment explaining that it must, because the engine's
|
|
13
|
+
# nav renders the partial. Three copies, and by 2026-08-14 they had drifted:
|
|
14
|
+
#
|
|
15
|
+
# mcritchie-studio name → email prefix (capitalized) → wallet → "anon"
|
|
16
|
+
# mcritchie-industries name → email prefix (raw) → "User"
|
|
17
|
+
# turf-monster username → name → email prefix (cap) → wallet → "anon"
|
|
18
|
+
#
|
|
19
|
+
# Same intent, three answers, and two different words for the same empty state.
|
|
20
|
+
# That is the drift a concern removes.
|
|
21
|
+
#
|
|
22
|
+
# ON THE MERGED CHAIN. It is turf-monster's — the richest of the three — with
|
|
23
|
+
# `first_name` folded in after `name`, because /profile now lets people set that
|
|
24
|
+
# field and a name someone just typed should be the name they see. Apps missing
|
|
25
|
+
# a link in the chain simply skip it; the chain is respond_to?-guarded end to
|
|
26
|
+
# end, so mcritchie-industries' eight-column users table walks it without
|
|
27
|
+
# raising. One consequence worth stating rather than burying: an MI user with no
|
|
28
|
+
# name now falls back to "anon" instead of "User", and an email-derived name is
|
|
29
|
+
# capitalized. That is MI adopting the house standard, and its adoption task
|
|
30
|
+
# owns the change.
|
|
31
|
+
#
|
|
32
|
+
# EVERY METHOD IS OVERRIDABLE. These are plain instance methods from an included
|
|
33
|
+
# module, so a host that defines its own `display_name` in the class body wins
|
|
34
|
+
# outright. Standardizing the default is the goal; forbidding an app from having
|
|
35
|
+
# an opinion is not.
|
|
36
|
+
module UserProfile
|
|
37
|
+
extend ActiveSupport::Concern
|
|
38
|
+
|
|
39
|
+
# The house palette for initial-circle backgrounds. Identical in all three
|
|
40
|
+
# apps today, which is the clearest possible signal it belonged here.
|
|
41
|
+
AVATAR_COLORS = %w[#EF4444 #F97316 #EAB308 #22C55E #06B6D4 #3B82F6 #8B5CF6 #EC4899].freeze
|
|
42
|
+
|
|
43
|
+
# What to call this person on screen.
|
|
44
|
+
def display_name
|
|
45
|
+
studio_profile_username.presence ||
|
|
46
|
+
studio_profile_attr(:name).presence ||
|
|
47
|
+
studio_profile_attr(:first_name).presence ||
|
|
48
|
+
studio_profile_email_prefix.presence ||
|
|
49
|
+
studio_profile_wallet.presence ||
|
|
50
|
+
"anon"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One character for the initials circle. Deliberately NOT derived from
|
|
54
|
+
# display_name: that chain ends in a wallet address or the word "anon", and
|
|
55
|
+
# "a" for every anonymous account is worse than a neutral mark.
|
|
56
|
+
def avatar_initials
|
|
57
|
+
source = studio_profile_username.presence ||
|
|
58
|
+
studio_profile_attr(:name).presence ||
|
|
59
|
+
studio_profile_attr(:first_name).presence ||
|
|
60
|
+
studio_profile_email_prefix.presence
|
|
61
|
+
|
|
62
|
+
# `source[0]`, not ActiveSupport's `String#first`: this module is included
|
|
63
|
+
# into a host model, and nothing here should depend on which core_ext that
|
|
64
|
+
# host happens to have loaded. Plain Ruby costs nothing and cannot vanish.
|
|
65
|
+
source.presence.to_s[0]&.upcase || "?"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A stable colour for this account — same person, same circle, every render.
|
|
69
|
+
# Hashed rather than random for exactly that reason, and keyed off identity
|
|
70
|
+
# fields so it survives a display-name change.
|
|
71
|
+
def avatar_color
|
|
72
|
+
key = studio_profile_username.presence ||
|
|
73
|
+
studio_profile_attr(:name).presence ||
|
|
74
|
+
studio_profile_attr(:email).presence ||
|
|
75
|
+
id.to_s
|
|
76
|
+
|
|
77
|
+
AVATAR_COLORS[Digest::MD5.hexdigest(key.to_s).hex % AVATAR_COLORS.size]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Read an attribute only if this host's model actually has it. The whole
|
|
83
|
+
# tolerance of the chain lives in this one method.
|
|
84
|
+
def studio_profile_attr(attribute)
|
|
85
|
+
return nil unless respond_to?(attribute)
|
|
86
|
+
|
|
87
|
+
public_send(attribute)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `username` is turf-monster's on-chain handle and does not exist in the hub
|
|
91
|
+
# apps. It leads the chain where it exists because it is what that app's
|
|
92
|
+
# people call each other.
|
|
93
|
+
def studio_profile_username
|
|
94
|
+
studio_profile_attr(:username)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def studio_profile_email_prefix
|
|
98
|
+
email = studio_profile_attr(:email)
|
|
99
|
+
return nil if email.blank?
|
|
100
|
+
|
|
101
|
+
email.to_s.split("@").first.to_s.capitalize
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Defers to the host's own truncation rather than reformatting an address
|
|
105
|
+
# here — mcritchie-studio and turf-monster both define `truncated_solana`,
|
|
106
|
+
# and turf's has to pick between a web2 and a web3 address to do it.
|
|
107
|
+
def studio_profile_wallet
|
|
108
|
+
studio_profile_attr(:truncated_solana)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -40,6 +40,33 @@
|
|
|
40
40
|
end
|
|
41
41
|
end %>
|
|
42
42
|
|
|
43
|
+
<%# Where the username and the avatar point — resolved ONCE, used by both.
|
|
44
|
+
|
|
45
|
+
A HOST'S OWN account_path WINS: an app that already has an account page keeps
|
|
46
|
+
it rather than being repointed at a thinner /profile by a routine dependency
|
|
47
|
+
bump. An app adopting /profile flips over by DELETING its account route, so
|
|
48
|
+
the last step of the migration is the deliberate one.
|
|
49
|
+
|
|
50
|
+
NO CONSUMER EXERCISES THAT BRANCH TODAY, and the comment here said otherwise
|
|
51
|
+
until 2026-08-14. turf-monster is the only app with an account_path, and it
|
|
52
|
+
ships its own app/views/components/_user_nav.html.erb — a host view SHADOWS
|
|
53
|
+
the engine's in this non-isolated engine, so turf never renders THIS file.
|
|
54
|
+
The rule is for apps that adopt /profile later.
|
|
55
|
+
|
|
56
|
+
nil when NEITHER exists, and then the name and avatar render as plain text.
|
|
57
|
+
They used to render `href="#"`, which was not a graceful degradation but a
|
|
58
|
+
dead link. Who that actually reached: mcritchie-industries and
|
|
59
|
+
acquisition-studio — the consumers rendering this partial rather than a fork
|
|
60
|
+
of it. A link that goes nowhere looks identical to one that works until it is
|
|
61
|
+
clicked, which is why nobody reported it.
|
|
62
|
+
%>
|
|
63
|
+
<% studio_nav_destination =
|
|
64
|
+
if defined?(account_path)
|
|
65
|
+
account_path
|
|
66
|
+
elsif defined?(profile_path)
|
|
67
|
+
profile_path
|
|
68
|
+
end %>
|
|
69
|
+
|
|
43
70
|
<div class="flex gap-2">
|
|
44
71
|
<% if logged_in? %>
|
|
45
72
|
<%# Left column: Div 1 + Div 2 stacked %>
|
|
@@ -61,9 +88,11 @@
|
|
|
61
88
|
<%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
62
89
|
<%= render "components/theme_toggle_morph" %>
|
|
63
90
|
</div>
|
|
64
|
-
<%
|
|
65
|
-
|
|
66
|
-
<%= current_user.display_name %>
|
|
91
|
+
<% nav_name_classes = "text-heading font-semibold hover:text-primary transition text-base leading-none truncate text-right" %>
|
|
92
|
+
<% if studio_nav_destination %>
|
|
93
|
+
<%= link_to current_user.display_name, studio_nav_destination, class: nav_name_classes %>
|
|
94
|
+
<% else %>
|
|
95
|
+
<span class="<%= nav_name_classes %>"><%= current_user.display_name %></span>
|
|
67
96
|
<% end %>
|
|
68
97
|
</div>
|
|
69
98
|
<% div2_content = studio_nav_slot.call(div2_slot, div2_html) %>
|
|
@@ -168,9 +197,14 @@
|
|
|
168
197
|
<% end %>
|
|
169
198
|
</div>
|
|
170
199
|
<%# Div 3 (Avatar): spans both rows %>
|
|
171
|
-
<%
|
|
172
|
-
|
|
173
|
-
|
|
200
|
+
<% if studio_nav_destination %>
|
|
201
|
+
<%= link_to studio_nav_destination, class: "hover:opacity-80 transition flex-shrink-0 flex items-center", "x-bind:style": "$store.devMode && 'background: lightgreen'" do %>
|
|
202
|
+
<%= render "components/avatar", user: current_user, size: "nav" %>
|
|
203
|
+
<% end %>
|
|
204
|
+
<% else %>
|
|
205
|
+
<span class="flex-shrink-0 flex items-center" x-bind:style="$store.devMode && 'background: lightgreen'">
|
|
206
|
+
<%= render "components/avatar", user: current_user, size: "nav" %>
|
|
207
|
+
</span>
|
|
174
208
|
<% end %>
|
|
175
209
|
<% else %>
|
|
176
210
|
<%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|