plutonium 0.65.0 → 0.66.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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -8,15 +8,15 @@ A `/profile` URL that shows the user's personal fields plus a "Security" section
8
8
 
9
9
  ## 🚨 Critical
10
10
 
11
- - **Profile association is always `:profile`** regardless of the model class — `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
12
- - **Profile needs `pu:profile:conn` to be visible** — without it, no `/profile` route, no `profile_url` helper.
13
- - **Every user needs a profile row.** Add an `after_create :create_profile!` callback to the user model. Without it, `current_user.profile` is nil.
11
+ - **Profile association is always `:profile`** regardless of the model class: `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
12
+ - **Profile needs `pu:profile:conn` to be visible.** Without it there is no `/profile` route, and `profile_url` stays `nil` so the user menu has no Profile link.
13
+ - **A user without a profile row is fine.** `profile_url` sends them to the new-profile form. Creating the row up front is optional (see step 4).
14
14
 
15
15
  The show page renders the user's fields, then the **Security Settings** block with Rodauth-backed actions:
16
16
 
17
17
  ![User profile show page with SecuritySection](/images/guides/user-profile-show.png)
18
18
 
19
- Editing produces a regular Plutonium form — same form generator the rest of your resources use:
19
+ Editing produces a regular Plutonium form, same form generator the rest of your resources use:
20
20
 
21
21
  ![User profile edit form](/images/guides/user-profile-edit.png)
22
22
 
@@ -28,7 +28,7 @@ rails g pu:profile:setup date_of_birth:date bio:text \
28
28
  --portal=competition_portal
29
29
  ```
30
30
 
31
- `pu:profile:setup` is a meta-generator — runs `pu:profile:install` + `pu:profile:conn` in one shot.
31
+ `pu:profile:setup` is a meta-generator: runs `pu:profile:install` + `pu:profile:conn` in one shot.
32
32
 
33
33
  ## Step-by-step
34
34
 
@@ -64,31 +64,38 @@ rails db:prepare
64
64
  rails g pu:profile:conn --dest=customer_portal
65
65
  ```
66
66
 
67
- This registers the profile as a **singular** resource — exposes `/profile` (no `:id`) and the `profile_url` helper.
67
+ This registers the profile as a **singular** resource: exposes `/profile` (no `:id`) and the `profile_url` helper.
68
68
 
69
- ### 4. Add the auto-create callback
69
+ It also generates a portal policy scoped to the current user: `create?` is `user.profile.nil?` (one profile per user), `update?` is `true`, and `destroy?` is `false`. The controller sets `user` from `current_user`, so `:user` is not a permitted attribute.
70
+
71
+ ::: tip Profile can't be edited?
72
+ Policies generated by older versions of `pu:profile:conn` lack `update?`, so it falls back to `create?` and the profile becomes read-only once it exists. Add `def update? = true` to the portal's profile policy.
73
+ :::
74
+
75
+ ### 4. (Optional) Create the row up front
76
+
77
+ Without a profile row, the first visit to the profile is the "create profile" form, and the Security section appears once it is saved. To land users on the show page from the first click, create the row when the user is created:
70
78
 
71
79
  ```ruby
72
- # app/models/user.rb (modified by pu:profile:install)
80
+ # app/models/user.rb (has_one added by pu:profile:install)
73
81
  class User < ApplicationRecord
74
82
  has_one :profile, class_name: "UserProfile", dependent: :destroy
75
83
 
76
- after_create :create_profile!
77
-
78
- private
79
- def create_profile! = create_profile
84
+ after_create :create_profile
80
85
  end
81
86
  ```
82
87
 
83
- For existing users at migration time:
88
+ and backfill existing users:
84
89
 
85
90
  ```bash
86
- rails runner "User.find_each(&:create_profile)"
91
+ rails runner "User.find_each { |u| u.profile || u.create_profile }"
87
92
  ```
88
93
 
94
+ Skip this when the profile has required fields the user must fill in, or when other code already creates profiles.
95
+
89
96
  ## What you get
90
97
 
91
- The generated definition injects a custom `ShowPage` that renders `SecuritySection` — dynamically lists Rodauth security links based on which features are enabled:
98
+ The generated definition injects a custom `ShowPage` that renders `SecuritySection`: dynamically lists Rodauth security links based on which features are enabled:
92
99
 
93
100
  | Feature enabled | Link rendered |
94
101
  |---|---|
@@ -100,15 +107,34 @@ The generated definition injects a custom `ShowPage` that renders `SecuritySecti
100
107
  | `active_sessions` | Active Sessions |
101
108
  | `close_account` | Close Account |
102
109
 
103
- If a feature isn't enabled, its link doesn't render — no configuration needed.
110
+ If a feature isn't enabled, its link doesn't render; no configuration needed.
104
111
 
105
112
  ## Linking to the profile
106
113
 
114
+ The topbar avatar menu already shows a "Profile" entry once `pu:profile:conn` has run. `Plutonium::Auth::Rodauth` defines `profile_url` as `nil`, and the generator overrides it in the portal's controller concern. To link from your own views:
115
+
107
116
  ```ruby
108
- link_to("Profile", profile_url) if respond_to?(:profile_url)
117
+ link_to("Profile", profile_url) if profile_url
118
+ ```
119
+
120
+ ## Account features on the profile page
121
+
122
+ Per-user settings that aren't profile fields (API tokens, connected accounts, notification preferences) belong on the profile page. Link them from the same `ShowPage` hook, next to `SecuritySection`:
123
+
124
+ ```ruby
125
+ class ShowPage < ShowPage
126
+ private
127
+
128
+ def render_after_content
129
+ render Plutonium::Profile::SecuritySection.new
130
+ div(class: "mt-8") do
131
+ a(href: resource_url_for(ApiToken), class: "font-medium text-[var(--pu-text)] hover:underline") { "API tokens" }
132
+ end
133
+ end
134
+ end
109
135
  ```
110
136
 
111
- The `respond_to?` guard is defensive — only portals that ran `pu:profile:conn` have the helper.
137
+ See [Reference › Auth › Profile](/reference/auth/profile#account-features-on-the-profile-page) for scoping such a resource to the user.
112
138
 
113
139
  ## Customizing the definition
114
140
 
@@ -152,12 +178,13 @@ rails g pu:profile:conn StaffUserProfile --dest=admin_portal
152
178
 
153
179
  ## Common issues
154
180
 
155
- - **`current_user.profile` is nil** — every user needs a profile row. Add `after_create :create_profile!` to the user model.
156
- - **`profile_url` is undefined** — the profile isn't connected to this portal. Run `pu:profile:conn --dest=<portal>`.
157
- - **`SecuritySection` shows nothing** — none of the relevant Rodauth features are enabled. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
181
+ - **First visit shows a "create profile" form.** The user has no profile row yet. That's expected; see step 4 if they should land on the show page.
182
+ - **Profile is read-only after creation.** The policy predates the generated `update?`. Add `def update? = true`.
183
+ - **No Profile link in the user menu.** The profile isn't connected to this portal, so `profile_url` returns `nil`. Run `pu:profile:conn --dest=<portal>`.
184
+ - **`SecuritySection` shows nothing**: none of the relevant Rodauth features are enabled. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
158
185
 
159
186
  ## Related
160
187
 
161
- - [Reference › Auth › Profile](/reference/auth/profile) — full surface
162
- - [Reference › Auth › Accounts](/reference/auth/accounts) — Rodauth feature flags that gate SecuritySection
163
- - [Authentication](./authentication) — the underlying auth setup
188
+ - [Reference › Auth › Profile](/reference/auth/profile): full surface
189
+ - [Reference › Auth › Accounts](/reference/auth/accounts): Rodauth feature flags that gate SecuritySection
190
+ - [Authentication](./authentication): the underlying auth setup
@@ -1,18 +1,18 @@
1
1
  # Wizards
2
2
 
3
3
  ::: warning Experimental
4
- Wizards are experimental — the DSL and behavior may change in a future release.
4
+ Wizards are experimental. The DSL and behavior may change in a future release.
5
5
  :::
6
6
 
7
- Build multi-step flows — onboarding, checkout, "create several related records across screens", branching questionnaires — as a single declarative Ruby class.
7
+ Build multi-step flows (onboarding, checkout, "create several related records across screens", branching questionnaires) as a single declarative Ruby class.
8
8
 
9
- A wizard collects typed `data` across ordered `step`s, optionally branches with `condition:`, and commits at the end via `execute`. It reuses Plutonium's existing field DSL (`attribute`/`input`/`validates`/`structured_input`/`form_layout`), form rendering, actions, and policies — it does **not** invent a parallel stack.
9
+ A wizard collects typed `data` across ordered `step`s, optionally branches with `condition:`, and commits at the end via `execute`. It reuses Plutonium's existing field DSL (`attribute`/`input`/`validates`/`structured_input`/`form_layout`), form rendering, actions, and policies, it does **not** invent a parallel stack.
10
10
 
11
11
  ## Goal
12
12
 
13
- The user lands on the first step, fills it in, clicks Next, and walks through the flow. Branching steps appear or disappear based on earlier answers. A built-in review step recaps everything and gates a Finish button. On finish, `execute` writes the records — atomically by default.
13
+ The user lands on the first step, fills it in, clicks Next, and walks through the flow. Branching steps appear or disappear based on earlier answers. A built-in review step recaps everything and gates a Finish button. On finish, `execute` writes the records, atomically by default.
14
14
 
15
- ## Prerequisites — enable the subsystem
15
+ ## Prerequisites: enable the subsystem
16
16
 
17
17
  Wizards are core code, but the storage table is **opt-in** so apps that don't use wizards stay schema-clean. Enable it in your Plutonium initializer:
18
18
 
@@ -24,7 +24,7 @@ Plutonium.configure do |config|
24
24
  end
25
25
  ```
26
26
 
27
- Then run the migration (it ships in the gem and runs in place — no copy step):
27
+ Then run the migration (it ships in the gem and runs in place, no copy step):
28
28
 
29
29
  ```bash
30
30
  rails db:migrate
@@ -68,28 +68,28 @@ class CompanyOnboardingWizard < Plutonium::Wizard::Base
68
68
  end
69
69
  ```
70
70
 
71
- - A wizard is a plain class — `< Plutonium::Wizard::Base`. There is no generator (just like interactions); author it by hand.
71
+ - A wizard is a plain class, `< Plutonium::Wizard::Base`. There is no generator (just like interactions); author it by hand.
72
72
  - `presents label:/icon:` sets the launch button's label and icon, exactly like interactions; an optional `description:` renders as the wizard's header subheading.
73
73
  - Each `step :key, label: do ... end` is one screen. Inside the block, declare its fields with the same DSL you use on a definition or interaction.
74
74
  - `data` is **step-keyed**: `data.company.name` reads the **typed** value entered on the `:company` step (cast to the declared type), available from any step and from `execute`. Each step has its own sub-object, so two steps may use the same field name without colliding.
75
75
  - `review` is a built-in terminal step (auto-summary + gated Finish). It must be **last**.
76
- - `execute` runs once at the end and returns an `Outcome` (`succeed(...)` / `failed(...)`). **Use bang methods** (`create!`/`update!`) — failure is signalled by a raised exception, never a return value.
76
+ - `execute` runs once at the end and returns an `Outcome` (`succeed(...)` / `failed(...)`). **Use bang methods** (`create!`/`update!`); failure is signalled by a raised exception, never a return value.
77
77
 
78
78
  ::: warning Use bang methods in `execute`
79
- The engine detects failure by a **raised exception**. Non-bang `create`/`save`/`update` return `false` on failure without raising — the engine can't see that, treats the step as successful, and advances, silently losing the data. Always use `create!`/`update!`/`save!`, or call `fail!("message")`.
79
+ The engine detects failure by a **raised exception**. Non-bang `create`/`save`/`update` return `false` on failure without raising; the engine can't see that, treats the step as successful, and advances, silently losing the data. Always use `create!`/`update!`/`save!`, or call `fail!("message")`.
80
80
  :::
81
81
 
82
82
  ::: tip `execute` is a presentation boundary, same as an interaction's
83
- A wizard is built with `view_context:` too, so everything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow* — which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it: the API signup that skips onboarding, the admin backfill, the importer. `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. See [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for) — the rule is identical.
83
+ A wizard is built with `view_context:` too, so everything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow*: which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it: the API signup that skips onboarding, the admin backfill, the importer. `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. See [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for), the rule is identical.
84
84
  :::
85
85
 
86
86
  Each step renders as a focused card with a numbered stepper rail (the terminal `review` shows a finish flag, not a number) and a Back / Next / Cancel strip:
87
87
 
88
- ![A wizard step page — numbered stepper rail, a focused step card with typed inputs, and Back/Next/Cancel navigation](/images/guides/wizards-step.png)
88
+ ![A wizard step page: numbered stepper rail, a focused step card with typed inputs, and Back/Next/Cancel navigation](/images/guides/wizards-step.png)
89
89
 
90
90
  ## Branching with `condition:`
91
91
 
92
- A step's `condition:` lambda decides whether the step is included. Branching is **subtractive** — a falsy `condition:` removes the step from the visible path.
92
+ A step's `condition:` lambda decides whether the step is included. Branching is **subtractive**: a falsy `condition:` removes the step from the visible path.
93
93
 
94
94
  ```ruby
95
95
  step :plan, label: "Plan" do
@@ -107,17 +107,17 @@ end
107
107
  ```
108
108
 
109
109
  ::: warning `condition:` lambdas must be nil-safe
110
- A `condition:` runs against the typed `data` snapshot at **every** transition — including before its deciding step has been filled, when the value is still `nil`. `-> { data.plan.plan == "pro" }` is fine (`nil == "pro"` is `false`); `-> { data.plan.plan.upcase == "PRO" }` raises on `nil`. Always write conditions that tolerate `nil`.
110
+ A `condition:` runs against the typed `data` snapshot at **every** transition, including before its deciding step has been filled, when the value is still `nil`. `-> { data.plan.plan == "pro" }` is fine (`nil == "pro"` is `false`); `-> { data.plan.plan.upcase == "PRO" }` raises on `nil`. Always write conditions that tolerate `nil`.
111
111
  :::
112
112
 
113
113
  The condition can also read `anchor` (for [anchored wizards](#anchored-wizards)). Data belonging to branch-hidden steps is pruned before `execute`, so `execute` only ever sees data for steps that actually applied.
114
114
 
115
- ## Reusing a model's fields — `using:`
115
+ ## Reusing a model's fields: `using:`
116
116
 
117
117
  Instead of re-declaring fields a model already defines, import them with `using:`. It is a **step option** (not a block method), and it targets a **model (record class) only**.
118
118
 
119
119
  ```ruby
120
- # Whole-step import — no block needed.
120
+ # Whole-step import, no block needed.
121
121
  step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
122
122
 
123
123
  # Mix imported + wizard-local fields: using: plus a block for the extras.
@@ -130,13 +130,13 @@ end
130
130
  What `using:` imports from the model:
131
131
 
132
132
  - **Field universe + types** from `Model.attribute_names` / `Model.attribute_types`. Selectors `fields:` (alias `only:`) and `except:` pick a subset.
133
- - **Input styling** overlaid from the auto-resolved `<Model>Definition` (its `as:`, options, labels) — best-effort; no definition found is fine.
133
+ - **Input styling** overlaid from the auto-resolved `<Model>Definition` (its `as:`, options, labels), best-effort; no definition found is fine.
134
134
  - **Validations** run via a transient `Model.new(slice).valid?`, keeping errors on the imported fields plus `:base`. Pass `validate: false` to skip and write your own inline `validates`.
135
135
  - **`form_layout`** inherited from the `<Model>Definition` (filtered to imported fields). Pass `layout: false` to opt out.
136
136
 
137
- `using:` is **declaration reuse only** — it never pulls in the model's persistence or callbacks. Data still stages into `data`; your `execute` does the writes. Full detail: [DSL reference › `using:`](/reference/wizard/dsl#using-a-model).
137
+ `using:` is **declaration reuse only**: it never pulls in the model's persistence or callbacks. Data still stages into `data`; your `execute` does the writes. Full detail: [DSL reference › `using:`](/reference/wizard/dsl#using-a-model).
138
138
 
139
- ## Sectioning a step — `form_layout`
139
+ ## Sectioning a step: `form_layout`
140
140
 
141
141
  A step is its own form, so you can group its fields with the same `form_layout` DSL you use on a definition, scoped to that step:
142
142
 
@@ -175,11 +175,11 @@ end
175
175
 
176
176
  Repeater rows rehydrate from staged `data` on GET, so navigating back (or resuming) re-renders the rows you already filled.
177
177
 
178
- ![A structured/repeater step — multiple invite rows with Add/Remove, inside the wizard step card](/images/guides/wizards-repeater.png)
178
+ ![A structured/repeater step: multiple invite rows with Add/Remove, inside the wizard step card](/images/guides/wizards-repeater.png)
179
179
 
180
180
  ## File uploads (attachments)
181
181
 
182
- A step can collect a file. You declare it like any other field — a **`:string`** attribute (it holds the upload **token**, not the bytes) plus a file input:
182
+ A step can collect a file. You declare it like any other field: a **`:string`** attribute (it holds the upload **token**, not the bytes) plus a file input:
183
183
 
184
184
  ```ruby
185
185
  step :photo, label: "Photo" do
@@ -188,7 +188,7 @@ step :photo, label: "Photo" do
188
188
  end
189
189
  ```
190
190
 
191
- A wizard stages its `data` as JSON across several requests, so a file can't ride along — only a **token** does. The field stages the backend's upload token (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data); your `execute` assigns that token to the model's attachment, which both backends accept natively:
191
+ A wizard stages its `data` as JSON across several requests, so a file can't ride along, only a **token** does. The field stages the backend's upload token (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data); your `execute` assigns that token to the model's attachment, which both backends accept natively:
192
192
 
193
193
  ```ruby
194
194
  def execute
@@ -199,19 +199,19 @@ def execute
199
199
  end
200
200
  ```
201
201
 
202
- The review summary and the step's preview (when you go Back or resume) render the file for you — reading `data.photo.photo` resolves the token to a displayable attachment automatically.
202
+ The review summary and the step's preview (when you go Back or resume) render the file for you; reading `data.photo.photo` resolves the token to a displayable attachment automatically.
203
203
 
204
204
  ### Server-side vs direct upload
205
205
 
206
206
  The same field works two ways:
207
207
 
208
- - **Server-side (default)** — `input :photo, as: :file`. The file is submitted with the step (a plain file input) and the wizard uploads it to the backend's cache while staging. Nothing else to wire up; works for both ActiveStorage and active_shrine.
209
- - **Direct upload** — `input :photo, as: :uppy, direct_upload: true, endpoint: "/upload"`. The browser uploads straight to the endpoint (with a progress UI) and posts back a token. Use this for large files or an async UX; it needs the backend's direct-upload endpoint reachable (ActiveStorage's direct uploads, or Shrine's `upload_endpoint`).
208
+ - **Server-side (default)**: `input :photo, as: :file`. The file is submitted with the step (a plain file input) and the wizard uploads it to the backend's cache while staging. Nothing else to wire up; works for both ActiveStorage and active_shrine.
209
+ - **Direct upload**: `input :photo, as: :uppy, direct_upload: true, endpoint: "/upload"`. The browser uploads straight to the endpoint (with a progress UI) and posts back a token. Use this for large files or an async UX; it needs the backend's direct-upload endpoint reachable (ActiveStorage's direct uploads, or Shrine's `upload_endpoint`).
210
210
 
211
211
  ::: tip Match the backend to the model
212
- In server-side mode the backend defaults to `config.wizards.attachment_backend` — auto-detected as Shrine when active_shrine is installed, else ActiveStorage. Override per field with `backend:` (`input :photo, as: :file, backend: :active_storage`). It must match the model your `execute` assigns to: an ActiveStorage model can't accept a Shrine token, and vice-versa.
212
+ In server-side mode the backend defaults to `config.wizards.attachment_backend`, auto-detected as Shrine when active_shrine is installed, else ActiveStorage. Override per field with `backend:` (`input :photo, as: :file, backend: :active_storage`). It must match the model your `execute` assigns to: an ActiveStorage model can't accept a Shrine token, and vice-versa.
213
213
 
214
- For Shrine, you can also cache through a specific uploader — `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader` — so that uploader's cache-stage plugins (mime/dimension extraction, `generate_location`, processing) run while staging. The minted token stays uploader-agnostic, so display and `execute` promotion are unchanged. That uploader's **validations are enforced on the step** too: a file that violates them is rejected right there with a field error (validated against the field's effective uploader — its `uploader:`, or base `Shrine`), rather than slipping through to `execute`.
214
+ For Shrine, you can also cache through a specific uploader, `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader`, so that uploader's cache-stage plugins (mime/dimension extraction, `generate_location`, processing) run while staging. The minted token stays uploader-agnostic, so display and `execute` promotion are unchanged. That uploader's **validations are enforced on the step** too: a file that violates them is rejected right there with a field error (validated against the field's effective uploader, its `uploader:`, or base `Shrine`), rather than slipping through to `execute`.
215
215
  :::
216
216
 
217
217
  For **multiple** files, use an array attribute with `multiple: true`; the staged value is then an array of tokens. A staged-but-abandoned upload (cancel/sweep) is an unattached blob / cached file that each storage backend's own cleanup reaps.
@@ -224,7 +224,7 @@ For **multiple** files, use an array attribute with `multiple: true`; the staged
224
224
  - Lists invalid/unvisited visible steps as "fix this" jump links.
225
225
  - Disables Finish until all visible steps are valid; clicking it runs `execute`.
226
226
 
227
- ![The review step — a grouped auto-summary of every step's data with per-step Edit links and a gated Finish](/images/guides/wizards-review.png)
227
+ ![The review step: a grouped auto-summary of every step's data with per-step Edit links and a gated Finish](/images/guides/wizards-review.png)
228
228
 
229
229
  ```ruby
230
230
  review label: "Review & submit"
@@ -245,9 +245,9 @@ review summary: false, header: false # no header, no summary → "ready to com
245
245
 
246
246
  See the [DSL reference](/reference/wizard/dsl#review) for the complete state table.
247
247
 
248
- ## Per-step writes — `on_submit` / `persist` / `on_rollback`
248
+ ## Per-step writes: `on_submit` / `persist` / `on_rollback`
249
249
 
250
- `execute` is the default — atomic, no orphans. Reach for per-step `on_submit` **only** when a real record must exist mid-flow (handing off to an external system that webhooks back, a reviewer who must see partial data, a payload too large for the session row).
250
+ `execute` is the default: atomic, no orphans. Reach for per-step `on_submit` **only** when a real record must exist mid-flow (handing off to an external system that webhooks back, a reviewer who must see partial data, a payload too large for the session row).
251
251
 
252
252
  ```ruby
253
253
  class ConfigureCompanyWizard < Plutonium::Wizard::Base
@@ -268,7 +268,7 @@ class ConfigureCompanyWizard < Plutonium::Wizard::Base
268
268
  end
269
269
 
270
270
  # ADDITIONAL cleanup on Cancel/abandonment. The engine ALWAYS destroys the
271
- # persist'd Billing record — on_rollback is only for side effects it can't see
271
+ # persist'd Billing record; on_rollback is only for side effects it can't see
272
272
  # (here, refunding the external charge). It runs BEFORE the destroy, so
273
273
  # persisted[:billing] is still alive to read.
274
274
  on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }
@@ -281,11 +281,11 @@ class ConfigureCompanyWizard < Plutonium::Wizard::Base
281
281
  end
282
282
  ```
283
283
 
284
- - `on_submit` runs in its own transaction when the step completes. Inside it, `persist record` registers record(s) the engine tracks for resume and cleanup — reachable later as `persisted[:step_key]`.
284
+ - `on_submit` runs in its own transaction when the step completes. Inside it, `persist record` registers record(s) the engine tracks for resume and cleanup, reachable later as `persisted[:step_key]`.
285
285
  - `fail!("msg")` aborts the step with a base (form-level) error; `fail!(:field, "msg")` attaches it to a field. Both roll back the step's transaction and re-render with input intact.
286
286
  - The engine **always** destroys every `persist`'d record on rollback (Cancel, abandonment-sweep, branch-prune), in reverse order, via `destroy!` (which respects a model's own soft-delete override). `on_rollback` is an **optional, additive** compensating block for side effects the engine can't see (refund a charge, call an external API), and runs **before** the destroy, so `persisted[:key]` is still alive inside it. Don't destroy the tracked record yourself; the engine does.
287
- - Because `on_submit` writes mid-flow, it isn't atomic across steps — that's why `cleanup_after` + the SweepJob exist. See [Storage & config](/reference/wizard/storage-config) and the [DSL reference](/reference/wizard/dsl#per-step-hooks).
288
- - `on_submit` / `on_rollback` are **wizard-flow hooks**, not a home for domain logic. They belong to a single wizard step and can't be called from anywhere else, so keep them to *when* and *what gets tracked* — `persist Billing.create!(...)` above is one call to a model. Once "authorize a card and record the billing row" is something the API also does, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
287
+ - Because `on_submit` writes mid-flow, it isn't atomic across steps; that's why `cleanup_after` + the SweepJob exist. See [Storage & config](/reference/wizard/storage-config) and the [DSL reference](/reference/wizard/dsl#per-step-hooks).
288
+ - `on_submit` / `on_rollback` are **wizard-flow hooks**, not a home for domain logic. They belong to a single wizard step and can't be called from anywhere else, so keep them to *when* and *what gets tracked*: `persist Billing.create!(...)` above is one call to a model. Once "authorize a card and record the billing row" is something the API also does, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
289
289
 
290
290
  ## Anchored wizards
291
291
 
@@ -305,7 +305,7 @@ end
305
305
  ```
306
306
 
307
307
  - `anchored with: Company` → a single type. `anchored with: [Company, Organization]` → polymorphic. `anchored` (no `with:`) → generic, bound at registration.
308
- - `anchor` raises `Plutonium::Wizard::NotAnchoredError` if the wizard wasn't declared `anchored` — it never returns `nil`.
308
+ - `anchor` raises `Plutonium::Wizard::NotAnchoredError` if the wizard wasn't declared `anchored`; it never returns `nil`.
309
309
  - Omit `anchored` for a pure create flow (the wizard creates the records it names itself).
310
310
 
311
311
  See [Anchoring & resume](/reference/wizard/anchoring-resume).
@@ -332,12 +332,12 @@ class WelcomeWizard < Plutonium::Wizard::Base
332
332
  def execute
333
333
  # User#complete_onboarding! sets the name and stamps onboarded_at. It lives on
334
334
  # the model because the gate below, an admin backfill, and the invite-accept
335
- # flow all need to mark a user onboarded — and none of them is a wizard.
335
+ # flow all need to mark a user onboarded, and none of them is a wizard.
336
336
  current_user.complete_onboarding!(full_name: data.profile.full_name)
337
337
  succeed.with_message("Welcome aboard!")
338
338
  end
339
339
 
340
- # Standalone wizards have no resource policy — gate entry with `authorize?`.
340
+ # Standalone wizards have no resource policy; gate entry with `authorize?`.
341
341
  def authorize?
342
342
  current_user.present?
343
343
  end
@@ -357,15 +357,15 @@ end
357
357
 
358
358
  An un-completed user hitting the gate is redirected into the wizard (their destination stashed); on completion they're bounced back (PRG). Completed users pass straight through. Re-opening a finished one-time wizard renders an "already completed" page (override its body with a `completed do |wizard| … end` block) rather than re-running it:
359
359
 
360
- ![The "already completed" page for a re-opened one-time wizard — a success badge, the wizard's label, and a Continue button](/images/guides/wizards-completed.png)
360
+ ![The "already completed" page for a re-opened one-time wizard: a success badge, the wizard's label, and a Continue button](/images/guides/wizards-completed.png)
361
361
 
362
362
  See [One-time wizards](/reference/wizard/one-time).
363
363
 
364
364
  ## Registration & launch
365
365
 
366
- A wizard reaches a user as a **resource action** (the `wizard` macro) or a **route-mounted entry** (`register_wizard`) — inside a portal, or on the main app. A portal mount inherits the portal's auth, tenant scoping, layout, and rendering; a main-app mount runs standalone.
366
+ A wizard reaches a user as a **resource action** (the `wizard` macro) or a **route-mounted entry** (`register_wizard`), inside a portal or on the main app. A portal mount inherits the portal's auth, tenant scoping, layout, and rendering; a main-app mount runs standalone.
367
367
 
368
- ### On a resource — the `wizard` macro
368
+ ### On a resource: the `wizard` macro
369
369
 
370
370
  Register a wizard on a resource definition. Placement follows `anchored?` automatically: an anchored wizard becomes a **record** action (the show page *and* each index row, like `edit`/`destroy`); a non-anchored wizard becomes a collection-level **resource** action.
371
371
 
@@ -376,13 +376,13 @@ class CompanyDefinition < Plutonium::Resource::Definition
376
376
  end
377
377
  ```
378
378
 
379
- ![A resource index — each row carries the anchored wizard's launch action (Show · Edit · Configure widget · ⋮)](/images/guides/wizards-index-action.png)
379
+ ![A resource index: each row carries the anchored wizard's launch action (Show · Edit · Configure widget · ⋮)](/images/guides/wizards-index-action.png)
380
380
 
381
- The anchor resolves through the resource controller's scoped, policy-gated `resource_record!` (IDOR-safe — an out-of-scope or missing id 404s), and the action is gated by a policy predicate named after the wizard key (`def configure? = update?`). For placement flags, routes, and the full option list see [Registration & launch › the `wizard` macro](/reference/wizard/registration-launch#on-a-resource-the-wizard-macro).
381
+ The anchor resolves through the resource controller's scoped, policy-gated `resource_record!` (IDOR-safe: an out-of-scope or missing id 404s), and the action is gated by a policy predicate named after the wizard key (`def configure? = update?`). For placement flags, routes, and the full option list see [Registration & launch › the `wizard` macro](/reference/wizard/registration-launch#on-a-resource-the-wizard-macro).
382
382
 
383
- ### Route-mounted — `register_wizard`
383
+ ### Route-mounted: `register_wizard`
384
384
 
385
- For a wizard not tied to a single resource (onboarding, welcome, set-up), mount it alongside `register_resource` — in a portal engine's routes or on the main app:
385
+ For a wizard not tied to a single resource (onboarding, welcome, set-up), mount it alongside `register_resource`, in a portal engine's routes or on the main app:
386
386
 
387
387
  ```ruby
388
388
  # packages/admin_portal/config/routes.rb
@@ -394,17 +394,17 @@ end
394
394
 
395
395
  This draws the step routes within the host and gives you an `onboarding_wizard_path` helper. See [Registration & launch › `register_wizard`](/reference/wizard/registration-launch#route-mounted-register_wizard) for `at:`/`as:`/`public:`/`layout:`, the per-host layout defaults, and the controller override hook.
396
396
 
397
- ::: danger Portal-level wizards are open to any authenticated user by default
398
- A `register_wizard` wizard has no resource policy and **defaults to allowed** — any authenticated portal user can run it. Always define `def authorize?` for anything privileged. (Resource-mounted wizards are gated by their action's policy predicate instead.)
397
+ ::: danger `register_wizard` wizards are open to any authenticated user by default
398
+ A `register_wizard` wizard (portal or main-app) has no resource policy and its default is `def authorize? = true`, so any authenticated user of that host can run it. Define `def authorize?` on every `register_wizard` wizard, including a gated `one_time` one (the gate forces users into the wizard but doesn't decide who may run it). Resource-mounted wizards are also gated by their action's policy predicate.
399
399
  :::
400
400
 
401
401
  ::: tip Authenticated main-app wizards: define your own controller
402
- A portal mount inherits the portal's auth; a bare main-app mount has no `current_user`. An authenticated main-app wizard therefore needs you to define `::WizardsController` yourself (`include Plutonium::Wizard::Controller` + your auth concern) — the same "app owns the controller" contract as `register_resource`. See [Hosting & the controller override hook](/reference/wizard/registration-launch#hosting-the-controller-override-hook).
402
+ A portal mount inherits the portal's auth; a bare main-app mount has no `current_user`. An authenticated main-app wizard therefore needs you to define `::WizardsController` yourself (`include Plutonium::Wizard::Controller` + your auth concern), the same "app owns the controller" contract as `register_resource`. See [Hosting & the controller override hook](/reference/wizard/registration-launch#hosting-the-controller-override-hook).
403
403
  :::
404
404
 
405
405
  ### Guest (unauthenticated) wizards
406
406
 
407
- Wizards require authentication by default — and every resume is **owner-scoped**, so a run id leaked in a URL can't be picked up by another user. Opt into pre-login access with the `anonymous` macro and mount it `public: true` (the default for `anonymous`). A guest run's identity is a server-minted id held in the **Rails session** (never a URL, no leak surface); it may authenticate only at its terminal `execute` (e.g. a signup that creates the account and logs in):
407
+ Wizards require authentication by default, and every resume is **owner-scoped**, so a run id leaked in a URL can't be picked up by another user. Opt into pre-login access with the `anonymous` macro and mount it `public: true` (the default for `anonymous`). A guest run's identity is a server-minted id held in the **Rails session** (never a URL, no leak surface); it may authenticate only at its terminal `execute` (e.g. a signup that creates the account and logs in):
408
408
 
409
409
  ```ruby
410
410
  class GuestSignupWizard < Plutonium::Wizard::Base
@@ -429,7 +429,7 @@ Full detail (owner-scoping, session-keying, the synthesized public controller):
429
429
 
430
430
  ### Listing in-progress & resume-or-new
431
431
 
432
- Build a "continue where you left off" dashboard with `Plutonium::Wizard.in_progress_for(view_context)` — it derives the owner, tenant scope, and portal from the view context and returns that user's in-progress runs for the current portal, each carrying `label` / `icon` / `current_step` / `updated_at` / `resume_url`:
432
+ Build a "continue where you left off" dashboard with `Plutonium::Wizard.in_progress_for(view_context)`, it derives the owner, tenant scope, and portal from the view context and returns that user's in-progress runs for the current portal, each carrying `label` / `icon` / `current_step` / `updated_at` / `resume_url`:
433
433
 
434
434
  ```ruby
435
435
  Plutonium::Wizard.in_progress_for(view_context).each do |entry|
@@ -440,16 +440,16 @@ end
440
440
  Plutonium::Wizard.in_progress_for(view_context, wizard: ConfigureCompanyWizard, anchor: @company).first
441
441
  ```
442
442
 
443
- A **tokened** wizard (no `concurrency_key`) doesn't silently fork on relaunch — by default it shows a resume-or-new chooser when a pending run exists (`on_relaunch :new` opts out). Keyed and guest wizards auto-resume their single run.
443
+ A **tokened** wizard (no `concurrency_key`) doesn't silently fork on relaunch; by default it shows a resume-or-new chooser when a pending run exists (`on_relaunch :new` opts out). Keyed and guest wizards auto-resume their single run.
444
444
 
445
- ![The resume-or-new chooser — pending runs with their current step and a Resume button, plus a Start new action](/images/guides/wizards-chooser.png)
445
+ ![The resume-or-new chooser: pending runs with their current step and a Resume button, plus a Start new action](/images/guides/wizards-chooser.png)
446
446
 
447
447
  See [Anchoring & resume › Listing](/reference/wizard/anchoring-resume#listing-in-progress-wizards) for the full entry fields, portal-scoping rules, `resume_unresolved_reason`, and the filter performance notes.
448
448
 
449
449
  ## Where to go next
450
450
 
451
- - [DSL reference](/reference/wizard/dsl) — every macro and accessor.
452
- - [Anchoring & resume](/reference/wizard/anchoring-resume) — anchors, instance keys, resume.
453
- - [Storage & config](/reference/wizard/storage-config) — the table, config, encryption, the sweep.
454
- - [Registration & launch](/reference/wizard/registration-launch) — the `wizard` macro, `register_wizard`, routes.
455
- - [One-time wizards](/reference/wizard/one-time) — completion markers + the gate.
451
+ - [DSL reference](/reference/wizard/dsl): every macro and accessor.
452
+ - [Anchoring & resume](/reference/wizard/anchoring-resume): anchors, instance keys, resume.
453
+ - [Storage & config](/reference/wizard/storage-config): the table, config, encryption, the sweep.
454
+ - [Registration & launch](/reference/wizard/registration-launch): the `wizard` macro, `register_wizard`, routes.
455
+ - [One-time wizards](/reference/wizard/one-time): completion markers + the gate.
@@ -15,7 +15,7 @@ Plutonium's `pu:*` CLI generators. Discoverable via `rails g pu:<tab>`. Always p
15
15
  | [`pu:rodauth:install`](#pu-rodauth-install) | Install Rodauth base |
16
16
  | [`pu:rodauth:account`](#pu-rodauth-account) | Basic Rodauth account |
17
17
  | [`pu:rodauth:admin`](#pu-rodauth-admin) | Hardened admin account (2FA, lockout, audit) |
18
- | [`pu:saas:setup`](#pu-saas-setup) | **Meta** — user + entity + membership + portal + profile + welcome + invites |
18
+ | [`pu:saas:setup`](#pu-saas-setup) | **Meta**: user + entity + membership + portal + profile + welcome + invites |
19
19
  | [`pu:saas:user`](#individual-saas-generators) | Individual: SaaS user account |
20
20
  | [`pu:saas:entity`](#individual-saas-generators) | Individual: entity model |
21
21
  | [`pu:saas:membership`](#individual-saas-generators) | Individual: membership join model |
@@ -23,7 +23,7 @@ Plutonium's `pu:*` CLI generators. Discoverable via `rails g pu:<tab>`. Always p
23
23
  | [`pu:saas:welcome`](#individual-saas-generators) | Individual: onboarding / select-entity flow |
24
24
  | [`pu:saas:api_client`](#pu-saas-api-client) | API client for M2M auth |
25
25
  | [`pu:profile:install`](#pu-profile-install) | Profile resource + security section |
26
- | [`pu:profile:setup`](#pu-profile-setup) | Meta — `pu:profile:install` + `pu:profile:conn` |
26
+ | [`pu:profile:setup`](#pu-profile-setup) | Meta: `pu:profile:install` + `pu:profile:conn` |
27
27
  | [`pu:profile:conn`](#pu-profile-conn) | Connect profile to a portal as a singular resource |
28
28
  | [`pu:invites:install`](#pu-invites-install) | User invitations package |
29
29
  | [`pu:invites:invitable`](#pu-invites-invitable) | Mark a model as invitable |
@@ -52,11 +52,11 @@ rails g pu:res:scaffold Post user:belongs_to title:string 'content:text?' --dest
52
52
 
53
53
  | Option | Description |
54
54
  |---|---|
55
- | `--dest=NAME` | Destination package (`main_app` or `<package>`) — required for unattended runs |
55
+ | `--dest=NAME` | Destination package (`main_app` or `<package>`), required for unattended runs |
56
56
  | `--no-model` | Skip model file (for existing models) |
57
57
  | `--no-migration` | Skip migration (use with `--no-model` for existing schema) |
58
58
 
59
- Field type syntax — full reference in [Resource › Model](/reference/resource/model). Quick recap:
59
+ Field type syntax: full reference in [Resource › Model](/reference/resource/model). Quick recap:
60
60
 
61
61
  ```bash
62
62
  'name:string' # required string
@@ -98,7 +98,7 @@ See [Portals › Connecting resources](./portals#connecting-resources-pu-res-con
98
98
 
99
99
  ### `pu:pkg:package`
100
100
 
101
- Feature package — models, policies, definitions, interactions.
101
+ Feature package: models, policies, definitions, interactions.
102
102
 
103
103
  ```bash
104
104
  rails g pu:pkg:package blogging
@@ -108,7 +108,7 @@ See [Packages › Feature packages](./packages#feature-packages).
108
108
 
109
109
  ### `pu:pkg:portal`
110
110
 
111
- Portal package — controllers, views, routes, auth.
111
+ Portal package: controllers, views, routes, auth.
112
112
 
113
113
  ```bash
114
114
  rails g pu:pkg:portal admin --auth=user
@@ -122,7 +122,7 @@ rails g pu:pkg:portal admin --auth=admin --scope=Organization
122
122
  | `--byo` | Bring your own auth |
123
123
  | `--scope=CLASS` | Entity class for multi-tenancy |
124
124
 
125
- See [Portals › Creating a portal](./portals#creating-a-portal).
125
+ The generator also mounts the engine at `/<name>` in `packages/<name>_portal/config/routes.rb`. Don't mount it again in `config/routes.rb`; edit `at:` there to change the path. See [Portals › Mounting](./portals#mounting).
126
126
 
127
127
  ---
128
128
 
@@ -130,7 +130,7 @@ See [Portals › Creating a portal](./portals#creating-a-portal).
130
130
 
131
131
  ### `pu:rodauth:install`
132
132
 
133
- Install the Rodauth base — Roda app, base plugin, controller, layout, PostgreSQL extension migration.
133
+ Install the Rodauth base: Roda app, base plugin, controller, layout, PostgreSQL extension migration.
134
134
 
135
135
  ```bash
136
136
  rails g pu:rodauth:install
@@ -151,7 +151,7 @@ For full option tables (features, defaults, individual feature flags) see [Auth
151
151
 
152
152
  ### `pu:rodauth:admin`
153
153
 
154
- Hardened admin account — pre-configured with multi-phase login, required TOTP, recovery codes, lockout, active sessions, audit logging, role-based access, invite interaction, and **no public signup**.
154
+ Hardened admin account: pre-configured with multi-phase login, required TOTP, recovery codes, lockout, active sessions, audit logging, role-based access, invite interaction, and **no public signup**.
155
155
 
156
156
  ```bash
157
157
  rails g pu:rodauth:admin admin
@@ -196,7 +196,7 @@ rails g pu:saas:setup --user Customer --entity Organization \
196
196
  | `--user=NAME` | (required) | User account model name |
197
197
  | `--entity=NAME` | (required) | Entity model name |
198
198
  | `--allow-signup` | `true` | Allow public registration |
199
- | `--roles` | `admin,member` | Additional roles — **`owner` always prepended as index 0** |
199
+ | `--roles` | `admin,member` | Additional roles: **`owner` always prepended as index 0** |
200
200
  | `--skip-entity` | | Skip entity model generation |
201
201
  | `--skip-membership` | | Skip membership model generation |
202
202
  | `--user-attributes` | | Additional user model attributes |
@@ -271,7 +271,7 @@ rails g pu:profile:install AccountSettings bio:text --dest=main_app # custom r
271
271
 
272
272
  ### `pu:profile:setup`
273
273
 
274
- Meta — runs `pu:profile:install` + `pu:profile:conn` in one shot.
274
+ Meta: runs `pu:profile:install` + `pu:profile:conn` in one shot.
275
275
 
276
276
  ```bash
277
277
  rails g pu:profile:setup date_of_birth:date bio:text \
@@ -281,7 +281,7 @@ rails g pu:profile:setup date_of_birth:date bio:text \
281
281
 
282
282
  ### `pu:profile:conn`
283
283
 
284
- Connect the profile resource to a portal as a **singular** resource (registers `/profile` and the `profile_url` helper).
284
+ Connect the profile resource to a portal as a **singular** resource (registers `/profile` and overrides the `profile_url` helper in the portal's controller concern). It also generates a user-scoped portal policy (`create? = user.profile.nil?`, `update? = true`, `destroy? = false`) and a `ShowPage` that renders the `SecuritySection`. See [Auth › Profile](/reference/auth/profile#what-pu-profile-conn-generates).
285
285
 
286
286
  ```bash
287
287
  rails g pu:profile:conn --dest=customer_portal
@@ -310,7 +310,7 @@ rails g pu:invites:install --entity-model=Organization --user-model=Customer --i
310
310
  | `--enforce-domain` | `false` | Require email domain to match entity |
311
311
  | `--dest=PACKAGE` | `main_app` | Package where the entity model lives (controls where `invite_user_interaction.rb` is generated) |
312
312
 
313
- Multiple invite flows are supported — run `pu:invites:install` once per flow.
313
+ Multiple invite flows are supported; run `pu:invites:install` once per flow.
314
314
 
315
315
  ### `pu:invites:invitable`
316
316
 
@@ -398,7 +398,7 @@ rails g pu:test:install
398
398
 
399
399
  ### `pu:test:scaffold`
400
400
 
401
- Scaffold integration tests — one file per (resource × portal) pairing.
401
+ Scaffold integration tests: one file per (resource × portal) pairing.
402
402
 
403
403
  ```bash
404
404
  rails g pu:test:scaffold Blogging::Post --portals=admin,org
@@ -432,7 +432,7 @@ rails g pu:skills:sync
432
432
  ### Full app setup
433
433
 
434
434
  ```bash
435
- # 1. Plutonium template (greenfield) — does all initial setup
435
+ # 1. Plutonium template (greenfield): does all initial setup
436
436
  rails new myapp -a propshaft -j esbuild -c tailwind \
437
437
  -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
438
438
 
@@ -505,13 +505,13 @@ Generators run from Rails root. Package names are case-sensitive.
505
505
 
506
506
  ### Migration already exists
507
507
 
508
- If a migration with the same timestamp exists, wait a second and retry — Rails generates timestamps to one-second resolution.
508
+ If a migration with the same timestamp exists, wait a second and retry; Rails generates timestamps to one-second resolution.
509
509
 
510
510
  ## Related
511
511
 
512
- - [Packages](./packages) — feature vs portal package structure
513
- - [Portals](./portals) — portal configuration and resource connection
514
- - [Resource › Model](/reference/resource/model) — field-type syntax for `pu:res:scaffold`
515
- - [Auth](/reference/auth/) — account type configuration
516
- - [Tenancy](/reference/tenancy/) — multi-tenancy and invitations
517
- - [Testing](/reference/testing/) — test scaffolding
512
+ - [Packages](./packages): feature vs portal package structure
513
+ - [Portals](./portals): portal configuration and resource connection
514
+ - [Resource › Model](/reference/resource/model): field-type syntax for `pu:res:scaffold`
515
+ - [Auth](/reference/auth/): account type configuration
516
+ - [Tenancy](/reference/tenancy/): multi-tenancy and invitations
517
+ - [Testing](/reference/testing/): test scaffolding