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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plutonium-tenancy
|
|
3
|
+
description: Use BEFORE any multi-tenant work — scoping a model to a tenant, writing relation_scope, configuring portal entity strategies, setting up parent/child nested resources, or wiring user invitations. The single source for entity scoping, nested resources, and invites.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Tenancy — Entity Scoping, Nested Resources, Invites
|
|
7
|
+
|
|
8
|
+
Three closely-coupled concerns:
|
|
9
|
+
|
|
10
|
+
1. **Entity scoping** — every record belongs to a tenant; queries are filtered automatically.
|
|
11
|
+
2. **Nested resources** — parent/child URLs; parent scoping takes precedence over entity scoping.
|
|
12
|
+
3. **Invites** — onboarding users into a tenant's membership.
|
|
13
|
+
|
|
14
|
+
Cross-references back to [[plutonium-resource]] (models, definitions) and [[plutonium-behavior]] (policies, controllers).
|
|
15
|
+
|
|
16
|
+
## 🚨 Critical (read first)
|
|
17
|
+
|
|
18
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly — not `super`.
|
|
19
|
+
- **Always declare an association path from model to entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, Plutonium raises. Fix the **model**, not the policy.
|
|
20
|
+
- **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, NOT via `entity_scope`. Don't double-scope.
|
|
21
|
+
- **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
|
|
22
|
+
- **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
|
|
23
|
+
- **Invite email must match the accepting user's email.** Security feature. Don't disable `enforce_email?` lightly.
|
|
24
|
+
- **Use generators.** `pu:saas:setup`, `pu:pkg:portal --scope=Entity`, `pu:res:scaffold`, `pu:invites:install`, `pu:invites:invitable`. Hand-wiring is how leaks happen.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Part 1 — Entity Scoping
|
|
29
|
+
|
|
30
|
+
Built on three cooperating pieces:
|
|
31
|
+
|
|
32
|
+
| Piece | Role |
|
|
33
|
+
|---|---|
|
|
34
|
+
| **Portal** | Declares the entity class and how to resolve it (`scope_to_entity Organization, strategy: :path`). |
|
|
35
|
+
| **Policy** | `default_relation_scope(relation)` calls `relation.associated_with(entity_scope)` on every collection query. Enforced via `verify_default_relation_scope_applied!`. |
|
|
36
|
+
| **Model** | `associated_with(entity)` resolves via custom scope, direct association, or `has_one :through`. |
|
|
37
|
+
|
|
38
|
+
## `associated_with` resolution order
|
|
39
|
+
|
|
40
|
+
`Model.associated_with(entity)` tries, in order:
|
|
41
|
+
|
|
42
|
+
1. **Custom scope** `associated_with_<entity_name>` — highest priority, full SQL control.
|
|
43
|
+
2. **Direct `belongs_to` to entity class** — `WHERE <entity>_id = ?`, most efficient.
|
|
44
|
+
3. **`has_one` / `has_one :through` to entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
|
|
45
|
+
4. **Reverse `has_many` from entity** — JOIN required, logs a warning (less efficient).
|
|
46
|
+
|
|
47
|
+
If none apply: `Could not resolve the association between 'Model' and 'Entity'`. Fix on the **model** — either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.
|
|
48
|
+
|
|
49
|
+
## Three model shapes
|
|
50
|
+
|
|
51
|
+
Pick the lightest that fits.
|
|
52
|
+
|
|
53
|
+
### Shape 1: Direct child (`belongs_to` the entity)
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class Organization < ResourceRecord
|
|
57
|
+
has_many :projects
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class Project < ResourceRecord
|
|
61
|
+
belongs_to :organization
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Project.associated_with(org)
|
|
65
|
+
# => Project.where(organization: org)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Auto-detected. No extra work.
|
|
69
|
+
|
|
70
|
+
### Shape 2: Join table (membership)
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class User < ResourceRecord
|
|
74
|
+
has_many :memberships
|
|
75
|
+
has_many :organizations, through: :memberships
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Membership < ResourceRecord
|
|
79
|
+
belongs_to :user
|
|
80
|
+
belongs_to :organization # auto-detected
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Membership.associated_with(org)
|
|
84
|
+
# => Membership.where(organization: org)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If `Membership` is itself a parent and the scoped target is two hops away, add `has_one :through`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class ProjectMember < ResourceRecord
|
|
91
|
+
belongs_to :project
|
|
92
|
+
belongs_to :user
|
|
93
|
+
has_one :organization, through: :project # enables auto-scoping
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Shape 3: Grandchild (multi-hop via `has_one :through`)
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class Project < ResourceRecord
|
|
101
|
+
belongs_to :organization
|
|
102
|
+
has_many :tasks
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class Task < ResourceRecord
|
|
106
|
+
belongs_to :project
|
|
107
|
+
has_one :organization, through: :project # critical
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
class Comment < ResourceRecord
|
|
111
|
+
belongs_to :task
|
|
112
|
+
has_one :project, through: :task
|
|
113
|
+
has_one :organization, through: :project # multi-hop chain
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`Task.associated_with(org)` and `Comment.associated_with(org)` both auto-resolve.
|
|
118
|
+
|
|
119
|
+
### When to fall back to a custom scope
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class Comment < ResourceRecord
|
|
123
|
+
scope :associated_with_organization, ->(org) do
|
|
124
|
+
joins(task: :project).where(projects: {organization_id: org.id})
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Use when:
|
|
130
|
+
- The path is polymorphic.
|
|
131
|
+
- Conditional logic is needed.
|
|
132
|
+
- You want explicit SQL for performance.
|
|
133
|
+
|
|
134
|
+
Picked up BEFORE association detection.
|
|
135
|
+
|
|
136
|
+
## `relation_scope` — safe overrides
|
|
137
|
+
|
|
138
|
+
`default_relation_scope(relation)` does two things:
|
|
139
|
+
|
|
140
|
+
1. If a **parent** is present (nested resource), scopes via the parent association.
|
|
141
|
+
2. Otherwise, applies `relation.associated_with(entity_scope)`.
|
|
142
|
+
|
|
143
|
+
### Correct
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# ✅ Best: don't override — the inherited scope already does it.
|
|
147
|
+
|
|
148
|
+
# ✅ Extra filters on top
|
|
149
|
+
relation_scope do |relation|
|
|
150
|
+
default_relation_scope(relation).where(archived: false)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# ✅ Role-based
|
|
154
|
+
relation_scope do |relation|
|
|
155
|
+
relation = default_relation_scope(relation)
|
|
156
|
+
user.admin? ? relation : relation.where(author: user)
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Wrong
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# ❌ Manually filtering by entity — bypasses default_relation_scope
|
|
164
|
+
relation_scope { |r| r.where(organization: current_scoped_entity) }
|
|
165
|
+
|
|
166
|
+
# ❌ Manual joins — same problem
|
|
167
|
+
relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
|
|
168
|
+
|
|
169
|
+
# ❌ Missing default_relation_scope entirely — raises at runtime
|
|
170
|
+
relation_scope { |r| r.where(published: true) }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Do not use `super`** from inside `relation_scope`. Call `default_relation_scope(relation)` explicitly — `super` semantics depend on how ActionPolicy's DSL registered the scope.
|
|
174
|
+
|
|
175
|
+
### Intentionally skipping
|
|
176
|
+
|
|
177
|
+
Rare. Before reaching for this, consider a separate, unscoped portal.
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
relation_scope do |relation|
|
|
181
|
+
skip_default_relation_scope!
|
|
182
|
+
relation
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Portal entity strategies
|
|
187
|
+
|
|
188
|
+
### Path strategy (most common)
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
module AdminPortal
|
|
192
|
+
class Engine < Rails::Engine
|
|
193
|
+
include Plutonium::Portal::Engine
|
|
194
|
+
|
|
195
|
+
config.after_initialize do
|
|
196
|
+
scope_to_entity Organization, strategy: :path
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Routes become `/organizations/:organization_id/posts`. Portal extracts `params[:organization_id]` and loads the entity automatically.
|
|
203
|
+
|
|
204
|
+
### Custom strategy (subdomain, session, etc.)
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
scope_to_entity Organization, strategy: :current_organization
|
|
208
|
+
|
|
209
|
+
module AdminPortal::Concerns::Controller
|
|
210
|
+
extend ActiveSupport::Concern
|
|
211
|
+
include Plutonium::Portal::Controller
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def current_organization
|
|
216
|
+
@current_organization ||= Organization.find_by!(subdomain: request.subdomain)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The strategy symbol must match a method name on the controller.
|
|
222
|
+
|
|
223
|
+
### Accessing the scoped entity
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
# Controller
|
|
227
|
+
current_scoped_entity
|
|
228
|
+
scoped_to_entity?
|
|
229
|
+
|
|
230
|
+
# Policy
|
|
231
|
+
entity_scope
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Gotchas
|
|
235
|
+
|
|
236
|
+
- **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises — override `scoped_entity_association` on the controller to pick one (`def scoped_entity_association = :home_team`).
|
|
237
|
+
- **`param_key` differs from association name.** Fine — Plutonium matches by **class**, not param key. `scope_to_entity Competition::Team, param_key: :team` works with `belongs_to :competition_team`.
|
|
238
|
+
- **Forgetting compound uniqueness.** `validates :code, uniqueness: true` leaks across tenants. Use `uniqueness: {scope: :organization_id}`.
|
|
239
|
+
- **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly. Never leave a `where` bypass in code.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
# Part 2 — Nested Resources
|
|
244
|
+
|
|
245
|
+
Plutonium auto-generates nested routes from `has_many` / `has_one` associations on a registered parent. **One level only** — no grandparent → parent → child chains.
|
|
246
|
+
|
|
247
|
+
## Setup
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
rails g pu:res:scaffold Company name:string --dest=main_app
|
|
251
|
+
rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
|
|
252
|
+
rails g pu:res:conn Company Property --dest=admin_portal
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Then register both in the portal routes:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
register_resource ::Company
|
|
259
|
+
register_resource ::Property # has belongs_to :company
|
|
260
|
+
register_resource ::CompanyProfile # has_one :company_profile on Company
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Generated routes
|
|
264
|
+
|
|
265
|
+
Plutonium prefixes nested routes with `nested_` to avoid conflicts with the top-level routes:
|
|
266
|
+
|
|
267
|
+
| Route | Purpose |
|
|
268
|
+
|---|---|
|
|
269
|
+
| `/companies/:company_id/nested_properties` | has_many index |
|
|
270
|
+
| `/companies/:company_id/nested_properties/new` | new |
|
|
271
|
+
| `/companies/:company_id/nested_properties/:id` | show |
|
|
272
|
+
| `/companies/:company_id/nested_company_profile` | has_one show (no `:id`) |
|
|
273
|
+
| `/companies/:company_id/nested_company_profile/new` | has_one new |
|
|
274
|
+
|
|
275
|
+
For `has_one`: index redirects to show (or new if no record exists); only one record per parent.
|
|
276
|
+
|
|
277
|
+
## Automatic behavior in nested routes
|
|
278
|
+
|
|
279
|
+
When the controller is hit through a nested route:
|
|
280
|
+
|
|
281
|
+
1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
|
|
282
|
+
2. **Scopes queries** via parent association (e.g. `parent.properties` for `has_many`, `where(foreign_key => parent.id)` for `has_one`).
|
|
283
|
+
3. **Assigns parent** on create (injected into `resource_params`).
|
|
284
|
+
4. **Hides parent field** in forms (already determined by URL).
|
|
285
|
+
|
|
286
|
+
You don't need to add hidden parent fields in forms or filter queries manually.
|
|
287
|
+
|
|
288
|
+
## Controller methods
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
current_parent # Parent record
|
|
292
|
+
current_nested_association # :properties
|
|
293
|
+
parent_route_param # :company_id
|
|
294
|
+
parent_input_param # :company
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Parent vs entity scoping
|
|
298
|
+
|
|
299
|
+
When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, not `entity_scope`. The parent was already authorized and entity-scoped during its own authorization — double-scoping isn't needed.
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
# In the child policy — just call default_relation_scope, it handles both cases
|
|
303
|
+
relation_scope do |relation|
|
|
304
|
+
default_relation_scope(relation) # uses parent when present, entity_scope otherwise
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## URL generation
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
# Collection
|
|
312
|
+
resource_url_for(Property, parent: company)
|
|
313
|
+
# => /companies/123/nested_properties
|
|
314
|
+
|
|
315
|
+
# Record
|
|
316
|
+
resource_url_for(property, parent: company)
|
|
317
|
+
# => /companies/123/nested_properties/456
|
|
318
|
+
|
|
319
|
+
# Form
|
|
320
|
+
resource_url_for(Property, action: :new, parent: company)
|
|
321
|
+
resource_url_for(property, action: :edit, parent: company)
|
|
322
|
+
|
|
323
|
+
# has_one
|
|
324
|
+
resource_url_for(CompanyProfile, action: :new, parent: company)
|
|
325
|
+
# => /companies/123/nested_company_profile/new
|
|
326
|
+
|
|
327
|
+
# Interactions
|
|
328
|
+
resource_url_for(property, parent: company, interaction: :archive)
|
|
329
|
+
resource_url_for(Property, parent: company, interaction: :import)
|
|
330
|
+
resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])
|
|
331
|
+
|
|
332
|
+
# Cross-package
|
|
333
|
+
resource_url_for(property, parent: company, package: CustomerPortal)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Authorization context
|
|
337
|
+
|
|
338
|
+
The child policy receives the parent:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
class PropertyPolicy < ResourcePolicy
|
|
342
|
+
# parent => the Company instance
|
|
343
|
+
# parent_association => :properties
|
|
344
|
+
|
|
345
|
+
def create?
|
|
346
|
+
parent.present? && user.member_of?(parent)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Presentation hooks
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
class PropertiesController < ResourceController
|
|
355
|
+
private
|
|
356
|
+
|
|
357
|
+
def present_parent? = true # show parent in displays (default: false)
|
|
358
|
+
def submit_parent? = false # allow changing in forms (defaults to present_parent?)
|
|
359
|
+
end
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Conditional pattern — show parent only when accessed standalone:
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
def present_parent?
|
|
366
|
+
current_parent.nil?
|
|
367
|
+
end
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Custom parent resolution
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
def current_parent
|
|
374
|
+
@current_parent ||= Company.friendly.find(params[:company_id])
|
|
375
|
+
end
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## Custom nested routes
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
register_resource ::Property do
|
|
382
|
+
member do
|
|
383
|
+
get :analytics, as: :analytics # `as:` is REQUIRED for resource_url_for to work
|
|
384
|
+
post :archive, as: :archive
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Generates `/companies/:company_id/nested_properties/:id/analytics` etc.
|
|
390
|
+
|
|
391
|
+
## Breadcrumbs
|
|
392
|
+
|
|
393
|
+
Auto-include parent: `Companies > Acme Corp > Properties > Property #123`.
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
# Part 3 — Invites
|
|
398
|
+
|
|
399
|
+
A complete user-invitation system: token-based emails, secure acceptance, Rodauth integration, entity membership creation, and "invitable" hooks for app-specific behavior.
|
|
400
|
+
|
|
401
|
+
## Prerequisites
|
|
402
|
+
|
|
403
|
+
User model + entity model + membership model. The fastest path:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
rails g pu:saas:setup --user Customer --entity Organization
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
This creates all three plus the join table.
|
|
410
|
+
|
|
411
|
+
## Install
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
rails generate pu:invites:install
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Options
|
|
418
|
+
|
|
419
|
+
| Option | Default | Description |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| `--entity-model=NAME` | `Entity` | Entity model name |
|
|
422
|
+
| `--user-model=NAME` | `User` | User model name |
|
|
423
|
+
| `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name (omit for single-flow apps) |
|
|
424
|
+
| `--membership-model=NAME` | `EntityUser` | Membership join model |
|
|
425
|
+
| `--roles=ROLES` | `member,admin` | Comma-separated |
|
|
426
|
+
| `--rodauth=NAME` | `user` | Rodauth configuration for signup |
|
|
427
|
+
| `--enforce-domain` | `false` | Require invited email domain to match entity |
|
|
428
|
+
|
|
429
|
+
### What gets created
|
|
430
|
+
|
|
431
|
+
```
|
|
432
|
+
packages/invites/
|
|
433
|
+
├── app/controllers/invites/
|
|
434
|
+
│ ├── user_invitations_controller.rb
|
|
435
|
+
│ └── welcome_controller.rb
|
|
436
|
+
├── app/definitions/invites/user_invite_definition.rb
|
|
437
|
+
├── app/interactions/invites/
|
|
438
|
+
│ ├── cancel_invite_interaction.rb
|
|
439
|
+
│ └── resend_invite_interaction.rb
|
|
440
|
+
├── app/mailers/invites/user_invite_mailer.rb
|
|
441
|
+
├── app/models/invites/user_invite.rb
|
|
442
|
+
├── app/policies/invites/user_invite_policy.rb
|
|
443
|
+
└── app/views/invites/...
|
|
444
|
+
|
|
445
|
+
app/interactions/{entity,user}/invite_user_interaction.rb
|
|
446
|
+
db/migrate/TIMESTAMP_create_user_invites.rb
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Routes added:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
get "welcome", to: "invites/welcome#index"
|
|
453
|
+
get "invitations/:token", to: "invites/user_invitations#show"
|
|
454
|
+
post "invitations/:token/accept", to: "invites/user_invitations#accept"
|
|
455
|
+
get "invitations/:token/signup", to: "invites/user_invitations#signup"
|
|
456
|
+
post "invitations/:token/signup", to: "invites/user_invitations#signup"
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Multiple invite flows in one app
|
|
460
|
+
|
|
461
|
+
Run `pu:invites:install` once per flow. Default class name derives as `<EntityModel><UserModel>Invite` — no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
rails g pu:invites:install \
|
|
465
|
+
--entity-model=FunderOrganization --user-model=SpenderAccount \
|
|
466
|
+
--invite-model=FunderInvite
|
|
467
|
+
|
|
468
|
+
rails g pu:invites:install \
|
|
469
|
+
--entity-model=Project --user-model=Member \
|
|
470
|
+
--invite-model=ProjectInvite
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Each invocation creates an independent model (`Invites::FunderInvite`), controller (`Invites::FunderInvitationsController`), route (`/funder_invitations/:token`), and helper (`funder_invitation_path`). The shared `Invites::WelcomeController` accumulates each class into `invite_classes`; `pending_invite` checks all flows in priority order (first-match wins).
|
|
474
|
+
|
|
475
|
+
Model-level overrides for non-default association names:
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
def user_attribute = :spender_account # belongs_to :spender_account
|
|
479
|
+
def invite_entity_attribute = :funder_organization # belongs_to :funder_organization
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Controller-level (auto-generated, but shown for clarity):
|
|
483
|
+
|
|
484
|
+
```ruby
|
|
485
|
+
# welcome_controller.rb
|
|
486
|
+
def invite_classes
|
|
487
|
+
[::Invites::FunderInvite, ::Invites::ProjectInvite]
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# funder_invitations_controller.rb
|
|
491
|
+
def invitation_path_for(token)
|
|
492
|
+
funder_invitation_path(token: token)
|
|
493
|
+
end
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
## Invitables — app models notified on accept
|
|
497
|
+
|
|
498
|
+
An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
|
|
499
|
+
|
|
500
|
+
```bash
|
|
501
|
+
rails generate pu:invites:invitable Tenant
|
|
502
|
+
rails generate pu:invites:invitable TeamMember --role=member
|
|
503
|
+
rails generate pu:invites:invitable Tenant --dest=my_package
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Then implement the callback:
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
class Tenant < ApplicationRecord
|
|
510
|
+
include Plutonium::Invites::Concerns::Invitable
|
|
511
|
+
|
|
512
|
+
belongs_to :entity
|
|
513
|
+
belongs_to :user, optional: true
|
|
514
|
+
|
|
515
|
+
def on_invite_accepted(user)
|
|
516
|
+
update!(user: user, status: :active)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Without `on_invite_accepted`, the invitable never learns about the new user.
|
|
522
|
+
|
|
523
|
+
## The flow
|
|
524
|
+
|
|
525
|
+
### 1. Admin sends the invite
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
entity.invite_user(email: "user@example.com", role: :member)
|
|
529
|
+
tenant.invite_user(email: "user@example.com") # from invitable context
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### 2. Email goes out
|
|
533
|
+
|
|
534
|
+
Token-based URL: `https://app.example.com/invitations/abc123...`
|
|
535
|
+
|
|
536
|
+
### 3. User accepts
|
|
537
|
+
|
|
538
|
+
**Existing user:** clicks link → logs in (or already logged in) → email validated → membership created → invitable notified via `on_invite_accepted`.
|
|
539
|
+
|
|
540
|
+
**New user:** clicks link → "Create Account" → signs up with the invited email → membership created → invitable notified.
|
|
541
|
+
|
|
542
|
+
### 4. Pending invite check
|
|
543
|
+
|
|
544
|
+
After login, users land on `/welcome` where pending invites are shown:
|
|
545
|
+
|
|
546
|
+
```ruby
|
|
547
|
+
include Plutonium::Invites::PendingInviteCheck
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Rodauth wiring (required for redirect):
|
|
551
|
+
|
|
552
|
+
```ruby
|
|
553
|
+
# app/rodauth/user_rodauth_plugin.rb
|
|
554
|
+
configure do
|
|
555
|
+
login_return_to_requested_location? true
|
|
556
|
+
login_redirect "/welcome"
|
|
557
|
+
|
|
558
|
+
after_login do
|
|
559
|
+
session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
## The UserInvite model
|
|
565
|
+
|
|
566
|
+
Generated as `Invites::<InviteModelName>`:
|
|
567
|
+
|
|
568
|
+
```ruby
|
|
569
|
+
class Invites::UserInvite < Invites::ResourceRecord
|
|
570
|
+
include Plutonium::Invites::Concerns::InviteToken
|
|
571
|
+
|
|
572
|
+
belongs_to :entity
|
|
573
|
+
belongs_to :invited_by, polymorphic: true
|
|
574
|
+
belongs_to :user, optional: true
|
|
575
|
+
belongs_to :invitable, polymorphic: true, optional: true
|
|
576
|
+
|
|
577
|
+
enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
|
|
578
|
+
enum :role, member: 0, admin: 1
|
|
579
|
+
end
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Key methods:
|
|
583
|
+
|
|
584
|
+
```ruby
|
|
585
|
+
invite = Invites::UserInvite.find_for_acceptance(token)
|
|
586
|
+
invite.accept_for_user!(current_user)
|
|
587
|
+
invite.resend!
|
|
588
|
+
invite.cancel!
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
## Customization
|
|
592
|
+
|
|
593
|
+
### Custom email templates
|
|
594
|
+
|
|
595
|
+
Override views in your package:
|
|
596
|
+
|
|
597
|
+
```erb
|
|
598
|
+
<%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
|
|
599
|
+
<h1>Welcome to <%= @invite.entity.name %>!</h1>
|
|
600
|
+
<p><%= @invite.invited_by.email %> has invited you.</p>
|
|
601
|
+
<p><%= link_to "Accept", @invitation_url %></p>
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### Custom validation
|
|
605
|
+
|
|
606
|
+
Extend the model:
|
|
607
|
+
|
|
608
|
+
```ruby
|
|
609
|
+
class Invites::UserInvite < Invites::ResourceRecord
|
|
610
|
+
validate :email_not_already_member
|
|
611
|
+
|
|
612
|
+
private
|
|
613
|
+
|
|
614
|
+
def email_not_already_member
|
|
615
|
+
existing = membership_model.joins(:user)
|
|
616
|
+
.where(entity: entity, users: {email: email}).exists?
|
|
617
|
+
errors.add(:email, "is already a member") if existing
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### Domain enforcement / custom roles
|
|
623
|
+
|
|
624
|
+
```bash
|
|
625
|
+
rails g pu:invites:install --enforce-domain
|
|
626
|
+
rails g pu:invites:install --roles=viewer,editor,admin,owner
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
## Portal connection
|
|
630
|
+
|
|
631
|
+
```ruby
|
|
632
|
+
module CustomerPortal
|
|
633
|
+
class Engine < Rails::Engine
|
|
634
|
+
include Plutonium::Portal::Engine
|
|
635
|
+
register_package Invites::Engine
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Invites are entity-scoped automatically: `Invites::UserInvite belongs_to :entity` → `associated_with` resolves directly → admins see only invites for their org.
|
|
641
|
+
|
|
642
|
+
## Common issues
|
|
643
|
+
|
|
644
|
+
- **"Invite not found"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
|
|
645
|
+
- **Email mismatch** — `enforce_email?` is on by default. The accepting user's email must match the invited email. Override `def enforce_email? = false` only if you fully understand the security trade-off.
|
|
646
|
+
- **Rodauth redirect after login** — make sure `login_redirect "/welcome"` is set in the rodauth plugin.
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## Related skills
|
|
651
|
+
|
|
652
|
+
- [[plutonium-resource]] — model declarations (`belongs_to`, `has_one :through`, custom scopes), `permitted_associations` for show-page tabs.
|
|
653
|
+
- [[plutonium-behavior]] — `relation_scope` syntax, policy authorization context, controller presentation hooks.
|
|
654
|
+
- [[plutonium-app]] — portal setup, `scope_to_entity`, mounting engines.
|
|
655
|
+
- [[plutonium-auth]] — Rodauth signup flow for invite acceptance.
|
|
@@ -260,9 +260,10 @@ Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
|
|
|
260
260
|
- **Nested resources need `parent: :foo`** in the DSL AND a real parent record from `parent_record!`. Without both, path interpolation fails.
|
|
261
261
|
- **`PortalAccess` doesn't use `resource_tests_for`** — use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
|
|
262
262
|
|
|
263
|
-
##
|
|
263
|
+
## Related skills
|
|
264
264
|
|
|
265
|
-
-
|
|
266
|
-
-
|
|
267
|
-
-
|
|
268
|
-
-
|
|
265
|
+
- [[plutonium-behavior]] — policies (verified by `ResourcePolicy`), interactions (asserted by `ResourceInteraction`)
|
|
266
|
+
- [[plutonium-resource]] — definition props the smoke test introspects (`field`, `input`, `display`, `column`, `scope`, `filter`, `sort`, `action`)
|
|
267
|
+
- [[plutonium-tenancy]] — `relation_scope`, parent scoping, nested resources (matched by `NestedResource`)
|
|
268
|
+
- [[plutonium-app]] — portal mounting and entity strategies that drive auth/scoping
|
|
269
|
+
- [[plutonium-auth]] — Rodauth setup behind the default login flow
|