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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Profile
|
|
2
2
|
|
|
3
|
-
Manages Rodauth account settings as a Plutonium resource
|
|
3
|
+
Manages Rodauth account settings as a Plutonium resource: users view/edit personal fields and access Rodauth security features (change password, 2FA, etc.) on one page.
|
|
4
4
|
|
|
5
5
|
## 🚨 Critical
|
|
6
6
|
|
|
7
|
-
- **Profile association is always `:profile`** regardless of the model class
|
|
8
|
-
- **Profile needs `pu:profile:conn
|
|
9
|
-
- **
|
|
7
|
+
- **Profile association is always `:profile`** regardless of the model class: `current_user.profile`, `build_profile`, `params.require(:profile)` always work. `pu:profile:conn` hardcodes `current_user.profile` in the code it generates.
|
|
8
|
+
- **Profile needs `pu:profile:conn`.** Without it: no route, no `profile_url` override, no user-menu link.
|
|
9
|
+
- **Check an existing profile policy for `update?`.** Policies generated by older versions lack `def update? = true`, which leaves the profile read-only once it exists. See [What `pu:profile:conn` generates](#what-pu-profile-conn-generates).
|
|
10
10
|
|
|
11
11
|
## Quick setup
|
|
12
12
|
|
|
@@ -42,7 +42,7 @@ rails g pu:profile:install AccountSettings bio:text --dest=main_app
|
|
|
42
42
|
|
|
43
43
|
## What gets created
|
|
44
44
|
|
|
45
|
-
By default the model is `{UserModel}Profile`
|
|
45
|
+
By default the model is `{UserModel}Profile` (`UserProfile`, `StaffUserProfile`, etc.), derived from `--user-model`.
|
|
46
46
|
|
|
47
47
|
```
|
|
48
48
|
app/models/[package/]user_profile.rb
|
|
@@ -59,10 +59,10 @@ has_one :profile, class_name: "UserProfile", dependent: :destroy
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
::: warning Association name is fixed
|
|
62
|
-
Even when the class is `StaffUserProfile`, the association is `:profile`. Don't rename it
|
|
62
|
+
Even when the class is `StaffUserProfile`, the association is `:profile`. Don't rename it: `current_user.profile`, `build_profile`, `params.require(:profile)` all assume this.
|
|
63
63
|
:::
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
If you reuse a profile model whose user association has a different name (e.g. `has_one :user_profile`), add `has_one :profile, class_name: "UserProfile"` to the user model rather than editing `current_user.profile` out of the generated policy and `profile_url`. Then connect it with `pu:profile:conn UserProfile --dest=<portal>`.
|
|
66
66
|
|
|
67
67
|
## The `SecuritySection` component
|
|
68
68
|
|
|
@@ -78,7 +78,7 @@ Dynamically lists Rodauth security links based on which features are enabled on
|
|
|
78
78
|
| `active_sessions` | Active Sessions |
|
|
79
79
|
| `close_account` | Close Account |
|
|
80
80
|
|
|
81
|
-
If a feature isn't enabled on the account, its link doesn't render
|
|
81
|
+
If a feature isn't enabled on the account, its link doesn't render; no configuration needed.
|
|
82
82
|
|
|
83
83
|
To customize (e.g. add chrome, reorder), override `ShowPage#render_after_content`:
|
|
84
84
|
|
|
@@ -96,40 +96,77 @@ end
|
|
|
96
96
|
|
|
97
97
|
See [UI › Pages](/reference/ui/pages) for page-class customization.
|
|
98
98
|
|
|
99
|
-
##
|
|
99
|
+
## Connecting to a portal
|
|
100
|
+
|
|
101
|
+
`pu:profile:conn` registers the profile as a **singular** resource: `/profile` (no `:id`), and overrides the `profile_url` helper:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
rails g pu:profile:conn --dest=customer_portal
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This is what makes the profile visible. Without it, the model exists but has no route in any portal.
|
|
108
|
+
|
|
109
|
+
### What `pu:profile:conn` generates
|
|
110
|
+
|
|
111
|
+
It runs `pu:res:conn` with `--singular --policy --definition`, then injects:
|
|
112
|
+
|
|
113
|
+
- **Policy:** `relation_scope { skip_default_relation_scope!; relation.where(user: user) }` (a profile belongs to the user, not the portal's entity), `create? = user.profile.nil?`, `update? = true`, `destroy? = false`, and `permitted_attributes_for_create` minus `:user`.
|
|
114
|
+
- **Controller:** `resource_params` merged with `user: current_user`, so the owner is set server-side.
|
|
115
|
+
- **Definition:** a `ShowPage` whose `render_after_content` renders `Plutonium::Profile::SecuritySection`.
|
|
116
|
+
- **Portal controller concern:** `profile_url`, which points at the profile or, when the user has none yet, at the new-profile form.
|
|
117
|
+
|
|
118
|
+
The explicit `update?` matters: the base policy's `update?` delegates to `create?`, which is false once the profile exists. Policies generated by older versions lack it and leave the profile read-only, so add it if it's missing:
|
|
100
119
|
|
|
101
120
|
```ruby
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
def update?
|
|
122
|
+
true
|
|
123
|
+
end
|
|
124
|
+
```
|
|
104
125
|
|
|
105
|
-
|
|
106
|
-
|
|
126
|
+
## Users without a profile row
|
|
127
|
+
|
|
128
|
+
The generated `profile_url` sends a user with no profile to the new-profile form, and `create?` allows exactly one, so a missing row does not break the route. It does mean the first visit is a "create profile" form, and the `SecuritySection` only appears after it is saved. If you want the show page from the first click, create the row up front:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class User < ApplicationRecord
|
|
132
|
+
after_create :create_profile
|
|
107
133
|
end
|
|
108
134
|
```
|
|
109
135
|
|
|
110
|
-
|
|
136
|
+
and backfill existing users:
|
|
111
137
|
|
|
112
138
|
```bash
|
|
113
|
-
rails runner "User.find_each
|
|
139
|
+
rails runner "User.find_each { |u| u.profile || u.create_profile }"
|
|
114
140
|
```
|
|
115
141
|
|
|
116
|
-
|
|
142
|
+
Skip this when the profile has required fields the user must fill in, or when existing code creates profiles itself (the unique `user_id` index makes a second insert fail).
|
|
117
143
|
|
|
118
|
-
|
|
144
|
+
## Linking to the profile
|
|
119
145
|
|
|
120
|
-
|
|
121
|
-
|
|
146
|
+
Nothing to add. `Plutonium::Auth::Rodauth` defines `profile_url` returning `nil`, and the topbar avatar menu renders a "Profile" entry whenever it returns a URL. `pu:profile:conn` overrides it in the portal's controller concern, which is what makes the link appear. Elsewhere:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
link_to("Profile", profile_url) if profile_url
|
|
122
150
|
```
|
|
123
151
|
|
|
124
|
-
|
|
152
|
+
## Account features on the profile page
|
|
125
153
|
|
|
126
|
-
|
|
154
|
+
Per-user settings that aren't part of the profile row (API tokens, connected accounts, notification preferences) are discovered from the profile page. Link them from the same `ShowPage` hook that renders `SecuritySection`, instead of adding custom items to the sidebar or overriding the topbar partial:
|
|
127
155
|
|
|
128
156
|
```ruby
|
|
129
|
-
|
|
157
|
+
class ShowPage < ShowPage
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def render_after_content
|
|
161
|
+
render Plutonium::Profile::SecuritySection.new
|
|
162
|
+
div(class: "mt-8") do
|
|
163
|
+
a(href: resource_url_for(ApiToken), class: "font-medium text-[var(--pu-text)] hover:underline") { "API tokens" }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
130
167
|
```
|
|
131
168
|
|
|
132
|
-
|
|
169
|
+
A resource like `ApiToken` belongs to the user, not the portal's entity, so connect it with `pu:res:conn ApiToken --dest=<portal> --policy` and give it the same shape the profile has: `skip_default_relation_scope!` plus `relation.where(user: user)` in the policy, and `resource_params` merged with `user: current_user` in the controller.
|
|
133
170
|
|
|
134
171
|
## Customizing the definition
|
|
135
172
|
|
|
@@ -173,13 +210,14 @@ rails g pu:profile:conn StaffUserProfile --dest=admin_portal
|
|
|
173
210
|
|
|
174
211
|
## Gotchas
|
|
175
212
|
|
|
176
|
-
-
|
|
177
|
-
-
|
|
178
|
-
- **
|
|
179
|
-
- **
|
|
213
|
+
- **Profile is read-only after creation.** The policy predates the generated `update?`, so `update?` falls back to `create?` (`user.profile.nil?`). Add `def update? = true`.
|
|
214
|
+
- **First visit shows a "create profile" form.** The user has no profile row yet. Not an error; add `after_create :create_profile` plus a backfill if they should land on the show page.
|
|
215
|
+
- **No Profile link in the user menu.** `profile_url` still returns the default `nil` because the profile isn't connected to this portal. Run `pu:profile:conn --dest=<portal>`.
|
|
216
|
+
- **Custom resource name**: pass it as the first positional argument to `pu:profile:install`. The association is still `:profile`.
|
|
217
|
+
- **SecuritySection shows nothing**: none of the relevant Rodauth features are enabled on the account. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
|
|
180
218
|
|
|
181
219
|
## Related
|
|
182
220
|
|
|
183
|
-
- [Accounts](./accounts)
|
|
184
|
-
- [Resource › Definition](/reference/resource/definition)
|
|
221
|
+
- [Accounts](./accounts): Rodauth feature flags that gate SecuritySection links
|
|
222
|
+
- [Resource › Definition](/reference/resource/definition): customizing the profile definition
|
|
185
223
|
- [App › Generators › Profile generators](/reference/app/generators#profile-generators)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Async Interactions
|
|
2
2
|
|
|
3
3
|
::: warning Experimental
|
|
4
|
-
Async interactions are experimental
|
|
4
|
+
Async interactions are experimental: the DSL and behavior may change in a future release.
|
|
5
5
|
:::
|
|
6
6
|
|
|
7
|
-
For the task-oriented walkthrough
|
|
7
|
+
For the task-oriented walkthrough (declaring the action, its policy, and where it appears), start with the [Custom actions guide](/guides/custom-actions). This page is the reference.
|
|
8
8
|
|
|
9
9
|
`async` turns an interaction from "does the work inline" into "persists a run, enqueues it, and redirects to it."
|
|
10
10
|
|
|
@@ -42,7 +42,7 @@ The flag gates the migration, not just the behaviour: while it is off the runs m
|
|
|
42
42
|
```ruby
|
|
43
43
|
class Blogging::ArchivePosts < ResourceInteraction
|
|
44
44
|
presents label: "Archive", icon: Phlex::TablerIcons::Archive
|
|
45
|
-
attribute :resources # bulk
|
|
45
|
+
attribute :resources # bulk: perform_on runs once per record
|
|
46
46
|
attribute :reason, :string
|
|
47
47
|
|
|
48
48
|
async do
|
|
@@ -59,7 +59,7 @@ One class. There is no second file, and no name to invent for the run.
|
|
|
59
59
|
|
|
60
60
|
### Why the block declares `perform_on` rather than executing
|
|
61
61
|
|
|
62
|
-
The block is **not** the body of `#execute`. The work happens later, in a job, in a process with no controller, no request and no `view_context
|
|
62
|
+
The block is **not** the body of `#execute`. The work happens later, in a job, in a process with no controller, no request and no `view_context`, so it cannot be a closure over anything in the interaction, which is the same reason the row records the initiator and tenant instead of serialising them. So the block declares a `Plutonium::Interaction::Async::Run` subclass with exactly the API a standalone run has, and the validated attributes arrive through `options`.
|
|
63
63
|
|
|
64
64
|
`def` opens a fresh scope, so those method bodies cannot accidentally capture the interaction's locals.
|
|
65
65
|
|
|
@@ -82,13 +82,13 @@ async do
|
|
|
82
82
|
end
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
`options` is a JSON column, so dispatch writes it through `ActiveJob::Arguments`. Primitives are stored verbatim
|
|
85
|
+
`options` is a JSON column, so dispatch writes it through `ActiveJob::Arguments`. Primitives are stored verbatim (a String stays a String in the column) and only values needing one get a serializer envelope, so a `Date` arrives as a `Date` and a `BigDecimal` as a `BigDecimal` rather than as strings. Hosts can register their own serializers.
|
|
86
86
|
|
|
87
87
|
An attribute that can't be carried at all is refused at dispatch, naming the interaction, rather than being written to a row whose work then fails deep in a job.
|
|
88
88
|
|
|
89
89
|
### Files
|
|
90
90
|
|
|
91
|
-
A file can't ride the options column: JSON has no files, and the request's tempfile is deleted on the way out. So an uploaded file is staged to its backend's cache at dispatch and carried as the token
|
|
91
|
+
A file can't ride the options column: JSON has no files, and the request's tempfile is deleted on the way out. So an uploaded file is staged to its backend's cache at dispatch and carried as the token: `options["import_file"]` is that token, a String.
|
|
92
92
|
|
|
93
93
|
Use `attachment` to read it back:
|
|
94
94
|
|
|
@@ -110,16 +110,16 @@ end
|
|
|
110
110
|
|
|
111
111
|
#### Per-field backend and uploader
|
|
112
112
|
|
|
113
|
-
`backend:` and `uploader:` are read off the attribute's own `input` declaration
|
|
113
|
+
`backend:` and `uploader:` are read off the attribute's own `input` declaration, the same options in the same place a wizard step reads them from:
|
|
114
114
|
|
|
115
115
|
```ruby
|
|
116
116
|
attribute :import_file
|
|
117
117
|
input :import_file, as: :uppy, uploader: Catalog::ImportUploader
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
The uploader's `Attacher.validate` rules run when the interaction validates, so a file that breaks them **fails the form
|
|
120
|
+
The uploader's `Attacher.validate` rules run when the interaction validates, so a file that breaks them **fails the form**: the submitter sees a field error and nothing is dispatched. Without that the interaction would validate clean, dispatch, and the author's `validate_max_size` would surface as a run failure on a page the submitter has already left. A no-op for ActiveStorage fields and for uploaders declaring no rules.
|
|
121
121
|
|
|
122
|
-
Validating means staging first, since Shrine validates an assigned cached file
|
|
122
|
+
Validating means staging first, since Shrine validates an assigned cached file, so an upload that fails validation has still been written to the cache, and is reaped by the backend's own unattached-cache cleanup. Wizards make the same trade on every step submit.
|
|
123
123
|
|
|
124
124
|
Where no `backend:` is declared: `config.async_interactions.attachment_backend`, then `config.attachment_backend`, then auto-detection (active_shrine loaded → Shrine, else ActiveStorage). Same layering wizards use.
|
|
125
125
|
|
|
@@ -139,7 +139,7 @@ class Blogging::ArchivePosts < ResourceInteraction
|
|
|
139
139
|
end
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
-
Passing both a class and a block raises `ArgumentError
|
|
142
|
+
Passing both a class and a block raises `ArgumentError`: the block *is* a run class, so there is nothing to combine.
|
|
143
143
|
|
|
144
144
|
`async` must be the only thing that defines `#execute` on the class. Declaring it on a class that already has its own `execute` raises `ArgumentError` at load time (an interaction either executes inline or runs async, never both).
|
|
145
145
|
|
|
@@ -149,7 +149,7 @@ Nothing is passed in explicitly. Everything the run needs is already reachable f
|
|
|
149
149
|
|
|
150
150
|
- **Targets.** `attribute :resource` / `attribute :resources`, already narrowed by the controller's policy scope, stored as ids (`target_ids`) and re-resolved at perform time, never serialized as records.
|
|
151
151
|
- **Initiator + tenant.** `current_user` / `current_scoped_entity`, two of the things every Plutonium policy authorizes on.
|
|
152
|
-
- **Nested-route parent.** `current_parent` and `current_nested_association
|
|
152
|
+
- **Nested-route parent.** `current_parent` and `current_nested_association`: `/orgs/1/posts/5/comments` records `(Post#5, :comments)`. Both halves or neither, since `Policy#default_relation_scope` raises on one alone. This is the third policy input, and it is not optional detail: that method picks **one** branch, parent *or* entity, so a nested run without its parent re-derives targets under the tenant where dispatch used the parent, wider than the scope the initiator was shown. It also leaves a host predicate reading `parent` looking at `nil`, which (being declared `optional: true`) answers false rather than raising, refusing every target for a reason that names the predicate instead of the missing context.
|
|
153
153
|
- **The policy actually resolved.** `policy_class_name`, not an inferred `"#{Model}Policy"` (a namespaced portal or an STI fallback would make that guess wrong), plus `policy_action`, the predicate dispatch checked (e.g. `"archive?"`).
|
|
154
154
|
- **`authorization_namespace`.** The portal's module name, so perform-time policy lookup finds the same narrowed policy dispatch did.
|
|
155
155
|
|
|
@@ -159,10 +159,10 @@ An opaque (untargeted) run records none of the target/policy columns: there's no
|
|
|
159
159
|
|
|
160
160
|
The job has no controller, no request, no `current_user`. `Async::Context` rebuilds the authorization triple from the row and re-checks it from scratch. It does not trust anything the dispatching request already decided:
|
|
161
161
|
|
|
162
|
-
- The **scope** check re-runs `Post.associated_with(tenant)` style filtering
|
|
162
|
+
- The **scope** check re-runs `Post.associated_with(tenant)` style filtering (or, for a nested dispatch, the parent's association). A target that left the tenant or the parent, or was deleted, between dispatch and perform is reported `missing`/`unauthorized`, never silently skipped.
|
|
163
163
|
- The **predicate** check re-asks the policy the same question dispatch asked (`policy_action`), per target, immediately before `perform_on`, not once up front. An initiator whose permission was revoked mid-run stops applying to the remaining targets.
|
|
164
164
|
- A **policy mismatch** (the class renamed/re-parented/re-namespaced since dispatch) refuses to run at all, rather than silently authorizing under a different policy than the initiator was ever subject to.
|
|
165
|
-
- A **deleted subject**
|
|
165
|
+
- A **deleted subject** (initiator, tenant or parent) refuses the run. In each case the association nils out, and nil reads as "there was never one": no tenant, or not a nested dispatch. Both of those drop a filter rather than narrowing, so the `*_type` column is what tells "carries none" apart from "carries one that is gone".
|
|
166
166
|
|
|
167
167
|
This is deliberate and asymmetric: a resolution failure always fails **closed** (refuse / report missing), never open (never "assume permitted").
|
|
168
168
|
|
|
@@ -201,12 +201,12 @@ end
|
|
|
201
201
|
|
|
202
202
|
`controller_for` is required: the controller's name doesn't match `Run`'s real, namespaced class, so inference can't find it on its own. No policy/definition files are generated: `Plutonium::Interaction::Async::RunPolicy`/`Async::RunDefinition` already resolve automatically (Rails matches `Plutonium::Interaction::Async::RunDefinition` by the exact class name, and ActionPolicy's own lookup finds `AsyncRunPolicy` the same way).
|
|
203
203
|
|
|
204
|
-
If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` (`--schedule` to override the default `every 15 minutes`)
|
|
204
|
+
If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` (`--schedule` to override the default `every 15 minutes`), see [Stalled runs and ReapJob](#stalled-runs-and-reapjob). Idempotent, so running the generator against a second portal doesn't duplicate the entry.
|
|
205
205
|
|
|
206
206
|
A registered run resource gets, for free:
|
|
207
207
|
|
|
208
208
|
- **A progress page.** The show page IS the progress page. It self-refreshes via polling (not ActionCable) while `state` is `pending`/`running`, and stops carrying the poll once the run settles, so a finished run is a static page, not a background request per viewer.
|
|
209
|
-
- **A self-refreshing index.** The runs index polls on the same terms as the progress page: one frame around the whole collection, armed only while some run is still working, disarmed the moment none is. One request per interval regardless of page size, and it re-fetches the URL you are on, so filters, sort and page survive the refresh. A frame per row is not an option
|
|
209
|
+
- **A self-refreshing index.** The runs index polls on the same terms as the progress page: one frame around the whole collection, armed only while some run is still working, disarmed the moment none is. One request per interval regardless of page size, and it re-fetches the URL you are on, so filters, sort and page survive the refresh. A frame per row is not an option: `turbo-frame` is not in the content model of `tr`, so the parser hoists it out of the table before Turbo sees it.
|
|
210
210
|
- **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
|
|
211
211
|
- **Tenant scoping.** A run's `associated_with` scope filters on the tenant it was dispatched in (recorded on the row), not on walking the object graph, since the two polymorphic tenant columns make the generic scope unusable.
|
|
212
212
|
- **A humanized target label.** `run.target_label` reads the target class through `model_name.human` ("Post", not "Blogging::Post"), falling back to the raw string if that class has since been renamed or removed.
|
|
@@ -235,7 +235,7 @@ production:
|
|
|
235
235
|
schedule: every 15 minutes
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
-
Without Solid Queue
|
|
238
|
+
Without Solid Queue, or for another scheduler like `whenever`, add it yourself:
|
|
239
239
|
|
|
240
240
|
```ruby
|
|
241
241
|
# whenever gem
|
|
@@ -249,13 +249,13 @@ A 15 to 30 minute cadence is reasonable against the default 1-hour `stall_after`
|
|
|
249
249
|
::: warning This is a time heuristic, not a lease
|
|
250
250
|
Resuming is based on elapsed time, not a true distributed lock. A run that is merely slow (not dead) and happens to cross `stall_after` gets resumed too.
|
|
251
251
|
|
|
252
|
-
What bounds that is `lock_version`. Both the reaper's resume and the executor's claim bump it, so the worker that is still alive holds a version the row no longer has: its very next write raises `ActiveRecord::StaleObjectError`, and the executor treats that as "no longer mine"
|
|
252
|
+
What bounds that is `lock_version`. Both the reaper's resume and the executor's claim bump it, so the worker that is still alive holds a version the row no longer has: its very next write raises `ActiveRecord::StaleObjectError`, and the executor treats that as "no longer mine": it abandons the pass without marking the run failed and without overwriting the new worker's progress. Two things it deliberately does not do: it cannot interrupt a `perform_on` already in flight, so one target may be applied twice (once by each side), and it cannot roll back what the superseded worker already committed. Set `stall_after` well above this app's slowest legitimate run: the fence bounds the damage of a bad value, it does not make one free.
|
|
253
253
|
|
|
254
254
|
### Long work must say it is alive
|
|
255
255
|
|
|
256
256
|
`stall_after` is a **silence** threshold, not a runtime limit. Every write the executor makes refreshes the clock, so a targeted run with quick targets heartbeats once per target for free. Two shapes get nothing, and both are exactly the "long-running task" case:
|
|
257
257
|
|
|
258
|
-
- **Opaque work.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and
|
|
258
|
+
- **Opaque work.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and, having no `handled_target_ids` to resume from, re-runs **from scratch**.
|
|
259
259
|
- **A single `perform_on`** that outlives `stall_after` on its own.
|
|
260
260
|
|
|
261
261
|
Call `heartbeat!` from inside such work:
|
|
@@ -265,7 +265,7 @@ class Billing::ReissueInvoicesRun < Plutonium::Interaction::Async::Run
|
|
|
265
265
|
def perform
|
|
266
266
|
invoices.each_slice(500) do |slice|
|
|
267
267
|
reissue(slice)
|
|
268
|
-
heartbeat! # "still working"
|
|
268
|
+
heartbeat! # "still working", resets the stall clock
|
|
269
269
|
end
|
|
270
270
|
end
|
|
271
271
|
end
|
|
@@ -273,11 +273,11 @@ end
|
|
|
273
273
|
|
|
274
274
|
This is deliberately not automatic. A background thread would have to guess a cadence, and would go on reporting a wedged worker as healthy; only the work itself knows it is making progress.
|
|
275
275
|
|
|
276
|
-
`heartbeat!` also **answers**. The write is conditional on this worker still holding the row's `lock_version`, so one that was superseded inside a long `perform` raises `ActiveRecord::StaleObjectError` at its next beat and abandons the pass
|
|
276
|
+
`heartbeat!` also **answers**. The write is conditional on this worker still holding the row's `lock_version`, so one that was superseded inside a long `perform` raises `ActiveRecord::StaleObjectError` at its next beat and abandons the pass; for opaque work that is the only place it can find out before `finish!`. Under `:transactional` the beat is inside the batch transaction like everything else, so it stays invisible to the reaper until the batch commits.
|
|
277
277
|
|
|
278
278
|
### Queue-level concurrency
|
|
279
279
|
|
|
280
|
-
On a queue that provides ActiveJob concurrency controls
|
|
280
|
+
On a queue that provides ActiveJob concurrency controls (Solid Queue does, whenever it is in the bundle), the run job also declares a per-run semaphore, and the reaper a global one:
|
|
281
281
|
|
|
282
282
|
| Job | Key | Limit | Duration |
|
|
283
283
|
|---|---|---|---|
|
|
@@ -286,10 +286,10 @@ On a queue that provides ActiveJob concurrency controls — Solid Queue does, wh
|
|
|
286
286
|
|
|
287
287
|
This is declared only when the method exists; Plutonium depends on no queue backend, and nothing above requires one.
|
|
288
288
|
|
|
289
|
-
It is not a second copy of the claim. `claim!` can only *refuse* a duplicate delivery, and only once a worker is already running it
|
|
289
|
+
It is not a second copy of the claim. `claim!` can only *refuse* a duplicate delivery, and only once a worker is already running it, by which point a reaper's resume has re-entered `perform_on` for one target. The semaphore removes the race a step earlier: the second delivery waits instead of racing, so on a queue that supports it the double-applied target does not happen at all. Keying the run job on `stall_after` matters here: Solid Queue's 3-minute default would expire the semaphore mid-batch on any run big enough to be worth dispatching.
|
|
290
290
|
:::
|
|
291
291
|
|
|
292
292
|
## Related
|
|
293
293
|
|
|
294
|
-
- [Interactions](/reference/behavior/interactions)
|
|
295
|
-
- [Policies](/reference/behavior/policies)
|
|
294
|
+
- [Interactions](/reference/behavior/interactions): `async` is declared inside `Plutonium::Resource::Interaction`; everything else about inputs, validation and outcomes is unchanged.
|
|
295
|
+
- [Policies](/reference/behavior/policies): the policy dispatch checks and the job re-checks are the same predicate.
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
# Controller
|
|
2
2
|
|
|
3
|
-
Plutonium controllers ship full CRUD out of the box; nearly all customization belongs elsewhere. The controller stays thin
|
|
3
|
+
Plutonium controllers ship full CRUD out of the box; nearly all customization belongs elsewhere. The controller stays thin; when in doubt, push the change to the definition (UI) or the policy (auth).
|
|
4
4
|
|
|
5
5
|
## 🚨 Critical
|
|
6
6
|
|
|
7
7
|
- **Don't override CRUD actions.** Use hooks (`resource_params`, `redirect_url_after_submit`, presentation hooks). Overriding `create` / `update` usually breaks authorization, params filtering, or both.
|
|
8
|
-
- **Named custom routes only.** Always pass `as
|
|
9
|
-
- **Authorization is verified after every action
|
|
8
|
+
- **Named custom routes only.** Always pass `as:`. Without it, `resource_url_for` can't build URLs (critical for nested resources).
|
|
9
|
+
- **Authorization is verified after every action.** If you write a custom action, you MUST call `authorize_current!` yourself or use `skip_verify_authorize_current` / `skip_verify_authorize_current!`.
|
|
10
10
|
- **Cross-resource queries: use `authorized_resource_scope(OtherModel)`, not raw `where`.** Otherwise you bypass that resource's tenancy and visibility rules.
|
|
11
11
|
|
|
12
12
|
## Base classes
|
|
13
13
|
|
|
14
14
|
```ruby
|
|
15
|
-
# app/controllers/resource_controller.rb
|
|
15
|
+
# app/controllers/resource_controller.rb (installed once)
|
|
16
16
|
class ResourceController < ApplicationController
|
|
17
17
|
include Plutonium::Resource::Controller
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# app/controllers/posts_controller.rb
|
|
20
|
+
# app/controllers/posts_controller.rb (per resource, generated)
|
|
21
21
|
class PostsController < ::ResourceController
|
|
22
|
-
# Empty
|
|
22
|
+
# Empty: all CRUD inherited
|
|
23
23
|
end
|
|
24
24
|
```
|
|
25
25
|
|
|
@@ -59,10 +59,10 @@ Plus interactive-action routes for every action declared in the definition (`/po
|
|
|
59
59
|
|---|---|
|
|
60
60
|
| Field rendering (inputs, displays, columns) | [Definition](/reference/resource/definition) |
|
|
61
61
|
| Search, filters, scopes, sorting | [Query](/reference/resource/query) |
|
|
62
|
-
| Custom operations (publish, archive, import)
|
|
63
|
-
| The operation itself, once a job/API/task also needs it | The **model
|
|
62
|
+
| Custom operations (publish, archive, import): the *button* | [Interaction](./interactions) + action on definition |
|
|
63
|
+
| The operation itself, once a job/API/task also needs it | The **model**, see [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for) |
|
|
64
64
|
| Authorization rules | [Policy](./policies) |
|
|
65
|
-
| Form / show / page chrome | Definition (custom page classes
|
|
65
|
+
| Form / show / page chrome | Definition (custom page classes, see [UI › Pages](/reference/ui/pages)) |
|
|
66
66
|
| **Custom redirect logic** | **[Controller hook](#redirect-hooks)** |
|
|
67
67
|
| **Param munging** | **[Controller hook](#parameter-hook)** |
|
|
68
68
|
| **Custom index query shape** | **[Controller hook](#index-query-hook)** |
|
|
@@ -128,7 +128,7 @@ def present_scoped_entity? = true
|
|
|
128
128
|
def submit_scoped_entity? = true
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
Conditional pattern
|
|
131
|
+
Conditional pattern: show parent only when accessed standalone:
|
|
132
132
|
|
|
133
133
|
```ruby
|
|
134
134
|
def present_parent?
|
|
@@ -156,9 +156,9 @@ end
|
|
|
156
156
|
|
|
157
157
|
## Custom actions
|
|
158
158
|
|
|
159
|
-
Prefer **interactive actions** (definition + interaction
|
|
159
|
+
Prefer **interactive actions** (definition + interaction, see [Resource › Actions](/reference/resource/actions)) for anything a user triggers from a page: you get the button, the policy check, the form, and the flash for free. The only reasons to hand-write a controller action: unusual response shapes, external service callbacks, etc.
|
|
160
160
|
|
|
161
|
-
Either way the *operation* should be a named method on the model
|
|
161
|
+
Either way the *operation* should be a named method on the model; that's what keeps it reachable from a job or an API. The controller and the interaction are two different front doors to the same `post.publish!`.
|
|
162
162
|
|
|
163
163
|
```ruby
|
|
164
164
|
class PostsController < ::ResourceController
|
|
@@ -179,7 +179,7 @@ end
|
|
|
179
179
|
```
|
|
180
180
|
|
|
181
181
|
::: warning Always name custom routes
|
|
182
|
-
Without `as:`, `resource_url_for` can't build the URL
|
|
182
|
+
Without `as:`, `resource_url_for` can't build the URL, which is particularly critical for nested resources.
|
|
183
183
|
:::
|
|
184
184
|
|
|
185
185
|
## Key methods
|
|
@@ -206,10 +206,10 @@ permitted_attributes # Allowed attributes for the current
|
|
|
206
206
|
current_authorized_scope # Scoped collection the user can access
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
-
**Other resources
|
|
209
|
+
**Other resources**: cross-resource auth. Use these, NOT raw `where` / `find`:
|
|
210
210
|
|
|
211
211
|
```ruby
|
|
212
|
-
authorize! other_record, to: :show? # ActionPolicy
|
|
212
|
+
authorize! other_record, to: :show? # ActionPolicy: raises if denied
|
|
213
213
|
allowed_to?(:show?, other_record) # Boolean check
|
|
214
214
|
policy_for(OtherModel) # Policy instance for class or record
|
|
215
215
|
policy_for(other_record).show?
|
|
@@ -219,7 +219,7 @@ authorized_resource_scope(OtherModel, relation: OtherModel.published) # On a re
|
|
|
219
219
|
authorized_resource_scope(OtherModel, type: :create) # Different action
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
`authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where`** in cross-resource controller code
|
|
222
|
+
`authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where`** in cross-resource controller code; otherwise you bypass that resource's tenancy and visibility rules.
|
|
223
223
|
|
|
224
224
|
### Definition access
|
|
225
225
|
|
|
@@ -268,7 +268,7 @@ current_nested_association # :posts
|
|
|
268
268
|
parent_input_param # :user
|
|
269
269
|
```
|
|
270
270
|
|
|
271
|
-
The nesting is declared by the route, not inferred from the URL: each nested route carries the key of its own registration, and `current_parent_class` / `current_nested_association` read it back. (There is no `parent_route_param
|
|
271
|
+
The nesting is declared by the route, not inferred from the URL: each nested route carries the key of its own registration, and `current_parent_class` / `current_nested_association` read it back. (There is no `parent_route_param`; the id parameter is derived from the parent's own route, and a singular parent contributes none at all.)
|
|
272
272
|
|
|
273
273
|
Parent fields are excluded from forms/displays by default. Toggle with the [presentation hooks](#presentation-hooks).
|
|
274
274
|
|
|
@@ -295,7 +295,7 @@ Plutonium auto-detects which `belongs_to` association points to the scoped class
|
|
|
295
295
|
# Portal config
|
|
296
296
|
scope_to_entity Competition::Team, param_key: :team
|
|
297
297
|
|
|
298
|
-
# Model
|
|
298
|
+
# Model: association name differs from param_key, but Plutonium finds by class
|
|
299
299
|
class Match < ApplicationRecord
|
|
300
300
|
belongs_to :competition_team
|
|
301
301
|
end
|
|
@@ -327,14 +327,14 @@ Full mechanics in [Tenancy › Entity scoping](/reference/tenancy/entity-scoping
|
|
|
327
327
|
After-action callbacks ensure authorization happened:
|
|
328
328
|
|
|
329
329
|
```ruby
|
|
330
|
-
verify_authorize_current # all actions
|
|
331
|
-
verify_current_authorized_scope # all except :new and :create
|
|
330
|
+
verify_authorize_current # all actions: `authorize_current!` must have been called
|
|
331
|
+
verify_current_authorized_scope # all except :new and :create: scope must have been loaded
|
|
332
332
|
```
|
|
333
333
|
|
|
334
334
|
Skip only when handling auth manually. Two forms:
|
|
335
335
|
|
|
336
336
|
```ruby
|
|
337
|
-
# Class-level
|
|
337
|
+
# Class-level: across multiple actions
|
|
338
338
|
class PostsController < ::ResourceController
|
|
339
339
|
skip_verify_authorize_current only: [:preview]
|
|
340
340
|
skip_verify_current_authorized_scope only: [:preview]
|
|
@@ -344,7 +344,7 @@ class PostsController < ::ResourceController
|
|
|
344
344
|
end
|
|
345
345
|
end
|
|
346
346
|
|
|
347
|
-
# Per-action
|
|
347
|
+
# Per-action: bang methods, inside the action body
|
|
348
348
|
def preview
|
|
349
349
|
skip_verify_authorize_current!
|
|
350
350
|
skip_verify_current_authorized_scope!
|
|
@@ -352,7 +352,7 @@ def preview
|
|
|
352
352
|
end
|
|
353
353
|
```
|
|
354
354
|
|
|
355
|
-
Prefer the per-action bang form when only one action skips
|
|
355
|
+
Prefer the per-action bang form when only one action skips, which keeps the exception co-located with the code that needs it.
|
|
356
356
|
|
|
357
357
|
## Response formats
|
|
358
358
|
|
|
@@ -401,8 +401,8 @@ See [App › Portals](/reference/app/portals) for the full portal controller sto
|
|
|
401
401
|
|
|
402
402
|
## Related
|
|
403
403
|
|
|
404
|
-
- [Policies](./policies)
|
|
405
|
-
- [Interactions](./interactions)
|
|
406
|
-
- [Resource › Definition](/reference/resource/definition)
|
|
407
|
-
- [Resource › Actions](/reference/resource/actions)
|
|
408
|
-
- [Tenancy › Nested resources](/reference/tenancy/nested-resources)
|
|
404
|
+
- [Policies](./policies): authorization called from controllers
|
|
405
|
+
- [Interactions](./interactions): business logic for custom actions
|
|
406
|
+
- [Resource › Definition](/reference/resource/definition): UI config (where most "controller-like" tweaks belong)
|
|
407
|
+
- [Resource › Actions](/reference/resource/actions): registering interactive actions
|
|
408
|
+
- [Tenancy › Nested resources](/reference/tenancy/nested-resources): parent/child routing
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
The behavior layer is intentionally thin:
|
|
4
4
|
|
|
5
|
-
- **[Controllers](./controllers) route
|
|
6
|
-
- **[Policies](./policies) authorize
|
|
7
|
-
- **[Interactions](./interactions) present
|
|
8
|
-
- **[Async Interactions](./async-interactions)
|
|
5
|
+
- **[Controllers](./controllers) route**: handle requests, redirect after submit, transform params.
|
|
6
|
+
- **[Policies](./policies) authorize**: decide who can do what, which fields they can see, which records they can access.
|
|
7
|
+
- **[Interactions](./interactions) present**: declare the inputs for a custom operation (publish, archive, import, send invitation), render as a button and a form, and hand back an outcome.
|
|
8
|
+
- **[Async Interactions](./async-interactions)**: `async` declares a persisted, resumable run instead of executing inline, for bulk operations and anything else too slow to hold a request open.
|
|
9
9
|
|
|
10
10
|
Registering an action and rendering it lives in [Resource › Definition](/reference/resource/definition) and [Resource › Actions](/reference/resource/actions). This section covers **writing** the controller hook, policy method, or interaction class behind it.
|
|
11
11
|
|
|
12
12
|
::: tip And the operation itself lives on the model
|
|
13
|
-
An interaction is a presentation object
|
|
13
|
+
An interaction is a presentation object, so it can only be constructed with a `view_context`. Logic may *start* in `execute`, but the second caller (a job, an API controller, a rake task, the console) is the trigger to move it onto the record, Rails-style. See [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for).
|
|
14
14
|
:::
|
|
15
15
|
|
|
16
16
|
For multi-tenant `relation_scope` and entity scoping, see [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
|