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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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]]
|
|
189
|
-
- [[plutonium-resource]]
|
|
190
|
-
- [[plutonium-tenancy]]
|
|
191
|
-
- [[plutonium-wizard]]
|
|
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
|
|
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
|
|
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
|
|
19
|
-
- **Profile needs `pu:profile:conn` to be visible
|
|
20
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
45
|
-
| Profile wired | grep the user model for `has_one :profile
|
|
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
|
|
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
|
|
78
|
+
Pick one (or several; apps can have multiple account types side-by-side).
|
|
77
79
|
|
|
78
|
-
### Basic 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
|
|
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]
|
|
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
|
|
134
|
-
- **Resend invitation
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
397
|
+
🚨 The association is **always `:profile`**, regardless of class, so `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
|
|
396
398
|
|
|
397
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
443
|
-
- **`pu:saas:setup` runs four other generators
|
|
444
|
-
- **Profile requires `pu:profile:conn
|
|
445
|
-
- **
|
|
446
|
-
- **
|
|
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
|
|
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]]
|
|
455
|
-
- [[plutonium-tenancy]]
|
|
456
|
-
- [[plutonium-behavior]]
|
|
457
|
-
- [[plutonium-resource]]
|
|
458
|
-
- [[plutonium-ui]]
|
|
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
|