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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -1,25 +1,25 @@
1
1
  ---
2
2
  name: plutonium-async-interactions
3
- description: Use BEFORE building any bulk operation or long-running interaction. Covers async, the Run STI model, failure policies (halt/continue/transactional), authorization re-derivation at perform time, registering AsyncRun as a resource (progress page + running banner), and scheduling ReapJob for stalled runs. The single source for "how do I make an interaction async".
3
+ description: 'Use BEFORE building any bulk operation or long-running interaction. Covers async, the Run STI model, failure policies (halt/continue/transactional), authorization re-derivation at perform time, registering AsyncRun as a resource (progress page + running banner), and scheduling ReapJob for stalled runs. The single source for "how do I make an interaction async".'
4
4
  ---
5
5
 
6
6
  # Plutonium Async Interactions
7
7
 
8
- `async` turns an interaction from "does the work inline" into "persists a run, enqueues it, and redirects to it." Reach for it once the work is too slow to hold a request open — thousands of records, report generation, a third-party call.
8
+ `async` turns an interaction from "does the work inline" into "persists a run, enqueues it, and redirects to it." Reach for it once the work is too slow to hold a request open: thousands of records, report generation, a third-party call.
9
9
 
10
10
  For everything about the interaction itself (inputs, validation, outcomes, `execute`), load [[plutonium-behavior]] first. `async` only replaces what `execute` does, not the rest of the interaction's shape.
11
11
 
12
12
  ## 🚨 Critical (read first)
13
13
 
14
- - **Experimental.** The DSL and behavior may change in a future release — same status as [[plutonium-wizard]] and [[plutonium-kanban]]. Fine to build on; expect to revisit it on upgrade.
14
+ - **Experimental.** The DSL and behavior may change in a future release, same status as [[plutonium-wizard]] and [[plutonium-kanban]]. Fine to build on; expect to revisit it on upgrade.
15
15
  - **Enable the subsystem first.** `rails g pu:async_interactions:install --dest=<portal>` flips `config.async_interactions.enabled = true`, schedules `ReapJob`, and connects the run resource to that portal; then `rails db:migrate`. Pass `--skip-portal` to enable it before any portal exists. Off by default, so no `plutonium_async_runs` table otherwise.
16
- - **`async` fully replaces `#execute`.** An interaction either executes inline or runs async — declaring both raises `ArgumentError` at load.
16
+ - **`async` fully replaces `#execute`.** An interaction either executes inline or runs async; declaring both raises `ArgumentError` at load.
17
17
  - **Define `perform_on(record)` for targeted work, `perform` for opaque work.** A run class implementing neither fails loudly (naming the class) the first time it's performed, rather than a bare `NoMethodError`.
18
- - **A nested dispatch records its parent.** `parent_type`/`parent_id`/`parent_association` join initiator and tenant on the row, because `Policy#default_relation_scope` picks parent scoping **or** entity scoping, not both — a nested run missing its parent re-derives targets under the wider tenant scope, and any predicate reading `parent` silently answers false. A parent deleted mid-run refuses the run, exactly like a deleted tenant.
18
+ - **A nested dispatch records its parent.** `parent_type`/`parent_id`/`parent_association` join initiator and tenant on the row, because `Policy#default_relation_scope` picks parent scoping **or** entity scoping, not both. A nested run missing its parent re-derives targets under the wider tenant scope, and any predicate reading `parent` silently answers false. A parent deleted mid-run refuses the run, exactly like a deleted tenant.
19
19
  - **Permissions are re-derived at perform time, never replayed from dispatch.** The job rebuilds `(initiator, tenant)` from the row and re-checks the policy scope and predicate per target, immediately before each `perform_on`. A permission revoked mid-run stops applying to what's left. Both failure directions (scope, predicate) fail closed (refuse/report), never open.
20
20
  - **Register `Run` as a resource per portal.** Its show page IS the progress page, and other resources' index pages get a "runs in progress" banner for free. Nothing renders without registration.
21
21
  - **Read `outcome`, never bare `state`, when displaying a run's result.** A `:continue` run that under-applied still has `state == "completed"`; only `outcome` says `"completed_with_errors"`.
22
- - **Long work must call `heartbeat!`.** `stall_after` measures SILENCE, and the executor only writes per target — so opaque `perform` writes nothing at all between claim and finish. An opaque run longer than `stall_after` is reaped and, having no `handled_target_ids`, re-run from scratch. Call `heartbeat!` inside the loop. It also raises `StaleObjectError` if another worker took the run over, which for opaque work is the only way to find out.
22
+ - **Long work must call `heartbeat!`.** `stall_after` measures SILENCE, and the executor only writes per target, so opaque `perform` writes nothing at all between claim and finish. An opaque run longer than `stall_after` is reaped and, having no `handled_target_ids`, re-run from scratch. Call `heartbeat!` inside the loop. It also raises `StaleObjectError` if another worker took the run over, which for opaque work is the only way to find out.
23
23
  - **A crashed/stalled run does not auto-heal.** Nothing revisits a `"running"` row on its own. `pu:async_interactions:install` schedules `Plutonium::Interaction::Async::ReapJob` for you when Solid Queue is in the bundle; otherwise (or on another scheduler) you must schedule it yourself. Unscheduled, a crash mid-batch leaves that row stuck forever.
24
24
 
25
25
  ---
@@ -37,7 +37,7 @@ For everything about the interaction itself (inputs, validation, outcomes, `exec
37
37
 
38
38
  ## Declaring the work
39
39
 
40
- `async` with a block. One file — the run is declared inline and needs no name:
40
+ `async` with a block. One file: the run is declared inline and needs no name:
41
41
 
42
42
  ```ruby
43
43
  class Blogging::ArchivePosts < ResourceInteraction
@@ -64,9 +64,9 @@ class Reports::GenerateMonthly < ResourceInteraction
64
64
  end
65
65
  ```
66
66
 
67
- **The block is the run's class body, not the body of `#execute`.** The work happens later, in a process with no controller and no `view_context`, so it cannot close over anything in the interaction — which is why it declares `perform_on`/`perform` rather than executing directly. Validated attributes arrive through `options`, and `def` opens a fresh scope, so those bodies can't accidentally capture the interaction's locals.
67
+ **The block is the run's class body, not the body of `#execute`.** The work happens later, in a process with no controller and no `view_context`, so it cannot close over anything in the interaction, which is why it declares `perform_on`/`perform` rather than executing directly. Validated attributes arrive through `options`, and `def` opens a fresh scope, so those bodies can't accidentally capture the interaction's locals.
68
68
 
69
- The block defines `<Interaction>::Run` — a real, named constant, because the class name is persisted in `type` and constantized in the job process.
69
+ The block defines `<Interaction>::Run`, a real, named constant, because the class name is persisted in `type` and constantized in the job process.
70
70
 
71
71
  **Pass a class instead** to share one run across several interactions that do the same kind of work:
72
72
 
@@ -96,7 +96,7 @@ An opaque run records none of the target/policy columns. Nothing to re-verify wi
96
96
 
97
97
  ## Attributes and files
98
98
 
99
- Validated attributes reach the run through `options`, a JSON column written via `ActiveJob::Arguments` — primitives verbatim, `Date`/`BigDecimal`/`Time` round-tripped with their types. An attribute that can't be carried is refused at dispatch.
99
+ Validated attributes reach the run through `options`, a JSON column written via `ActiveJob::Arguments`: primitives verbatim, `Date`/`BigDecimal`/`Time` round-tripped with their types. An attribute that can't be carried is refused at dispatch.
100
100
 
101
101
  Files can't ride a JSON column, and the request's tempfile is gone by the time the job runs, so an uploaded file is staged to its backend's cache and carried as a token. Read it back with `attachment`:
102
102
 
@@ -112,7 +112,7 @@ end
112
112
 
113
113
  `attachment(:key)` / `attachments(:key)` give `filename`, `content_type`, `url`, `open`, `download`.
114
114
 
115
- `backend:` and `uploader:` come off the attribute's `input` declaration, exactly as in a wizard step — `input :import_file, as: :uppy, uploader: Catalog::ImportUploader`. The uploader's `Attacher.validate` rules run when the interaction validates, so a bad file **fails the form** rather than surfacing as a run failure the submitter never sees. Where no `backend:` is declared: `config.async_interactions.attachment_backend` → `config.attachment_backend` → auto-detect.
115
+ `backend:` and `uploader:` come off the attribute's `input` declaration, exactly as in a wizard step: `input :import_file, as: :uppy, uploader: Catalog::ImportUploader`. The uploader's `Attacher.validate` rules run when the interaction validates, so a bad file **fails the form** rather than surfacing as a run failure the submitter never sees. Where no `backend:` is declared: `config.async_interactions.attachment_backend` → `config.attachment_backend` → auto-detect.
116
116
 
117
117
  ## Registering the Run resource
118
118
 
@@ -136,9 +136,9 @@ class AdminPortal::AsyncRunsController < AdminPortal::ResourceController
136
136
  end
137
137
  ```
138
138
 
139
- `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 (exact class-name match for the definition, ActionPolicy's own lookup for the policy).
139
+ `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 (exact class-name match for the definition, ActionPolicy's own lookup for the policy).
140
140
 
141
- If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` — see [Scheduling ReapJob](#scheduling-reapjob-stalled-runs). `--schedule` overrides the default `every 15 minutes`. Idempotent, so running it against a second portal doesn't duplicate the entry.
141
+ If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml`: see [Scheduling ReapJob](#scheduling-reapjob-stalled-runs). `--schedule` overrides the default `every 15 minutes`. Idempotent, so running it against a second portal doesn't duplicate the entry.
142
142
 
143
143
  Registering gets you, for free:
144
144
 
@@ -166,7 +166,7 @@ production:
166
166
  schedule: every 15 minutes
167
167
  ```
168
168
 
169
- Without Solid Queue — or for another scheduler like `whenever` — add it yourself:
169
+ Without Solid Queue, or for another scheduler like `whenever`, add it yourself:
170
170
 
171
171
  ```ruby
172
172
  # whenever gem
@@ -175,7 +175,7 @@ every 15.minutes do
175
175
  end
176
176
  ```
177
177
 
178
- 15 to 30 minutes is a reasonable cadence against the default 1-hour `stall_after`. This is a time heuristic, not a true lease: a merely-slow (not dead) run that crosses `stall_after` gets resumed too. `lock_version` bounds what that costs — the resumed row's version no longer matches the still-live worker's, so that worker stops at its next write instead of racing the new one. It does **not** interrupt an in-flight `perform_on` (one target can be applied twice, once by each side), and it does not roll back what the superseded worker already committed. Set `stall_after` well above the app's slowest legitimate run.
178
+ 15 to 30 minutes is a reasonable cadence against the default 1-hour `stall_after`. This is a time heuristic, not a true lease: a merely-slow (not dead) run that crosses `stall_after` gets resumed too. `lock_version` bounds what that costs: the resumed row's version no longer matches the still-live worker's, so that worker stops at its next write instead of racing the new one. It does **not** interrupt an in-flight `perform_on` (one target can be applied twice, once by each side), and it does not roll back what the superseded worker already committed. Set `stall_after` well above the app's slowest legitimate run.
179
179
 
180
180
  On Solid Queue (or any queue providing ActiveJob concurrency controls) this is tightened further, automatically and with no configuration: `Async::Job` declares a semaphore of 1 keyed on the run id, held for `stall_after`, and `ReapJob` one global sweep at a time. A second delivery of the same run then waits rather than racing, so the one target the fence cannot save from a double apply is not applied twice either. Nothing declares it when the queue does not support it.
181
181
 
@@ -185,7 +185,7 @@ On Solid Queue (or any queue providing ActiveJob concurrency controls) this is t
185
185
 
186
186
  ## Related Skills
187
187
 
188
- - [[plutonium-behavior]] — the interaction itself: inputs, validation, `succeed`/`failed`, policies.
189
- - [[plutonium-resource]] — Actions (inferred bulk/record/resource shape), Definition (`field`/`display`/`column`).
190
- - [[plutonium-tenancy]] — entity scoping, `associated_with`, portal tenant strategies.
191
- - [[plutonium-wizard]] — the other long-lived, persisted flow primitive (multi-step, not async execution). `Wizard::SweepJob` is `ReapJob`'s sibling for abandoned wizard sessions.
188
+ - [[plutonium-behavior]]: the interaction itself: inputs, validation, `succeed`/`failed`, policies.
189
+ - [[plutonium-resource]]: Actions (inferred bulk/record/resource shape), Definition (`field`/`display`/`column`).
190
+ - [[plutonium-tenancy]]: entity scoping, `associated_with`, portal tenant strategies.
191
+ - [[plutonium-wizard]]: the other long-lived, persisted flow primitive (multi-step, not async execution). `Wizard::SweepJob` is `ReapJob`'s sibling for abandoned wizard sessions.
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: plutonium-auth
3
- description: Use BEFORE installing Rodauth, configuring account types, building login/password flows, or wiring a profile / account-settings page. Covers the full auth surface — Rodauth installation, accounts, admin accounts, SaaS setup, profile resource, security section.
3
+ description: 'Use BEFORE installing Rodauth, configuring account types, building login/password flows, or wiring a profile / account-settings page. Covers the full auth surface: Rodauth installation, accounts, admin accounts, SaaS setup, profile resource, security section.'
4
4
  ---
5
5
 
6
- # Plutonium Auth — Rodauth + Profile
6
+ # Plutonium Auth: Rodauth + Profile
7
7
 
8
8
  Plutonium integrates [Rodauth](http://rodauth.jeremyevans.net/) via [rodauth-rails](https://github.com/janko/rodauth-rails). This skill covers installing Rodauth, generating account types (basic / admin / SaaS), wiring auth into controllers and portals, and the profile / account-settings resource.
9
9
 
@@ -15,39 +15,40 @@ For multi-tenant invitations and membership, see [[plutonium-tenancy]] › Invit
15
15
  - **Role index 0 is the most privileged** (`owner`, `super_admin`). Invite interactions default new invitees to **index 1**.
16
16
  - **`pu:saas:setup --roles=...` always prepends `owner` as index 0.** Don't include `owner` in the option.
17
17
  - **`pu:saas:setup` 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.
18
- - **Profile association is always `:profile`** regardless of the model class — `current_user.profile`, `build_profile`, `params.require(:profile)`.
19
- - **Profile needs `pu:profile:conn` to be visible** — without it, the singular `/profile` route and `profile_url` helper don't exist.
20
- - **Every user needs a profile row.** Add an `after_create` callback or `find_or_create_by` — otherwise `current_user.profile` is nil.
18
+ - **Profile association is always `:profile`** regardless of the model class: `current_user.profile`, `build_profile`, `params.require(:profile)`. `pu:profile:conn` hardcodes `current_user.profile` in the code it generates. If an existing user model names it differently (e.g. `has_one :user_profile`), add `has_one :profile, class_name: "UserProfile"` to the model; don't rewrite the generated code to the other name.
19
+ - **Profile needs `pu:profile:conn` to be visible.** Without it, the singular `/profile` route and `profile_url` helper don't exist. A profile that exists but isn't wired is something to fix with `pu:profile:conn`, not something to route around.
20
+ - **Check an existing profile policy for `update?`.** Conn generates `create? = user.profile.nil?`, and the base `update?`/`edit?` delegate to `create?`. Current conn also generates `def update? = true`; a policy generated by an older version lacks it, so the profile can't be edited once it exists. Add it (the `relation_scope` already limits it to the user's own row).
21
+ - **Per-user account features hang off the profile page.** API tokens, connected accounts, notification settings: link them from the profile `ShowPage#render_after_content` next to `SecuritySection` (see Account features on the profile page).
21
22
 
22
23
  ---
23
24
 
24
- ## 🛑 Before you set up auth: confirm the shape (ASK — don't infer)
25
+ ## 🛑 Before you set up auth: confirm the shape (ASK: don't infer)
25
26
 
26
- "Set up login" hides the one decision that determines everything: **is this multi-tenant SaaS or a single auth surface?** Pick wrong and you either hand-assemble what a meta-generator does in one shot, or scaffold a SaaS spine an app doesn't need. Resolve each — confirming by inspection (next section):
27
+ "Set up login" hides the one decision that determines everything: **is this multi-tenant SaaS or a single auth surface?** Pick wrong and you either hand-assemble what a meta-generator does in one shot, or scaffold a SaaS spine an app doesn't need. Resolve each, confirming by inspection (next section):
27
28
 
28
29
  1. **Single auth or multi-tenant SaaS?** "Each user belongs to / manages an org/team" ⇒ SaaS ⇒ **`pu:saas:setup`** (the meta-generator: user + entity + membership + portal + profile + welcome + invites in one). A plain login with no tenant ⇒ `pu:rodauth:install` + `pu:rodauth:account`.
29
- 2. **Account type.** Basic user, **hardened admin** (`pu:rodauth:admin` — 2FA/lockout/audit, no public signup), or **API** (`--api_only --jwt`)? They're different generators.
30
+ 2. **Account type.** Basic user, **hardened admin** (`pu:rodauth:admin`, 2FA/lockout/audit, no public signup), or **API** (`--api_only --jwt`)? They're different generators.
30
31
  3. **Public signup allowed?** Default yes for `account`; admin accounts are invite-only.
31
32
  4. **Profile / account-settings page?** Needs `pu:profile:install` **and** `pu:profile:conn` (without conn there's no `/profile` route).
32
- 5. **Roles.** Index 0 is most privileged (`owner`/`super_admin`); invites default new members to `roles[1]`. `pu:saas:setup` prepends `owner` — don't list it.
33
+ 5. **Roles.** Index 0 is most privileged (`owner`/`super_admin`); invites default new members to `roles[1]`. `pu:saas:setup` prepends `owner`; don't list it.
33
34
 
34
35
  **Never ship a guessed account-type, model name, or `--roles` as applied commands.** Read them off the app first; fall back to `AskUserQuestion` only for product choices (separate staff accounts vs shared, is signup open).
35
36
 
36
- ## ✅ Before you run a generator: verify the ground truth (CHECK — read it, don't ask for it)
37
+ ## ✅ Before you run a generator: verify the ground truth (CHECK: read it, don't ask for it)
37
38
 
38
- You have file access — **inspect**; don't ask the user to describe their app.
39
+ You have file access, **inspect**; don't ask the user to describe their app.
39
40
 
40
41
  | Check | How | Why it matters |
41
42
  |---|---|---|
42
43
  | Rodauth already installed | `ls app/rodauth/rodauth_app.rb`; grep `Gemfile` for `rodauth` | Re-running `pu:rodauth:install` clobbers config |
43
44
  | Existing account models | grep `app/models` for `Rodauth::Rails.model` | Which account type exists / don't duplicate |
44
- | SaaS spine already run | `ls` for the entity portal + membership model | **`pu:saas:setup` chains 4 generators — don't re-run them separately** |
45
- | Profile wired | grep the user model for `has_one :profile` + `after_create`; is `profile_url` defined? | Else `current_user.profile` is nil / no route (`pu:profile:conn` missing) |
45
+ | SaaS spine already run | `ls` for the entity portal + membership model | **`pu:saas:setup` chains 4 generators; don't re-run them separately** |
46
+ | Profile wired | grep the user model for `has_one :profile`; grep the portal's `concerns/controller.rb` for `def profile_url` | No `:profile` association ⇒ add it. No `profile_url` ⇒ the profile isn't connected: run `pu:profile:conn` (even if the model and a `register_resource` line already exist) |
46
47
  | Role ordering | Read the membership `enum :role` | Index 0 = most privileged; invites default to `roles[1]` |
47
48
 
48
49
  Inspect with your own tools **before** running any generator.
49
50
 
50
- ## 🛠 Use the generator — never hand-write Rodauth
51
+ ## 🛠 Use the generator: never hand-write Rodauth
51
52
 
52
53
  Never hand-write Rodauth plugin files, account models, or profile resources.
53
54
 
@@ -58,6 +59,7 @@ Never hand-write Rodauth plugin files, account models, or profile resources.
58
59
  | Basic account | `pu:rodauth:account NAME --defaults` | Rodauth installed |
59
60
  | Hardened admin | `pu:rodauth:admin NAME --roles=…` | Rodauth installed |
60
61
  | Profile page | `pu:profile:install …` + `pu:profile:conn --dest=portal` | Migrated; portal exists |
62
+ | Profile page for an existing profile model | `pu:profile:conn UserProfile --dest=portal` | User model has `has_one :profile, class_name: "UserProfile"` |
61
63
 
62
64
  ---
63
65
 
@@ -73,9 +75,9 @@ Installs gems (`rodauth-rails`, `bcrypt`, `sequel-activerecord_connection`), the
73
75
 
74
76
  ## Account types
75
77
 
76
- Pick one (or several — apps can have multiple account types side-by-side).
78
+ Pick one (or several; apps can have multiple account types side-by-side).
77
79
 
78
- ### Basic account — `pu:rodauth:account`
80
+ ### Basic account: `pu:rodauth:account`
79
81
 
80
82
  ```bash
81
83
  rails generate pu:rodauth:account user [options]
@@ -108,7 +110,7 @@ rails generate pu:rodauth:account user [options]
108
110
  | `--sms_codes` | | SMS 2FA |
109
111
  | `--jwt`, `--jwt_refresh` | | JWT for API auth |
110
112
 
111
- ### Admin account — `pu:rodauth:admin`
113
+ ### Admin account: `pu:rodauth:admin`
112
114
 
113
115
  Pre-configured secure admin with multi-phase login, required TOTP, recovery codes, lockout, active session tracking, audit logging, role-based access, invite + resend-invite interactions, and **no public signup**.
114
116
 
@@ -123,17 +125,17 @@ rails generate pu:rodauth:admin admin --extra-attributes=name:string,department:
123
125
  | `--roles` | `super_admin,admin` | Comma-separated roles (positional enum) |
124
126
  | `--extra_attributes` | | Additional model attributes (e.g. `name:string`) |
125
127
 
126
- **Role-ordering convention:** index 0 is the most privileged. Generated invite interaction defaults new invitees to `roles[1]` — the order in `--roles=` matters.
128
+ **Role-ordering convention:** index 0 is the most privileged. Generated invite interaction defaults new invitees to `roles[1]`, so the order in `--roles=` matters.
127
129
 
128
130
  ```ruby
129
131
  enum :role, super_admin: 0, admin: 1
130
132
  ```
131
133
 
132
134
  **Invite + resend actions.** The admin resource gets two actions:
133
- - **Invite** — invite a new admin by email; they set their password via the verification link.
134
- - **Resend invitation** — re-send that verification email, shown only for admins who haven't verified yet.
135
+ - **Invite**: invite a new admin by email; they set their password via the verification link.
136
+ - **Resend invitation**: re-send that verification email, shown only for admins who haven't verified yet.
135
137
 
136
- This is Rodauth account verification — distinct from the tenancy invitation system (see [[plutonium-tenancy]]).
138
+ This is Rodauth account verification, distinct from the tenancy invitation system (see [[plutonium-tenancy]]).
137
139
 
138
140
  Rake task for direct admin creation (namespace is `rodauth`, task name is the account name):
139
141
 
@@ -144,7 +146,7 @@ EMAIL=admin@example.com rails rodauth:admin
144
146
 
145
147
  Creates the account and sends a verification email; the admin sets their own password through the flow. No password is passed on the command line.
146
148
 
147
- ### SaaS setup — `pu:saas:setup` (meta-generator)
149
+ ### SaaS setup: `pu:saas:setup` (meta-generator)
148
150
 
149
151
  Creates the User + Entity + Membership trio AND runs:
150
152
 
@@ -207,7 +209,7 @@ class ResourceController < PlutoniumController
207
209
  end
208
210
  ```
209
211
 
210
- Multiple account types — include the matching `:name`:
212
+ Multiple account types: include the matching `:name`:
211
213
 
212
214
  ```ruby
213
215
  class AdminController < PlutoniumController
@@ -216,7 +218,7 @@ class AdminController < PlutoniumController
216
218
  end
217
219
  ```
218
220
 
219
- `Plutonium::Auth::Rodauth(:name)` exposes `current_user`, `logout_url`, and `rodauth` in the controller. It also adds a named accessor `current_<name>` aliased to `current_user` — e.g. `Rodauth(:admin)` gives `current_admin`. Read the signed-in account with `current_user` or its named alias (e.g. `current_admin`).
221
+ `Plutonium::Auth::Rodauth(:name)` exposes `current_user`, `logout_url`, and `rodauth` in the controller. It also adds a named accessor `current_<name>` aliased to `current_user`, e.g. `Rodauth(:admin)` gives `current_admin`. Read the signed-in account with `current_user` or its named alias (e.g. `current_admin`).
220
222
 
221
223
  For portal wiring (`AdminPortal::Concerns::Controller`), see [[plutonium-app]] › Portal controller concern.
222
224
 
@@ -224,34 +226,34 @@ For portal wiring (`AdminPortal::Concerns::Controller`), see [[plutonium-app]]
224
226
 
225
227
  ## Multiple portals in one browser
226
228
 
227
- Each portal authenticates through its own Rodauth configuration, and a person can hold a session in several at once — sign into the admin portal and the customer portal in the same browser without either evicting the other.
229
+ Each portal authenticates through its own Rodauth configuration, and a person can hold a session in several at once: sign into the admin portal and the customer portal in the same browser without either evicting the other.
228
230
 
229
231
  Two settings make that work, and the generators emit both:
230
232
 
231
233
  ```ruby
232
- # app/rodauth/rodauth_plugin.rb — the shared base
234
+ # app/rodauth/rodauth_plugin.rb: the shared base
233
235
  configure do
234
236
  enable :session_isolation
235
237
  end
236
238
 
237
- # app/rodauth/<name>_rodauth_plugin.rb — once per account type
239
+ # app/rodauth/<name>_rodauth_plugin.rb: once per account type
238
240
  configure do
239
241
  session_key_prefix "admin_"
240
242
  remember_cookie_key "_admin_remember"
241
243
  end
242
244
  ```
243
245
 
244
- 🚨 **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`, … `session_isolation` uses that prefix to decide ownership and stops a login from clearing the other configurations' keys.
246
+ 🚨 **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`, … `session_isolation` uses that prefix to decide ownership and stops a login from clearing the other configurations' keys.
245
247
 
246
- 🚨 **Never set `session_key` alongside it.** Explicit values bypass `convert_session_key` (`rodauth/features/base.rb:686`) so they are NOT prefixed — the account id then rotates separately from every other key. A session holding an account id with no `authenticated_by` makes Rodauth raise on every request (`logged_in_via_remember_key?` → `nil.include?`, `remember.rb:175`). A config with no prefix at all is simply not isolated: its keys are the unprefixed defaults, so nothing is carried for it.
248
+ 🚨 **Never set `session_key` alongside it.** Explicit values bypass `convert_session_key` (`rodauth/features/base.rb:686`) so they are NOT prefixed, and the account id then rotates separately from every other key. A session holding an account id with no `authenticated_by` makes Rodauth raise on every request (`logged_in_via_remember_key?` → `nil.include?`, `remember.rb:175`). A config with no prefix at all is simply not isolated: its keys are the unprefixed defaults, so nothing is carried for it.
247
249
 
248
- **Why:** 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 as a full `reset_session`. 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 autologins from its remember cookie on the next request and evicts the new one right back. The last `load_memory` in the route block wins permanently, so the other portal can never hold a session at all.
250
+ **Why:** 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 as a full `reset_session`. 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 autologins from its remember cookie on the next request and evicts the new one right back. The last `load_memory` in the route block wins permanently, so the other portal can never hold a session at all.
249
251
 
250
252
  `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.
251
253
 
252
- **Reading a raw Rodauth session key?** Go through its accessor, never the literal — `session.delete(login_redirect_session_key)`, not `session.delete(:login_redirect)`. With a prefix set, the literal is the wrong key.
254
+ **Reading a raw Rodauth session key?** Go through its accessor, never the literal: `session.delete(login_redirect_session_key)`, not `session.delete(:login_redirect)`. With a prefix set, the literal is the wrong key.
253
255
 
254
- **Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin, add `session_key_prefix` to each account plugin, and **delete any existing `session_key "_x_session"` line**. Every key name changes together, so stale session cookies are simply ignored — the safe outcome. Keeping the old `session_key` to preserve logins is exactly what produces the crashing half-migrated session. Only *unremembered* sessions actually drop: `remember_cookie_key` is a cookie name and is not prefixed, so anyone holding a valid `_x_remember` cookie is restored by `load_memory` on their next request.
256
+ **Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin, add `session_key_prefix` to each account plugin, and **delete any existing `session_key "_x_session"` line**. Every key name changes together, so stale session cookies are simply ignored (the safe outcome). Keeping the old `session_key` to preserve logins is exactly what produces the crashing half-migrated session. Only *unremembered* sessions actually drop: `remember_cookie_key` is a cookie name and is not prefixed, so anyone holding a valid `_x_remember` cookie is restored by `load_memory` on their next request.
255
257
 
256
258
  ---
257
259
 
@@ -376,7 +378,7 @@ rails g pu:profile:install AccountSettings bio:text --dest=main_app
376
378
 
377
379
  ### What gets created
378
380
 
379
- By default the model is `{UserModel}Profile` — `UserProfile`, `StaffUserProfile`, etc. — derived from `--user-model`.
381
+ By default the model is `{UserModel}Profile` (`UserProfile`, `StaffUserProfile`, etc.), derived from `--user-model`.
380
382
 
381
383
  ```
382
384
  app/models/[package/]user_profile.rb
@@ -392,9 +394,28 @@ The generator modifies the user model:
392
394
  has_one :profile, class_name: "UserProfile", dependent: :destroy
393
395
  ```
394
396
 
395
- 🚨 The association is **always `:profile`**, regardless of class — `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
397
+ 🚨 The association is **always `:profile`**, regardless of class, so `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
396
398
 
397
- The generated definition injects a custom `ShowPage` that renders the `SecuritySection` component.
399
+ If you are reusing a profile model that predates the generator and the user model has a different association name, add the `:profile` one rather than editing `current_user.profile` out of the generated policy and `profile_url`. Keeping the convention means the next `pu:profile:conn` (another portal, a re-run) works unedited. The dummy app's `has_one :user_profile` is a test fixture, not a pattern to copy.
400
+
401
+ ### What `pu:profile:conn` generates in the portal
402
+
403
+ It runs `pu:res:conn` with `--singular --policy --definition`, then injects:
404
+
405
+ - **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`.
406
+ - **Controller:** `resource_params` merged with `user: current_user`, so the owner is set server-side.
407
+ - **Definition:** a `ShowPage` whose `render_after_content` renders `Plutonium::Profile::SecuritySection`.
408
+ - **Portal concern:** `profile_url`, which points at the profile or, when the user has none yet, at the new-profile form.
409
+
410
+ 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:
411
+
412
+ ```ruby
413
+ def update?
414
+ true
415
+ end
416
+ ```
417
+
418
+ Narrowing the editable fields is `permitted_attributes_for_create` / `_for_update` in this portal policy; leave `:user` out.
398
419
 
399
420
  ### The `SecuritySection` component
400
421
 
@@ -412,26 +433,69 @@ Dynamically lists Rodauth security links based on which features are enabled:
412
433
 
413
434
  To customize the show page (e.g. wrap, reorder), override `ShowPage#render_after_content` (see [[plutonium-ui]] › Page hooks).
414
435
 
415
- ### Required: every user gets a profile
436
+ ### Users without a profile row
437
+
438
+ 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` (change email / password) only appears after it is saved. If you want the show page from the first click, create the row up front:
416
439
 
417
440
  ```ruby
418
441
  class User < ApplicationRecord
419
- after_create :create_profile!
442
+ after_create :create_profile
443
+ end
444
+ ```
445
+
446
+ and backfill existing users with `User.find_each { |u| u.profile || u.create_profile }`. 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).
447
+
448
+ ### Linking to the profile
420
449
 
450
+ 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, `link_to("Profile", profile_url) if profile_url`.
451
+
452
+ ### Account features on the profile page
453
+
454
+ 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:
455
+
456
+ ```ruby
457
+ # packages/org_portal/app/definitions/org_portal/user_profile_definition.rb
458
+ class ShowPage < ShowPage
421
459
  private
422
- def create_profile! = create_profile
460
+
461
+ def render_after_content
462
+ render Plutonium::Profile::SecuritySection.new
463
+ div(class: "mt-8") do
464
+ a(href: resource_url_for(ApiToken), class: "font-medium text-[var(--pu-text)] hover:underline") { "API tokens" }
465
+ end
466
+ end
423
467
  end
424
468
  ```
425
469
 
426
- Without this, `current_user.profile` is `nil` and the profile route errors. For existing users at migration time, run a one-off `User.find_each(&:create_profile)`.
470
+ If the profile isn't wired in that portal yet (no `profile_url`), wire it with `pu:profile:conn` first; that is the page users are sent to from the avatar menu.
427
471
 
428
- ### Linking to the profile
472
+ The feature resource itself belongs to the user, not the portal's entity, so it follows the same shape `pu:profile:conn` generates for the profile:
473
+
474
+ ```bash
475
+ rails g pu:res:scaffold ApiToken user:belongs_to name:string token_digest:string:uniq 'last_used_at:datetime?' --dest=main_app
476
+ rails g pu:res:conn ApiToken --dest=org_portal --policy
477
+ ```
429
478
 
430
479
  ```ruby
431
- link_to("Profile", profile_url) if respond_to?(:profile_url)
480
+ # packages/org_portal/app/policies/org_portal/api_token_policy.rb
481
+ relation_scope do |relation|
482
+ skip_default_relation_scope!
483
+ relation.where(user: user)
484
+ end
485
+
486
+ def permitted_attributes_for_create
487
+ %i[name]
488
+ end
489
+
490
+ # packages/org_portal/app/controllers/org_portal/api_tokens_controller.rb
491
+ private
492
+
493
+ def resource_params
494
+ super.merge(user: current_user)
495
+ end
432
496
  ```
433
497
 
434
- `profile_url` only exists when the profile resource is connected via `pu:profile:conn` (which registers it as a singular resource — see [[plutonium-app]] › Routes).
498
+ Use `--policy` so the user-only scope lives in this portal's policy. In an entity-scoped portal the default scope calls `associated_with(entity)`, which raises for a model with no path to the entity; skipping it and filtering by `user` is the intended pattern here, as in the profile policy. Don't invent an `associated_with_<entity>` scope through the user's memberships to satisfy it. Gate per-record actions such as revoke in the policy (see [[plutonium-behavior]]).
435
499
 
436
500
  ---
437
501
 
@@ -439,20 +503,22 @@ link_to("Profile", profile_url) if respond_to?(:profile_url)
439
503
 
440
504
  - **Role index 0 is the most privileged.** For admin/SaaS roles, index 0 is `owner`/`super_admin`. Generated invite interactions default invitees to index 1.
441
505
  - **`owner` is always prepended** by `pu:saas:setup --roles`. Don't include it manually.
442
- - **Profile association is always `:profile`** — even when the class is `StaffUserProfile`.
443
- - **`pu:saas:setup` runs four other generators** — don't re-run portal, profile, welcome, or invites separately.
444
- - **Profile requires `pu:profile:conn`** — without it, no route, no `profile_url`, no menu link.
445
- - **Users need a profile row.** Add an `after_create` callback (or `find_or_create_by`) — `current_user.profile` is otherwise nil.
446
- - **Concurrent portal logins need `enable :session_isolation` + `session_key_prefix`.** Missing either and signing into one portal silently evicts the others — see Multiple portals in one browser.
506
+ - **Profile association is always `:profile`**: even when the class is `StaffUserProfile`.
507
+ - **`pu:saas:setup` runs four other generators**: don't re-run portal, profile, welcome, or invites separately.
508
+ - **Profile requires `pu:profile:conn`**: without it, no route, no `profile_url`, no menu link.
509
+ - **Profile is read-only after creation.** The policy predates the generated `update?`; `update?` falls back to the injected `create?` (`user.profile.nil?`). Add `def update? = true`.
510
+ - **Users without a profile row land on the new-profile form.** Not an error; add `after_create :create_profile` plus a backfill if they should land on the show page.
511
+ - **User-owned resources in an entity-scoped portal** use `pu:res:conn --policy` with `skip_default_relation_scope!` + `where(user: user)`, and are linked from the profile show page.
512
+ - **Concurrent portal logins need `enable :session_isolation` + `session_key_prefix`.** Missing either and signing into one portal silently evicts the others; see Multiple portals in one browser.
447
513
  - **Never hardcode a Rodauth session key.** Use the accessor (`login_redirect_session_key`), since `session_key_prefix` changes the literal.
448
- - **"Remember me" is opt-in.** Configs use `after_login { remember_login if param_or_nil(remember_param) == remember_remember_param_value }` and the login form renders the checkbox. Compare against the value, not just presence — a bare truthiness check means `remember=disable` would remember you. Plutonium's form also passes `include_hidden: false` so an unticked box sends nothing — belt-and-braces next to the value comparison, not the thing holding it up.
514
+ - **"Remember me" is opt-in.** Configs use `after_login { remember_login if param_or_nil(remember_param) == remember_remember_param_value }` and the login form renders the checkbox. Compare against the value, not just presence: a bare truthiness check means `remember=disable` would remember you. Plutonium's form also passes `include_hidden: false` so an unticked box sends nothing, which is belt-and-braces next to the value comparison, not the thing holding it up.
449
515
 
450
516
  ---
451
517
 
452
518
  ## Related skills
453
519
 
454
- - [[plutonium-app]] — initial install, portal wiring, mounting auth-constrained routes
455
- - [[plutonium-tenancy]] — invites + memberships for multi-tenant onboarding
456
- - [[plutonium-behavior]] — policies (auth runs first, policy checks the authenticated user)
457
- - [[plutonium-resource]] — customizing the profile definition (fields, inputs, displays)
458
- - [[plutonium-ui]] — overriding the profile's `ShowPage`, theming the security section
520
+ - [[plutonium-app]]: initial install, portal wiring, mounting auth-constrained routes
521
+ - [[plutonium-tenancy]]: invites + memberships for multi-tenant onboarding
522
+ - [[plutonium-behavior]]: policies (auth runs first, policy checks the authenticated user)
523
+ - [[plutonium-resource]]: customizing the profile definition (fields, inputs, displays)
524
+ - [[plutonium-ui]]: overriding the profile's `ShowPage`, theming the security section