studio-engine 0.84.0 → 0.86.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: 440b221a14bf9d80f6e58358f49d7b84f6495361e8d8a1a68e00b3980e02f024
4
- data.tar.gz: 1efda067ddfb1277ceb5cfd6b399195689081c3d02b077489e75ff0c756aaf19
3
+ metadata.gz: 0b6007443c9eeb21b16c521e76c956e5c05755ff0c264bbbf0f387f04ee79bd8
4
+ data.tar.gz: 1c6dd57eaf48aa3900de531b440766c8cb69281172f6c8a1aeb0daae45b7ca1b
5
5
  SHA512:
6
- metadata.gz: bde626a90189833853f31e49c8767d72fe48f82f99b06a3c7b3c282a790b55dc8479fcf066c12d7fe5324e7f84e6aa1c2f22536fa2af98908cb6c7cf237f30b2
7
- data.tar.gz: 5e5034bebe474a62d7821e9e8cc3efd23f23a2daad1ac709a335bc88f52c8c816e37c121ae710ddc0643d5fad26a0912297dd47c6451835b7af23f0bd294fa14
6
+ metadata.gz: 9fe2eb18d609180eeaaf03be1cf497bfc1b57407266565b8328757de5dd7f7c0633d5c5a81ab244cb39e4be9a211080a903c2d0490e181cfa07e89aca4838f97
7
+ data.tar.gz: 997135fe0ac9f3aea6d0da57c0d8af31b95e49320464fda777f441479250edf81af4bdbd4e93043a971d406e1779f819a7490707e733621e156ad95432c1c204
data/CHANGELOG.md CHANGED
@@ -4,6 +4,102 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.86.0 — 2026-10-03
8
+
9
+ ### Added
10
+
11
+ - **Footer-only consumers: an app with no ActiveRecord can take the site
12
+ footer alone, with no workarounds.** When `ActiveRecord::Railtie` is not
13
+ loaded (`Studio.active_record?` reads false) the engine requires the
14
+ ActiveSupport core extensions it calls at load time, skips the user-contract
15
+ check, keeps every `app/` root but `app/helpers` (the two `concerns` roots
16
+ included) out of eager loading, and answers
17
+ `studio_engine:install:migrations` with a no-op that exits 0 and writes
18
+ nothing. A host with ActiveRecord is unchanged. docs/SITE_FOOTER.md, *An app
19
+ with no database*, has the recipe; rantly can drop its four workarounds
20
+ (`require "active_support/core_ext/integer/time"`,
21
+ `Studio.validate_user_contract = false`, its `do_not_eager_load` loop, and its
22
+ replacement rake task).
23
+ - **docs/SITE_FOOTER.md and docs/BOOKING.md ship in the gem**, so a consumer's
24
+ citation of them resolves in the installed copy.
25
+
26
+ ### Changed
27
+
28
+ - **Footer links are painted at full opacity in `--ftr-link-ink`**, which
29
+ defaults to `--color-text-secondary`, instead of the inherited body colour at
30
+ 78% opacity. On the default theme the links clear WCAG AA on the band in both
31
+ themes; an app that maps the tokens onto its own palette sets
32
+ `--color-text-secondary` (or `--ftr-link-ink`) to an ink that clears 4.5:1 on
33
+ its `--color-surface-alt` (docs/SITE_FOOTER.md, *Colours*). A refused href's
34
+ label (`.ftr-link-plain`) takes the same ink.
35
+ - **A footer with no address names no Leaflet.** The map's styles and mount
36
+ script moved from `studio/site_footer/_assets` to a new
37
+ `studio/site_footer/_map_assets`, rendered only with a map
38
+ (`studio_footer_map_assets`).
39
+
40
+ ### Fixed
41
+
42
+ - **`/signup` honours `Studio.auth_methods`, as `/login` already did.** The
43
+ engine's `registrations/new` rendered Password, Confirm Password and "Sign up
44
+ with Google" whatever the app configured, while the passwordless
45
+ `RegistrationsController#create` reads the email alone. It now renders the
46
+ password form only when `Studio.password_login_available?`, otherwise the
47
+ email-only form (button "Send sign-in link", wording from
48
+ `Studio.sign_in_label`) when `:magic_link` is enabled, and the Google button and
49
+ its divider only with `:google`. The Name field renders only on the password
50
+ path, the one path that stores it; a passwordless signup discarded the name, so
51
+ it no longer asks. `#create` now branches on the same predicate the view does
52
+ (`password_login_available?` rather than `auth_method?(:password)`); for every
53
+ app that boots, the two agree. A password app renders exactly as before.
54
+ Consumers that wrote a host `app/views/registrations/new.html.erb` to hide the
55
+ password fields (McRitchie Industries) may delete it after the bump; the hub
56
+ and Turf Monster override it for their unified auth card and keep theirs.
57
+
58
+ ## 0.85.0 — 2026-10-02
59
+
60
+ ### Changed
61
+
62
+ - **`Studio::S3.delete` now moves the object to `trash/` instead of deleting
63
+ it.** The object is copied to `trash/<UTC date>/<epoch ms>/<key>` in the same
64
+ bucket, then deleted; a failed copy raises and never deletes. It returns the
65
+ trash key (nil when nothing was there) rather than the S3 response. The
66
+ bucket's lifecycle rule must expire `trash/` after three days, or trash never
67
+ expires: set it up before upgrading (README, *Trash and restore*). The
68
+ `/admin/emails` banner and logo replace and revert paths go through this
69
+ delete.
70
+ - **`Studio::S3.list` leaves `trash/` keys out.** Pass `include_trash: true` to
71
+ keep them. The filter runs after `max_keys`, so a page can come back short.
72
+ - **The engine's auth pages speak `Studio.sign_in_label`.** 0.80.0 put the label
73
+ on the navbar's signed-out buttons only, so an app that set `"Sign in"` still
74
+ showed "Log in to continue", a "Log In" button and a "Log in" link on its
75
+ `/login` and `/signup` pages. Those words, the magic-link button, the SSO
76
+ divider, the magic-link confirm page's button and the link-sent notice now
77
+ derive from the label (`lib/studio/auth_labels.rb`; README, *Navbar
78
+ identity*). An app that leaves the label at its default renders those pages
79
+ byte-identical to before. No routes change.
80
+
81
+ ### Added
82
+
83
+ - **`Studio::S3.purge!(key:)`**, the hard delete, for objects with no value
84
+ after deletion.
85
+ - **A production-bucket guard.** `Studio::S3.delete`, `purge!`, and the trash
86
+ service's `delete` and `delete_prefixed` raise
87
+ `Studio::S3::Trash::ProductionBucketRefused` before any request when the
88
+ bucket ends in `-production` and the process is not production (`QA_ENV`
89
+ first, then `Rails.env`).
90
+ - **`ActiveStorage::Service::StudioTrashS3Service`** (`service: StudioTrashS3`
91
+ in `config/storage.yml`): Active Storage's S3 service whose `delete` trashes
92
+ the blob's object, recording the content type, byte size, checksum and
93
+ filename it can read off the object so a blob row can be rebuilt.
94
+ `delete_prefixed` (variants) stays a hard delete. Opt-in per app.
95
+ - **`rake studio:trash:list[KEY]` and `studio:trash:restore[TRASH_KEY]`**: find
96
+ a key's trash copies, and copy one back to its original key (`FORCE=1` to
97
+ overwrite, `SERVICE=<name>` for an Active Storage bucket). A restore prints the
98
+ `ActiveStorage::Blob.create!` that re-creates the blob row.
99
+ - **`Studio::S3::Trash`**, the shared copy-then-delete, key mapping, listing and
100
+ restore. A single CopyObject moves at most 5 GiB, so a larger object raises
101
+ `TooLarge` and is left in place.
102
+
7
103
  ## 0.84.0 — 2026-10-01
8
104
 
9
105
  ### Changed
data/README.md CHANGED
@@ -73,6 +73,14 @@ Studio.configure do |config|
73
73
  end
74
74
  ```
75
75
 
76
+ `auth_methods` draws both auth pages. `/login` and `/signup` render the password
77
+ form only when `:password` is enabled and the User has `authenticate`
78
+ (`Studio.password_login_available?`); otherwise they render the email-only
79
+ sign-in-link form when `:magic_link` is enabled, and the Google button only with
80
+ `:google`. A passwordless signup stores nothing from the form: it mails a link,
81
+ and using the link creates the account. So `registration_params`' `:name` shows a
82
+ Name field only on a password app.
83
+
76
84
  ### Local log rotation (automatic — nothing to configure)
77
85
 
78
86
  The engine caps the host app's **development** log at 16 MB and its **test** log
@@ -629,7 +637,8 @@ Studio.configure do |config|
629
637
  config.navbar_user_name = :player_name # a method on the user
630
638
  # config.navbar_user_name = ->(user, view) { user.player_name } # or a callable
631
639
 
632
- # The signed-out button in layouts/_navbar and components/_user_nav.
640
+ # The signed-out button in layouts/_navbar and components/_user_nav, and the
641
+ # sign-in wording on the engine's own auth pages (below).
633
642
  config.sign_in_label = "Sign in" # default "Log in"
634
643
  end
635
644
  ```
@@ -646,6 +655,24 @@ end
646
655
  - The engine has no I18n catalogue, so the label is plain config, not a locale
647
656
  key. A blank or non-String label, or a `navbar_user_name` of any other type,
648
657
  raises `Studio::NavbarIdentity::InvalidConfig` at assignment.
658
+ - The label also sets the sign-in words on the engine's own auth pages, so the
659
+ navbar and `/login` never disagree. Rules: `lib/studio/auth_labels.rb`.
660
+
661
+ | Where | `"Log in"` (default) | `"Sign in"` |
662
+ |-------|----------------------|-------------|
663
+ | `/login` prompt | Log in to continue | Sign in to continue |
664
+ | `/login` password button | Log In | Sign In |
665
+ | `/login` magic-link button | Send sign-in link | Send sign-in link |
666
+ | `/login` SSO divider | or sign in below | or sign in below |
667
+ | `/signup` link back to `/login` | Log in | Sign in |
668
+ | Magic-link confirm page button | Sign in to *App* | Sign in to *App* |
669
+ | Link-sent notice | …emailed you a sign-in link. | …emailed you a sign-in link. |
670
+
671
+ The default reproduces the pages' earlier wording byte for byte, including the
672
+ four places that already said "sign in". Any other label derives every form:
673
+ `"Log on"` gives *Log On*, *Log on to continue*, *or log on below* and
674
+ *log-on link*. The "Sign in with Google" and "Sign up" buttons are not
675
+ sign-in labels and do not change.
649
676
 
650
677
  ### User nav slots
651
678
 
@@ -920,6 +947,9 @@ set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
920
947
  endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
921
948
  `upload` writes but returns `nil`; serve private objects with `signed_url`.
922
949
 
950
+ `Studio::S3.delete` moves the object to `trash/` for three days rather than
951
+ deleting it; see [Trash and restore](#trash-and-restore).
952
+
923
953
  An app with **no** bucket configured does not error — `/admin/emails` renders
924
954
  read-only, showing the inherited defaults it is genuinely sending and naming the
925
955
  one setting that turns uploads on.
@@ -932,6 +962,137 @@ Add it to the app's admin sidebar section:
932
962
  { label: "Emails", href: admin_emails_path, emoji: "✉️", desc: "Transactional email banners" }
933
963
  ```
934
964
 
965
+ ## Trash and restore
966
+
967
+ A delete through the engine is recoverable for three days. R2 has no object
968
+ versioning, so instead of deleting, the engine **moves** the object under
969
+ `trash/` in the same bucket, and a lifecycle rule on the bucket expires
970
+ `trash/` after three days:
971
+
972
+ ```text
973
+ avatars/abc.png -> trash/2026-10-01/1759302000123/avatars/abc.png
974
+ trash/<UTC date>/<epoch ms>/<original key>
975
+ ```
976
+
977
+ The copy goes first and the delete second. A copy that fails raises and the
978
+ delete is never sent, so a failure leaves the original in place. The trash copy
979
+ keeps the object's content type, cache headers and metadata, and adds
980
+ `original-key`, `deleted-at`, `deleted-env`, and what an Active Storage blob row
981
+ needs to be rebuilt (`blob-content-type`, `blob-byte-size`, `blob-checksum`
982
+ from a single-part ETag, and `blob-filename` when the upload carried a
983
+ Content-Disposition). A single CopyObject moves at most 5 GiB, so a larger
984
+ object raises `Studio::S3::Trash::TooLarge` and stays where it is.
985
+
986
+ `trash/` always sits at the bucket **root**, even for an app under a
987
+ `s3_key_prefix` in a shared bucket, so one lifecycle rule and one Cloudflare
988
+ rule cover every app in the bucket.
989
+
990
+ ### What deletes, and what purges
991
+
992
+ | Call | Effect |
993
+ |------|--------|
994
+ | `Studio::S3.delete(key:)` | Moves to trash; returns the trash key (nil if nothing was there) |
995
+ | `Studio::S3.purge!(key:)` | Hard delete, gone at once |
996
+ | `Studio::S3.list` | Leaves `trash/` keys out; `include_trash: true` keeps them |
997
+ | Active Storage `service: StudioTrashS3`, `delete` | Moves the blob's object to trash (`Blob#purge`, a replaced attachment) |
998
+ | Active Storage `service: StudioTrashS3`, `delete_prefixed` | Hard delete: Active Storage calls it for `variants/<key>/`, which regenerate from the original |
999
+
1000
+ **The production guard.** All four destructive paths refuse a bucket whose name
1001
+ ends in `-production` when the process is not production, by `Studio::S3`'s own
1002
+ resolution (`QA_ENV` first, so a QA app running Rails as production is NOT
1003
+ production, then `Rails.env`). They raise
1004
+ `Studio::S3::Trash::ProductionBucketRefused` before sending any request. The
1005
+ guard reads the bucket NAME, so it protects only buckets that follow the
1006
+ `<app>-production` convention.
1007
+
1008
+ ### Set up the bucket (once per bucket, before an app adopts)
1009
+
1010
+ Without the lifecycle rule, trash never expires. In the Cloudflare dashboard:
1011
+ R2 → the bucket → Settings → Object lifecycle rules → add a rule for prefix
1012
+ `trash/` that deletes objects 3 days after upload. A trash copy is a new object,
1013
+ so "uploaded" is the moment it was deleted. The same rule as S3 lifecycle JSON
1014
+ (R2's S3 API and AWS both take it). `aws s3api
1015
+ put-bucket-lifecycle-configuration` REPLACES the bucket's whole lifecycle
1016
+ configuration, R2's default abort-incomplete-multipart rule included, so read
1017
+ the current rules first (`get-bucket-lifecycle-configuration`) and send them
1018
+ together with this one, or use the dashboard:
1019
+
1020
+ ```json
1021
+ {
1022
+ "Rules": [
1023
+ {
1024
+ "ID": "expire-trash-3d",
1025
+ "Status": "Enabled",
1026
+ "Filter": { "Prefix": "trash/" },
1027
+ "Expiration": { "Days": 3 }
1028
+ }
1029
+ ]
1030
+ }
1031
+ ```
1032
+
1033
+ A public bucket also serves `trash/` from its custom domain unless it is
1034
+ blocked, which would keep a "deleted" profile picture reachable by URL for three
1035
+ days. Add a Cloudflare WAF custom rule on the zone, action **Block**:
1036
+
1037
+ ```text
1038
+ (http.host in {"assets.example.com"} and starts_with(http.request.uri.path, "/trash/"))
1039
+ ```
1040
+
1041
+ Name every `assets.<domain>` host the bucket answers on. If the bucket has the
1042
+ `r2.dev` public URL enabled, turn it off; a WAF rule cannot cover it.
1043
+
1044
+ ### Adopt it in an app
1045
+
1046
+ Active Storage: change the R2 service in `config/storage.yml` from `service: S3`
1047
+ to `service: StudioTrashS3`. Every other key stays as it is:
1048
+
1049
+ ```yaml
1050
+ r2:
1051
+ service: StudioTrashS3
1052
+ bucket: my-app-production
1053
+ endpoint: <%= ENV["R2_ENDPOINT"] %>
1054
+ region: auto
1055
+ access_key_id: <%= ENV["R2_ACCESS_KEY_ID"] %>
1056
+ secret_access_key: <%= ENV["R2_SECRET_ACCESS_KEY"] %>
1057
+ ```
1058
+
1059
+ An app with its own S3 service subclass (turf-monster's
1060
+ `ActiveStorage::Service::R2PublicService`) inherits from the trash service
1061
+ instead:
1062
+
1063
+ ```ruby
1064
+ require "active_storage/service/studio_trash_s3_service"
1065
+
1066
+ module ActiveStorage
1067
+ class Service::R2PublicService < Service::StudioTrashS3Service
1068
+ # ...
1069
+ end
1070
+ end
1071
+ ```
1072
+
1073
+ `Studio::S3` callers need no change: `delete` trashes from this release on. Call
1074
+ `purge!` where an object truly has no value after deletion.
1075
+
1076
+ ### Find and restore a deleted object
1077
+
1078
+ ```bash
1079
+ bin/rails "studio:trash:list" # everything in trash
1080
+ bin/rails "studio:trash:list[avatars/abc.png]" # one key's trash copies
1081
+ bin/rails "studio:trash:restore[trash/2026-10-01/1759302000123/avatars/abc.png]"
1082
+ ```
1083
+
1084
+ Both read `Studio::S3`'s bucket. `SERVICE=<storage.yml service name>` reads an
1085
+ Active Storage service's bucket instead, e.g. `SERVICE=r2`. A restore copies the
1086
+ object back to its original key and leaves the trash copy for the lifecycle rule
1087
+ to expire. It refuses to overwrite an object already at the original key unless
1088
+ `FORCE=1`.
1089
+
1090
+ Restoring an Active Storage object restores its **bytes only**. The blob row was
1091
+ destroyed before the object was trashed, so the task prints the
1092
+ `ActiveStorage::Blob.create!(...)` to run, filled from the trash copy's metadata,
1093
+ and the record must be re-attached by hand. `filename` is known only when the
1094
+ upload carried a Content-Disposition; supply it otherwise.
1095
+
935
1096
  ## Overriding Views
936
1097
 
937
1098
  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.
@@ -28,7 +28,7 @@ class MagicLinksController < ApplicationController
28
28
  end
29
29
  respond_to do |format|
30
30
  format.json { render json: { success: true } }
31
- format.html { redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link." }
31
+ format.html { redirect_to login_path, notice: "Check your inbox — we just emailed you a #{Studio::AuthLabels.link_noun(Studio.sign_in_label)}." }
32
32
  end
33
33
  end
34
34
 
@@ -12,13 +12,15 @@ class RegistrationsController < ApplicationController
12
12
  # straight from a signup POST would skip proof of email ownership, so we
13
13
  # treat "sign up" as a magic-link request — the create-or-login link (which
14
14
  # only fires after the recipient clicks it) does the account creation.
15
- unless Studio.auth_method?(:password)
15
+ # Gated on the same predicate registrations/new renders the password form on,
16
+ # so the form a visitor sees is always the form this action reads.
17
+ unless Studio.password_login_available?
16
18
  email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
17
19
  if email.match?(URI::MailTo::EMAIL_REGEXP)
18
20
  token = issue_magic_link(email, nil)
19
21
  Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
20
22
  end
21
- return redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link."
23
+ return redirect_to login_path, notice: "Check your inbox — we just emailed you a #{Studio::AuthLabels.link_noun(Studio.sign_in_label)}."
22
24
  end
23
25
 
24
26
  @user = User.new(user_params)
@@ -155,6 +155,15 @@ module Studio
155
155
  render "studio/site_footer/assets"
156
156
  end
157
157
 
158
+ # The map's styles and mount script, once per page. Only the map partial
159
+ # asks, so a page with no map names no Leaflet.
160
+ def studio_footer_map_assets
161
+ return if @_studio_footer_map_assets_rendered
162
+
163
+ @_studio_footer_map_assets_rendered = true
164
+ render "studio/site_footer/map_assets"
165
+ end
166
+
158
167
  def studio_booking_assets
159
168
  return if @_studio_booking_assets_rendered
160
169
 
@@ -3,6 +3,15 @@
3
3
  words = Studio.app_name.split
4
4
  last_word = words.pop
5
5
  prefix = words.join(" ")
6
+ # /signup renders from the app's configured auth_methods, exactly as /login does
7
+ # (sessions/new.html.erb): the PASSWORD form only when passwords are available
8
+ # (Studio.password_login_available?), otherwise the passwordless email form when
9
+ # :magic_link is enabled, and the Google button only when :google is. The passwordless
10
+ # POST (RegistrationsController#create) reads the email alone and mails a sign-in link,
11
+ # so that form asks for no password and no name: neither would be stored.
12
+ password_signup = Studio.password_login_available?
13
+ email_signup = password_signup || Studio.auth_method?(:magic_link)
14
+ ask_name = password_signup && Studio.registration_params.include?(:name)
6
15
  %>
7
16
 
8
17
  <div class="min-h-[70vh] flex items-center justify-center">
@@ -24,56 +33,70 @@
24
33
  </div>
25
34
  <% end %>
26
35
 
27
- <%= form_with model: @user, url: signup_path, method: :post do |f| %>
28
- <% if Studio.registration_params.include?(:name) %>
36
+ <% if email_signup %>
37
+ <%= form_with model: @user, url: signup_path, method: :post do |f| %>
38
+ <% if ask_name %>
39
+ <div class="mb-5">
40
+ <label for="user_name" class="block text-sm text-secondary mb-2 font-medium">Name</label>
41
+ <%= f.text_field :name, id: "user_name", autofocus: true,
42
+ class: "input-field",
43
+ placeholder: "Your name" %>
44
+ </div>
45
+ <% end %>
46
+
29
47
  <div class="mb-5">
30
- <label for="user_name" class="block text-sm text-secondary mb-2 font-medium">Name</label>
31
- <%= f.text_field :name, id: "user_name", autofocus: true,
48
+ <label for="user_email" class="block text-sm text-secondary mb-2 font-medium">Email</label>
49
+ <%= f.email_field :email, id: "user_email", autofocus: !ask_name,
32
50
  class: "input-field",
33
- placeholder: "Your name" %>
51
+ placeholder: "you@example.com" %>
34
52
  </div>
35
- <% end %>
36
53
 
37
- <div class="mb-5">
38
- <label for="user_email" class="block text-sm text-secondary mb-2 font-medium">Email</label>
39
- <%= f.email_field :email, id: "user_email", autofocus: !Studio.registration_params.include?(:name),
40
- class: "input-field",
41
- placeholder: "you@example.com" %>
42
- </div>
54
+ <% if password_signup %>
55
+ <div class="mb-5">
56
+ <label for="user_password" class="block text-sm text-secondary mb-2 font-medium">Password</label>
57
+ <%= f.password_field :password, id: "user_password",
58
+ class: "input-field",
59
+ placeholder: "Choose a password" %>
60
+ </div>
43
61
 
44
- <div class="mb-5">
45
- <label for="user_password" class="block text-sm text-secondary mb-2 font-medium">Password</label>
46
- <%= f.password_field :password, id: "user_password",
47
- class: "input-field",
48
- placeholder: "Choose a password" %>
49
- </div>
62
+ <div class="mb-6">
63
+ <label for="user_password_confirmation" class="block text-sm text-secondary mb-2 font-medium">Confirm Password</label>
64
+ <%= f.password_field :password_confirmation, id: "user_password_confirmation",
65
+ class: "input-field",
66
+ placeholder: "Confirm your password" %>
67
+ </div>
50
68
 
51
- <div class="mb-6">
52
- <label for="user_password_confirmation" class="block text-sm text-secondary mb-2 font-medium">Confirm Password</label>
53
- <%= f.password_field :password_confirmation, id: "user_password_confirmation",
54
- class: "input-field",
55
- placeholder: "Confirm your password" %>
56
- </div>
69
+ <button type="submit" class="btn btn-primary btn-lg w-full">
70
+ Sign Up
71
+ </button>
72
+ <% else %>
73
+ <button type="submit" class="btn btn-primary btn-lg w-full">
74
+ Send <%= Studio::AuthLabels.link_noun(Studio.sign_in_label) %>
75
+ </button>
57
76
 
58
- <button type="submit" class="btn btn-primary btn-lg w-full">
59
- Sign Up
60
- </button>
77
+ <p class="text-center text-secondary text-sm mt-4">We will email you a link that signs you in and creates your account.</p>
78
+ <% end %>
79
+ <% end %>
61
80
  <% end %>
62
81
 
63
- <div class="relative my-6">
64
- <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-subtle"></div></div>
65
- <div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
66
- </div>
82
+ <% if Studio.auth_method?(:google) %>
83
+ <% if email_signup %>
84
+ <div class="relative my-6">
85
+ <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-subtle"></div></div>
86
+ <div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
87
+ </div>
88
+ <% end %>
67
89
 
68
- <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
69
- class: "btn btn-google btn-lg w-full gap-3" do %>
70
- <%= render "components/google_logo" %>
71
- Sign up with Google
90
+ <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
91
+ class: "btn btn-google btn-lg w-full gap-3" do %>
92
+ <%= render "components/google_logo" %>
93
+ Sign up with Google
94
+ <% end %>
72
95
  <% end %>
73
96
 
74
97
  <p class="text-center text-secondary text-sm mt-6">
75
98
  Already have an account?
76
- <%= link_to "Log in", login_path, class: "text-primary hover:text-primary-300 font-medium underline underline-offset-2" %>
99
+ <%= link_to Studio::AuthLabels.link(Studio.sign_in_label), login_path, class: "text-primary hover:text-primary-300 font-medium underline underline-offset-2" %>
77
100
  </p>
78
101
  </div>
79
102
  </div>
@@ -11,7 +11,7 @@
11
11
 
12
12
  <div class="flex items-center gap-3 mt-5">
13
13
  <div class="flex-1 h-px border-t border-subtle"></div>
14
- <span class="text-muted text-xs uppercase tracking-wider">or sign in below</span>
14
+ <span class="text-muted text-xs uppercase tracking-wider"><%= Studio::AuthLabels.or_below(Studio.sign_in_label) %></span>
15
15
  <div class="flex-1 h-px border-t border-subtle"></div>
16
16
  </div>
17
17
  </div>
@@ -4,6 +4,9 @@
4
4
  words = Studio.app_name.split
5
5
  last_word = words.pop
6
6
  prefix = words.join(" ")
7
+ # The sign-in wording follows Studio.sign_in_label, as the navbar does
8
+ # (lib/studio/auth_labels.rb); the default label prints the wording these pages always had.
9
+ sign_in_label = Studio.sign_in_label
7
10
  %>
8
11
 
9
12
  <div class="min-h-[70vh] flex items-center justify-center">
@@ -13,7 +16,7 @@
13
16
  <img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="w-20 h-20 rounded-full shadow-lg mx-auto mb-4">
14
17
  <% end %>
15
18
  <h1 class="text-3xl font-extrabold text-heading tracking-tight"><%= prefix %> <span class="text-primary"><%= last_word %></span></h1>
16
- <p class="text-secondary mt-2">Log in to continue</p>
19
+ <p class="text-secondary mt-2"><%= Studio::AuthLabels.prompt(sign_in_label) %></p>
17
20
  </div>
18
21
 
19
22
  <% if has_sso %>
@@ -57,7 +60,7 @@
57
60
  </div>
58
61
 
59
62
  <button type="submit" class="btn btn-primary btn-lg w-full">
60
- Log In
63
+ <%= Studio::AuthLabels.button(sign_in_label) %>
61
64
  </button>
62
65
  <% end %>
63
66
  <% elsif Studio.auth_method?(:magic_link) %>
@@ -71,7 +74,7 @@
71
74
  </div>
72
75
 
73
76
  <button type="submit" class="btn btn-primary btn-lg w-full">
74
- Send sign-in link
77
+ Send <%= Studio::AuthLabels.link_noun(sign_in_label) %>
75
78
  </button>
76
79
  <% end %>
77
80
  <% end %>
@@ -75,7 +75,7 @@
75
75
  method: :post,
76
76
  form: { id: "magic-consume-form", data: { turbo: "false" } },
77
77
  class: "magic-submit" do %>
78
- Sign in to <%= Studio.app_name %>
78
+ <%= Studio::AuthLabels.to_app(Studio.sign_in_label, Studio.app_name) %>
79
79
  <% end %>
80
80
  </div>
81
81
  </main>