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
|
@@ -6,7 +6,7 @@ Add Rodauth-based authentication to your Plutonium app.
|
|
|
6
6
|
|
|
7
7
|
Authenticated users can sign up, log in, change passwords, and reset forgotten passwords. Pages in protected portals are gated.
|
|
8
8
|
|
|
9
|
-
## Quick path
|
|
9
|
+
## Quick path: basic user auth
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
# 1. Install Rodauth
|
|
@@ -22,7 +22,7 @@ rails db:prepare
|
|
|
22
22
|
# (when you run `pu:pkg:portal admin --auth=user`, this happens automatically)
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
If you generated the portal with `--auth=user`, the engine is already mounted with the `Rodauth::Rails.authenticate(:user)` constraint
|
|
25
|
+
If you generated the portal with `--auth=user`, the engine is already mounted with the `Rodauth::Rails.authenticate(:user)` constraint: open `packages/admin_portal/config/routes.rb` to see it. The wiring looks like:
|
|
26
26
|
|
|
27
27
|
```ruby
|
|
28
28
|
# packages/admin_portal/config/routes.rb (generated)
|
|
@@ -64,13 +64,13 @@ EMAIL=admin@example.com rails rodauth:admin
|
|
|
64
64
|
|
|
65
65
|
The task creates the account and triggers a verification email; the admin sets their own password through that flow. No password is passed on the command line.
|
|
66
66
|
|
|
67
|
-
### Multi-tenant SaaS
|
|
67
|
+
### Multi-tenant SaaS: user + entity + membership in one shot
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
70
|
rails generate pu:saas:setup --user Customer --entity Organization
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
⚠️ This is a **meta-generator
|
|
73
|
+
⚠️ This is a **meta-generator**: it also runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install`. Don't re-run those manually. See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup).
|
|
74
74
|
|
|
75
75
|
### API-only (JWT)
|
|
76
76
|
|
|
@@ -103,7 +103,7 @@ module CustomerPortal::Concerns::Controller
|
|
|
103
103
|
end
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
Multiple account types
|
|
106
|
+
Multiple account types (different portals use different Rodauth instances):
|
|
107
107
|
|
|
108
108
|
```ruby
|
|
109
109
|
# Admin portal
|
|
@@ -117,40 +117,40 @@ See [Reference › App › Portals](/reference/app/portals#controller-concern-au
|
|
|
117
117
|
|
|
118
118
|
## Multiple portals in one browser
|
|
119
119
|
|
|
120
|
-
A person can hold a session in several portals at once
|
|
120
|
+
A person can hold a session in several portals at once (signed into the admin portal and the customer portal in the same browser, with neither evicting the other). Two settings make that work, and the generators emit both:
|
|
121
121
|
|
|
122
122
|
```ruby
|
|
123
|
-
# app/rodauth/rodauth_plugin.rb
|
|
123
|
+
# app/rodauth/rodauth_plugin.rb: the shared base
|
|
124
124
|
configure do
|
|
125
125
|
enable :session_isolation
|
|
126
126
|
end
|
|
127
127
|
```
|
|
128
128
|
|
|
129
129
|
```ruby
|
|
130
|
-
# app/rodauth/admin_rodauth_plugin.rb
|
|
130
|
+
# app/rodauth/admin_rodauth_plugin.rb: once per account type
|
|
131
131
|
configure do
|
|
132
132
|
session_key_prefix "admin_"
|
|
133
133
|
remember_cookie_key "_admin_remember"
|
|
134
134
|
end
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
Both are required. `session_key_prefix` namespaces *every* session key a configuration touches
|
|
137
|
+
Both are required. `session_key_prefix` namespaces *every* session key a configuration touches, the account id plus `authenticated_by`, `login_redirect`, `two_factor_auth_setup` and the rest. `session_isolation` uses that prefix to decide which entries belong to whom, and stops one configuration's login from clearing another's.
|
|
138
138
|
|
|
139
139
|
::: danger Do not also set `session_key`
|
|
140
|
-
An explicit `session_key` is **not** prefixed (`convert_session_key`, `rodauth/features/base.rb:686
|
|
140
|
+
An explicit `session_key` is **not** prefixed (`convert_session_key`, `rodauth/features/base.rb:686`, only the default value passes through it). Setting both leaves the account id on a different name from every other key, so they stop rotating together. A session that holds an account id but no `authenticated_by` makes Rodauth raise on *every request*, `logged_in_via_remember_key?` calls `nil.include?` (`remember.rb:175`).
|
|
141
141
|
|
|
142
142
|
A config without a prefix at all is also not isolated: its keys are Rodauth's unprefixed defaults, indistinguishable from any other unprefixed config's, so `session_isolation` carries nothing for it.
|
|
143
143
|
:::
|
|
144
144
|
|
|
145
145
|
::: details Why this is needed
|
|
146
|
-
Rodauth resets the session on every login
|
|
146
|
+
Rodauth resets the session on every login, including the `remember` feature's `load_memory` autologin, to defend against session fixation, and rodauth-rails implements that reset as a full `reset_session`.
|
|
147
147
|
|
|
148
148
|
Without `session_isolation`, signing into one portal wipes every other portal's session. And because `RodauthApp#route` calls `load_memory` for *every* configuration on *every* request, the evicted configuration immediately autologins from its remember cookie and evicts the new one right back. The last `load_memory` call in the route block wins permanently, so the other portal can never hold a session at all.
|
|
149
149
|
|
|
150
150
|
`session_isolation` carries only the *other configurations'* session entries across the reset. The session id is still rotated and application session data is still cleared, so session fixation is still defeated.
|
|
151
151
|
:::
|
|
152
152
|
|
|
153
|
-
Reading a raw Rodauth session key? Go through its accessor, never the literal
|
|
153
|
+
Reading a raw Rodauth session key? Go through its accessor, never the literal: with a prefix set, the literal is the wrong key:
|
|
154
154
|
|
|
155
155
|
```ruby
|
|
156
156
|
after_login do
|
|
@@ -159,9 +159,9 @@ after_login do
|
|
|
159
159
|
end
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
**Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin and `session_key_prefix` in each account plugin
|
|
162
|
+
**Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin and `session_key_prefix` in each account plugin, and **delete the existing `session_key "_x_session"` line** while you're there, for the reason above.
|
|
163
163
|
|
|
164
|
-
Every key name changes together, so old session cookies stop matching and are ignored
|
|
164
|
+
Every key name changes together, so old session cookies stop matching and are ignored (the safe outcome). Keeping the old `session_key` to "preserve" logins is what produces the half-migrated session that crashes.
|
|
165
165
|
|
|
166
166
|
In practice only *unremembered* sessions drop: `remember_cookie_key` is a cookie name and isn't touched by the prefix, so anyone holding a valid `_x_remember` cookie is restored by `load_memory` on their next request, which writes the new prefixed keys. Expect logouts for users who never ticked "Remember Me", not a full sign-out.
|
|
167
167
|
|
|
@@ -180,9 +180,9 @@ after_login { remember_login }
|
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
::: warning Compare the value, don't just check presence
|
|
183
|
-
`param_or_nil(remember_param)` on its own is a truthiness check, and `remember_param` is shared with Rodauth's `/remember` settings page
|
|
183
|
+
`param_or_nil(remember_param)` on its own is a truthiness check, and `remember_param` is shared with Rodauth's `/remember` settings page, which submits `forget` and `disable` as well as `remember`. A bare presence check means `remember=disable` would remember you. Compare against `remember_remember_param_value`, as above.
|
|
184
184
|
|
|
185
|
-
Plutonium's login form also passes `include_hidden: false` to `check_box`, so an unticked box sends nothing at all rather than Rails' default `"0"`. With the value comparison that's belt-and-braces rather than load-bearing (`"0" == "remember"` is already false)
|
|
185
|
+
Plutonium's login form also passes `include_hidden: false` to `check_box`, so an unticked box sends nothing at all rather than Rails' default `"0"`. With the value comparison that's belt-and-braces rather than load-bearing (`"0" == "remember"` is already false), but it keeps a junk param off the wire and keeps the form correct for anyone who reverts the hook to a bare truthiness check.
|
|
186
186
|
:::
|
|
187
187
|
|
|
188
188
|
Logged-in users can change the setting later on Rodauth's `/remember` page (Remember / Forget / Disable).
|
|
@@ -254,15 +254,15 @@ user
|
|
|
254
254
|
|
|
255
255
|
## Common issues
|
|
256
256
|
|
|
257
|
-
- **"You need to set up Rodauth"
|
|
258
|
-
- **Portal redirects to login even though you're authenticated
|
|
259
|
-
- **Email confirmation never arrives in development
|
|
260
|
-
- **Signing into one portal signs you out of another
|
|
257
|
+
- **"You need to set up Rodauth"**: run `pu:rodauth:install` first.
|
|
258
|
+
- **Portal redirects to login even though you're authenticated**: the portal mount constraint references a different Rodauth account than the portal's controller concern uses. Match them up.
|
|
259
|
+
- **Email confirmation never arrives in development**: Plutonium sets ActionMailer to `:test` by default. Check `tmp/letter_opener/` or your mail interceptor. In production, configure SMTP (see above).
|
|
260
|
+
- **Signing into one portal signs you out of another**: or one portal can never stay signed in at all. Missing `enable :session_isolation` and/or `session_key_prefix`; see [Multiple portals in one browser](#multiple-portals-in-one-browser).
|
|
261
261
|
|
|
262
262
|
## Related
|
|
263
263
|
|
|
264
|
-
- [Reference › Auth](/reference/auth/)
|
|
265
|
-
- [Authorization](./authorization)
|
|
266
|
-
- [Multi-tenancy](./multi-tenancy)
|
|
267
|
-
- [User invites](./user-invites)
|
|
268
|
-
- [User profile](./user-profile)
|
|
264
|
+
- [Reference › Auth](/reference/auth/): full auth surface
|
|
265
|
+
- [Authorization](./authorization): controlling who can do what AFTER login
|
|
266
|
+
- [Multi-tenancy](./multi-tenancy): entity scoping for SaaS apps
|
|
267
|
+
- [User invites](./user-invites): invitation-based onboarding
|
|
268
|
+
- [User profile](./user-profile): account-settings page
|
|
@@ -10,9 +10,9 @@ For each resource, decide who can create / read / update / destroy / run custom
|
|
|
10
10
|
|
|
11
11
|
Every policy controls three things:
|
|
12
12
|
|
|
13
|
-
1. **Action permissions
|
|
14
|
-
2. **Attribute permissions
|
|
15
|
-
3. **Collection scope
|
|
13
|
+
1. **Action permissions**: `create?`, `read?`, `update?`, `destroy?`, plus your custom action methods.
|
|
14
|
+
2. **Attribute permissions**: `permitted_attributes_for_create`, `_for_read`, etc.
|
|
15
|
+
3. **Collection scope**: `relation_scope` (which records show up in lists).
|
|
16
16
|
|
|
17
17
|
## 🚨 Critical
|
|
18
18
|
|
|
@@ -39,7 +39,7 @@ class PostPolicy < ResourcePolicy
|
|
|
39
39
|
end
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
These default to `false
|
|
42
|
+
These default to `false`, without an explicit override, nobody can create or read records.
|
|
43
43
|
|
|
44
44
|
### 3. Override derived methods only when rules differ
|
|
45
45
|
|
|
@@ -55,7 +55,7 @@ def destroy?
|
|
|
55
55
|
end
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
🚨 **`record` is the resource CLASS on collection routes.** `read?` backs both `show?` (called with the record) and `index?` (called with the class
|
|
58
|
+
🚨 **`record` is the resource CLASS on collection routes.** `read?` backs both `show?` (called with the record) and `index?` (called with the class: there is no single record to pass). The same goes for `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates. So `def read? = record.published?` raises `NoMethodError` the moment the index renders. Keep record-state rules out of these methods: filter what the list shows in `relation_scope` (step 6 below), and gate individual records in `show?` (which always receives the record). Record-action methods like `publish?` are safe; they are always evaluated against an instance.
|
|
59
59
|
|
|
60
60
|
### 4. Declare attribute permissions
|
|
61
61
|
|
|
@@ -69,8 +69,8 @@ def permitted_attributes_for_read
|
|
|
69
69
|
end
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
::: warning Index has no `record`
|
|
73
|
-
`permitted_attributes_for_index` runs at collection level
|
|
72
|
+
::: warning Index has no `record` instance
|
|
73
|
+
`permitted_attributes_for_index` runs at collection level, where `record` is the resource class. `_for_index` falls back to `_for_read` (and `_for_export` to `_for_index`), so if you write a `record`-dependent `_for_read`, you MUST also declare an explicit `_for_index` that never touches `record`. See [Reference › Behavior › Policies › Index has no record](/reference/behavior/policies#index-has-no-record).
|
|
74
74
|
:::
|
|
75
75
|
|
|
76
76
|
### 5. Custom action methods
|
|
@@ -87,7 +87,7 @@ end
|
|
|
87
87
|
|
|
88
88
|
The method name matches the action name plus `?`. Undefined methods return `false`.
|
|
89
89
|
|
|
90
|
-
### 6. Optionally filter the collection
|
|
90
|
+
### 6. Optionally filter the collection: `relation_scope`
|
|
91
91
|
|
|
92
92
|
```ruby
|
|
93
93
|
relation_scope do |relation|
|
|
@@ -95,7 +95,7 @@ relation_scope do |relation|
|
|
|
95
95
|
end
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
🚨 `default_relation_scope(relation)` must be called somewhere in the chain
|
|
98
|
+
🚨 `default_relation_scope(relation)` must be called somewhere in the chain, otherwise `verify_default_relation_scope_applied!` raises at runtime. Calling it explicitly here is safest. `super` works only when the parent policy also calls it.
|
|
99
99
|
|
|
100
100
|
## Common patterns
|
|
101
101
|
|
|
@@ -143,7 +143,7 @@ def update?
|
|
|
143
143
|
end
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
-
## Bulk action authorization
|
|
146
|
+
## Bulk action authorization: per record
|
|
147
147
|
|
|
148
148
|
```ruby
|
|
149
149
|
def bulk_archive?
|
|
@@ -154,7 +154,7 @@ end
|
|
|
154
154
|
- **Backend:** if any selected record fails, the entire request is rejected.
|
|
155
155
|
- **UI:** only actions ALL selected records support are shown (intersection).
|
|
156
156
|
|
|
157
|
-
Records come from `current_authorized_scope
|
|
157
|
+
Records come from `current_authorized_scope`; users can only select records they can access.
|
|
158
158
|
|
|
159
159
|
## Portal-specific policies
|
|
160
160
|
|
|
@@ -163,7 +163,7 @@ class PostPolicy < ResourcePolicy
|
|
|
163
163
|
def create? = user.present?
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
-
# Admin
|
|
166
|
+
# Admin: more permissive
|
|
167
167
|
class AdminPortal::PostPolicy < ::PostPolicy
|
|
168
168
|
include AdminPortal::ResourcePolicy
|
|
169
169
|
|
|
@@ -171,7 +171,7 @@ class AdminPortal::PostPolicy < ::PostPolicy
|
|
|
171
171
|
def permitted_attributes_for_create = %i[title content featured internal_notes]
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
# Public
|
|
174
|
+
# Public: read-only
|
|
175
175
|
class PublicPortal::PostPolicy < ::PostPolicy
|
|
176
176
|
include PublicPortal::ResourcePolicy
|
|
177
177
|
def create? = false
|
|
@@ -186,7 +186,7 @@ def permitted_associations
|
|
|
186
186
|
end
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
Drives the show-page tablist. Each named association must exist on the model AND be a registered Plutonium resource. See [Reference › Behavior › Policies › Association permissions](/reference/behavior/policies#association-permissions).
|
|
189
|
+
Drives the show-page tablist. Each named association must exist on the model AND be a registered Plutonium resource in every portal that uses the policy; a portal that doesn't register the child raises `ArgumentError ... is not a registered resource` on its show page. To add a tab in one portal only, put it in a portal-specific policy (`rails g pu:res:conn <Resource> --dest=<portal> --policy`). See [Reference › Behavior › Policies › Association permissions](/reference/behavior/policies#association-permissions).
|
|
190
190
|
|
|
191
191
|
::: warning Not for nested forms
|
|
192
192
|
`permitted_associations` is for show-page navigation tabs, NOT nested forms. Nested forms come from `nested_input :variants` in the definition. See [Reference › Resource › Definition › Nested inputs](/reference/resource/definition#nested-inputs).
|
|
@@ -194,7 +194,7 @@ Drives the show-page tablist. Each named association must exist on the model AND
|
|
|
194
194
|
|
|
195
195
|
## Multi-tenant scoping
|
|
196
196
|
|
|
197
|
-
When the portal sets `scope_to_entity Organization`, the inherited `relation_scope` automatically filters everything to the current org
|
|
197
|
+
When the portal sets `scope_to_entity Organization`, the inherited `relation_scope` automatically filters everything to the current org, no work in the policy. To add filters on top:
|
|
198
198
|
|
|
199
199
|
```ruby
|
|
200
200
|
relation_scope do |relation|
|
|
@@ -243,15 +243,15 @@ end
|
|
|
243
243
|
|
|
244
244
|
## Common issues
|
|
245
245
|
|
|
246
|
-
- **Undefined custom action policy method
|
|
247
|
-
- **`record.X` crashes during index
|
|
248
|
-
- **`verify_default_relation_scope_applied!` raises
|
|
249
|
-
- **`super` in `relation_scope
|
|
246
|
+
- **Undefined custom action policy method**: the button silently disappears (undefined returns `false`). Add `def my_action?` to the policy.
|
|
247
|
+
- **`record.X` crashes during index**: `record` is the resource class on index, not an instance. Add an explicit `permitted_attributes_for_index` that doesn't depend on `record`.
|
|
248
|
+
- **`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(...)`.
|
|
249
|
+
- **`super` in `relation_scope`**; works when you're extending a parent policy that itself calls `default_relation_scope`. If you're not sure (or you're inheriting from `Plutonium::Resource::Policy` directly), call `default_relation_scope(relation)` explicitly. The runtime check verifies `default_relation_scope` was hit somewhere: not that you wrote it in this class.
|
|
250
250
|
|
|
251
251
|
## Related
|
|
252
252
|
|
|
253
|
-
- [Reference › Behavior › Policies](/reference/behavior/policies)
|
|
254
|
-
- [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping)
|
|
255
|
-
- [Authentication](./authentication)
|
|
256
|
-
- [Multi-tenancy](./multi-tenancy)
|
|
257
|
-
- [Custom actions](./custom-actions)
|
|
253
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies): full policy surface
|
|
254
|
+
- [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping): `default_relation_scope`, multi-tenant patterns
|
|
255
|
+
- [Authentication](./authentication): who's the user in the first place
|
|
256
|
+
- [Multi-tenancy](./multi-tenancy): entity scoping setup
|
|
257
|
+
- [Custom actions](./custom-actions): defining the actions that need policy methods
|
|
@@ -13,7 +13,7 @@ Domain code (models, policies, definitions, interactions) lives in **feature pac
|
|
|
13
13
|
| **Feature** | Business logic | `pu:pkg:package NAME` | `blogging`, `billing`, `inventory` |
|
|
14
14
|
| **Portal** | Web interface | `pu:pkg:portal NAME` | `admin_portal`, `customer_portal`, `public_portal` |
|
|
15
15
|
|
|
16
|
-
🚨 Don't mix the two. Feature packages own the **domain code
|
|
16
|
+
🚨 Don't mix the two. Feature packages own the **domain code**: models, interactions, policies/definitions for resources owned by that feature. Portal packages own the **web surface**: controllers, routes, auth, and portal-specific policy/definition *overrides* for resources they expose.
|
|
17
17
|
|
|
18
18
|
## Feature package
|
|
19
19
|
|
|
@@ -59,12 +59,12 @@ rails g pu:pkg:portal admin --auth=user
|
|
|
59
59
|
|
|
60
60
|
Options:
|
|
61
61
|
|
|
62
|
-
- `--auth=NAME
|
|
63
|
-
- `--public
|
|
64
|
-
- `--byo
|
|
65
|
-
- `--scope=CLASS
|
|
62
|
+
- `--auth=NAME`: Rodauth account to authenticate with.
|
|
63
|
+
- `--public`: public access, no auth.
|
|
64
|
+
- `--byo`: bring your own auth.
|
|
65
|
+
- `--scope=CLASS`: entity class for multi-tenancy.
|
|
66
66
|
|
|
67
|
-
The generator mounts the engine for you
|
|
67
|
+
The generator mounts the engine for you: at `/admin` in this case, wrapped in `constraints Rodauth::Rails.authenticate(:user)` because you passed `--auth=user`. Open `packages/admin_portal/config/routes.rb` to see the generated mount.
|
|
68
68
|
|
|
69
69
|
### 2. Connect resources
|
|
70
70
|
|
|
@@ -87,7 +87,7 @@ Every file under `app/<kind>/blogging/` resolves to `Blogging::*`:
|
|
|
87
87
|
- `app/models/blogging/post.rb` → `Blogging::Post`
|
|
88
88
|
- `app/policies/blogging/post_policy.rb` → `Blogging::PostPolicy`
|
|
89
89
|
|
|
90
|
-
Each feature package gets base classes
|
|
90
|
+
Each feature package gets base classes, `Blogging::ApplicationRecord`, `Blogging::ResourceRecord`, `Blogging::ResourcePolicy`, `Blogging::ResourceDefinition`, `Blogging::ResourceInteraction`, that inherit from the main app's.
|
|
91
91
|
|
|
92
92
|
## Cross-package references
|
|
93
93
|
|
|
@@ -129,7 +129,7 @@ packages/
|
|
|
129
129
|
└── customer_portal/ # Portal: customer dashboard
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
The portals expose the features. A single feature can be exposed by multiple portals
|
|
132
|
+
The portals expose the features. A single feature can be exposed by multiple portals, usually with different policies and definitions per portal.
|
|
133
133
|
|
|
134
134
|
## Package loading
|
|
135
135
|
|
|
@@ -147,13 +147,13 @@ Loaded from `config/application.rb`. Migrations from all packages are picked up
|
|
|
147
147
|
## Per-portal overrides
|
|
148
148
|
|
|
149
149
|
```ruby
|
|
150
|
-
# Definition
|
|
150
|
+
# Definition: how fields render per portal
|
|
151
151
|
class AdminPortal::PostDefinition < ::PostDefinition
|
|
152
152
|
scope :pending_review
|
|
153
153
|
input :internal_notes, hint: "Not shown to the author"
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
-
# Policy
|
|
156
|
+
# Policy: which fields exist, and who may act
|
|
157
157
|
# `internal_notes` appears for admins because THIS permits it,
|
|
158
158
|
# not because the definition above mentions it.
|
|
159
159
|
class AdminPortal::PostPolicy < ::PostPolicy
|
|
@@ -166,13 +166,13 @@ end
|
|
|
166
166
|
|
|
167
167
|
## Common issues
|
|
168
168
|
|
|
169
|
-
- **Class not loading
|
|
170
|
-
- **Migration not running
|
|
171
|
-
- **Cross-package association fails
|
|
169
|
+
- **Class not loading**: namespace must match the directory: `app/models/blogging/post.rb` MUST be `Blogging::Post`.
|
|
170
|
+
- **Migration not running**: package migrations are auto-included. If they aren't running, check `config/packages.rb` is loaded from `application.rb`.
|
|
171
|
+
- **Cross-package association fails**: use `blogging/post:belongs_to` in `pu:res:scaffold`, OR manually set `class_name: "Blogging::Post"` on the `belongs_to`.
|
|
172
172
|
|
|
173
173
|
## Related
|
|
174
174
|
|
|
175
|
-
- [Reference › App › Packages](/reference/app/packages)
|
|
176
|
-
- [Reference › App › Portals](/reference/app/portals)
|
|
177
|
-
- [Adding resources](./adding-resources)
|
|
178
|
-
- [Authentication](./authentication)
|
|
175
|
+
- [Reference › App › Packages](/reference/app/packages): full package surface
|
|
176
|
+
- [Reference › App › Portals](/reference/app/portals): portal-specific configuration
|
|
177
|
+
- [Adding resources](./adding-resources): `pu:res:scaffold` and `pu:res:conn`
|
|
178
|
+
- [Authentication](./authentication): portal auth setup
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Custom Actions
|
|
2
2
|
|
|
3
|
-
Add buttons beyond CRUD
|
|
3
|
+
Add buttons beyond CRUD: Publish, Archive, Import, Send invitation, Bulk-update, etc.
|
|
4
4
|
|
|
5
5
|
## Goal
|
|
6
6
|
|
|
@@ -10,17 +10,17 @@ A button appears in the right place (show page / table row / index header / bulk
|
|
|
10
10
|
|
|
11
11
|
| Flavor | Use for |
|
|
12
12
|
|---|---|
|
|
13
|
-
| **Simple action
|
|
14
|
-
| **Interactive action
|
|
13
|
+
| **Simple action**: navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
|
|
14
|
+
| **Interactive action**: run an interaction class | Anything that *does* something (the common case) |
|
|
15
15
|
|
|
16
|
-
Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects
|
|
16
|
+
Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects, all for free.
|
|
17
17
|
|
|
18
|
-
## Quick recipe
|
|
18
|
+
## Quick recipe: interactive action
|
|
19
19
|
|
|
20
20
|
### 1. Write the interaction
|
|
21
21
|
|
|
22
22
|
```ruby
|
|
23
|
-
# app/models/post.rb
|
|
23
|
+
# app/models/post.rb: what publishing actually means
|
|
24
24
|
class Post < ApplicationRecord
|
|
25
25
|
def publish!(on: Time.current)
|
|
26
26
|
update!(published: true, published_at: on)
|
|
@@ -29,7 +29,7 @@ end
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
```ruby
|
|
32
|
-
# app/interactions/publish_post_interaction.rb
|
|
32
|
+
# app/interactions/publish_post_interaction.rb: the button in front of it
|
|
33
33
|
class PublishPostInteraction < ResourceInteraction
|
|
34
34
|
presents label: "Publish",
|
|
35
35
|
icon: Phlex::TablerIcons::Send,
|
|
@@ -51,7 +51,7 @@ Plutonium doesn't rescue it automatically. Always rescue when using `create!` /
|
|
|
51
51
|
:::
|
|
52
52
|
|
|
53
53
|
::: tip Why `publish!` is on the model
|
|
54
|
-
An interaction can only be built with a `view_context
|
|
54
|
+
An interaction can only be built with a `view_context`; it's a presentation object. A two-line `update!` inline in `execute` is fine while the button is the only caller; the moment a scheduled-publishing job wants the same behaviour it has to duplicate it or fake a view context. Full rule: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
55
55
|
:::
|
|
56
56
|
|
|
57
57
|
### 2. Register it in the definition
|
|
@@ -62,7 +62,7 @@ class PostDefinition < ResourceDefinition
|
|
|
62
62
|
end
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes
|
|
65
|
+
Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes; no need to declare `record_action: true`. See [Inferred visibility](#inferred-visibility) below.
|
|
66
66
|
|
|
67
67
|
### 3. Add a policy method
|
|
68
68
|
|
|
@@ -88,7 +88,7 @@ For `interaction:`-based actions, visibility flags are inferred from the interac
|
|
|
88
88
|
| `attribute :resources` (plural) | `bulk_action: true` → bulk toolbar |
|
|
89
89
|
| neither | `resource_action: true` → index page header |
|
|
90
90
|
|
|
91
|
-
User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden"
|
|
91
|
+
User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden"; the interaction's attribute shape is semantic:
|
|
92
92
|
|
|
93
93
|
```ruby
|
|
94
94
|
# Hide from per-row menu, keep on show page
|
|
@@ -127,7 +127,7 @@ class Company::InviteUserInteraction < ResourceInteraction
|
|
|
127
127
|
end
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
`Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight
|
|
130
|
+
`Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight, see the [full worked example](/reference/behavior/interactions#complete-example).
|
|
131
131
|
|
|
132
132
|
## Bulk actions
|
|
133
133
|
|
|
@@ -146,7 +146,7 @@ class BulkArchiveInteraction < ResourceInteraction
|
|
|
146
146
|
end
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
Policy
|
|
149
|
+
Policy: checked **per record** (fails the whole request if any record is unauthorized):
|
|
150
150
|
|
|
151
151
|
```ruby
|
|
152
152
|
def bulk_archive?
|
|
@@ -181,13 +181,13 @@ class BulkArchiveInteraction < ResourceInteraction
|
|
|
181
181
|
end
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
Nothing else about the action changes
|
|
184
|
+
Nothing else about the action changes: the definition, the policy method and the form are the same. Only the work moves.
|
|
185
185
|
|
|
186
186
|
Three things worth knowing:
|
|
187
187
|
|
|
188
188
|
- **The block is the run's class body, not `execute`.** The work runs later, in a job with no controller, so it cannot close over anything in the interaction. Its inputs arrive through `options`.
|
|
189
189
|
- **The user is sent back where they were**, and the index they land on shows a banner for the run with a link to its progress page.
|
|
190
|
-
- **Permissions are re-checked per record, at perform time
|
|
190
|
+
- **Permissions are re-checked per record, at perform time**: not replayed from dispatch. A permission revoked while the run is working stops applying to the rest of it.
|
|
191
191
|
|
|
192
192
|
Full detail, including file attributes, failure policies and resuming a crashed run: [Async Interactions](/reference/behavior/async-interactions).
|
|
193
193
|
|
|
@@ -212,8 +212,8 @@ end
|
|
|
212
212
|
|
|
213
213
|
## Immediate vs form
|
|
214
214
|
|
|
215
|
-
- **Immediate
|
|
216
|
-
- **Form
|
|
215
|
+
- **Immediate**: interaction has only `:resource` / `:resources` (no extra inputs). Browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom message"` or `confirmation: false` on the action.
|
|
216
|
+
- **Form**: interaction has additional `attribute` / `input`. Renders modal form first; no auto-confirmation (the form is the confirmation).
|
|
217
217
|
|
|
218
218
|
## Action options
|
|
219
219
|
|
|
@@ -231,10 +231,10 @@ action :name,
|
|
|
231
231
|
|
|
232
232
|
# Behavior
|
|
233
233
|
confirmation: "Are you sure?",
|
|
234
|
-
modal: :slideover, # :slideover / :centered
|
|
235
|
-
size: :lg, # :sm / :md / :lg / :xl / :auto / :full
|
|
234
|
+
modal: :slideover, # :slideover / :centered, overrides definition's modal mode
|
|
235
|
+
size: :lg, # :sm / :md / :lg / :xl / :auto / :full, overrides definition's modal size
|
|
236
236
|
|
|
237
|
-
# HTML attributes
|
|
237
|
+
# HTML attributes: author wins over the framework's on every key
|
|
238
238
|
link: {target: "_blank", rel: "noopener"}, # every <a> rendering (toolbar GET link, dropdown items, bulk links, card show link)
|
|
239
239
|
button: {data: {analytics: "archive"}} # the button_to <form> wrapper (non-GET toolbar rendering)
|
|
240
240
|
```
|
|
@@ -283,7 +283,7 @@ Every resource gets `:archive` automatically.
|
|
|
283
283
|
|
|
284
284
|
## Where the logic goes
|
|
285
285
|
|
|
286
|
-
Interactions are presentation objects
|
|
286
|
+
Interactions are presentation objects: they need a `view_context` to exist at all. So the reflex to reach for when an operation grows:
|
|
287
287
|
|
|
288
288
|
```ruby
|
|
289
289
|
# 🚫 Three interactions, three view contexts, none of it callable from a job
|
|
@@ -300,22 +300,22 @@ def execute
|
|
|
300
300
|
end
|
|
301
301
|
```
|
|
302
302
|
|
|
303
|
-
Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does
|
|
303
|
+
Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does, and neither has a view context to hand.
|
|
304
304
|
|
|
305
|
-
The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger
|
|
305
|
+
The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger**, and the destination is the model, Rails-style, not a new service layer. Chaining three interactions is usually the tell that you already crossed it. Full explanation: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
306
306
|
|
|
307
307
|
## Common issues
|
|
308
308
|
|
|
309
|
-
- **Action button missing
|
|
310
|
-
- **`ActiveRecord::RecordInvalid` crashes the action
|
|
311
|
-
- **Bulk action fails on some records
|
|
312
|
-
- **Confirmation prompt shows when you don't want one
|
|
313
|
-
- **The action times out on a large selection
|
|
309
|
+
- **Action button missing**: check the policy method (`def my_action?`). Undefined returns `false`.
|
|
310
|
+
- **`ActiveRecord::RecordInvalid` crashes the action**: not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
|
|
311
|
+
- **Bulk action fails on some records**: that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
|
|
312
|
+
- **Confirmation prompt shows when you don't want one**: pass `confirmation: false` on the action.
|
|
313
|
+
- **The action times out on a large selection**: the work is running inside the request. Move it to a background run with `async`, above.
|
|
314
314
|
|
|
315
315
|
## Related
|
|
316
316
|
|
|
317
|
-
- [Reference › Resource › Actions](/reference/resource/actions)
|
|
318
|
-
- [Reference › Behavior › Interactions](/reference/behavior/interactions)
|
|
319
|
-
- [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions)
|
|
320
|
-
- [Reference › Behavior › Policies](/reference/behavior/policies)
|
|
321
|
-
- [Authorization](./authorization)
|
|
317
|
+
- [Reference › Resource › Actions](/reference/resource/actions): full action options and bulk patterns
|
|
318
|
+
- [Reference › Behavior › Interactions](/reference/behavior/interactions): interaction class anatomy
|
|
319
|
+
- [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions): `async`, progress pages, resuming a crashed run
|
|
320
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies): `def <action>?` methods
|
|
321
|
+
- [Authorization](./authorization): policy patterns
|