Class: Studio::ProfilesController
- Inherits:
-
ApplicationController
- Object
- ApplicationController
- Studio::ProfilesController
- Defined in:
- app/controllers/studio/profiles_controller.rb
Overview
/profile — the shared account page every Studio app gets.
A plain host-inherited controller in the same shape as StyleController and Studio::EmailsController: its view is a bare content wrapper, so it renders inside each host's application layout and picks up that app's navbar, theme and flash. The engine supplies the page; the app supplies the frame.
WHY /profile AND NOT /account (2026-08-14, operator's call). turf-monster owns
AccountsController and the account_path helper today. Drawing a shared
/account route would raise Invalid route name, already in use while turf's
own routes.rb loads, which takes down EVERY route in that app — the exact
failure that forced draw_admin_emails_routes and draw_onboarding_routes to be
opt-in. profile is unclaimed in all five consumers, so this page can be
drawn by default, which is what makes a brand-new app correct on day one.
It also buys the migration path: turf keeps /account working untouched while its rows move to /profile one at a time, and /account is deleted only once it is empty. Two pages briefly coexisting is the point, not an accident.
WHAT ROWS RENDER is Studio.profile_sections — see lib/studio/profile_sections.rb. Iteration one ships two: the avatar and the first name.
Constant Summary collapse
- MAX_FIRST_NAME =
THE shared cap, not a copy of it — see Studio::FIRST_NAME_MAX_LENGTH. The onboarding step writes this same column and reads this same constant, so the two surfaces cannot drift apart.
Studio::FIRST_NAME_MAX_LENGTH
Instance Method Summary collapse
-
#avatar ⇒ Object
PATCH /profile/avatar — the picture, on its own route.
-
#edit ⇒ Object
GET /profile/edit — the form.
- #show ⇒ Object
-
#subscribe_newsletter ⇒ Object
POST /profile/newsletter — join the mailing list.
-
#unlink_google ⇒ Object
DELETE /profile/google — drop the linked Google identity.
-
#unsubscribe_newsletter ⇒ Object
DELETE /profile/newsletter — leave it.
-
#update ⇒ Object
PATCH /profile — every editable field, in one request.
Instance Method Details
#avatar ⇒ Object
PATCH /profile/avatar — the picture, on its own route.
SEPARATE FROM #update deliberately: an attachment param submitted empty PURGES the attachment, so a combined form carrying both would delete someone's photo every time they saved a name. A separate route makes that unreachable rather than merely avoided.
GUARDED DIFFERENTLY FROM THE FIELD ROWS, and the difference is real: the avatar stopped being a row when it moved into the identity header, so "would its row render?" has no answer for it. The MODEL gate is the right question here — can this host's user hold an attachment at all.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'app/controllers/studio/profiles_controller.rb', line 95 def avatar return unsupported("profile photo") unless avatar_supported? file = params.dig(:profile, :avatar) if file.blank? return redirect_to edit_profile_path, alert: "Choose an image first.", status: :see_other end unless Studio::ProfileImage.acceptable?(file) return redirect_to edit_profile_path, alert: Studio::ProfileImage::MESSAGE, status: :see_other end rescue_and_log(target: current_user) do current_user.avatar.attach(file) redirect_to edit_profile_path, notice: "Photo updated." end end |
#edit ⇒ Object
GET /profile/edit — the form. Read and edit are separate pages (operator's call, 2026-08-14): /profile is "you at a glance", this is where you change things.
42 43 44 |
# File 'app/controllers/studio/profiles_controller.rb', line 42 def edit @profile_sections = Studio.profile_sections_for(view_context, page: :edit) end |
#show ⇒ Object
35 36 37 |
# File 'app/controllers/studio/profiles_controller.rb', line 35 def show @profile_sections = Studio.profile_sections_for(view_context, page: :show) end |
#subscribe_newsletter ⇒ Object
POST /profile/newsletter — join the mailing list.
DIRECT, no confirmation. Joining is reversible in one click from the same card, so a confirm step would be friction protecting nothing. LEAVING is the one that asks, because a mis-click there is silent until the next send that never arrives.
An account with no address on file supplies one here — a wallet-only sign-in has no email, and a newsletter needs somewhere to send. It is written as the account email but NOT marked verified: this proves the person can type an address, not that they hold it, and treating it as verified would turn a mailing-list form into an account-recovery path.
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'app/controllers/studio/profiles_controller.rb', line 157 def return unsupported("newsletter") unless row_rendered?(:newsletter) if Studio::Newsletter.needs_email?(current_user) value = params.dig(:profile, :email).to_s.strip unless value.match?(URI::MailTo::EMAIL_REGEXP) return redirect_to profile_path, status: :see_other, alert: "Enter an email address to subscribe." end current_user.email = value end rescue_and_log(target: current_user) do # left_email_list_at is CLEARED rather than left in place. `subscribed?` # compares the two dates, so a stale leave date in the future of the join # would read as unsubscribed the moment the clock disagreed. current_user.update!(joined_email_list_at: Time.current, left_email_list_at: nil) redirect_to profile_path, notice: "You're on the list." end end |
#unlink_google ⇒ Object
DELETE /profile/google — drop the linked Google identity.
REFUSES WHEN IT WOULD ORPHAN THE ACCOUNT. turf-monster's version is an
unconditional update!(provider: nil, uid: nil); for an account whose only
sign-in is Google (no email, so no magic link; no wallet; no password) that
locks someone out of their own account behind a button labelled "Unlink".
It is safe in turf only because turf's users happen to carry an email —
a property of that app's data, not of the code.
Studio::OauthIdentity gates on Studio.auth_methods, not merely on the column: an app with an email column that does not offer magic-link sign-in cannot use it to get back in.
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'app/controllers/studio/profiles_controller.rb', line 126 def unlink_google return unsupported("Google account") unless row_rendered?(:google) unless Studio::OauthIdentity.google_linked?(current_user) return redirect_to profile_path, alert: "No Google account is linked.", status: :see_other end if Studio::OauthIdentity.unlink_orphans_account?(current_user) return redirect_to profile_path, status: :see_other, alert: "Google is the only way to sign in to this account. " \ "Add an email address first, then unlink." end rescue_and_log(target: current_user) do current_user.update!(provider: nil, uid: nil) redirect_to profile_path, notice: "Google account unlinked." end end |
#unsubscribe_newsletter ⇒ Object
DELETE /profile/newsletter — leave it.
STAMPS A DATE, never clears the join. "Have they ever joined" is a different question from "are they on the list", and a consumer that pays a once-ever welcome bonus (turf-monster does, on-chain) needs the first one to survive every leave and rejoin. Clearing joined_email_list_at here would let someone re-earn it by cycling.
185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'app/controllers/studio/profiles_controller.rb', line 185 def return unsupported("newsletter") unless row_rendered?(:newsletter) unless Studio::Newsletter.subscribed?(current_user) return redirect_to profile_path, alert: "You're not subscribed.", status: :see_other end rescue_and_log(target: current_user) do current_user.update!(left_email_list_at: Time.current) redirect_to profile_path, notice: "You've been unsubscribed." end end |
#update ⇒ Object
PATCH /profile — every editable field, in one request.
ONE FORM, ONE SAVE (operator's call). Email used to be its own action, and needed to be only while it was out-of-band; now that it applies directly there is no reason for a second mechanism on the same page. Its side effects stay here rather than moving into the form.
Each field is applied only if its ROW would render — the same resolver the page uses — so a host that cannot serve a field cannot have one posted into it either.
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'app/controllers/studio/profiles_controller.rb', line 56 def update attrs = {} attrs.merge!(name_attributes) if row_rendered?(:name) attrs.merge!(birthday_attributes) if row_rendered?(:birthday) # May redirect (the Google lock); if it did, we are done. prepare_email_change(attrs) if row_rendered?(:email) return if performed? if attrs.empty? return redirect_to edit_profile_path, alert: "Nothing to save.", status: :see_other end previous_email = current_user.email.to_s if current_user.respond_to?(:email) rescue_and_log(target: current_user) do unless current_user.update(attrs) next redirect_to edit_profile_path, status: :see_other, alert: current_user.errors..to_sentence.presence || "Could not save those changes." end after_email_change(previous_email) if attrs.key?(:email) redirect_to profile_path, notice: "Profile updated." end end |