plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,146 @@
1
+ # Packages
2
+
3
+ Plutonium apps are organized into **packages** — Rails engines with stricter conventions. Two flavors, hard split:
4
+
5
+ | Type | Purpose | Generator | Examples |
6
+ |---|---|---|---|
7
+ | **Feature** | Business logic (models, policies, definitions, interactions, migrations) | `pu:pkg:package NAME` | `blogging`, `billing`, `inventory` |
8
+ | **Portal** | Web interface (controllers, views, routes, auth) | `pu:pkg:portal NAME` | `admin_portal`, `customer_portal`, `public_portal` |
9
+
10
+ ## 🚨 Critical
11
+
12
+ - **Feature ↔ portal split is hard.** Feature packages hold models/policies/definitions/interactions. Portal packages hold controllers/views/routes/auth. Don't mix.
13
+ - **Package classes are auto-namespaced.** `packages/blogging/app/models/blogging/post.rb` resolves to `Blogging::Post`. Don't fight it.
14
+ - **Cross-package references use full namespace.** `rails g pu:res:conn Blogging::Post --dest=admin_portal`.
15
+ - **A resource is invisible until `pu:res:conn` registers it with a portal.**
16
+
17
+ ## Feature packages
18
+
19
+ ```bash
20
+ rails g pu:pkg:package blogging
21
+ ```
22
+
23
+ ### Structure
24
+
25
+ ```
26
+ packages/blogging/
27
+ ├── app/
28
+ │ ├── models/blogging/ # Blogging::Post
29
+ │ ├── definitions/blogging/ # Blogging::PostDefinition
30
+ │ ├── policies/blogging/ # Blogging::PostPolicy
31
+ │ └── interactions/blogging/ # Blogging::PublishPostInteraction
32
+ ├── db/migrate/
33
+ └── lib/engine.rb
34
+ ```
35
+
36
+ ### Engine
37
+
38
+ ```ruby
39
+ module Blogging
40
+ class Engine < Rails::Engine
41
+ include Plutonium::Package::Engine
42
+ end
43
+ end
44
+ ```
45
+
46
+ ### Auto-namespacing
47
+
48
+ Every file under `app/<kind>/blogging/` resolves to `Blogging::*`:
49
+
50
+ - `app/models/blogging/post.rb` → `Blogging::Post`
51
+ - `app/policies/blogging/post_policy.rb` → `Blogging::PostPolicy`
52
+ - `app/definitions/blogging/post_definition.rb` → `Blogging::PostDefinition`
53
+ - `app/interactions/blogging/publish_post_interaction.rb` → `Blogging::PublishPostInteraction`
54
+
55
+ Each feature package gets its own base classes:
56
+
57
+ - `Blogging::ApplicationRecord`
58
+ - `Blogging::ResourceRecord`
59
+ - `Blogging::ResourcePolicy`
60
+ - `Blogging::ResourceDefinition`
61
+ - `Blogging::ResourceInteraction`
62
+
63
+ These inherit from the main app's base classes — extend them for package-wide defaults.
64
+
65
+ ### Creating resources inside a feature package
66
+
67
+ ```bash
68
+ rails g pu:res:scaffold Blogging::Post title:string --dest=blogging
69
+ ```
70
+
71
+ Cross-package references use the full namespace:
72
+
73
+ ```bash
74
+ rails g pu:res:scaffold Comment user:belongs_to blogging/post:belongs_to body:text --dest=comments
75
+ ```
76
+
77
+ ## Portal packages
78
+
79
+ ```bash
80
+ rails g pu:pkg:portal admin
81
+ ```
82
+
83
+ See [Portals](./portals) for full details on portal generators, engine config, and routing. Key structural points here:
84
+
85
+ ```
86
+ packages/admin_portal/
87
+ ├── app/
88
+ │ ├── controllers/admin_portal/
89
+ │ │ ├── concerns/controller.rb # auth + shared filters
90
+ │ │ ├── dashboard_controller.rb
91
+ │ │ ├── plutonium_controller.rb
92
+ │ │ └── resource_controller.rb
93
+ │ ├── definitions/admin_portal/ # per-portal overrides
94
+ │ ├── policies/admin_portal/ # per-portal overrides
95
+ │ └── views/layouts/admin_portal.html.erb
96
+ ├── config/routes.rb
97
+ └── lib/engine.rb
98
+ ```
99
+
100
+ ## Package loading
101
+
102
+ `config/packages.rb` (created by `pu:core:install`):
103
+
104
+ ```ruby
105
+ Dir.glob(File.expand_path("../packages/**/lib/engine.rb", __dir__)) do |package|
106
+ load package
107
+ end
108
+ ```
109
+
110
+ This is loaded from `config/application.rb`. Migrations from all packages are picked up by `rails db:migrate` automatically.
111
+
112
+ ## When to use which
113
+
114
+ **Feature packages** — domain logic that:
115
+
116
+ - Could be reused across multiple portals (admin and customer both edit `Blogging::Post`).
117
+ - Has no inherent UI / auth (it's just behavior).
118
+ - You want isolated from other domains (`billing` should not depend on `blogging`).
119
+
120
+ **Portal packages** — user-facing surfaces that:
121
+
122
+ - Have a specific auth flow (admin vs customer vs public).
123
+ - Render different views of the same underlying resources.
124
+ - Need different policies / definitions per audience.
125
+
126
+ ## Typical architecture
127
+
128
+ ```
129
+ packages/
130
+ ├── blogging/ # Feature: blog functionality
131
+ │ └── models, definitions, policies, interactions
132
+ ├── billing/ # Feature: payments/invoicing
133
+ │ └── models, definitions, policies, interactions
134
+ ├── admin_portal/ # Portal: admin interface
135
+ │ └── controllers, views, routes
136
+ └── customer_portal/ # Portal: customer dashboard
137
+ └── controllers, views, routes
138
+ ```
139
+
140
+ The portals expose the features. A single feature can be exposed by multiple portals — usually with different policies and definitions per portal.
141
+
142
+ ## Related
143
+
144
+ - [Portals](./portals) — portal-specific configuration (mounting, auth, route registration)
145
+ - [Generators](./generators) — `pu:pkg:package` and `pu:pkg:portal` flags
146
+ - [Guide: Creating Packages](/guides/creating-packages) — task-oriented walkthrough
@@ -0,0 +1,377 @@
1
+ # Portals
2
+
3
+ A portal is a Rails engine mixing in `Plutonium::Portal::Engine`. It defines its own routes, controller concern, and (optionally) entity scoping.
4
+
5
+ ## 🚨 Critical
6
+
7
+ - **Use `pu:pkg:portal` for everything.** Never hand-write the engine file, controller concern, or layout.
8
+ - **Pass `--auth=<name>`, `--public`, or `--byo`** for unattended runs — without one of these flags, the generator prompts.
9
+ - **Always connect resources with `pu:res:conn`.** Until connected, a resource has no portal routes and is invisible.
10
+ - **For custom routes on a registered resource, pass `as:`.** Without it, `resource_url_for` can't build URLs.
11
+
12
+ ## Creating a portal
13
+
14
+ ```bash
15
+ rails g pu:pkg:portal <name>
16
+ ```
17
+
18
+ ### Options
19
+
20
+ | Option | Description |
21
+ |---|---|
22
+ | `--auth=NAME` | Rodauth account to authenticate with (e.g. `--auth=user`) |
23
+ | `--public` | Public access — no authentication |
24
+ | `--byo` | Bring your own authentication |
25
+ | `--scope=CLASS` | Entity class for multi-tenancy (e.g. `--scope=Organization`) |
26
+
27
+ ```bash
28
+ rails g pu:pkg:portal admin --auth=admin
29
+ rails g pu:pkg:portal api --public
30
+ rails g pu:pkg:portal custom --byo
31
+ rails g pu:pkg:portal admin --auth=admin --scope=Organization
32
+ ```
33
+
34
+ Without flags, the generator prompts interactively.
35
+
36
+ ## Engine file
37
+
38
+ ```ruby
39
+ # packages/admin_portal/lib/engine.rb
40
+ module AdminPortal
41
+ class Engine < Rails::Engine
42
+ include Plutonium::Portal::Engine
43
+
44
+ config.after_initialize do
45
+ # Optional: multi-tenancy. See Tenancy › Entity scoping for strategies.
46
+ scope_to_entity Organization, strategy: :path
47
+ end
48
+ end
49
+ end
50
+ ```
51
+
52
+ ## Controller concern (auth)
53
+
54
+ Every portal has a `Concerns::Controller` mixed into its `ResourceController`. The generator wires this up; you customize for auth flow and shared before_action hooks.
55
+
56
+ ### Rodauth
57
+
58
+ ```ruby
59
+ module AdminPortal::Concerns::Controller
60
+ extend ActiveSupport::Concern
61
+ include Plutonium::Portal::Controller
62
+ include Plutonium::Auth::Rodauth(:user)
63
+ end
64
+ ```
65
+
66
+ ### Public access
67
+
68
+ ```ruby
69
+ module AdminPortal::Concerns::Controller
70
+ extend ActiveSupport::Concern
71
+ include Plutonium::Portal::Controller
72
+ include Plutonium::Auth::Public
73
+ end
74
+ ```
75
+
76
+ ### BYO auth
77
+
78
+ ```ruby
79
+ module AdminPortal::Concerns::Controller
80
+ extend ActiveSupport::Concern
81
+ include Plutonium::Portal::Controller
82
+ include Plutonium::Auth::Public # disables the Rodauth requirement
83
+
84
+ def current_user
85
+ @current_user ||= User.find_by(api_key: request.headers["X-API-Key"])
86
+ end
87
+ end
88
+ ```
89
+
90
+ ## Mounting
91
+
92
+ ```ruby
93
+ # config/routes.rb
94
+ Rails.application.routes.draw do
95
+ # Authenticated mount
96
+ constraints Rodauth::Rails.authenticate(:user) do
97
+ mount AdminPortal::Engine, at: "/admin"
98
+ end
99
+
100
+ # Unconstrained — the portal handles its own auth
101
+ mount PublicPortal::Engine, at: "/public"
102
+ end
103
+ ```
104
+
105
+ ## Routes & `register_resource`
106
+
107
+ Portal routes live in `packages/<name>_portal/config/routes.rb`:
108
+
109
+ ```ruby
110
+ AdminPortal::Engine.routes.draw do
111
+ root to: "dashboard#index"
112
+
113
+ register_resource ::Post
114
+ register_resource Blogging::Comment
115
+
116
+ # Non-resource pages
117
+ get "settings", to: "settings#index"
118
+ end
119
+ ```
120
+
121
+ ### What `register_resource` does
122
+
123
+ For each call, Plutonium auto-generates:
124
+
125
+ - Top-level CRUD routes (`/posts`, `/posts/:id`, etc.)
126
+ - Nested routes for every registered `has_many` / `has_one` parent (prefixed `nested_`)
127
+ - Route names that `resource_url_for` can resolve
128
+
129
+ You list every resource the portal exposes. If a resource isn't registered, it has no URLs in that portal — `resource_url_for` will fail.
130
+
131
+ ### Singular (singleton) resources
132
+
133
+ For resources with no collection — a single per-user `Profile`, app-wide `Settings`, etc.:
134
+
135
+ ```ruby
136
+ register_resource ::Profile, singular: true
137
+ ```
138
+
139
+ Generates singular routes (no `:id`, no index):
140
+
141
+ - `GET /profile` → show
142
+ - `GET /profile/new` → new
143
+ - `GET /profile/edit` → edit
144
+ - `POST /profile` → create
145
+ - `PATCH /profile` → update
146
+ - `DELETE /profile` → destroy
147
+
148
+ Use the `--singular` flag on `pu:res:conn`:
149
+
150
+ ```bash
151
+ rails g pu:res:conn Profile --dest=customer_portal --singular
152
+ ```
153
+
154
+ ### Custom member / collection routes
155
+
156
+ ```ruby
157
+ register_resource ::Post do
158
+ member do
159
+ get :preview, as: :preview
160
+ get :analytics, as: :analytics
161
+ post :publish, as: :publish
162
+ end
163
+ collection do
164
+ get :archived, as: :archived
165
+ post :bulk_publish, as: :bulk_publish
166
+ end
167
+ end
168
+ ```
169
+
170
+ ::: warning Always pass `as:`
171
+ Without `as:`, `resource_url_for(@post, action: :preview)` fails because there's no named route — especially critical for nested resources.
172
+ :::
173
+
174
+ For most operations with business logic, prefer **interactive actions** (definition + interaction — see [Resource › Actions](/reference/resource/actions)) over custom controller routes. Action routes wire automatically with no `register_resource` block needed.
175
+
176
+ ## Connecting resources — `pu:res:conn`
177
+
178
+ A resource is invisible until connected to at least one portal. The generator wires up the portal-specific controller, policy, definition, and route registration.
179
+
180
+ ```bash
181
+ rails g pu:res:conn RESOURCE [RESOURCE...] --dest=PORTAL_NAME [--singular]
182
+ ```
183
+
184
+ Pass resources directly — avoids interactive prompts. No `--src` needed.
185
+
186
+ ```bash
187
+ # Main app resources
188
+ rails g pu:res:conn Post Comment Tag --dest=admin_portal
189
+
190
+ # Namespaced (from a feature package)
191
+ rails g pu:res:conn Blogging::Post Blogging::Comment --dest=admin_portal
192
+
193
+ # Singular
194
+ rails g pu:res:conn Profile --dest=customer_portal --singular
195
+ ```
196
+
197
+ ::: tip Run after migrations
198
+ The generator reads model columns to seed the policy's `permitted_attributes_for_*`. Run `rails db:migrate` first.
199
+ :::
200
+
201
+ ### What gets generated
202
+
203
+ For `Post` connected to `admin_portal`:
204
+
205
+ ```
206
+ packages/admin_portal/app/
207
+ ├── controllers/admin_portal/posts_controller.rb
208
+ ├── policies/admin_portal/post_policy.rb
209
+ └── definitions/admin_portal/post_definition.rb
210
+ ```
211
+
212
+ Plus route registration appended to `packages/admin_portal/config/routes.rb`:
213
+
214
+ ```ruby
215
+ register_resource ::Post
216
+ register_resource ::Profile, singular: true # if --singular
217
+ ```
218
+
219
+ #### Generated controller
220
+
221
+ ```ruby
222
+ class AdminPortal::PostsController < ::PostsController
223
+ include AdminPortal::Concerns::Controller
224
+ end
225
+ ```
226
+
227
+ #### Generated policy (seeded from model columns)
228
+
229
+ ```ruby
230
+ class AdminPortal::PostPolicy < ::PostPolicy
231
+ include AdminPortal::ResourcePolicy
232
+
233
+ def permitted_attributes_for_create
234
+ [:title, :content, :user_id]
235
+ end
236
+
237
+ def permitted_attributes_for_read
238
+ [:title, :content, :user_id, :created_at, :updated_at]
239
+ end
240
+
241
+ def permitted_associations
242
+ %i[]
243
+ end
244
+ end
245
+ ```
246
+
247
+ ::: warning Review the generated policy
248
+ The generator is liberal. Drop `_id` fields when the form uses the association name. Add `:price` (not `:price_cents`) for `has_cents` fields. See [Behavior › Policy](/reference/behavior/policies).
249
+ :::
250
+
251
+ ## Controller hierarchy
252
+
253
+ Portal controllers inherit from the feature-package controller if one exists, OR from the portal's `ResourceController` otherwise.
254
+
255
+ ```ruby
256
+ # Feature controller exists → inherit from it AND include portal concern
257
+ class AdminPortal::PostsController < ::PostsController
258
+ include AdminPortal::Concerns::Controller
259
+ end
260
+
261
+ # No feature controller → inherit from portal's ResourceController
262
+ class AdminPortal::PostsController < AdminPortal::ResourceController
263
+ end
264
+ ```
265
+
266
+ For non-resource portal pages (dashboard, settings):
267
+
268
+ ```ruby
269
+ module AdminPortal
270
+ class DashboardController < PlutoniumController
271
+ def index; end
272
+ end
273
+ end
274
+ ```
275
+
276
+ ## Per-portal overrides
277
+
278
+ ```ruby
279
+ # Definition — different fields per portal
280
+ class AdminPortal::PostDefinition < ::PostDefinition
281
+ input :internal_notes, as: :text # admins see this; customers don't
282
+ scope :pending_review
283
+ end
284
+
285
+ # Policy — different rules per portal
286
+ class AdminPortal::PostPolicy < ::PostPolicy
287
+ include AdminPortal::ResourcePolicy
288
+
289
+ def destroy? = true
290
+ def permitted_attributes_for_create = %i[title content featured internal_notes]
291
+ end
292
+
293
+ # Controller — different redirect after submit
294
+ module AdminPortal
295
+ class PostsController < ResourceController
296
+ private
297
+ def preferred_action_after_submit = "index"
298
+ end
299
+ end
300
+ ```
301
+
302
+ ## Entity scoping
303
+
304
+ Portals can scope ALL their resources to a parent entity automatically:
305
+
306
+ ```ruby
307
+ config.after_initialize do
308
+ scope_to_entity Organization, strategy: :path
309
+ end
310
+ ```
311
+
312
+ Strategies: `:path` (entity id in URL — default) or a custom method name on the portal controller concern.
313
+
314
+ For the full multi-tenancy story, see [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
315
+
316
+ ## Dashboard / non-resource pages
317
+
318
+ ```ruby
319
+ # config/routes.rb
320
+ AdminPortal::Engine.routes.draw do
321
+ root to: "dashboard#index"
322
+ get "settings", to: "settings#index"
323
+ end
324
+
325
+ # Controller — inherit from PlutoniumController, NOT ResourceController
326
+ module AdminPortal
327
+ class DashboardController < PlutoniumController
328
+ def index
329
+ @stats = { posts: Post.count, users: User.count }
330
+ end
331
+ end
332
+ end
333
+ ```
334
+
335
+ See [UI › Pages](/reference/ui/pages) for custom Phlex page classes.
336
+
337
+ ## Multiple portals
338
+
339
+ ```ruby
340
+ # Admin — full access, entity-scoped
341
+ module AdminPortal
342
+ class Engine < Rails::Engine
343
+ include Plutonium::Portal::Engine
344
+
345
+ config.after_initialize do
346
+ scope_to_entity Organization, strategy: :path
347
+ end
348
+ end
349
+ end
350
+
351
+ # Customer dashboard — entity-scoped to the customer's organization
352
+ module DashboardPortal
353
+ class Engine < Rails::Engine
354
+ include Plutonium::Portal::Engine
355
+
356
+ config.after_initialize do
357
+ scope_to_entity Organization, strategy: :path
358
+ end
359
+ end
360
+ end
361
+
362
+ # Public — no auth, no entity scoping
363
+ module PublicPortal
364
+ class Engine < Rails::Engine
365
+ include Plutonium::Portal::Engine
366
+ end
367
+ end
368
+ ```
369
+
370
+ ## Related
371
+
372
+ - [Packages](./packages) — feature vs portal split, structure, namespacing
373
+ - [Generators](./generators) — full `pu:pkg:portal` / `pu:res:conn` option reference
374
+ - [Behavior › Controllers](/reference/behavior/controllers) — controller key methods, hooks, customizations
375
+ - [Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — multi-tenancy mechanics
376
+ - [Auth](/reference/auth/) — Rodauth account types referenced by `--auth=`
377
+ - [UI › Layouts](/reference/ui/layouts) — customizing portal chrome