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 +4 -4
- data/CHANGELOG.md +222 -0
- data/README.md +157 -0
- data/app/assets/images/emails/email-change-confirmation.png +0 -0
- data/app/assets/images/emails/magic-link.png +0 -0
- data/app/controllers/studio/email_images_controller.rb +38 -3
- data/app/controllers/studio/emails_controller.rb +85 -0
- data/app/controllers/studio/local_reviews_controller.rb +85 -1
- data/app/mailers/user_mailer.rb +4 -1
- data/app/services/studio/email_image.rb +277 -33
- data/app/views/studio/email_images/index.html.erb +30 -1
- data/app/views/studio/emails/_row.html.erb +102 -0
- data/app/views/studio/emails/index.html.erb +116 -0
- data/app/views/studio/modals/_image_upload.html.erb +38 -7
- data/app/views/studio/modals/_scoped_host.html.erb +195 -0
- data/lib/studio/engine.rb +20 -0
- data/lib/studio/s3.rb +44 -8
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +98 -4
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc5fc673a7b9d072c93f482c90919c0f26836bd5e90194691ca30b5beb7c22d4
|
|
4
|
+
data.tar.gz: 9cbedec8a84b9619061217f90bceadbd51485e691351233bb67af6f03231b76e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
module Studio
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
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 =
|
|
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
|