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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +43 -43
- data/.claude/skills/plutonium-app/SKILL.md +101 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +144 -133
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +151 -96
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +21 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/blog/introducing-plutonium-dashboards.md +4 -5
- data/docs/blog/introducing-plutonium-i18n.md +4 -5
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +1 -1
- data/docs/guides/index.md +3 -3
- data/docs/guides/kanban.md +55 -55
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/reference/app/generators.md +22 -22
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +75 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +6 -6
- data/docs/reference/dashboard/dsl.md +2 -2
- data/docs/reference/dashboard/index.md +1 -1
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +23 -0
- data/docs/reference/index.md +1 -1
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +32 -32
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +17 -15
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +73 -73
- data/docs/reference/resource/export.md +6 -6
- data/docs/reference/resource/index.md +16 -16
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/resource/positioning.md +78 -76
- data/docs/reference/resource/query.md +13 -13
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +57 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +14 -14
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +44 -44
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -10
- metadata +2 -2
|
@@ -8,15 +8,15 @@ Each tenant sees only their own records. Queries are filtered, forms inject the
|
|
|
8
8
|
|
|
9
9
|
## 🚨 Critical
|
|
10
10
|
|
|
11
|
-
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain
|
|
11
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain: explicitly here, or via `super(relation)` (the framework's `Plutonium::Resource::Policy` base calls it for you).
|
|
12
12
|
- **Always declare an association path from the model to the entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, fix the **model**, not the policy.
|
|
13
|
-
- **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}
|
|
13
|
+
- **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}`, without this, uniqueness leaks across tenants.
|
|
14
14
|
|
|
15
15
|
After login, users with memberships in multiple entities land on a workspace selector:
|
|
16
16
|
|
|
17
17
|

|
|
18
18
|
|
|
19
|
-
Picking one lands them on the entity-scoped dashboard
|
|
19
|
+
Picking one lands them on the entity-scoped dashboard: note the entity slug in the URL:
|
|
20
20
|
|
|
21
21
|

|
|
22
22
|
|
|
@@ -62,14 +62,17 @@ end
|
|
|
62
62
|
|
|
63
63
|
Or pass `--scope=Organization` to `pu:pkg:portal` and the engine wires this automatically.
|
|
64
64
|
|
|
65
|
-
### 4.
|
|
65
|
+
### 4. Check the mount
|
|
66
|
+
|
|
67
|
+
`pu:pkg:portal` already mounted the engine in `packages/customer_portal/config/routes.rb`:
|
|
66
68
|
|
|
67
69
|
```ruby
|
|
68
|
-
# config/routes.rb
|
|
69
70
|
mount CustomerPortal::Engine, at: "/customer"
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
To change the path, edit `at:` there. Don't add a second `mount` to `config/routes.rb`; Rails raises `Invalid route name, already in use`.
|
|
74
|
+
|
|
75
|
+
URLs now include the entity id as the first path segment after the mount: `/customer/42/posts`. The underlying param name is `organization_scoped` (Plutonium suffixes `_scoped` to avoid a name collision with any `belongs_to :organization` on child models: `params[:organization_scoped]` vs `params[:organization]`). Pass `param_key:` to `scope_to_entity` if you want a different param name.
|
|
73
76
|
|
|
74
77
|
### 5. Compound uniqueness
|
|
75
78
|
|
|
@@ -82,6 +85,10 @@ end
|
|
|
82
85
|
|
|
83
86
|
🚨 Without the `scope:`, the same slug in different orgs would collide.
|
|
84
87
|
|
|
88
|
+
### 6. Leave the entity in the policy
|
|
89
|
+
|
|
90
|
+
`organization` can stay in `permitted_attributes_for_create`. In the scoped portal the controller removes it from the form and params and sets it from the current entity (a forged `organization_id` is overwritten), while an unscoped admin portal using the same policy still gets a normal select.
|
|
91
|
+
|
|
85
92
|
## Strategies
|
|
86
93
|
|
|
87
94
|
### Path strategy (default)
|
|
@@ -143,7 +150,7 @@ class Membership < ResourceRecord
|
|
|
143
150
|
end
|
|
144
151
|
```
|
|
145
152
|
|
|
146
|
-
### 3. Grandchild
|
|
153
|
+
### 3. Grandchild: `has_one :through`
|
|
147
154
|
|
|
148
155
|
```ruby
|
|
149
156
|
class Post < ResourceRecord
|
|
@@ -187,9 +194,9 @@ relation_scope do |relation|
|
|
|
187
194
|
end
|
|
188
195
|
```
|
|
189
196
|
|
|
190
|
-
🚨 `default_relation_scope(relation)` must be called somewhere in the chain
|
|
197
|
+
🚨 `default_relation_scope(relation)` must be called somewhere in the chain, otherwise the runtime verification raises. `super(relation)` works when extending `Plutonium::Resource::Policy` directly (its block calls `default_relation_scope`); call `default_relation_scope` by name when you're not chaining via `super`.
|
|
191
198
|
|
|
192
|
-
## Cross-tenant operations
|
|
199
|
+
## Cross-tenant operations: super-admin portal
|
|
193
200
|
|
|
194
201
|
Create a separate portal **without** `scope_to_entity`:
|
|
195
202
|
|
|
@@ -197,7 +204,7 @@ Create a separate portal **without** `scope_to_entity`:
|
|
|
197
204
|
module SuperAdminPortal
|
|
198
205
|
class Engine < Rails::Engine
|
|
199
206
|
include Plutonium::Portal::Engine
|
|
200
|
-
# No scope_to_entity
|
|
207
|
+
# No scope_to_entity: sees all tenants
|
|
201
208
|
end
|
|
202
209
|
end
|
|
203
210
|
```
|
|
@@ -206,14 +213,14 @@ This portal's policies see everything. Don't enable public signup here.
|
|
|
206
213
|
|
|
207
214
|
## Multiple associations to the same entity
|
|
208
215
|
|
|
209
|
-
If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`),
|
|
216
|
+
If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`), the controller raises:
|
|
210
217
|
|
|
211
218
|
```
|
|
212
219
|
Match has multiple associations to Competition::Team: home_team, away_team.
|
|
213
220
|
Plutonium cannot auto-detect which one to use for entity scoping.
|
|
214
221
|
```
|
|
215
222
|
|
|
216
|
-
Override on the controller:
|
|
223
|
+
Override on the portal controller:
|
|
217
224
|
|
|
218
225
|
```ruby
|
|
219
226
|
class MatchesController < ::ResourceController
|
|
@@ -222,18 +229,24 @@ class MatchesController < ::ResourceController
|
|
|
222
229
|
end
|
|
223
230
|
```
|
|
224
231
|
|
|
232
|
+
Query scoping (`associated_with`) raises `AmbiguousAssociationError` too, so add an explicit scope on the model:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
scope :associated_with_competition_team, ->(team) { where(home_team: team) }
|
|
236
|
+
```
|
|
237
|
+
|
|
225
238
|
## Common issues
|
|
226
239
|
|
|
227
|
-
- **`verify_default_relation_scope_applied!` raises
|
|
228
|
-
- **`Could not resolve the association between 'Model' and 'Entity'
|
|
229
|
-
- **Records leak across tenants
|
|
230
|
-
- **Forms show the entity field anyway
|
|
231
|
-
- **Want to bypass scoping in one place
|
|
240
|
+
- **`verify_default_relation_scope_applied!` raises**: your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
|
|
241
|
+
- **`Could not resolve the association between 'Model' and 'Entity'`**: the model has no path to the entity. Fix on the **model** (declare `has_one :through` or a custom `associated_with_<entity>` scope). Never paper over with `where` in the policy.
|
|
242
|
+
- **Records leak across tenants**: likely a missing compound-uniqueness scope on the model. Add `validates :code, uniqueness: {scope: :organization_id}`.
|
|
243
|
+
- **Forms show the entity field anyway**: check `present_scoped_entity?` / `submit_scoped_entity?` on the controller (defaults are `false`).
|
|
244
|
+
- **Want to bypass scoping in one place**: use `skip_default_relation_scope!` explicitly, NOT a silent `where` bypass.
|
|
232
245
|
|
|
233
246
|
## Related
|
|
234
247
|
|
|
235
|
-
- [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping)
|
|
236
|
-
- [Reference › Behavior › Policies](/reference/behavior/policies)
|
|
237
|
-
- [Reference › App › Portals](/reference/app/portals)
|
|
238
|
-
- [Nested resources](./nested-resources)
|
|
239
|
-
- [User invites](./user-invites)
|
|
248
|
+
- [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping): full surface
|
|
249
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies): `relation_scope` syntax
|
|
250
|
+
- [Reference › App › Portals](/reference/app/portals): `scope_to_entity` engine config
|
|
251
|
+
- [Nested resources](./nested-resources): parent scoping (takes precedence over entity scoping)
|
|
252
|
+
- [User invites](./user-invites): invitation-based membership onboarding
|
|
@@ -11,7 +11,7 @@ Set up parent/child relationships so `/companies/:id/nested_properties` works au
|
|
|
11
11
|
- Forms that auto-fill the parent (no manual hidden field).
|
|
12
12
|
- Queries scoped to the parent (sibling companies' properties invisible).
|
|
13
13
|
|
|
14
|
-
All of this happens with no manual route wiring
|
|
14
|
+
All of this happens with no manual route wiring; Plutonium generates it from the association.
|
|
15
15
|
|
|
16
16
|
## Steps
|
|
17
17
|
|
|
@@ -65,7 +65,7 @@ Plutonium prefixes nested routes with `nested_` so they don't conflict with top-
|
|
|
65
65
|
| `/companies/:company_id/nested_company_profile` | `has_one` show (no `:id`) |
|
|
66
66
|
| `/companies/:company_id/nested_company_profile/new` | `has_one` new |
|
|
67
67
|
|
|
68
|
-
`has_one` associations get singular routes
|
|
68
|
+
`has_one` associations get singular routes: index redirects to show (or new if no record exists).
|
|
69
69
|
|
|
70
70
|
Every routable association gets one by default. To draw only some of them:
|
|
71
71
|
|
|
@@ -149,7 +149,7 @@ end
|
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
::: warning Always pass `as:`
|
|
152
|
-
Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails
|
|
152
|
+
Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails: no named route to look up.
|
|
153
153
|
:::
|
|
154
154
|
|
|
155
155
|
## Policy authorization context
|
|
@@ -167,13 +167,13 @@ class PropertyPolicy < ResourcePolicy
|
|
|
167
167
|
end
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
-
The parent is authorized for `:read?` before `current_parent` returns
|
|
170
|
+
The parent is authorized for `:read?` before `current_parent` returns; children inherit the parent's access requirements.
|
|
171
171
|
|
|
172
172
|
## Parent scoping vs entity scoping
|
|
173
173
|
|
|
174
|
-
When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization
|
|
174
|
+
When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization; double-scoping isn't needed.
|
|
175
175
|
|
|
176
|
-
In the child policy, just call `default_relation_scope
|
|
176
|
+
In the child policy, just call `default_relation_scope`: it handles both cases:
|
|
177
177
|
|
|
178
178
|
```ruby
|
|
179
179
|
relation_scope do |relation|
|
|
@@ -194,7 +194,7 @@ For deeper hierarchies, use top-level routes plus association tabs on the show p
|
|
|
194
194
|
|
|
195
195
|
## Nested inputs (sub-records inside a parent form)
|
|
196
196
|
|
|
197
|
-
A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form
|
|
197
|
+
A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form: a single submit creates/updates/deletes them in one go, backed by Rails' `accepts_nested_attributes_for`.
|
|
198
198
|
|
|
199
199
|
Use nested inputs when the children are conceptually part of the parent (line items on an order, variants on a product, contact methods on a person) and don't deserve their own page.
|
|
200
200
|
|
|
@@ -214,7 +214,7 @@ class PostDefinition < ResourceDefinition
|
|
|
214
214
|
end
|
|
215
215
|
end
|
|
216
216
|
|
|
217
|
-
# Policy
|
|
217
|
+
# Policy: list the association name (NOT `comments_attributes`)
|
|
218
218
|
class PostPolicy < ResourcePolicy
|
|
219
219
|
def permitted_attributes_for_create
|
|
220
220
|
[:title, :body, :comments]
|
|
@@ -223,7 +223,7 @@ end
|
|
|
223
223
|
```
|
|
224
224
|
|
|
225
225
|
::: warning Permit the association, not the strong-params shape
|
|
226
|
-
List `:comments` in `permitted_attributes_for_
|
|
226
|
+
List `:comments` in `permitted_attributes_for_*`: Plutonium translates it to `comments_attributes: [...]` for you. If you write the raw hash, the form renders the field name as a literal label instead of the nested editor.
|
|
227
227
|
:::
|
|
228
228
|
|
|
229
229
|
### Result
|
|
@@ -251,13 +251,13 @@ nested_input :profile, macro: :has_one # singular sub-form, no Add button
|
|
|
251
251
|
|
|
252
252
|
| | Nested inputs (`nested_input :comments`) | Nested resources (this guide's main topic) |
|
|
253
253
|
|---|---|---|
|
|
254
|
-
| URL | None
|
|
255
|
-
| Submit | One
|
|
254
|
+
| URL | None, inline in parent form | `/posts/:id/nested_comments` |
|
|
255
|
+
| Submit | One, saves parent + children together | Independent CRUD per child |
|
|
256
256
|
| Discoverability | Always visible in parent form | Tab on parent show page (with `permitted_associations`) |
|
|
257
257
|
| Best for | Tightly-owned children (line items, variants) | Children users browse on their own (orders, posts) |
|
|
258
258
|
| Backing | `accepts_nested_attributes_for` | Plutonium's nested controller routing |
|
|
259
259
|
|
|
260
|
-
You can use both on the same association
|
|
260
|
+
You can use both on the same association; they're not mutually exclusive.
|
|
261
261
|
|
|
262
262
|
## Inline `+` add on the parent form
|
|
263
263
|
|
|
@@ -265,15 +265,15 @@ When a form has an association select (e.g. picking the company on a Property fo
|
|
|
265
265
|
|
|
266
266
|
## Common issues
|
|
267
267
|
|
|
268
|
-
- **Nested route doesn't exist
|
|
269
|
-
- **Parent shows up in the form anyway
|
|
270
|
-
- **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`)
|
|
271
|
-
- **`resource_url_for` returns wrong URL for a nested resource
|
|
268
|
+
- **Nested route doesn't exist**: both parent AND child must be registered in the same portal (`pu:res:conn`).
|
|
269
|
+
- **Parent shows up in the form anyway**: check `present_parent?` / `submit_parent?` on the controller. Default is to hide on nested routes.
|
|
270
|
+
- **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`): give the parent one `has_many` per side (`has_many :home_matches, class_name: "Match", foreign_key: :home_team_id`). Each becomes its own nested route, and Plutonium fills in the matching `belongs_to` through `inverse_of` or the foreign key, so nothing raises. `scoped_entity_association` only matters when the parent class is also the portal's entity; see [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping#multiple-associations-to-the-same-entity-class).
|
|
271
|
+
- **`resource_url_for` returns wrong URL for a nested resource**: check that custom routes use `as:`.
|
|
272
272
|
|
|
273
273
|
## Related
|
|
274
274
|
|
|
275
|
-
- [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources)
|
|
276
|
-
- [Reference › Behavior › Controllers](/reference/behavior/controllers)
|
|
277
|
-
- [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions)
|
|
278
|
-
- [Multi-tenancy](./multi-tenancy)
|
|
279
|
-
- [Adding resources](./adding-resources)
|
|
275
|
+
- [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources): full surface
|
|
276
|
+
- [Reference › Behavior › Controllers](/reference/behavior/controllers): `current_parent`, presentation hooks
|
|
277
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions): `permitted_associations`
|
|
278
|
+
- [Multi-tenancy](./multi-tenancy): how entity scoping interacts with parent scoping
|
|
279
|
+
- [Adding resources](./adding-resources): basic resource setup
|
data/docs/guides/performance.md
CHANGED
|
@@ -20,7 +20,7 @@ Index pages, kanban boards and CSV exports already eager-load the associations a
|
|
|
20
20
|
|
|
21
21
|
Each rendering passes its own field set, because they differ: the index renders its permitted attributes, an export renders `permitted_attributes_for_export`, and a kanban card renders its `card_fields`.
|
|
22
22
|
|
|
23
|
-
It covers every association kind
|
|
23
|
+
It covers every association kind (`belongs_to`, `has_one`, `has_many`) and attachments on both ActiveStorage and Shrine.
|
|
24
24
|
|
|
25
25
|
Turn it off globally:
|
|
26
26
|
|
|
@@ -99,6 +99,6 @@ Those need to leave the request rather than be optimised inside it. An interacti
|
|
|
99
99
|
|
|
100
100
|
## Related
|
|
101
101
|
|
|
102
|
-
- **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables
|
|
102
|
+
- **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables; see [Resource › Query](/reference/resource/query#search).
|
|
103
103
|
- **Page size.** Query cost scales with rows per page.
|
|
104
|
-
- [Async Interactions](/reference/behavior/async-interactions)
|
|
104
|
+
- [Async Interactions](/reference/behavior/async-interactions): moving slow work out of the request
|
|
@@ -28,7 +28,7 @@ All declared in the definition.
|
|
|
28
28
|
|
|
29
29
|
```ruby
|
|
30
30
|
class PostDefinition < ResourceDefinition
|
|
31
|
-
# Search box
|
|
31
|
+
# Search box: searches title and body
|
|
32
32
|
search do |scope, query|
|
|
33
33
|
scope.where("title ILIKE :q OR body ILIKE :q", q: "%#{query}%")
|
|
34
34
|
end
|
|
@@ -81,7 +81,7 @@ end
|
|
|
81
81
|
|
|
82
82
|
When an association input targets this resource, the dropdown's autocomplete calls the resource's `search` block. Same code, two surfaces.
|
|
83
83
|
|
|
84
|
-
### Without a `search` block
|
|
84
|
+
### Without a `search` block: typeahead fallback
|
|
85
85
|
|
|
86
86
|
The framework falls back to a case-insensitive `LIKE` on the first column it finds, in priority order:
|
|
87
87
|
|
|
@@ -89,7 +89,7 @@ The framework falls back to a case-insensitive `LIKE` on the first column it fin
|
|
|
89
89
|
2. Otherwise the first match from `[name, title, label, slug, display_name, email]`.
|
|
90
90
|
3. Otherwise the relation is returned unfiltered (capped).
|
|
91
91
|
|
|
92
|
-
For large tables, write an explicit `search` block
|
|
92
|
+
For large tables, write an explicit `search` block: the leading-wildcard `LIKE` can't use a b-tree index. See [Reference › Resource › Query › Search](/reference/resource/query#search).
|
|
93
93
|
|
|
94
94
|
## Filters
|
|
95
95
|
|
|
@@ -154,7 +154,7 @@ class PostDefinition < ResourceDefinition
|
|
|
154
154
|
scope :published # uses Post.published
|
|
155
155
|
scope :draft # uses Post.draft
|
|
156
156
|
|
|
157
|
-
# Inline scope
|
|
157
|
+
# Inline scope: block runs with scope as argument
|
|
158
158
|
scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
|
|
159
159
|
|
|
160
160
|
# Scope with controller context
|
|
@@ -206,9 +206,9 @@ Query params are namespaced under `q`:
|
|
|
206
206
|
## Performance tips
|
|
207
207
|
|
|
208
208
|
- **Add indexes** for filtered and sorted columns.
|
|
209
|
-
- **Use `.distinct`** when joining associations in search
|
|
209
|
+
- **Use `.distinct`** when joining associations in search: duplicate rows otherwise.
|
|
210
210
|
- **Prefer scopes over filters** for queries used often (no input parsing).
|
|
211
|
-
- **`LIKE '%q%'` can't use a b-tree index
|
|
211
|
+
- **`LIKE '%q%'` can't use a b-tree index**: for large tables, use `pg_search` or a trigram/GIN/full-text index.
|
|
212
212
|
|
|
213
213
|
## Full-text search with `pg_search`
|
|
214
214
|
|
|
@@ -227,13 +227,13 @@ end
|
|
|
227
227
|
|
|
228
228
|
## Common issues
|
|
229
229
|
|
|
230
|
-
- **Filter not showing up
|
|
231
|
-
- **Slow search on large tables
|
|
232
|
-
- **Duplicate rows in results
|
|
233
|
-
- **Typeahead works on small dev tables but slows in production
|
|
230
|
+
- **Filter not showing up**: make sure the attribute is in `permitted_attributes_for_index` on the policy.
|
|
231
|
+
- **Slow search on large tables**: `LIKE '%q%'` can't be indexed by a b-tree. Switch to FTS or trigram.
|
|
232
|
+
- **Duplicate rows in results**: add `.distinct` when joining associations.
|
|
233
|
+
- **Typeahead works on small dev tables but slows in production**: same b-tree issue. Write an explicit `search` block backed by a proper index.
|
|
234
234
|
|
|
235
235
|
## Related
|
|
236
236
|
|
|
237
|
-
- [Reference › Resource › Query](/reference/resource/query)
|
|
238
|
-
- [Adding resources](./adding-resources)
|
|
239
|
-
- [Authorization](./authorization)
|
|
237
|
+
- [Reference › Resource › Query](/reference/resource/query): full surface
|
|
238
|
+
- [Adding resources](./adding-resources): basic resource setup
|
|
239
|
+
- [Authorization](./authorization): `permitted_attributes_for_index` gates which fields can be filtered
|
data/docs/guides/testing.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
Plutonium ships `Plutonium::Testing
|
|
3
|
+
Plutonium ships `Plutonium::Testing`, opt-in Minitest concerns that give your app default test coverage for resources, policies, definitions, interactions, models, nested scoping, portal access, and authentication.
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
@@ -75,7 +75,7 @@ resource_tests_for ResourceClass,
|
|
|
75
75
|
has_cents: %i[price] # ResourceModel only
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
The **portal symbol** drives path prefix, default auth strategy, and scoping expectations. The resolver walks `Rails.application.routes.routes` for the engine mount
|
|
78
|
+
The **portal symbol** drives path prefix, default auth strategy, and scoping expectations. The resolver walks `Rails.application.routes.routes` for the engine mount, with no manual configuration.
|
|
79
79
|
|
|
80
80
|
## Concerns
|
|
81
81
|
|
|
@@ -84,15 +84,17 @@ The **portal symbol** drives path prefix, default auth strategy, and scoping exp
|
|
|
84
84
|
| `ResourceCrud` | index/show/new/create/edit/update/destroy | `create_resource!`, `valid_create_params`, `valid_update_params` |
|
|
85
85
|
| `ResourcePolicy` | permit? × role × action matrix + relation_scope smoke | `policy_roles`, `policy_record`, `policy_matrix` |
|
|
86
86
|
| `ResourceDefinition` | definition class + defineable prop smoke | none |
|
|
87
|
-
| `ResourceInteraction` | `assert_interaction_success/failure` helpers |
|
|
87
|
+
| `ResourceInteraction` | `assert_interaction_success/failure` helpers | none |
|
|
88
88
|
| `ResourceModel` | `associated_with`, SGID, `has_cents` | `model_test_record` |
|
|
89
89
|
| `NestedResource` | nested CRUD + sibling-tenant boundaries | `parent_record!`, `other_parent_record!`, `create_resource!(parent:)` |
|
|
90
90
|
| `PortalAccess` | cross-portal access matrix | `login_as_role`, `portal_root_path` |
|
|
91
91
|
|
|
92
|
-
Mix and match
|
|
92
|
+
Mix and match: `include` only what you want.
|
|
93
93
|
|
|
94
94
|
## Auth helpers
|
|
95
95
|
|
|
96
|
+
`login_as` and friends come from `Plutonium::Testing::AuthHelpers`. Only `ResourceCrud`, `NestedResource` and `PortalAccess` include it; the policy, definition, model and interaction concerns don't. A bare `login_as(account)` takes its portal from `resource_tests_for`, so in a `PortalAccess` class (or a hand-written test that adds `include Plutonium::Testing::AuthHelpers` itself) pass `portal:` every time.
|
|
97
|
+
|
|
96
98
|
```ruby
|
|
97
99
|
login_as(account) # uses portal from DSL
|
|
98
100
|
login_as(account, portal: :admin) # explicit override
|
|
@@ -130,7 +132,7 @@ Idempotent. Adds the require line and creates the override stub.
|
|
|
130
132
|
|---|---|---|
|
|
131
133
|
| `--portals=admin,org` | required | Emit one file per portal |
|
|
132
134
|
| `--concerns=...` | `crud,policy,definition` | Subset of concerns to include |
|
|
133
|
-
| `--parent=organization` | none |
|
|
135
|
+
| `--parent=organization` | none | Adds `parent:` to `resource_tests_for`; the `NestedResource` include and stubs also need `nested` in `--concerns` (`--concerns=crud,nested --parent=organization`) |
|
|
134
136
|
| `--dest=main_app\|<package>` | `main_app` | Output destination |
|
|
135
137
|
|
|
136
138
|
Output: `test/integration/<portal>_portal/<resource>_test.rb`.
|
|
@@ -144,16 +146,18 @@ Output: `test/integration/<portal>_portal/<resource>_test.rb`.
|
|
|
144
146
|
|
|
145
147
|
## Common pitfalls
|
|
146
148
|
|
|
147
|
-
- **Forgotten stubs raise `NotImplementedError`** with the stub name
|
|
149
|
+
- **Forgotten stubs raise `NotImplementedError`** with the stub name: look for the missing method.
|
|
148
150
|
- **Portal mismatch:** `:admin` expects `AdminPortal::Engine`. Pass `path_prefix:` if your engine is named differently.
|
|
149
151
|
- **Tenant leakage in stubs:** for an org portal, `create_resource!` must return a record bound to the test's `@org`.
|
|
150
|
-
- **`policy_record` for tenant-scoped resources** must belong to a tenant the role can access
|
|
151
|
-
- **Nested
|
|
152
|
+
- **`policy_record` for tenant-scoped resources** must belong to a tenant the role can access, otherwise even allowed roles see `false`.
|
|
153
|
+
- **Nested paths come from `parent_record!.id`**, so it must return the same persisted tenant on every call (e.g. `@org`). `parent:` in the DSL documents the relationship; the concern doesn't read it.
|
|
154
|
+
- **Entity-scoped (`:path`) portals need two classes** for CRUD and tenant isolation, since `ResourceCrud` needs the tenant in `current_path_prefix` and `NestedResource` adds it itself. See [Reference › Testing](/reference/testing/#entity-scoped-portals-crud-tenant-isolation).
|
|
155
|
+
- **`valid_update_params` is compared literally** after the PATCH: use strings for enums and leave association SGIDs out.
|
|
152
156
|
- **`PortalAccess` uses `portal_access_for`**, not `resource_tests_for`. Don't mix them on the same class.
|
|
153
157
|
|
|
154
158
|
## Related
|
|
155
159
|
|
|
156
|
-
- [Reference › Testing](/reference/testing/)
|
|
157
|
-
- [Authorization](./authorization)
|
|
158
|
-
- [Multi-tenancy](./multi-tenancy)
|
|
159
|
-
- [Authentication](./authentication)
|
|
160
|
+
- [Reference › Testing](/reference/testing/): full DSL reference, all concern stubs, override hooks
|
|
161
|
+
- [Authorization](./authorization): write the policy this concern verifies
|
|
162
|
+
- [Multi-tenancy](./multi-tenancy): entity scoping that drives nested-resource tests
|
|
163
|
+
- [Authentication](./authentication): Rodauth setup behind the default login flow
|
data/docs/guides/theming.md
CHANGED
|
@@ -18,11 +18,12 @@ Adapt Plutonium's defaults to match your brand: primary color, fonts, logo, dark
|
|
|
18
18
|
|
|
19
19
|
## 🚨 Critical
|
|
20
20
|
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
21
|
+
- **Run `pu:core:assets` before any CSS or brand-color change.** Out of the box the app serves the gem's prebuilt `plutonium.css` / `plutonium.min.js`, so app-side Tailwind classes, palette changes and token overrides have nowhere to compile. Don't hand-write the Tailwind/PostCSS pipeline.
|
|
22
|
+
- **Once the app owns its JS bundle, register Stimulus controllers** with `registerControllers(application)` (`pu:core:assets` adds it). Your bundle replaces the gem's, so without it the entire interactive layer is dead.
|
|
23
|
+
- **Use `plutoniumTailwindConfig.merge`** when overriding Tailwind theme: plain object spread drops Plutonium's defaults.
|
|
24
|
+
- **Tokens are CSS variables, not Tailwind keys**: `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
|
|
25
|
+
- **Dark mode is `selector`, not `class`**: toggle by adding/removing `dark` on `<html>`.
|
|
26
|
+
- **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs last.** Banners, badges, cards and buttons all have a `.pu-*` class that carries its own `.dark` rule; a hand-written `bg-warning-50 dark:bg-warning-950/30` pair duplicates that and drifts from the theme.
|
|
26
27
|
|
|
27
28
|
## Step 1: Run the assets generator
|
|
28
29
|
|
|
@@ -30,7 +31,9 @@ Adapt Plutonium's defaults to match your brand: primary color, fonts, logo, dark
|
|
|
30
31
|
rails generate pu:core:assets
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
This installs npm packages, creates `tailwind.config.js`, imports Plutonium CSS, registers Stimulus controllers, and points `Plutonium.configure` at your asset files. Run once per app.
|
|
34
|
+
This installs npm packages, creates `tailwind.config.js` and `postcss.config.js`, imports Plutonium CSS, registers Stimulus controllers, and points `Plutonium.configure` at your asset files. Run once per app.
|
|
35
|
+
|
|
36
|
+
It aborts unless `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` exist (an app created with `-j esbuild -c tailwind` plus Stimulus). For an app without them, run `bin/rails javascript:install:esbuild`, `css:install:tailwind` and `stimulus:install` first. See [Reference › UI › Assets › Generator](/reference/ui/assets#generator).
|
|
34
37
|
|
|
35
38
|
## Step 2: Asset configuration
|
|
36
39
|
|
|
@@ -75,6 +78,8 @@ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
|
|
|
75
78
|
})
|
|
76
79
|
```
|
|
77
80
|
|
|
81
|
+
These palettes are compiled into the CSS at build time (`.pu-btn-primary` is `@apply bg-primary-600 ...`), so a brand color change needs this `merge` plus a rebuild (the `build:css` script, or the running `bin/dev` watcher). A `--pu-*` override won't recolor `primary`.
|
|
82
|
+
|
|
78
83
|
### Default palette
|
|
79
84
|
|
|
80
85
|
| Color | Use |
|
|
@@ -105,7 +110,15 @@ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
|
|
|
105
110
|
}
|
|
106
111
|
```
|
|
107
112
|
|
|
108
|
-
Tokens auto-switch when the user toggles dark mode.
|
|
113
|
+
Tokens auto-switch when the user toggles dark mode.
|
|
114
|
+
|
|
115
|
+
::: warning Mirror every `:root` override in `.dark`
|
|
116
|
+
Your stylesheet loads after Plutonium's and `:root` / `.dark` have equal specificity, so a token overridden only in `:root` wins in dark mode too and ships your light value there. Re-assert every customized color token in `.dark`, shadows included: `--pu-shadow-sm/md/lg` have their own dark values in `src/css/tokens.css`.
|
|
117
|
+
|
|
118
|
+
Put dark values in a `.dark { ... }` block, not `@media (prefers-color-scheme: dark)`. Dark mode is the `dark` class on `<html>`, so a media query ignores the user's toggle.
|
|
119
|
+
:::
|
|
120
|
+
|
|
121
|
+
See [Reference › UI › Assets › Design tokens](/reference/ui/assets#design-tokens) for the full token catalog.
|
|
109
122
|
|
|
110
123
|
## Using tokens in your code
|
|
111
124
|
|
|
@@ -145,6 +158,8 @@ Pre-styled ready-to-use components:
|
|
|
145
158
|
| Buttons | `.pu-btn`, `.pu-btn-md/-sm/-xs`, `.pu-btn-primary/-secondary/-danger/-success/-warning/-info/-accent`, `.pu-btn-ghost/-outline`, `.pu-btn-soft-*` |
|
|
146
159
|
| Inputs | `.pu-input/-invalid/-valid`, `.pu-label/-required`, `.pu-hint`, `.pu-error`, `.pu-checkbox` |
|
|
147
160
|
| Cards | `.pu-card`, `.pu-card-body`, `.pu-panel-header`, `.pu-panel-title`, `.pu-panel-description` |
|
|
161
|
+
| Badges | `.pu-badge`, `.pu-badge-neutral/-primary/-secondary/-success/-danger/-warning/-info/-accent` |
|
|
162
|
+
| Alerts (inline banners) | `.pu-alert`, `.pu-alert-success/-warning/-danger/-info`, `.pu-alert-message`, `.pu-alert-close` |
|
|
148
163
|
| Tables | `.pu-table-wrapper`, `.pu-table`, `-header`, `-header-cell`, `-body-row`, `-body-row-selected`, `-body-cell`, `.pu-selection-cell` |
|
|
149
164
|
| Toolbars / empty states | `.pu-toolbar`, `-text`, `-actions`; `.pu-empty-state`, `-icon`, `-title`, `-description` |
|
|
150
165
|
|
|
@@ -184,7 +199,7 @@ end
|
|
|
184
199
|
```
|
|
185
200
|
|
|
186
201
|
::: warning Always `super.merge(...)`
|
|
187
|
-
Don't replace the theme wholesale
|
|
202
|
+
Don't replace the theme wholesale (Plutonium's defaults handle invalid states, focus rings, and dark mode). `super.merge` keeps them.
|
|
188
203
|
:::
|
|
189
204
|
|
|
190
205
|
Full theme key catalog: [Reference › UI › Assets › Phlexi component themes](/reference/ui/assets#phlexi-component-themes).
|
|
@@ -225,7 +240,7 @@ document.documentElement.classList.toggle('dark')
|
|
|
225
240
|
|
|
226
241
|
If you've overridden tokens via `:root` and `.dark`, both modes Just Work.
|
|
227
242
|
|
|
228
|
-
## Per-portal chrome
|
|
243
|
+
## Per-portal chrome: eject the shell
|
|
229
244
|
|
|
230
245
|
For per-portal headers/sidebars:
|
|
231
246
|
|
|
@@ -245,7 +260,7 @@ Copies `layouts/resource.html.erb` for layout-level edits.
|
|
|
245
260
|
|
|
246
261
|
```ruby
|
|
247
262
|
Plutonium.configure do |config|
|
|
248
|
-
config.shell = :modern # default
|
|
263
|
+
config.shell = :modern # default: topbar + icon rail
|
|
249
264
|
# config.shell = :classic # legacy header + sidebar (only when upgrading)
|
|
250
265
|
end
|
|
251
266
|
```
|
|
@@ -268,13 +283,13 @@ Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fi
|
|
|
268
283
|
|
|
269
284
|
## Common issues
|
|
270
285
|
|
|
271
|
-
- **Stimulus controllers silently fail
|
|
272
|
-
- **`plutoniumTailwindConfig.merge` is mandatory
|
|
273
|
-
- **Tokens not switching in dark mode
|
|
274
|
-
- **`.pu-btn` styles not applying
|
|
286
|
+
- **Stimulus controllers silently fail**: once the app serves its own JS bundle, if `registerControllers(application)` isn't called, the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error: just no behavior.
|
|
287
|
+
- **`plutoniumTailwindConfig.merge` is mandatory**: plain spread drops defaults silently.
|
|
288
|
+
- **Tokens not switching in dark mode**: you used `bg-pu-surface` instead of `bg-[var(--pu-surface)]`. Tokens are CSS variables, not Tailwind keys.
|
|
289
|
+
- **`.pu-btn` styles not applying**: check that Plutonium CSS is imported BEFORE Tailwind: `@import "gem:plutonium/src/css/plutonium.css";` then `@import "tailwindcss";`.
|
|
275
290
|
|
|
276
291
|
## Related
|
|
277
292
|
|
|
278
|
-
- [Reference › UI › Assets](/reference/ui/assets)
|
|
279
|
-
- [Reference › UI › Layouts](/reference/ui/layouts)
|
|
280
|
-
- [Reference › UI › Forms › Theming](/reference/ui/forms#theming)
|
|
293
|
+
- [Reference › UI › Assets](/reference/ui/assets): full Tailwind / Stimulus / design tokens / component classes surface
|
|
294
|
+
- [Reference › UI › Layouts](/reference/ui/layouts): shell, eject, ResourceLayout
|
|
295
|
+
- [Reference › UI › Forms › Theming](/reference/ui/forms#theming): Form theme keys
|
|
@@ -79,6 +79,6 @@ If you encounter an issue not covered here, please [open an issue](https://githu
|
|
|
79
79
|
|
|
80
80
|
- [Nested resources](./nested-resources)
|
|
81
81
|
- [Adding resources](./adding-resources)
|
|
82
|
-
- [Reference › Behavior › Controllers](/reference/behavior/controllers)
|
|
83
|
-
- [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources)
|
|
82
|
+
- [Reference › Behavior › Controllers](/reference/behavior/controllers): `controller_for`, `resource_url_for`, `current_parent`
|
|
83
|
+
- [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources): nested URL generation
|
|
84
84
|
- [Rails Inflections](https://api.rubyonrails.org/classes/ActiveSupport/Inflector/Inflections.html)
|
data/docs/guides/user-invites.md
CHANGED
|
@@ -8,7 +8,7 @@ An admin enters an email, the user gets an invite link, clicks it, signs up (or
|
|
|
8
8
|
|
|
9
9
|
## Prerequisites
|
|
10
10
|
|
|
11
|
-
You need a user model, an entity model, and a membership model. The fastest path is `pu:saas:setup
|
|
11
|
+
You need a user model, an entity model, and a membership model. The fastest path is `pu:saas:setup`: it creates all three and runs `pu:invites:install` automatically:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
rails g pu:saas:setup --user Customer --entity Organization
|
|
@@ -43,7 +43,7 @@ rails g pu:invites:install \
|
|
|
43
43
|
| `--enforce-domain` | `false` | Require email domain to match entity |
|
|
44
44
|
|
|
45
45
|
::: info Roles come from the membership model
|
|
46
|
-
`pu:invites:install` reads the role list from the membership model's `enum :role
|
|
46
|
+
`pu:invites:install` reads the role list from the membership model's `enum :role`; it does not accept a `--roles=` flag. Define roles when you generate the membership model (`pu:saas:membership --roles=...`), or edit the enum directly. **Index 0 is the most privileged** (typically `owner`); the invite interaction excludes `owner` from selectable choices and defaults new invitees to the second role.
|
|
47
47
|
:::
|
|
48
48
|
|
|
49
49
|
### 2. Migrate
|
|
@@ -112,7 +112,7 @@ Users land on `/welcome` where pending invites are shown. Including `Plutonium::
|
|
|
112
112
|
include Plutonium::Invites::PendingInviteCheck
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
## Invitables
|
|
115
|
+
## Invitables: app models notified on acceptance
|
|
116
116
|
|
|
117
117
|
An invitable is a model that gets notified when its invitation is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
|
|
118
118
|
|
|
@@ -137,7 +137,7 @@ end
|
|
|
137
137
|
```
|
|
138
138
|
|
|
139
139
|
::: warning Without `on_invite_accepted`
|
|
140
|
-
The invitable never learns about the new user
|
|
140
|
+
The invitable never learns about the new user: the invite is consumed but your app doesn't update its state.
|
|
141
141
|
:::
|
|
142
142
|
|
|
143
143
|
## Multiple invite flows in one app
|
|
@@ -158,7 +158,7 @@ rails g pu:invites:install \
|
|
|
158
158
|
|
|
159
159
|
Each invocation creates an independent flow: model, controller, route, helper all named for the invite-model.
|
|
160
160
|
|
|
161
|
-
The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array
|
|
161
|
+
The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array; `pending_invite` checks all flows in priority order (first-match wins).
|
|
162
162
|
|
|
163
163
|
See [Reference › Tenancy › Invites › Multiple invite flows](/reference/tenancy/invites#multiple-invite-flows).
|
|
164
164
|
|
|
@@ -222,27 +222,27 @@ entity.user_invites.pending # list pending
|
|
|
222
222
|
|
|
223
223
|
## Security
|
|
224
224
|
|
|
225
|
-
- **Token security
|
|
226
|
-
- **Email validation
|
|
227
|
-
- **Rate limiting
|
|
225
|
+
- **Token security**: `SecureRandom.urlsafe_base64(32)`, 256 bits, URL-safe. Stored hashed, raw token shown only at creation.
|
|
226
|
+
- **Email validation**: `enforce_email?` is `true` by default. The accepting user's email must match the invited email; this prevents account hijacking via invite forwarding.
|
|
227
|
+
- **Rate limiting**: use Rack::Attack or similar to throttle invite creation per admin and acceptance attempts per IP.
|
|
228
228
|
|
|
229
229
|
::: danger Don't disable enforce_email?
|
|
230
230
|
```ruby
|
|
231
231
|
def enforce_email? = false # ← only if you fully understand the trade-off
|
|
232
232
|
```
|
|
233
|
-
Without this, anyone with the token can sign up
|
|
233
|
+
Without this, anyone with the token can sign up, which defeats the purpose of an invitation system.
|
|
234
234
|
:::
|
|
235
235
|
|
|
236
236
|
## Common issues
|
|
237
237
|
|
|
238
|
-
- **"Invitation not found or expired"
|
|
239
|
-
- **Email mismatch error
|
|
240
|
-
- **Rodauth redirect after login doesn't go to `/welcome
|
|
241
|
-
- **`on_invite_accepted` not called
|
|
238
|
+
- **"Invitation not found or expired"**: token expired (default 1 week), invite cancelled, or no longer `pending`.
|
|
239
|
+
- **Email mismatch error**: the accepting user's email doesn't match the invited email. This is by design (security).
|
|
240
|
+
- **Rodauth redirect after login doesn't go to `/welcome`**: check `login_redirect "/welcome"` in the rodauth plugin's `configure` block.
|
|
241
|
+
- **`on_invite_accepted` not called**: ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
|
|
242
242
|
|
|
243
243
|
## Related
|
|
244
244
|
|
|
245
|
-
- [Reference › Tenancy › Invites](/reference/tenancy/invites)
|
|
246
|
-
- [Multi-tenancy](./multi-tenancy)
|
|
247
|
-
- [Authentication](./authentication)
|
|
248
|
-
- [User profile](./user-profile)
|
|
245
|
+
- [Reference › Tenancy › Invites](/reference/tenancy/invites): full surface, multi-flow apps, customization
|
|
246
|
+
- [Multi-tenancy](./multi-tenancy): entity scoping (invites are entity-scoped automatically)
|
|
247
|
+
- [Authentication](./authentication): Rodauth setup
|
|
248
|
+
- [User profile](./user-profile): account-settings page
|