studio-engine 0.33.0 → 0.37.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: 9ed77550c057e9ec4e232cc40ea934ef4753cc1d73d30e98b52c0a56f7ad8c8d
4
- data.tar.gz: 01b04c9da431ef4d43b75cf12533c3aece29c5557157af4c0f91eadf1fe9e2bb
3
+ metadata.gz: dc5fc673a7b9d072c93f482c90919c0f26836bd5e90194691ca30b5beb7c22d4
4
+ data.tar.gz: 9cbedec8a84b9619061217f90bceadbd51485e691351233bb67af6f03231b76e
5
5
  SHA512:
6
- metadata.gz: a1b26944f9f08b070f00aec356cc2435e3e35096d1353c970dd797b486293e50827fa6e6a11b3bc67ea306ef505f740114640595a4db10da4e9883a71561633b
7
- data.tar.gz: ce46eddd0627c5eae83daa9fc2eca518d74eae7500ce0e44a22faf016c0059d68b01f1d7b874e1f38d063918c460d36bf981d33b2a7f5dc3fe07ac6e0db85811
6
+ metadata.gz: '088bca3db79b1ad97d52bf2478aa08518a6139e73c700cc63d30903c1d31e4fddb391213c2061cd0711d0d5656a7115e53dd10817b094271c459543133e36ea7'
7
+ data.tar.gz: 96eb836f896ed9a12dd8c71a308178aa112387bc40f266323dad82db726df9698a4881ff4450c7fa7ce9f42d61cf1bcab9ca80a88940f243891e1cfe0307f3d0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,228 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## 0.37.0 — 2026-08-10
6
+
7
+ ### Added
8
+ - Standard transactional email primitive with a registry-backed catalog, and the
9
+ email preview folded into that registry.
10
+
11
+ ### Fixed
12
+ - The crop confirm is guarded by owner, so one confirmed crop can no longer fan out
13
+ and overwrite every row's banner on /admin/emails.
14
+
15
+ ## 0.36.0 — 2026-08-10
16
+
17
+ **Transactional emails become an engine primitive.** Every consuming app now
18
+ ships working, branded transactional email the moment it boots, and can grow its
19
+ own workflows and artwork. Additive — no host change is required to take this
20
+ release.
21
+
22
+ **`Studio::EmailImage` is now a registry.** It was a one-entry `VARIANTS` hash;
23
+ it is now a host-declared registry following the `Studio::ModelPage.register`
24
+ precedent. A registered email is mostly symbolic of a workflow — a key, a label,
25
+ a description — and its only real asset is its banner image.
26
+
27
+ - The engine **pre-registers the two every Studio app sends**, `magic_link` and
28
+ `email_change_confirmation`, so a host inherits both without declaring
29
+ anything.
30
+ - A host registers its own from an initializer:
31
+ `Studio::EmailImage.register("winnings", label: "Contest winnings")`.
32
+ Re-registering an inherited key **updates it in place**, keeping its page
33
+ position and its inherited artwork.
34
+ - `variants`, `label`, `known?`, `record`, `url`, and `store` keep their old
35
+ shapes, so `Studio::EmailImage.url(:magic_link)` — the only external caller
36
+ today — is untouched.
37
+
38
+ **Inheritance with per-app override.** Resolution is now two-layered:
39
+
40
+ ```
41
+ resolved_url(:magic_link) -> this app's ImageCache row (its own S3 bucket) app-owned
42
+ -> the engine's default gem asset inherited
43
+ -> nil bannerless
44
+ ```
45
+
46
+ `resolved_url`, **not** `url`. `url` deliberately keeps its pre-registry meaning —
47
+ this app's own image, or nil — so a caller written before the registry keeps the
48
+ behavior it was built against. A mailer that falls back itself
49
+ (`url(...) || own_banner`) must stay on `url`; switching it to `resolved_url`
50
+ would make that fallback dead code and replace the app's own artwork with the
51
+ engine default.
52
+
53
+ Defaults **ride the gem** (`app/assets/images/emails/*`), so a brand-new app with
54
+ an empty bucket sends good-looking email on day one with no cross-app S3
55
+ permission. Uploading writes to that app's own bucket and its own `image_caches`
56
+ row — which is exactly "the asset now belongs to this app". Placeholder artwork
57
+ ships for both standard emails; real artwork lands in a later release.
58
+
59
+ **New: `/admin/emails`**, modelled on the living style guide — one shared engine
60
+ page, admin-gated, rendering inside each host's layout.
61
+
62
+ **It is OPT-IN, and must be.** Add to `config/initializers/studio.rb`:
63
+
64
+ ```ruby
65
+ config.draw_admin_emails_routes = true
66
+ ```
67
+
68
+ `turf-monster` already owns `/admin/emails` (its `EmailCatalog` manager:
69
+ `namespace :admin { get "emails", as: :emails }`) and therefore both helper
70
+ names, `admin_emails_path` and `admin_email_path`. Drawing the engine's page
71
+ unconditionally raises `ArgumentError: Invalid route name, already in use:
72
+ 'admin_emails'` *while turf-monster's own routes are loading*, which takes down
73
+ its **entire** route set — every `admin_*_path` in the app goes undefined. A host
74
+ cannot opt out of a failure that happens before its config is read, so the
75
+ default is off until no consumer owns the name.
76
+
77
+ The gate covers only the **page**. The registry and the inherited-default
78
+ resolution are always on, and the engine's `UserMailer` already calls
79
+ `resolved_url`, so an app sends branded email whether or not it draws the page.
80
+
81
+ **`/admin/email_images` is deprecated, not deleted.** It still renders, and its
82
+ `admin_email_images_path` / `admin_email_image_path` helpers still resolve —
83
+ `mcritchie-studio` and `turf-monster` both have tests on `main` that drive it,
84
+ and consumer CI runs each consumer's default branch. A later engine minor deletes
85
+ the controller, view, and routes once no consumer references them. In the
86
+ meantime the old page had its own reporting bug fixed too: it now reads
87
+ `preview_url`, so it no longer claims "No image yet" about an email that is
88
+ visibly sending one.
89
+
90
+ - The table is the primary view: **name + live image on every row**, so an email
91
+ is identifiable at a glance.
92
+ - Each row says whether the live image is the **inherited default** or
93
+ **app-owned**, and an app-owned image can be reverted back to the default.
94
+ - Uploading goes through the **standard crop modal**, not a bare file input.
95
+
96
+ **Fix — the page misreported what was actually shipping.** It read only the S3
97
+ override, so it announced *"No image yet — emails send without a banner until you
98
+ upload one"* for an email that was visibly sending a banner from a committed repo
99
+ asset. `current_url` now resolves what really ships, and the copy distinguishes
100
+ "inherited default" from "no image at all".
101
+
102
+ **New: `Studio.s3_key_prefix`** — an optional key namespace inside the bucket, so
103
+ a satellite app can share an already-provisioned bucket instead of standing up
104
+ its own pair:
105
+
106
+ ```ruby
107
+ config.s3_bucket_prefix = "mcritchie-studio"
108
+ config.s3_key_prefix = "mcritchie-industries/"
109
+ # -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
110
+ ```
111
+
112
+ `Studio::S3` applies it to `upload`, `download`, `url`, `signed_url`, `exists?`,
113
+ `delete`, and `list` — callers keep passing logical keys and never see it, and
114
+ `list` strips it back off so a result feeds straight into `download`/`url`.
115
+ **Unset by default, so every already-shipped app's keys are byte-identical.**
116
+ Also new: `Studio::S3.configured?`, for callers that must degrade rather than
117
+ rescue `NotConfigured`.
118
+
119
+ **Honest degradation.** An app whose host never set `s3_bucket_prefix` renders
120
+ `/admin/emails` **read-only** — showing the inherited defaults it is genuinely
121
+ sending, naming the one setting that turns uploads on — instead of 500ing on the
122
+ first upload.
123
+
124
+ **New partial: `studio/modals/scoped_host`** — a self-contained modal host on its
125
+ own Alpine store, for a page that must bring its own modals. `/admin/emails` uses
126
+ it (`store: "emailModals"`) to ship its crop + saving modals, so the page works
127
+ identically in an app that renders a shared modal host and one that renders none
128
+ at all. `imageUploadHost` and `submitFormWithProgress` gained a matching `store`
129
+ option (default `"modals"`, so existing call sites are unchanged); the crop-photo
130
+ and saving partials already had one.
131
+
132
+ It is a **separate partial rather than a local on `studio/modals/_host`** for a
133
+ reason worth knowing: this is a non-isolated engine, so an app view at the same
134
+ path shadows the engine's — and `mcritchie-studio` and `turf-monster` both ship
135
+ their own `app/views/studio/modals/_host.html.erb`. A page rendering
136
+ `studio/modals/host` in those apps silently gets the app's fork, which knows
137
+ nothing of a `store:` local, so the page-scoped store is never registered and
138
+ every modal trigger on the page does nothing. `scoped_host` is unforked in every
139
+ app. The living style guide's hand-rolled `dsModals` host can rebase onto it.
140
+
141
+ Register modals with `current()?.id`, not `current().id` — the outer template
142
+ unmounts one tick after the stack empties, so a bare `.id` throws on close.
143
+
144
+ **Compatibility.** `Studio::EmailImage.url(key)` keeps its **pre-registry
145
+ contract** — this app's own uploaded image, or `nil`. The two-layer resolution
146
+ lives in the new `resolved_url(key)`. This matters: `turf-monster`'s mailer is
147
+ `Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`,
148
+ and making `url` resolve to the engine default would have turned that `||` into
149
+ dead code and silently replaced turf-monster's committed branded banner with the
150
+ engine's **placeholder** in live sign-in email. A method whose signature is
151
+ unchanged but whose return value flips from `nil` to a value is not additive.
152
+
153
+ **Consumer note.** Nothing here is required to upgrade. To adopt: set
154
+ `config.draw_admin_emails_routes = true`, link `admin_emails_path` from the app's
155
+ admin sidebar, register any extra workflows, switch mailers from `url` to
156
+ `resolved_url` where you want the inherited default, and drop any local
157
+ `branded_mailer.html.erb` fork — the engine's copy is app-name-aware through
158
+ `Studio.app_name`.
159
+
160
+ **The local-review link now signs the reviewer in as someone who can SEE the
161
+ page.** `/_studio/local_review` — the local half of the task board's WAITING
162
+ APPROVAL button — provisions the account before it mints, at the new
163
+ `Studio.local_review_role` (default `"admin"`).
164
+
165
+ The board hands this endpoint the operator's **production** email address. A
166
+ fresh worktree database has never seen it, so consuming the link took
167
+ `Studio::LinkConsumption#sign_up_new` and created the account at the default
168
+ role, `viewer`. `require_admin` on the page under review then redirected it to
169
+ `/`. The sign-in **succeeded** every time, which is what kept this quiet: the
170
+ button worked, the token was valid, and the operator simply arrived on the home
171
+ page having never seen the thing he was asked to review. A seeded admin
172
+ (`alex@mcritchie.studio`) worked fine, so only his real address ever hit it.
173
+
174
+ The endpoint now find-or-creates the reviewer and ensures the role BEFORE
175
+ minting, so the consume takes `sign_in_existing` onto an account that already
176
+ has rights. An existing account is promoted, never duplicated; an already-correct
177
+ one is not rewritten.
178
+
179
+ New public config:
180
+
181
+ ```ruby
182
+ Studio.configure do |config|
183
+ config.local_review_role = "admin" # default; nil provisions without a role
184
+ end
185
+ ```
186
+
187
+ Set it to `nil` for an app whose review pages are not admin-gated — the account
188
+ is still provisioned (that is what avoids `sign_up_new`), but its role is left
189
+ to the host's own `configure_new_user`.
190
+
191
+ **The endpoint also answers "who is sitting at this desk?" when the caller names
192
+ nobody.** `?email=` is now optional, and an explicit one still wins. With none,
193
+ the reviewer resolves to `Studio.local_review_email`, and failing that to the
194
+ **first user already holding `Studio.local_review_role`, by id** (that query
195
+ falls back to `"admin"` when the setting is `nil`):
196
+
197
+ ```ruby
198
+ config.local_review_email = "someone@example.com" # nil (default) = derive
199
+ ```
200
+
201
+ This exists so the board's WAITING APPROVAL CTA can be a **public, sign-in-free
202
+ redirect**. Requiring a board session to click it is what broke one-click review
203
+ in the first place; sending an email in that public URL would publish the
204
+ operator's address. The local stack is the machine the reviewer is sitting at,
205
+ so it is the right place to decide. A desk with nobody at that role mints
206
+ nothing and says so, rather than guessing: the derive only ever picks someone
207
+ who ALREADY holds the role, so it never promotes a stranger into it. (The
208
+ `?email=` path does promote the account it is handed — that is the point of it.)
209
+
210
+ **The floor is unchanged, and now asserted rather than assumed.** The endpoint
211
+ grants a role, so both gates in front of it are tested directly: the
212
+ developer-desk routes are drawn only outside production (proved by drawing
213
+ `Studio.routes` into a throwaway route set under a production env), and a
214
+ non-loopback request 404s **before** provisioning or minting anything.
215
+ Provisioning is best-effort: a host whose `User` rejects the write gets a logged
216
+ warning and a working link, never a 500.
217
+
218
+ ### Added
219
+
220
+ - **`Studio.local_review_role`** — the role `/_studio/local_review` stamps on the
221
+ account it provisions before minting. Defaults to `"admin"`; `nil` provisions
222
+ the account without touching its role.
223
+ - **`Studio.local_review_email`** — who the local-review mint signs in when the
224
+ caller sends no `?email=`. `nil` (the default) derives the first admin in this
225
+ database, by id.
226
+
5
227
  ## 0.33.0 — 2026-08-10
6
228
 
7
229
  Two changes ship together in this release: local log files are now capped, and
data/README.md CHANGED
@@ -24,6 +24,7 @@ Then `bundle install`. The current release is **v0.6.1**; see [`CHANGELOG.md`](.
24
24
  - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions.
25
25
  - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
26
26
  - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
27
+ - **Transactional emails**: `Studio::EmailImage` registry + the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
27
28
 
28
29
  ## Configuration
29
30
 
@@ -195,6 +196,39 @@ the block:
195
196
  <% end %>
196
197
  ```
197
198
 
199
+ **Page-scoped hosts — `studio/modals/scoped_host`.** When a page must bring its
200
+ own modals (because not every consuming app renders a shared host, and the ones
201
+ that do register their own modal set), render a second host on its own Alpine
202
+ store:
203
+
204
+ ```erb
205
+ <%= render "studio/modals/scoped_host", store: "emailModals" do %>
206
+ <template x-if="$store.emailModals.current()?.id === 'crop-photo'">
207
+ <%= render "studio/modals/crop_photo", store: "emailModals" %>
208
+ </template>
209
+ <% end %>
210
+ ```
211
+
212
+ `studio/modals/_crop_photo` and `_saving` take the same `store:` local, and
213
+ `imageUploadHost({ store: "emailModals", ... })` /
214
+ `submitFormWithProgress(form, { store: "emailModals" })` route through it — all
215
+ default to `"modals"`, so existing call sites are unchanged. The engine's
216
+ `/admin/emails` is the live example.
217
+
218
+ Two things that will bite you:
219
+
220
+ - **Render `scoped_host`, not `host`.** This is a non-isolated engine, so an app
221
+ view at the same path shadows the engine's — and `mcritchie-studio` and
222
+ `turf-monster` both ship their own `app/views/studio/modals/_host.html.erb`.
223
+ A page rendering `studio/modals/host` in those apps silently gets the app's
224
+ fork. `scoped_host` is unforked everywhere.
225
+ - **Guard registrations with `current()?.id`.** The outer template unmounts one
226
+ tick *after* the stack empties, so a bare `.id` throws on every close.
227
+
228
+ The scoped host takes its animations from `engine-motion.css` rather than an
229
+ inline copy, so a consumer bundling that layer gets the same spring as the shared
230
+ host.
231
+
198
232
  Store API (`Alpine.store('modals')`):
199
233
 
200
234
  | Call | Behavior |
@@ -268,6 +302,129 @@ The legacy string locals (`balance_html`, `extra_icons_html`, `div2_html` —
268
302
  pre-rendered HTML injected via `raw`) are deprecated but still honored when the
269
303
  matching slot is absent, so existing call sites render unchanged.
270
304
 
305
+ ## Transactional emails
306
+
307
+ Every consuming app can render the same admin page — **`/admin/emails`** —
308
+ listing each transactional email it sends with the banner riding at the top of
309
+ that email, and whether that banner is the **inherited default** or an
310
+ **app-owned override**. Supersedes the old `/admin/email_images`, which is
311
+ deprecated but still renders for one release.
312
+
313
+ **The page is opt-in:**
314
+
315
+ ```ruby
316
+ # config/initializers/studio.rb
317
+ config.draw_admin_emails_routes = true
318
+ ```
319
+
320
+ Off by default because `turf-monster` already owns `/admin/emails` and both of
321
+ its helper names; drawing them there raises at route-load and kills every route
322
+ in the app. The gate covers only the page — the registry and the inherited
323
+ defaults are always on.
324
+
325
+ ### The registry
326
+
327
+ A registered email is mostly symbolic of a workflow — a key, a label, a
328
+ description. The only real asset is its image. The engine pre-registers the two
329
+ every Studio app sends, so a new app inherits both without declaring anything:
330
+
331
+ | Key | Label |
332
+ |-----|-------|
333
+ | `magic_link` | Magic-link sign-in |
334
+ | `email_change_confirmation` | Email change confirmation |
335
+
336
+ A host adds its own workflows from an initializer, mirroring
337
+ `Studio::ModelPage.register`:
338
+
339
+ ```ruby
340
+ # config/initializers/studio_emails.rb
341
+ Rails.application.config.to_prepare do
342
+ Studio::EmailImage.register("winnings", label: "Contest winnings",
343
+ description: "Sent when a player wins a contest.")
344
+ Studio::EmailImage.register("wallet_export", label: "Wallet export")
345
+ end
346
+ ```
347
+
348
+ Re-registering an inherited key updates it in place and keeps its position, so
349
+ relabeling `magic_link` does not reorder the page or drop its default artwork.
350
+
351
+ ### Resolution — inherit, then own
352
+
353
+ ```
354
+ Studio::EmailImage.resolved_url(:magic_link)
355
+ 1. this app's ImageCache row (its own S3 bucket) -> app-owned override
356
+ 2. the engine's default gem asset -> inherited default
357
+ 3. nil -> sends bannerless
358
+ ```
359
+
360
+ Note the method: **`resolved_url` walks all three layers; `url` returns only
361
+ layer 1** (this app's own image, or nil). That split is deliberate — it keeps
362
+ every caller written before the registry behaving exactly as it did. A mailer
363
+ that already falls back on its own, `url(:magic_link) || own_banner`, must stay
364
+ on `url`; moving it to `resolved_url` makes that fallback unreachable and swaps
365
+ the app's committed artwork for the engine's default.
366
+
367
+ Defaults **ride the gem** (`app/assets/images/emails/*`), so a brand-new app with
368
+ an empty bucket sends branded email on day one and needs no cross-app S3
369
+ permission. Uploading on an app's `/admin/emails` writes to **that** app's bucket
370
+ and **that** app's `image_caches` row — which is exactly "the asset now belongs
371
+ to this app". Every app has its own bucket and table, so an override never leaks
372
+ between apps.
373
+
374
+ Three accessors, and picking the wrong one changes what real people receive:
375
+
376
+ | Call | Returns | Use for |
377
+ |---|---|---|
378
+ | `url(key)` | this app's **own** image, or `nil` | the pre-registry contract; a host doing its own `\|\| fallback` |
379
+ | `resolved_url(key)` | own → inherited default → `nil`, **absolute** | mailers |
380
+ | `preview_url(key)` | own → inherited default (root-relative) | the admin page |
381
+
382
+ `url` deliberately does **not** resolve to the default. `turf-monster`'s mailer
383
+ reads `Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`
384
+ — making `url` resolve would turn that `||` into dead code and swap its committed
385
+ branded banner for the engine placeholder in live email.
386
+
387
+ ```ruby
388
+ class UserMailer < ApplicationMailer
389
+ layout "branded_mailer"
390
+
391
+ def magic_link(email, token)
392
+ @banner_url = Studio::EmailImage.resolved_url(:magic_link) # nil renders bannerless
393
+ # ...
394
+ end
395
+ end
396
+ ```
397
+
398
+ Do **not** fork `layouts/branded_mailer.html.erb` — the engine's copy is
399
+ app-name-aware through `Studio.app_name`.
400
+
401
+ ### Sharing a bucket — `s3_key_prefix`
402
+
403
+ Uploads need `Studio.s3_bucket_prefix`. A satellite app that has no bucket of its
404
+ own can share an existing one under its own key namespace:
405
+
406
+ ```ruby
407
+ config.s3_bucket_prefix = "mcritchie-studio"
408
+ config.s3_key_prefix = "mcritchie-industries/"
409
+ # -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
410
+ ```
411
+
412
+ `Studio::S3` applies the prefix to every operation, so callers keep passing
413
+ logical keys and never see it. Unset (the default) leaves keys byte-identical to
414
+ what every already-shipped app wrote.
415
+
416
+ An app with **no** bucket configured does not error — `/admin/emails` renders
417
+ read-only, showing the inherited defaults it is genuinely sending and naming the
418
+ one setting that turns uploads on.
419
+
420
+ ### Linking it
421
+
422
+ Add it to the app's admin sidebar section:
423
+
424
+ ```ruby
425
+ { label: "Emails", href: admin_emails_path, emoji: "✉️", desc: "Transactional email banners" }
426
+ ```
427
+
271
428
  ## Overriding Views
272
429
 
273
430
  This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
@@ -1,7 +1,22 @@
1
1
  module Studio
2
- # Admin page to manage the banner image on transactional emails (Studio::
3
- # EmailImage). One managed variant today (magic_link); the registry is the
4
- # extension point. Shared by every app surfaced from each app's admin hub.
2
+ # DEPRECATED superseded by Studio::EmailsController (/admin/emails), which
3
+ # shows every registered email with its live image, says whether that image is
4
+ # the inherited default or app-owned, and uploads through the crop modal.
5
+ #
6
+ # Kept alive for ONE release on purpose, not by neglect. consumer-ci.yml checks
7
+ # out each consumer repo with no `ref:` — i.e. its DEFAULT BRANCH — and runs
8
+ # that suite against the engine PR. mcritchie-studio and turf-monster both have
9
+ # tests on `main` that drive this page and its admin_email_image_path helper,
10
+ # so deleting it here would redden their lanes from the moment the PR opens,
11
+ # and nothing inside the engine PR could fix it: the consumer PRs would have to
12
+ # travel accepted -> release -> main in BOTH apps before this could go green.
13
+ #
14
+ # So the retirement is staged: this release adds /admin/emails and leaves this
15
+ # page working; each app's adoption task moves its link and its tests; a later
16
+ # engine minor deletes this file, its view, and its two routes once no
17
+ # consumer's main references them.
18
+ #
19
+ # It is not frozen in its broken state, though — see #index.
5
20
  class EmailImagesController < ApplicationController
6
21
  before_action :require_admin
7
22
 
@@ -11,6 +26,26 @@ module Studio
11
26
  @variants = Studio::EmailImage.variants
12
27
  end
13
28
 
29
+ # The canonical page this one is being retired in favour of, or nil when this
30
+ # app has not drawn it. Rendered as a banner at the top of the old view so an
31
+ # admin who lands here by a stale link is walked forward.
32
+ #
33
+ # The nil case is REAL, not defensive padding: /admin/emails is opt-in
34
+ # (Studio.draw_admin_emails_routes, default off because turf-monster owns the
35
+ # name), so on any app that has not opted in the helper does not exist and a
36
+ # bare call raises NameError — which 500s this page. Consumer CI caught
37
+ # exactly that on mcritchie-studio.
38
+ #
39
+ # Asks the ROUTER rather than the config flag: the flag is read at route-draw
40
+ # time, so a host that flips it afterwards would still have no route, and the
41
+ # router is the thing that actually knows.
42
+ def successor_path
43
+ return nil unless Rails.application.routes.named_routes.key?(:admin_emails)
44
+
45
+ admin_emails_path
46
+ end
47
+ helper_method :successor_path
48
+
14
49
  # PATCH /admin/email_images/:variant — upload/replace a banner.
15
50
  def update
16
51
  variant = params[:variant].to_s
@@ -0,0 +1,85 @@
1
+ module Studio
2
+ # /admin/emails — the standard transactional-email page every Studio app gets,
3
+ # modelled on the living style guide (/admin/style): a plain host-inherited
4
+ # controller whose view is a bare content wrapper, so it renders inside each
5
+ # host's application layout and picks up that app's navbar and theme.
6
+ #
7
+ # It lists Studio::EmailImage's registry — one row per registered email, each
8
+ # showing its live banner and whether that banner is the INHERITED engine
9
+ # default or an APP-OWNED override — and writes an override through the shared
10
+ # crop modal. Replaces /admin/email_images, which now redirects here.
11
+ #
12
+ # An app whose host never set Studio.s3_bucket_prefix cannot store an override.
13
+ # That is a read-only page, not an error: uploads_available? gates the write
14
+ # actions and the view explains why, so the page still shows what each email
15
+ # is currently sending.
16
+ class EmailsController < ApplicationController
17
+ before_action :require_admin
18
+ before_action :load_entry, only: %i[update destroy]
19
+ before_action :require_uploads, only: %i[update destroy]
20
+
21
+ MAX_BYTES = 8.megabytes
22
+
23
+ def index
24
+ @entries = Studio::EmailImage.entries
25
+ @uploads_available = Studio::EmailImage.uploads_available?
26
+ end
27
+
28
+ # PATCH /admin/emails/:key — upload/replace this app's own banner.
29
+ def update
30
+ file = params[:image]
31
+ unless valid_image?(file)
32
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
33
+ return redirect_to admin_emails_path, alert: message, status: :see_other
34
+ end
35
+
36
+ rescue_and_log do
37
+ Studio::EmailImage.store(@key, io: file, content_type: file.content_type)
38
+ redirect_to admin_emails_path, notice: "#{Studio::EmailImage.label(@key)} banner updated.", status: :see_other
39
+ end
40
+ rescue StandardError
41
+ redirect_to admin_emails_path, alert: "Couldn't save the image. Please try again.", status: :see_other
42
+ end
43
+
44
+ # DELETE /admin/emails/:key — drop this app's override and fall back to the
45
+ # inherited default.
46
+ def destroy
47
+ # rescue_and_log, like #update: destroy is a WRITE path (it drops an
48
+ # ImageCache row and deletes the S3 object behind it), and the bare rescue
49
+ # below turns any failure into a friendly alert. Without the log that
50
+ # failure is invisible — the admin sees "try again" and nothing reaches
51
+ # ErrorLog to say why.
52
+ rescue_and_log do
53
+ reverted = Studio::EmailImage.revert(@key)
54
+ notice = if reverted
55
+ "#{Studio::EmailImage.label(@key)} reverted to the inherited default."
56
+ else
57
+ "#{Studio::EmailImage.label(@key)} was already using the inherited default."
58
+ end
59
+ redirect_to admin_emails_path, notice: notice, status: :see_other
60
+ end
61
+ rescue StandardError
62
+ redirect_to admin_emails_path, alert: "Couldn't revert the image. Please try again.", status: :see_other
63
+ end
64
+
65
+ private
66
+
67
+ def load_entry
68
+ @key = params[:key].to_s
69
+ head :not_found unless Studio::EmailImage.known?(@key)
70
+ end
71
+
72
+ def require_uploads
73
+ return if Studio::EmailImage.uploads_available?
74
+
75
+ redirect_to admin_emails_path, status: :see_other,
76
+ alert: "This app has no object storage configured, so email images can't be changed here yet."
77
+ end
78
+
79
+ def valid_image?(file)
80
+ file.respond_to?(:content_type) &&
81
+ file.content_type.to_s.start_with?("image/") &&
82
+ file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
83
+ end
84
+ end
85
+ end
@@ -19,6 +19,15 @@ module Studio
19
19
  # exposure the inbox already carries, which hands every captured sign-in link
20
20
  # to any local reader. It is a development desk convenience; it is not a
21
21
  # sign-in path.
22
+ #
23
+ # It also PROVISIONS the account before minting, at Studio.local_review_role
24
+ # (default "admin"). The email the board hands over is the operator's
25
+ # PRODUCTION address, and a fresh worktree database has never seen it — so
26
+ # without this the consume takes Studio::LinkConsumption#sign_up_new, creates
27
+ # him at the default role ("viewer"), and `require_admin` on the page under
28
+ # review redirects him to "/". The sign-in SUCCEEDS and he never sees the
29
+ # page, which is what made the failure so quiet: a seeded admin works, so only
30
+ # the operator's real address ever hit it.
22
31
  class LocalReviewsController < ApplicationController
23
32
  include Studio::MagicLinkIssuing
24
33
 
@@ -27,9 +36,13 @@ module Studio
27
36
  before_action :require_local_development!
28
37
 
29
38
  def show
30
- email = Studio::LinkToken.normalize_email(params[:email])
39
+ email = reviewer_email
31
40
  return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
32
41
 
42
+ # Provision BEFORE minting, so the consume finds an existing account and
43
+ # takes sign_in_existing rather than sign_up_new-at-the-default-role.
44
+ provision_reviewer(email)
45
+
33
46
  # return_to is passed through raw: the store sanitizes it to a same-origin
34
47
  # path on the way in (Studio::Link.create_magic_link calls
35
48
  # Studio::LinkToken.sanitize_path). Re-sanitizing here would be a second
@@ -43,6 +56,77 @@ module Studio
43
56
 
44
57
  MISSING_EMAIL = "Add ?email=<your address> to mint a local review link."
45
58
 
59
+ # WHO to sign in, in priority order:
60
+ #
61
+ # 1. `?email=` — an explicit caller still wins.
62
+ # 2. `Studio.local_review_email` — the app's declared desk operator.
63
+ # 3. the first admin in this database — the seeded operator, by id.
64
+ #
65
+ # The board deliberately stops at (1) being ABSENT. Its WAITING APPROVAL CTA
66
+ # is a public, sign-in-free redirect, so an email in that URL would be an
67
+ # address published on a public page for anyone to read. The local stack is
68
+ # the right place to answer "who is at this desk": it is the machine the
69
+ # reviewer is sitting at, and it already knows its own operator.
70
+ def reviewer_email
71
+ Studio::LinkToken.normalize_email(
72
+ params[:email].presence || Studio.local_review_email.presence || seeded_admin_email
73
+ )
74
+ end
75
+
76
+ # The database's own first admin. Ordered by id so a re-seeded desk resolves
77
+ # to the same person every time rather than to whoever was touched last.
78
+ # Rescued because this runs before the mint on every click: a host with no
79
+ # role column must fall through to MISSING_EMAIL, not a 500.
80
+ def seeded_admin_email
81
+ return nil unless User.respond_to?(:column_names) && User.column_names.include?("role")
82
+
83
+ User.where(role: Studio.local_review_role.presence || "admin").order(:id).first&.email
84
+ rescue StandardError => e
85
+ Rails.logger.warn("[Studio::LocalReviewsController] no default reviewer: #{e.class}: #{e.message}")
86
+ nil
87
+ end
88
+
89
+ # Find-or-create the reviewer and ensure Studio.local_review_role, so the
90
+ # page under review actually RENDERS for whoever follows the link.
91
+ #
92
+ # Best-effort by design. Provisioning is an upgrade to the mint, not a
93
+ # precondition of it: a host with an extra User validation must not turn the
94
+ # review button into a 500 — it should still hand back a working sign-in
95
+ # link, exactly as it did before this method existed. So a failure is logged
96
+ # loudly and the mint proceeds. The end-to-end check that the operator
97
+ # actually lands ON the page is the real gate; this is the thing that makes
98
+ # it pass, not the thing that proves it.
99
+ # `User` is referenced bare, with no defined?/const_defined? guard: those do
100
+ # not agree with each other about a Zeitwerk autoload that has not fired
101
+ # yet, and a guard that reads "absent" on a cold boot would skip
102
+ # provisioning silently — the failure mode being fixed. Studio::LinkConsumption
103
+ # already requires every consuming app to have User, so a genuinely missing
104
+ # one is a NameError the rescue below logs like any other host mismatch.
105
+ def provision_reviewer(email)
106
+ user = User.find_by(email: email) || build_reviewer(email)
107
+ role = Studio.local_review_role.presence
108
+ user.role = role if role && user.respond_to?(:role=) && user.role != role
109
+ user.save! if user.new_record? || user.changed?
110
+ user
111
+ rescue StandardError => e
112
+ # No re-raise: see "best-effort" above. Logged at warn because a silent
113
+ # miss here reappears as the exact symptom this endpoint exists to remove
114
+ # — a successful sign-in that lands on the wrong page.
115
+ Rails.logger.warn(
116
+ "[Studio::LocalReviewsController] could not provision #{email}: #{e.class}: #{e.message}"
117
+ )
118
+ nil
119
+ end
120
+
121
+ # A brand-new reviewer goes through the host's own new-user hook first, so
122
+ # the account this endpoint creates is shaped like every other account in
123
+ # the app (usernames, defaults, associations) — then the role is stamped on
124
+ # top. Mirrors Studio::LinkConsumption#sign_up_new, which is the path this
125
+ # provisioning is standing in for.
126
+ def build_reviewer(email)
127
+ User.new(email: email).tap { |user| Studio.configure_new_user.call(user) }
128
+ end
129
+
46
130
  def require_local_development!
47
131
  head :not_found unless Studio.local_tool_enabled?(request_local: request.local?)
48
132
  end