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,10 +1,10 @@
|
|
|
1
1
|
# Registration & launch
|
|
2
2
|
|
|
3
|
-
A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`)
|
|
3
|
+
A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`), inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth); see [Hosting & the controller override hook](#hosting-the-controller-override-hook).
|
|
4
4
|
|
|
5
|
-
## On a resource
|
|
5
|
+
## On a resource: the `wizard` macro
|
|
6
6
|
|
|
7
|
-
A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action
|
|
7
|
+
A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action, sugar over the Action system.
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
10
|
class CompanyDefinition < Plutonium::Resource::Definition
|
|
@@ -14,19 +14,19 @@ class CompanyDefinition < Plutonium::Resource::Definition
|
|
|
14
14
|
end
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
- **Placement is dictated by the wizard, not chosen
|
|
18
|
-
- **Auto-mounted on the resource controller
|
|
19
|
-
- **The anchor is IDOR-safe
|
|
20
|
-
- **Bulk wizards are not supported
|
|
21
|
-
- **Authorization mirrors actions
|
|
17
|
+
- **Placement is dictated by the wizard, not chosen**: an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
|
|
18
|
+
- **Auto-mounted on the resource controller**: the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
|
|
19
|
+
- **The anchor is IDOR-safe**: an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
|
|
20
|
+
- **Bulk wizards are not supported**: wizards are inherently per-instance flows. Use a bulk interaction instead.
|
|
21
|
+
- **Authorization mirrors actions**: a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
|
|
22
22
|
|
|
23
23
|
| Option | Meaning |
|
|
24
24
|
|---|---|
|
|
25
25
|
| `record_action:` | **(Record wizards only)** show on the record's **show page**. Default `true`; `false` removes it from there. |
|
|
26
26
|
| `collection_record_action:` | **(Record wizards only)** show on each **list row** (scoped to that row's record). Default `true`; `false` keeps it on the show page but off the list. |
|
|
27
|
-
| `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome
|
|
27
|
+
| `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome, any `action` option passes through. |
|
|
28
28
|
|
|
29
|
-
Placement isn't an option
|
|
29
|
+
Placement isn't an option; it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
|
|
30
30
|
|
|
31
31
|
### Synthesized routes (resource-mounted)
|
|
32
32
|
|
|
@@ -42,9 +42,9 @@ GET/POST /companies/wizards/:wizard_name(/:token)/:step
|
|
|
42
42
|
|
|
43
43
|
The synthesized launch action points at the **bare** wizard URL (no step). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed identity) and redirects to its current step: the **resumed cursor** for an in-progress keyed run, else the **first step**, with the token already in the URL. So clicking the launch button resumes where the user left off (rather than jumping back to step 1) and never forks a fresh run on a first-step reload.
|
|
44
44
|
|
|
45
|
-
## Route-mounted
|
|
45
|
+
## Route-mounted: `register_wizard`
|
|
46
46
|
|
|
47
|
-
For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard
|
|
47
|
+
For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard`, **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
|
|
48
48
|
|
|
49
49
|
```ruby
|
|
50
50
|
# packages/admin_portal/config/routes.rb
|
|
@@ -55,7 +55,7 @@ AdminPortal::Engine.routes.draw do
|
|
|
55
55
|
register_resource ::Company
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# config/routes.rb
|
|
58
|
+
# config/routes.rb: a main-app (portal-less) wizard
|
|
59
59
|
Rails.application.routes.draw do
|
|
60
60
|
register_wizard ::AppOnboardingWizard, at: "onboarding" # main-app default → :basic
|
|
61
61
|
end
|
|
@@ -66,21 +66,21 @@ end
|
|
|
66
66
|
| `at:` (required) | The host-relative base path for the wizard's steps. |
|
|
67
67
|
| `as:` | Override the route helper name prefix (defaults to `at:`, then the wizard's name, e.g. `OnboardOrganizationWizard` → `onboarding`). |
|
|
68
68
|
| `public:` | Mount on a **public (unauthenticated) route** for an [`anonymous`](#public-mount-for-anonymous-wizards) wizard. Defaults to the wizard's own `anonymous?` flag. |
|
|
69
|
-
| `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host
|
|
69
|
+
| `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host, **portal → the resource shell**, **main-app → `:basic`**. |
|
|
70
70
|
|
|
71
71
|
This draws the wizard's step routes within the host (so a portal mount inherits the portal's scope/auth/layout) and dispatches them to a wizard controller. It provides `<name>_wizard_path` / `_url` helpers.
|
|
72
72
|
|
|
73
73
|
### Hosting & the controller override hook
|
|
74
74
|
|
|
75
|
-
`register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource
|
|
75
|
+
`register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource`, there is no hand-written file unless you want to customize.
|
|
76
76
|
|
|
77
77
|
| Host | Controller | Base / auth |
|
|
78
78
|
|---|---|---|
|
|
79
79
|
| **Portal** | `<Portal>::WizardsController` if defined, else synthesized | the portal's `PlutoniumController` (inherits its auth/scope/layout) |
|
|
80
|
-
| **Main app, authenticated** | `::WizardsController
|
|
80
|
+
| **Main app, authenticated** | `::WizardsController`, **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
|
|
81
81
|
| **Main app, public** (`anonymous`) | `::PublicWizardsController` (synthesized) | a bare base + `Plutonium::Auth::Public` (guest) |
|
|
82
82
|
|
|
83
|
-
The synthesized **main-app** controller is **bare
|
|
83
|
+
The synthesized **main-app** controller is **bare**, rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
|
|
84
84
|
|
|
85
85
|
```ruby
|
|
86
86
|
# app/controllers/wizards_controller.rb
|
|
@@ -94,17 +94,17 @@ end
|
|
|
94
94
|
|
|
95
95
|
### Layout
|
|
96
96
|
|
|
97
|
-
`layout:` is the **Rails layout** the wizard renders in
|
|
97
|
+
`layout:` is the **Rails layout** the wizard renders in, a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
|
|
98
98
|
|
|
99
99
|
| `layout:` | Appearance | Layout |
|
|
100
100
|
|---|---|---|
|
|
101
|
-
| `:basic` | no sidebar/topbar
|
|
101
|
+
| `:basic` | no sidebar/topbar, e.g. "set up your organization" | `basic` (`BasicLayout`) |
|
|
102
102
|
| `:resource` | sidebar + topbar + wizard (in-app) | the `resource` shell layout |
|
|
103
103
|
| *(any app layout)* | whatever that layout renders | passed straight to Rails |
|
|
104
|
-
| *(omitted)* | host default
|
|
104
|
+
| *(omitted)* | host default, see below | - |
|
|
105
105
|
|
|
106
106
|
- **Defaults by host:** portal → the `resource` shell; main-app → `:basic` (a bare main-app host has no shell to embed in). Pass `layout:` to override.
|
|
107
|
-
- **Turbo-frame requests are always layout-less** regardless of the setting
|
|
107
|
+
- **Turbo-frame requests are always layout-less** regardless of the setting; that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
|
|
108
108
|
- `layout:` is a **`register_wizard` option only**; it travels with the mount, not the wizard class. Resource-defined wizards take no `layout:` (always embedded).
|
|
109
109
|
|
|
110
110
|
### Synthesized routes
|
|
@@ -123,7 +123,7 @@ The POST `_direction` param carries `next` / `back` / `cancel`. **Where Cancel s
|
|
|
123
123
|
|
|
124
124
|
## Entry authorization
|
|
125
125
|
|
|
126
|
-
A portal-
|
|
126
|
+
A `register_wizard` wizard (portal or main-app) has no resource policy, so gate entry with an `authorize?` instance method on the wizard, checked before each request:
|
|
127
127
|
|
|
128
128
|
```ruby
|
|
129
129
|
class WelcomeWizard < Plutonium::Wizard::Base
|
|
@@ -135,12 +135,12 @@ end
|
|
|
135
135
|
|
|
136
136
|
A falsy return → `ActionPolicy::Unauthorized` (403). Resource-attached wizards instead use their action's policy predicate (`def onboard?` etc.).
|
|
137
137
|
|
|
138
|
-
::: danger A
|
|
139
|
-
A
|
|
138
|
+
::: danger A `register_wizard` wizard with no `authorize?` is open to ANY authenticated user
|
|
139
|
+
A wizard registered with `register_wizard` (in a portal or on the main app) has **no resource policy**, and the base class defines `def authorize? = true`. If you omit it, every authenticated user of that portal or app can run it. **Define `def authorize?` on every `register_wizard` wizard.** A gated `one_time` wizard is no exception: the gate forces users *into* the wizard but doesn't decide who may run it. For a flow that is genuinely fine for any signed-in user (e.g. self-service onboarding), say so with `def authorize? = true`.
|
|
140
140
|
:::
|
|
141
141
|
|
|
142
142
|
::: warning As-built: `authorize?` is an instance method
|
|
143
|
-
Define `def authorize?` on the wizard. The controller checks it
|
|
143
|
+
Define `def authorize?` on the wizard. The controller checks it before every request; the inherited default returns `true`.
|
|
144
144
|
:::
|
|
145
145
|
|
|
146
146
|
## Public mount for `anonymous` wizards
|
|
@@ -154,12 +154,12 @@ register_wizard ::GuestSignupWizard, at: "signup", public: true
|
|
|
154
154
|
```
|
|
155
155
|
|
|
156
156
|
- `public: true` is the **default for an `anonymous` wizard**; you can pass it explicitly for clarity.
|
|
157
|
-
- A **non-`anonymous`** wizard may **not** be mounted public (raises)
|
|
157
|
+
- A **non-`anonymous`** wizard may **not** be mounted public (raises), and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
|
|
158
158
|
- The public route dispatches to a synthesized top-level `::PublicWizardsController` (a **distinct** const from an authenticated main-app `::WizardsController`, so the two never collapse onto each other) that renders **full-page** with a standalone layout (no resource sidebar / user menu) and treats the request as a guest via `Plutonium::Auth::Public`.
|
|
159
159
|
|
|
160
160
|
## One-time gating
|
|
161
161
|
|
|
162
|
-
To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time)
|
|
162
|
+
To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time), a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
|
|
163
163
|
|
|
164
164
|
For a **one-time** wizard, the launch action this macro synthesizes also **hides itself once the current user has completed it** (via a render-time action `condition:`); a custom `condition:` composes with that check. See [The launch action hides itself once completed](/reference/wizard/one-time#the-launch-action-hides-itself-once-completed).
|
|
165
165
|
|
|
@@ -167,11 +167,11 @@ For a **one-time** wizard, the launch action this macro synthesizes also **hides
|
|
|
167
167
|
|
|
168
168
|
- **`with:`-anchored wizards mount on the resource, not route-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro (it auto-mounts a member action whose anchor is the scoped `resource_record!`). Passing a `with:`-anchored wizard to **`register_wizard`** raises: a route-level mount has no resource record to anchor to. A **`via:`-anchored** (context-anchored) wizard *does* mount route-level; its anchor is resolved by a controller method, not a URL `:id`.
|
|
169
169
|
- **An authenticated main-app wizard needs an app-defined controller.** The synthesized main-app controller is bare (no `current_user`); supply your own `::WizardsController` with an auth concern (see [Hosting & the controller override hook](#hosting-the-controller-override-hook)). Portal mounts and `anonymous` public mounts need nothing.
|
|
170
|
-
- **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time
|
|
170
|
+
- **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time, give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
|
|
171
171
|
|
|
172
172
|
## Related
|
|
173
173
|
|
|
174
|
-
- [DSL reference](/reference/wizard/dsl)
|
|
175
|
-
- [Anchoring & resume](/reference/wizard/anchoring-resume)
|
|
176
|
-
- [One-time wizards](/reference/wizard/one-time)
|
|
177
|
-
- [Custom actions guide](/guides/custom-actions)
|
|
174
|
+
- [DSL reference](/reference/wizard/dsl): `authorize?`, the wizard body.
|
|
175
|
+
- [Anchoring & resume](/reference/wizard/anchoring-resume): anchors, instance keys.
|
|
176
|
+
- [One-time wizards](/reference/wizard/one-time): completion + gating.
|
|
177
|
+
- [Custom actions guide](/guides/custom-actions): the Action system the `wizard` macro builds on.
|
|
@@ -23,25 +23,25 @@ rails db:migrate
|
|
|
23
23
|
|
|
24
24
|
| Config | Default | Meaning |
|
|
25
25
|
|---|---|---|
|
|
26
|
-
| `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes
|
|
26
|
+
| `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes, both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
|
|
27
27
|
| `config.wizards.cleanup_after` | `14.days` | Global default idle TTL for the abandonment sweep; overridable per wizard via `cleanup_after`. |
|
|
28
|
-
| `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only
|
|
28
|
+
| `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only**, see below. |
|
|
29
29
|
| `config.wizards.encrypt_data` | `false` | Encrypt **every** wizard's staged `data` at rest by default. Off by default because it needs ActiveRecord encryption keys; a wizard still overrides it individually with `encrypt_data` / `encrypt_data false`. See [Encryption](#encryption). |
|
|
30
|
-
| `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects
|
|
30
|
+
| `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects, active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
|
|
31
31
|
|
|
32
32
|
## Gem-shipped migration
|
|
33
33
|
|
|
34
|
-
The migration ships **in the gem** and Rails runs it **in place
|
|
34
|
+
The migration ships **in the gem** and Rails runs it **in place**; there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
|
|
35
35
|
|
|
36
36
|
- Once run, the table is dumped into your `schema.rb` / `structure.sql` like any other, so `db:schema:load` on fresh/CI databases recreates it normally.
|
|
37
37
|
- Disable later → the path isn't registered; the existing table is left alone (never auto-dropped).
|
|
38
|
-
- `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed)
|
|
38
|
+
- `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed), standard for gem-shipped migrations.
|
|
39
39
|
|
|
40
40
|
::: warning v1 supports the primary database only
|
|
41
|
-
The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use
|
|
41
|
+
The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use**, multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
|
|
42
42
|
:::
|
|
43
43
|
|
|
44
|
-
## The table
|
|
44
|
+
## The table: `plutonium_wizard_sessions`
|
|
45
45
|
|
|
46
46
|
One framework-owned table serves everything; **no changes to your models.**
|
|
47
47
|
|
|
@@ -51,7 +51,7 @@ One framework-owned table serves everything; **no changes to your models.**
|
|
|
51
51
|
| `status` | `in_progress` \| `completing` \| `completed` (see the note below). |
|
|
52
52
|
| `current_step` | The step cursor. |
|
|
53
53
|
| `instance_key` (unique) | The deterministic identity digest (see [Anchoring & resume](/reference/wizard/anchoring-resume#instance-identity)). |
|
|
54
|
-
| `owner_type` / `owner_id` | The user (nullable
|
|
54
|
+
| `owner_type` / `owner_id` | The user (nullable, `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
|
|
55
55
|
| `anchor_type` / `anchor_id` | The anchor record (nullable). |
|
|
56
56
|
| `scope_type` / `scope_id` | The portal scoping entity / tenant (nullable). |
|
|
57
57
|
| `engine` | The portal (engine class name) the run was launched in, e.g. `"OrgPortal::Engine"`. The "continue where you left off" listing only shows (and links) runs whose `engine` matches the portal being viewed (two portals can share an entity scope, so `scope` alone can't identify the portal). |
|
|
@@ -64,14 +64,14 @@ One framework-owned table serves everything; **no changes to your models.**
|
|
|
64
64
|
|
|
65
65
|
What the single table powers:
|
|
66
66
|
|
|
67
|
-
- **Resume
|
|
68
|
-
- **One-time check
|
|
69
|
-
- **In-progress listing
|
|
70
|
-
- **Multi-tenancy
|
|
71
|
-
- **Sweep
|
|
67
|
+
- **Resume**: look up the `in_progress` row by `instance_key`.
|
|
68
|
+
- **One-time check**: does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
|
|
69
|
+
- **In-progress listing**: by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
|
|
70
|
+
- **Multi-tenancy**: the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
|
|
71
|
+
- **Sweep**: idle `in_progress`/`completing` rows past `expires_at`.
|
|
72
72
|
|
|
73
73
|
::: tip The `persisted` / `tracked_records` naming
|
|
74
|
-
The column is `tracked_records`, not `persisted
|
|
74
|
+
The column is `tracked_records`, not `persisted`: an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
|
|
75
75
|
:::
|
|
76
76
|
|
|
77
77
|
## Encryption
|
|
@@ -85,7 +85,7 @@ class CheckoutWizard < Plutonium::Wizard::Base
|
|
|
85
85
|
end
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
This encrypts the `data` column (the staged step values)
|
|
88
|
+
This encrypts the `data` column (the staged step values), off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
|
|
89
89
|
|
|
90
90
|
**Encrypt everything by default.** Once your app has ActiveRecord encryption keys, you can flip encryption on for *all* wizards with one global flag, then override per wizard:
|
|
91
91
|
|
|
@@ -99,7 +99,7 @@ class PublicSurveyWizard < Plutonium::Wizard::Base
|
|
|
99
99
|
end
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys
|
|
102
|
+
Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys, see the warning below.
|
|
103
103
|
|
|
104
104
|
**How it works.** Because `data` is one shared `jsonb` column across all wizards (some opting in, some not), a static model-level `encrypts :data` doesn't fit (it would encrypt every row, and fights the `jsonb` type). Instead, the store encrypts at write time using **ActiveRecord's configured encryptor** (`ActiveRecord::Encryption.encryptor`, the same keys as `encrypts`) and stores a self-describing envelope inside the column:
|
|
105
105
|
|
|
@@ -107,17 +107,17 @@ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard alwa
|
|
|
107
107
|
{ "_enc": "<ciphertext>" }
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data
|
|
110
|
+
A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?`, so toggling the flag never strands existing runs.
|
|
111
111
|
|
|
112
112
|
::: warning Requires ActiveRecord encryption keys
|
|
113
|
-
`encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard
|
|
113
|
+
`encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard, rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
|
|
114
114
|
:::
|
|
115
115
|
|
|
116
116
|
## Files
|
|
117
117
|
|
|
118
118
|
A file can't sit in the JSON `data` column, so a wizard stages only the backend's **upload token** (an ActiveStorage `signed_id`, or Shrine cached-file data) and `execute` assigns it to the model's attachment. This works for both server-side and direct uploads, ActiveStorage and active_shrine. See [DSL › Attachment fields](/reference/wizard/dsl#attachment-fields) and the [guide](/guides/wizards#file-uploads-attachments) for the full surface (`backend:`, `multiple:`, direct upload).
|
|
119
119
|
|
|
120
|
-
A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it
|
|
120
|
+
A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it, the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
|
|
121
121
|
|
|
122
122
|
## Cleanup & the SweepJob
|
|
123
123
|
|
|
@@ -126,7 +126,7 @@ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Eac
|
|
|
126
126
|
`Plutonium::Wizard::SweepJob` reaps idle `in_progress` / `completing` rows past `expires_at`: for each it runs the wizard's cleanup (each step's `on_rollback` if declared, then always destroy every tracked record, in reverse order) and deletes the row. Completed rows are never touched. The job is idempotent and safe to re-run.
|
|
127
127
|
|
|
128
128
|
::: tip The `completing` state and its grace window
|
|
129
|
-
A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window
|
|
129
|
+
A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window, long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
|
|
130
130
|
:::
|
|
131
131
|
|
|
132
132
|
### SweepJob is load-bearing for save-as-you-go
|
|
@@ -147,6 +147,6 @@ On completion of a one-time wizard, the row is kept as the durable marker but it
|
|
|
147
147
|
|
|
148
148
|
## Related
|
|
149
149
|
|
|
150
|
-
- [Anchoring & resume](/reference/wizard/anchoring-resume)
|
|
151
|
-
- [DSL reference](/reference/wizard/dsl)
|
|
152
|
-
- [One-time wizards](/reference/wizard/one-time)
|
|
150
|
+
- [Anchoring & resume](/reference/wizard/anchoring-resume): `instance_key`, resume.
|
|
151
|
+
- [DSL reference](/reference/wizard/dsl): `cleanup_after`, `encrypt_data`, `persist`.
|
|
152
|
+
- [One-time wizards](/reference/wizard/one-time): durable completion markers.
|
|
@@ -65,6 +65,12 @@ module Pu
|
|
|
65
65
|
user.#{profile_association}.nil?
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# The base update? delegates to create?, which is false once the profile
|
|
69
|
+
# exists. relation_scope already limits this to the user's own profile.
|
|
70
|
+
def update?
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
68
74
|
def destroy?
|
|
69
75
|
false
|
|
70
76
|
end
|
|
@@ -6,6 +6,12 @@ module Plutonium
|
|
|
6
6
|
module AssociatedWith
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
|
+
# Raised when more than one association links the two classes and no
|
|
10
|
+
# `associated_with_<record>` scope says which one to use. Picking the
|
|
11
|
+
# first would scope by declaration order, so a reordered model could
|
|
12
|
+
# silently widen what a tenant sees.
|
|
13
|
+
class AmbiguousAssociationError < StandardError; end
|
|
14
|
+
|
|
9
15
|
included do
|
|
10
16
|
scope :associated_with, ->(record) do
|
|
11
17
|
# If scoping to same class, just match by ID (e.g., Team scoped to Team)
|
|
@@ -45,21 +51,27 @@ module Plutonium
|
|
|
45
51
|
|
|
46
52
|
class_methods do
|
|
47
53
|
def find_association_from_self_to_record(record)
|
|
48
|
-
reflect_on_all_associations.
|
|
54
|
+
matches = reflect_on_all_associations.select do |assoc|
|
|
49
55
|
assoc.klass.name == record.class.name unless assoc.polymorphic?
|
|
50
56
|
rescue
|
|
51
57
|
assoc.check_validity!
|
|
52
58
|
raise
|
|
53
59
|
end
|
|
60
|
+
raise_ambiguous_association_error(self, record.class, matches, record) if matches.size > 1
|
|
61
|
+
|
|
62
|
+
matches.first
|
|
54
63
|
end
|
|
55
64
|
|
|
56
65
|
def find_association_to_self_from_record(record)
|
|
57
|
-
record.class.reflect_on_all_associations.
|
|
66
|
+
matches = record.class.reflect_on_all_associations.select do |assoc|
|
|
58
67
|
assoc.klass.name == name
|
|
59
68
|
rescue
|
|
60
69
|
assoc.check_validity!
|
|
61
70
|
raise
|
|
62
71
|
end
|
|
72
|
+
raise_ambiguous_association_error(record.class, self, matches, record) if matches.size > 1
|
|
73
|
+
|
|
74
|
+
matches.first
|
|
63
75
|
end
|
|
64
76
|
|
|
65
77
|
def query_based_on_association(assoc, record)
|
|
@@ -75,6 +87,15 @@ module Plutonium
|
|
|
75
87
|
end
|
|
76
88
|
end
|
|
77
89
|
|
|
90
|
+
def raise_ambiguous_association_error(from, to, matches, record)
|
|
91
|
+
named_scope = :"associated_with_#{record.model_name.singular}"
|
|
92
|
+
raise AmbiguousAssociationError,
|
|
93
|
+
"#{from.name} has multiple associations to #{to.name}: #{matches.map(&:name).join(", ")}. " \
|
|
94
|
+
"Plutonium cannot tell which one scopes #{name} to a #{record.class.name}.\n\n" \
|
|
95
|
+
"Define a named scope on #{name} e.g.\n\n" \
|
|
96
|
+
"scope :#{named_scope}, ->(#{record.model_name.singular}) { do_something_here }"
|
|
97
|
+
end
|
|
98
|
+
|
|
78
99
|
def raise_unresolvable_association_error(record, named_scope)
|
|
79
100
|
raise "Could not resolve the association between '#{name}' and '#{record.class.name}'\n\n" \
|
|
80
101
|
"Define\n" \
|
|
@@ -64,6 +64,11 @@ module Plutonium
|
|
|
64
64
|
return nil unless name
|
|
65
65
|
|
|
66
66
|
route_key = resource_class.model_name.route_key
|
|
67
|
+
# Path-scoped portals prefix every route name with the entity
|
|
68
|
+
# param key (`organization_scoped_`) and take the entity as the
|
|
69
|
+
# first positional arg, the same as Controller#resource_url_for.
|
|
70
|
+
path_scoped = current_engine.scoped_to_entity? && current_engine.scoped_entity_strategy == :path
|
|
71
|
+
route_key = "#{current_engine.scoped_entity_param_key}_#{route_key}" if path_scoped
|
|
67
72
|
helper = (kind == :filter) ? :"typeahead_filter_#{route_key}_path" : :"typeahead_input_#{route_key}_path"
|
|
68
73
|
|
|
69
74
|
# Engine route helpers are the source of truth for routes
|
|
@@ -74,7 +79,8 @@ module Plutonium
|
|
|
74
79
|
# uses its eager list.
|
|
75
80
|
url_helpers = current_engine.routes.url_helpers
|
|
76
81
|
return nil unless url_helpers.respond_to?(helper)
|
|
77
|
-
|
|
82
|
+
helper_args = path_scoped ? [view_context.current_scoped_entity.to_param] : []
|
|
83
|
+
url_helpers.public_send(helper, *helper_args, name: name)
|
|
78
84
|
end
|
|
79
85
|
end
|
|
80
86
|
end
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -971,7 +971,7 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
971
971
|
@apply flex items-center p-4 mb-4 rounded-lg;
|
|
972
972
|
}
|
|
973
973
|
.dark .pu-alert {
|
|
974
|
-
@apply bg-
|
|
974
|
+
@apply bg-gray-800;
|
|
975
975
|
}
|
|
976
976
|
|
|
977
977
|
.pu-alert-message {
|
|
@@ -982,7 +982,7 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
982
982
|
@apply ms-auto -mx-1.5 -my-1.5 rounded-lg focus:ring-2 p-1.5 inline-flex items-center justify-center h-8 w-8;
|
|
983
983
|
}
|
|
984
984
|
.dark .pu-alert-close {
|
|
985
|
-
@apply bg-
|
|
985
|
+
@apply bg-gray-800 hover:bg-gray-700;
|
|
986
986
|
}
|
|
987
987
|
|
|
988
988
|
/* --- Variants: success / warning / danger / info ---
|
|
@@ -995,11 +995,11 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
995
995
|
.pu-toast-success .pu-toast-icon { @apply text-success-500 bg-success-100; }
|
|
996
996
|
.dark .pu-toast-success .pu-toast-icon { @apply bg-success-800 text-success-200; }
|
|
997
997
|
.pu-toast-success .pu-toast-close { @apply bg-success-50 text-success-400 hover:bg-success-200 focus:ring-success-400; }
|
|
998
|
-
.dark .pu-toast-success .pu-toast-close { @apply text-success-400; }
|
|
998
|
+
.dark .pu-toast-success .pu-toast-close { @apply bg-gray-800 text-success-400 hover:bg-gray-700; }
|
|
999
999
|
.pu-alert-success { @apply bg-success-50 text-success-800; }
|
|
1000
1000
|
.dark .pu-alert-success { @apply text-success-400; }
|
|
1001
1001
|
.pu-alert-success .pu-alert-close { @apply bg-success-50 text-success-500 hover:bg-success-200 focus:ring-success-400; }
|
|
1002
|
-
.dark .pu-alert-success .pu-alert-close { @apply text-success-400; }
|
|
1002
|
+
.dark .pu-alert-success .pu-alert-close { @apply bg-gray-800 text-success-400 hover:bg-gray-700; }
|
|
1003
1003
|
|
|
1004
1004
|
/* warning */
|
|
1005
1005
|
.pu-toast-warning { @apply bg-warning-50; }
|
|
@@ -1007,11 +1007,11 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
1007
1007
|
.pu-toast-warning .pu-toast-icon { @apply text-warning-500 bg-warning-100; }
|
|
1008
1008
|
.dark .pu-toast-warning .pu-toast-icon { @apply bg-warning-800 text-warning-200; }
|
|
1009
1009
|
.pu-toast-warning .pu-toast-close { @apply bg-warning-50 text-warning-400 hover:bg-warning-200 focus:ring-warning-400; }
|
|
1010
|
-
.dark .pu-toast-warning .pu-toast-close { @apply text-warning-400; }
|
|
1010
|
+
.dark .pu-toast-warning .pu-toast-close { @apply bg-gray-800 text-warning-400 hover:bg-gray-700; }
|
|
1011
1011
|
.pu-alert-warning { @apply bg-warning-50 text-warning-800; }
|
|
1012
1012
|
.dark .pu-alert-warning { @apply text-warning-400; }
|
|
1013
1013
|
.pu-alert-warning .pu-alert-close { @apply bg-warning-50 text-warning-500 hover:bg-warning-200 focus:ring-warning-400; }
|
|
1014
|
-
.dark .pu-alert-warning .pu-alert-close { @apply text-warning-400; }
|
|
1014
|
+
.dark .pu-alert-warning .pu-alert-close { @apply bg-gray-800 text-warning-400 hover:bg-gray-700; }
|
|
1015
1015
|
|
|
1016
1016
|
/* danger */
|
|
1017
1017
|
.pu-toast-danger { @apply bg-danger-50; }
|
|
@@ -1019,11 +1019,11 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
1019
1019
|
.pu-toast-danger .pu-toast-icon { @apply text-danger-500 bg-danger-100; }
|
|
1020
1020
|
.dark .pu-toast-danger .pu-toast-icon { @apply bg-danger-800 text-danger-200; }
|
|
1021
1021
|
.pu-toast-danger .pu-toast-close { @apply bg-danger-50 text-danger-400 hover:bg-danger-200 focus:ring-danger-400; }
|
|
1022
|
-
.dark .pu-toast-danger .pu-toast-close { @apply text-danger-400; }
|
|
1022
|
+
.dark .pu-toast-danger .pu-toast-close { @apply bg-gray-800 text-danger-400 hover:bg-gray-700; }
|
|
1023
1023
|
.pu-alert-danger { @apply bg-danger-50 text-danger-800; }
|
|
1024
1024
|
.dark .pu-alert-danger { @apply text-danger-400; }
|
|
1025
1025
|
.pu-alert-danger .pu-alert-close { @apply bg-danger-50 text-danger-500 hover:bg-danger-200 focus:ring-danger-400; }
|
|
1026
|
-
.dark .pu-alert-danger .pu-alert-close { @apply text-danger-400; }
|
|
1026
|
+
.dark .pu-alert-danger .pu-alert-close { @apply bg-gray-800 text-danger-400 hover:bg-gray-700; }
|
|
1027
1027
|
|
|
1028
1028
|
/* info (default / :notice) */
|
|
1029
1029
|
.pu-toast-info { @apply bg-info-50; }
|
|
@@ -1031,11 +1031,11 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
1031
1031
|
.pu-toast-info .pu-toast-icon { @apply text-info-500 bg-info-100; }
|
|
1032
1032
|
.dark .pu-toast-info .pu-toast-icon { @apply bg-info-800 text-info-200; }
|
|
1033
1033
|
.pu-toast-info .pu-toast-close { @apply bg-info-50 text-info-400 hover:bg-info-200 focus:ring-info-400; }
|
|
1034
|
-
.dark .pu-toast-info .pu-toast-close { @apply text-info-400; }
|
|
1034
|
+
.dark .pu-toast-info .pu-toast-close { @apply bg-gray-800 text-info-400 hover:bg-gray-700; }
|
|
1035
1035
|
.pu-alert-info { @apply bg-info-50 text-info-800; }
|
|
1036
1036
|
.dark .pu-alert-info { @apply text-info-400; }
|
|
1037
1037
|
.pu-alert-info .pu-alert-close { @apply bg-info-50 text-info-500 hover:bg-info-200 focus:ring-info-400; }
|
|
1038
|
-
.dark .pu-alert-info .pu-alert-close { @apply text-info-400; }
|
|
1038
|
+
.dark .pu-alert-info .pu-alert-close { @apply bg-gray-800 text-info-400 hover:bg-gray-700; }
|
|
1039
1039
|
|
|
1040
1040
|
/* ===========================================================================
|
|
1041
1041
|
Wizard — horizontal "steps" stepper (numbered nodes on a connector track)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: plutonium
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.66.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stefan Froelich
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: zeitwerk
|