plutonium 0.51.0 → 0.52.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-app/SKILL.md +2 -0
  3. data/.claude/skills/plutonium-auth/SKILL.md +6 -4
  4. data/.claude/skills/plutonium-behavior/SKILL.md +1 -1
  5. data/.claude/skills/plutonium-tenancy/SKILL.md +25 -6
  6. data/.claude/skills/plutonium-testing/SKILL.md +3 -1
  7. data/.claude/skills/plutonium-ui/SKILL.md +3 -3
  8. data/CHANGELOG.md +17 -0
  9. data/app/assets/plutonium.css +1 -1
  10. data/app/assets/plutonium.js +1 -0
  11. data/app/assets/plutonium.js.map +3 -3
  12. data/app/assets/plutonium.min.js +1 -1
  13. data/app/assets/plutonium.min.js.map +3 -3
  14. data/docs/.vitepress/config.ts +1 -2
  15. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  16. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  17. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  18. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  19. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  20. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  21. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  22. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  23. data/docs/.vitepress/theme/custom.css +144 -0
  24. data/docs/.vitepress/theme/index.ts +58 -1
  25. data/docs/getting-started/index.md +33 -50
  26. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  27. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  28. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  29. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  30. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  31. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  32. data/docs/guides/authentication.md +10 -5
  33. data/docs/guides/authorization.md +3 -3
  34. data/docs/guides/creating-packages.md +8 -11
  35. data/docs/guides/custom-actions.md +6 -1
  36. data/docs/guides/customizing-ui.md +258 -0
  37. data/docs/guides/index.md +49 -32
  38. data/docs/guides/multi-tenancy.md +10 -2
  39. data/docs/guides/nested-resources.md +69 -0
  40. data/docs/guides/search-filtering.md +6 -0
  41. data/docs/guides/testing.md +5 -1
  42. data/docs/guides/theming.md +13 -0
  43. data/docs/guides/user-invites.md +10 -4
  44. data/docs/guides/user-profile.md +8 -0
  45. data/docs/index.md +10 -219
  46. data/docs/public/asciinema/home-scaffold.cast +305 -0
  47. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  48. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  49. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  50. data/docs/public/images/guides/nested-inputs.png +0 -0
  51. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  52. data/docs/public/images/guides/search-filtering-index.png +0 -0
  53. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  54. data/docs/public/images/guides/theming-after.png +0 -0
  55. data/docs/public/images/guides/theming-before.png +0 -0
  56. data/docs/public/images/guides/user-invites-landing.png +0 -0
  57. data/docs/public/images/guides/user-profile-edit.png +0 -0
  58. data/docs/public/images/guides/user-profile-show.png +0 -0
  59. data/docs/public/images/home-index.png +0 -0
  60. data/docs/public/images/home-new.png +0 -0
  61. data/docs/public/images/home-show.png +0 -0
  62. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  63. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  64. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  65. data/docs/public/images/tutorial/02-new-form.png +0 -0
  66. data/docs/public/images/tutorial/03-create-account.png +0 -0
  67. data/docs/public/images/tutorial/03-login.png +0 -0
  68. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  69. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  70. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  71. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  72. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  73. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  74. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  75. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  76. data/docs/reference/app/generators.md +4 -4
  77. data/docs/reference/auth/accounts.md +6 -7
  78. data/docs/reference/auth/index.md +1 -1
  79. data/docs/reference/behavior/policies.md +1 -1
  80. data/docs/reference/index.md +67 -55
  81. data/docs/reference/resource/definition.md +1 -1
  82. data/docs/reference/tenancy/entity-scoping.md +8 -1
  83. data/docs/reference/tenancy/index.md +1 -1
  84. data/docs/reference/tenancy/invites.md +12 -5
  85. data/docs/reference/ui/tables.md +8 -4
  86. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  87. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  88. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  89. data/gemfiles/rails_7.gemfile.lock +1 -1
  90. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  91. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  92. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  93. data/lib/generators/pu/invites/install_generator.rb +44 -0
  94. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  95. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  96. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  97. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  98. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  99. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  100. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  101. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  102. data/lib/generators/pu/saas/membership/USAGE +4 -1
  103. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  104. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  105. data/lib/plutonium/helpers/turbo_helper.rb +19 -0
  106. data/lib/plutonium/resource/controllers/crud_actions.rb +4 -4
  107. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  108. data/lib/plutonium/ui/component/methods.rb +1 -0
  109. data/lib/plutonium/ui/form/base.rb +17 -1
  110. data/lib/plutonium/ui/form/components/secure_association.rb +11 -6
  111. data/lib/plutonium/ui/form/interaction.rb +1 -1
  112. data/lib/plutonium/ui/form/theme.rb +1 -1
  113. data/lib/plutonium/ui/page/edit.rb +1 -1
  114. data/lib/plutonium/ui/page/new.rb +1 -1
  115. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  116. data/lib/plutonium/version.rb +1 -1
  117. data/package.json +4 -1
  118. data/src/js/controllers/form_controller.js +5 -4
  119. data/yarn.lock +108 -1
  120. metadata +45 -3
@@ -418,3 +418,147 @@ Brand Colors:
418
418
  line-height: 1.5;
419
419
  padding-bottom: 4px;
420
420
  }
421
+
422
+ /* ===== Public-pages design tokens (2026-05) ===== */
423
+ :root {
424
+ --pu-bg-dark: #0d1117;
425
+ --pu-bg-dark-2: #161b22;
426
+ --pu-bg-light: #ffffff;
427
+ --pu-bg-band: #fafafa;
428
+ --pu-border: #e0e0e0;
429
+ --pu-border-soft: #ececec;
430
+ --pu-text: #1a1a1a;
431
+ --pu-text-muted: #666666;
432
+ --pu-text-faint: #888888;
433
+ --pu-accent: #d33;
434
+ --pu-accent-soft: #fff7f7;
435
+ --pu-success-bg: #ecf9f1;
436
+ --pu-success-fg: #0a7c3f;
437
+ --pu-warn-bg: #fdf3e6;
438
+ --pu-warn-fg: #a86b00;
439
+ --pu-term-prompt: #7ee787;
440
+ --pu-term-cursor: #58a6ff;
441
+ --pu-term-text: #e6edf3;
442
+ }
443
+
444
+ .dark {
445
+ --pu-bg-light: #0d1117;
446
+ --pu-bg-band: #161b22;
447
+ --pu-text: #e6edf3;
448
+ --pu-text-muted: #9da7b1;
449
+ --pu-text-faint: #6e7681;
450
+ --pu-border: #30363d;
451
+ --pu-border-soft: #21262d;
452
+ }
453
+
454
+ /* Eyebrow */
455
+ .pu-eyebrow {
456
+ font-size: 11px;
457
+ text-transform: uppercase;
458
+ letter-spacing: 0.1em;
459
+ color: var(--pu-accent);
460
+ font-weight: 600;
461
+ margin-bottom: 8px;
462
+ }
463
+ .pu-eyebrow--muted { color: var(--pu-text-faint); }
464
+
465
+ /* Buttons */
466
+ .pu-btn {
467
+ display: inline-block;
468
+ padding: 10px 18px;
469
+ border-radius: 6px;
470
+ font-size: 14px;
471
+ font-weight: 500;
472
+ text-decoration: none;
473
+ transition: opacity 0.15s ease;
474
+ }
475
+ .pu-btn:hover { opacity: 0.85; }
476
+ .pu-btn:focus-visible {
477
+ outline: 2px solid var(--pu-accent);
478
+ outline-offset: 2px;
479
+ }
480
+ .pu-btn-primary { background: var(--pu-accent); color: #ffffff; }
481
+ .pu-btn-ghost {
482
+ border: 1px solid currentColor;
483
+ color: var(--pu-text);
484
+ opacity: 0.85;
485
+ }
486
+ .pu-btn-ghost.on-dark { color: #e6edf3; border-color: rgba(255,255,255,0.25); }
487
+
488
+ /* Terminal block */
489
+ .pu-term {
490
+ background: var(--pu-bg-dark);
491
+ color: var(--pu-term-text);
492
+ border-radius: 8px;
493
+ padding: 16px 18px;
494
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
495
+ font-size: 13px;
496
+ line-height: 1.7;
497
+ overflow-x: auto;
498
+ }
499
+ .pu-term--inline { padding: 12px 14px; font-size: 12.5px; }
500
+ .pu-term .prompt { color: var(--pu-term-prompt); }
501
+ .pu-term .dim { opacity: 0.55; }
502
+ .pu-term-cursor {
503
+ background: var(--pu-term-cursor);
504
+ display: inline-block;
505
+ width: 7px;
506
+ height: 13px;
507
+ vertical-align: text-bottom;
508
+ animation: pu-blink 1s steps(2) infinite;
509
+ }
510
+ @keyframes pu-blink { 50% { opacity: 0; } }
511
+ @media (prefers-reduced-motion: reduce) {
512
+ .pu-term-cursor { animation: none; }
513
+ }
514
+
515
+ /* Section frames */
516
+ .pu-section {
517
+ padding: 64px 24px;
518
+ }
519
+ .pu-section--dark {
520
+ background: var(--pu-bg-dark);
521
+ color: var(--pu-term-text);
522
+ }
523
+ .pu-section--band {
524
+ background: var(--pu-bg-band);
525
+ }
526
+ .pu-section .pu-section-inner {
527
+ max-width: 1100px;
528
+ margin: 0 auto;
529
+ }
530
+ .pu-section-title {
531
+ font-size: 28px;
532
+ letter-spacing: -0.02em;
533
+ margin: 0 0 24px;
534
+ color: var(--pu-text);
535
+ }
536
+ .pu-section--dark .pu-section-title { color: var(--pu-term-text); }
537
+
538
+ .vp-doc img:not(a img),
539
+ img.pu-zoomable { cursor: zoom-in; }
540
+ .medium-zoom-overlay { z-index: 100; }
541
+ .medium-zoom-image--opened { z-index: 101; }
542
+
543
+ .pu-zoom-close {
544
+ position: fixed;
545
+ top: 16px;
546
+ right: 16px;
547
+ z-index: 102;
548
+ width: 40px;
549
+ height: 40px;
550
+ border: 1px solid var(--vp-c-divider);
551
+ background: var(--vp-c-bg-soft);
552
+ color: var(--vp-c-text-1);
553
+ border-radius: 50%;
554
+ font-size: 24px;
555
+ line-height: 1;
556
+ cursor: pointer;
557
+ display: inline-flex;
558
+ align-items: center;
559
+ justify-content: center;
560
+ padding: 0;
561
+ transition: background 0.15s ease, transform 0.15s ease;
562
+ }
563
+ .pu-zoom-close:hover { background: var(--vp-c-bg-mute); transform: scale(1.05); }
564
+ .pu-zoom-close:focus-visible { outline: 2px solid var(--vp-c-brand-1); outline-offset: 2px; }
@@ -1,4 +1,61 @@
1
1
  import DefaultTheme from "vitepress/theme"
2
+ import { onMounted, watch, nextTick } from "vue"
3
+ import { useRoute } from "vitepress"
4
+ import mediumZoom from "medium-zoom"
2
5
  import "./custom.css"
3
6
 
4
- export default DefaultTheme
7
+ import HomeHero from "./components/HomeHero.vue"
8
+ import HomeStopWriting from "./components/HomeStopWriting.vue"
9
+ import HomePillars from "./components/HomePillars.vue"
10
+ import HomeWalkthrough from "./components/HomeWalkthrough.vue"
11
+ import HomeAudienceSplit from "./components/HomeAudienceSplit.vue"
12
+ import HomeInTheBox from "./components/HomeInTheBox.vue"
13
+ import HomeCta from "./components/HomeCta.vue"
14
+ import SectionLanding from "./components/SectionLanding.vue"
15
+
16
+ export default {
17
+ extends: DefaultTheme,
18
+ enhanceApp({ app }) {
19
+ app.component("HomeHero", HomeHero)
20
+ app.component("HomeStopWriting", HomeStopWriting)
21
+ app.component("HomePillars", HomePillars)
22
+ app.component("HomeWalkthrough", HomeWalkthrough)
23
+ app.component("HomeAudienceSplit", HomeAudienceSplit)
24
+ app.component("HomeInTheBox", HomeInTheBox)
25
+ app.component("HomeCta", HomeCta)
26
+ app.component("SectionLanding", SectionLanding)
27
+ },
28
+ setup() {
29
+ const route = useRoute()
30
+
31
+ let closeBtn: HTMLButtonElement | null = null
32
+
33
+ const attachCloseButton = (z: ReturnType<typeof mediumZoom>) => {
34
+ z.on("opened", () => {
35
+ const overlay = document.querySelector<HTMLElement>(".medium-zoom-overlay")
36
+ if (!overlay) return
37
+ closeBtn = document.createElement("button")
38
+ closeBtn.type = "button"
39
+ closeBtn.setAttribute("aria-label", "Close")
40
+ closeBtn.className = "pu-zoom-close"
41
+ closeBtn.innerHTML = "&times;"
42
+ closeBtn.addEventListener("click", () => z.close())
43
+ document.body.appendChild(closeBtn)
44
+ })
45
+ z.on("close", () => {
46
+ closeBtn?.remove()
47
+ closeBtn = null
48
+ })
49
+ }
50
+
51
+ const zoom = () => {
52
+ const z = mediumZoom(".vp-doc img:not(a img), img.pu-zoomable", {
53
+ background: "var(--vp-c-bg)",
54
+ margin: 16,
55
+ })
56
+ attachCloseButton(z)
57
+ }
58
+ onMounted(() => nextTick(zoom))
59
+ watch(() => route.path, () => nextTick(zoom))
60
+ }
61
+ }
@@ -1,50 +1,33 @@
1
- # Getting Started
2
-
3
- Welcome to Plutonium.
4
-
5
- ## Prerequisites
6
-
7
- - **Ruby 3.2+**
8
- - **Rails 7.2+** (Rails 8 recommended)
9
- - **Node.js 18+** (for asset compilation)
10
- - Basic familiarity with Ruby on Rails
11
-
12
- ## Pick your starting point
13
-
14
- ### New Rails app
15
-
16
- The fastest way use the application template:
17
-
18
- ```bash
19
- rails new myapp -a propshaft -j esbuild -c tailwind \
20
- -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
21
- ```
22
-
23
- This sets up Rails + Propshaft + esbuild + TailwindCSS + Plutonium in one shot, with Rodauth ready to go.
24
-
25
- [→ Installation](./installation)
26
-
27
- ### Existing Rails app
28
-
29
- For pre-existing apps, use `base.rb` (not `plutonium.rb` — that one runs full app bootstrap and clobbers history):
30
-
31
- ```bash
32
- bin/rails app:template \
33
- LOCATION=https://radioactive-labs.github.io/plutonium-core/templates/base.rb
34
- ```
35
-
36
- [→ Installation › Existing app](./installation#existing-application)
37
-
38
- ### Tutorial
39
-
40
- Want to learn by building? The [8-step tutorial](./tutorial/) walks through a complete blog app — auth, authorization, custom actions, nested resources, multi-portal.
41
-
42
- [→ Tutorial](./tutorial/)
43
-
44
- ## After installation
45
-
46
- 1. **Create resources** with `pu:res:scaffold` (see [Adding resources](/guides/adding-resources))
47
- 2. **Connect them to a portal** with `pu:res:conn`
48
- 3. **Customize** the definition, policy, controller as needed
49
-
50
- Reference for each layer: [App](/reference/app/), [Resource](/reference/resource/), [Behavior](/reference/behavior/), [UI](/reference/ui/), [Auth](/reference/auth/), [Tenancy](/reference/tenancy/), [Testing](/reference/testing/).
1
+ ---
2
+ layout: page
3
+ sidebar: false
4
+ aside: false
5
+ ---
6
+
7
+ <SectionLanding
8
+ eyebrow="Getting Started"
9
+ title="Learn Plutonium by building."
10
+ lede="Walk the path top to bottom, or skip to the part you need."
11
+ mode="numbered"
12
+ :rail="[
13
+ { name: 'Project setup', desc: 'Bootstrap a Rails app with the Plutonium template.', link: '/plutonium-core/getting-started/tutorial/01-setup' },
14
+ { name: 'First resource', desc: 'Model, definition, scaffold, connect to a portal.', link: '/plutonium-core/getting-started/tutorial/02-first-resource' },
15
+ { name: 'Authentication', desc: 'Add Rodauth with login + signup.', link: '/plutonium-core/getting-started/tutorial/03-authentication' },
16
+ { name: 'Authorization', desc: 'ActionPolicy-scoped resource access.', link: '/plutonium-core/getting-started/tutorial/04-authorization' },
17
+ { name: 'Custom actions', desc: 'Add a domain-specific action to a resource.', link: '/plutonium-core/getting-started/tutorial/05-custom-actions' },
18
+ { name: 'Nested resources', desc: 'Posts → Comments, scoped through routing.', link: '/plutonium-core/getting-started/tutorial/06-nested-resources' },
19
+ { name: 'Author portal', desc: 'A second portal with its own auth and pages.', link: '/plutonium-core/getting-started/tutorial/07-author-portal' },
20
+ { name: 'Customizing UI', desc: 'Theme tokens, custom Phlex components, layouts.', link: '/plutonium-core/getting-started/tutorial/08-customizing-ui' },
21
+ ]"
22
+ :sidebar="[
23
+ { heading: 'Already know your way around?', items: [
24
+ { label: 'Installation', href: '/plutonium-core/getting-started/installation', note: 'bootstrap a new app' },
25
+ { label: 'Concepts overview', href: '/plutonium-core/reference/' },
26
+ { label: 'Generators reference', href: '/plutonium-core/reference/app/generators' },
27
+ ]},
28
+ { heading: 'Need help?', items: [
29
+ { label: 'GitHub Discussions', href: 'https://github.com/radioactive-labs/plutonium-core/discussions' },
30
+ { label: 'Open an issue', href: 'https://github.com/radioactive-labs/plutonium-core/issues' },
31
+ ]},
32
+ ]"
33
+ />
@@ -118,13 +118,13 @@ rails db:migrate
118
118
 
119
119
  ## Creating a Portal
120
120
 
121
- Resources need a portal to be accessible via the web. Let's create an admin portal:
121
+ Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away — we'll add authentication in [Chapter 3](./03-authentication).
122
122
 
123
123
  ```bash
124
- rails generate pu:pkg:portal admin
124
+ rails generate pu:pkg:portal admin --public
125
125
  ```
126
126
 
127
- This creates the AdminPortal package with authentication configured.
127
+ This creates the `AdminPortal` package mounted at `/admin`. The `--public` flag wires the portal's controller with `Plutonium::Auth::Public`, so any visitor can access it. (Other options: `--auth=ACCOUNT` to gate via a Rodauth account, or `--byo` for your own auth.)
128
128
 
129
129
  ## Connecting the Resource
130
130
 
@@ -145,12 +145,21 @@ This:
145
145
  bin/dev
146
146
  ```
147
147
 
148
- Visit `http://localhost:3000/admin/blogging/posts`. You should see:
149
- - An empty posts table
150
- - A "New Post" button
151
- - Search and filter options
148
+ Visit `http://localhost:3000/admin/blogging/posts`. You should see an empty posts table with a "New Post" button:
152
149
 
153
- Try creating a post. The form is automatically generated from your model's attributes.
150
+ ![Empty posts index](/images/tutorial/02-empty-index.png)
151
+
152
+ Click "New" — the form is automatically generated from your model's attributes. By default Plutonium opens it as a slideover (right) so you keep the index visible; visiting `/admin/blogging/posts/new` directly renders the same form as a standalone page (left):
153
+
154
+ | Default — slideover from index | Standalone page (direct URL) |
155
+ |:--:|:--:|
156
+ | ![Slideover new form](/images/tutorial/02-new-form-modal.png) | ![Standalone new form](/images/tutorial/02-new-form.png) |
157
+
158
+ To always render full-page instead, set `modal false` in the definition. To pick a different style, use `modal :centered`. See [Reference › Resource › Definition › Modal](/reference/resource/definition).
159
+
160
+ Create a few posts and the table fills in:
161
+
162
+ ![Posts index with rows](/images/tutorial/02-index-with-posts.png)
154
163
 
155
164
  ## Understanding Auto-Detection
156
165
 
@@ -12,23 +12,27 @@ Rodauth is a Ruby authentication framework that Plutonium uses for:
12
12
 
13
13
  Plutonium integrates Rodauth seamlessly with its portal system.
14
14
 
15
- ## Setting Up Authentication
15
+ ## Installing Rodauth
16
16
 
17
- If you used the Plutonium template, Rodauth is already installed. If not:
17
+ Run the Plutonium Rodauth installer once per app — it creates the Rodauth app, plugin, and initializer:
18
18
 
19
19
  ```bash
20
20
  rails generate pu:rodauth:install
21
- rails db:migrate
22
21
  ```
23
22
 
23
+ (No migration is needed yet; the account-type generator below creates its own tables.)
24
+
24
25
  ## Creating an Account Type
25
26
 
26
- Plutonium supports multiple account types. For admin accounts that cannot self-register, use the `admin` generator:
27
+ Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator — it's a preset on top of `pu:rodauth:account` that enables 2FA, lockout, audit logging, and disables public signup:
27
28
 
28
29
  ```bash
29
30
  rails generate pu:rodauth:admin admin
31
+ rails db:migrate
30
32
  ```
31
33
 
34
+ For self-service user accounts, the corresponding command is `rails generate pu:rodauth:account user`.
35
+
32
36
  This creates:
33
37
 
34
38
  ### Account Model (`app/models/admin.rb`)
@@ -57,30 +61,28 @@ end
57
61
 
58
62
  The generator also creates migrations for the account table and authentication features.
59
63
 
60
- ## Configuring the Portal
64
+ ## Gating the Portal with Authentication
61
65
 
62
- Authentication is configured in the portal's controller concern. Update it to use Rodauth:
66
+ In [Chapter 2](./02-first-resource) you generated the admin portal with `--public`. Now that you have an `admin` Rodauth account, swap the portal over to require login. The fastest way is to re-run the portal generator with `--auth=admin --force`:
63
67
 
64
- ```ruby
65
- # packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb
66
- module AdminPortal
67
- module Concerns
68
- module Controller
69
- extend ActiveSupport::Concern
70
- include Plutonium::Portal::Controller
71
- include Plutonium::Auth::Rodauth(:admin)
72
- end
73
- end
74
- end
68
+ ```bash
69
+ rails generate pu:pkg:portal admin --auth=admin --force
75
70
  ```
76
71
 
77
- This provides `current_user` and authentication helpers throughout the portal.
72
+ This updates two files:
78
73
 
79
- ## Running Migrations
74
+ - `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb` — swaps `include Plutonium::Auth::Public` for `include Plutonium::Auth::Rodauth(:admin)`, giving you `current_user`, `logout_url`, and `profile_url` helpers throughout the portal.
75
+ - `packages/admin_portal/config/routes.rb` — wraps the engine mount in a routes-level constraint:
80
76
 
81
- ```bash
82
- rails db:migrate
83
- ```
77
+ ```ruby
78
+ constraints Rodauth::Rails.authenticate(:admin) do
79
+ mount AdminPortal::Engine, at: "/admin"
80
+ end
81
+ ```
82
+
83
+ The routes constraint is what actually gates access — unauthenticated requests to `/admin/*` are redirected to `/admins/login` before they hit any controller or policy.
84
+
85
+ (If you prefer not to regenerate, you can apply both edits by hand — they're shown above.)
84
86
 
85
87
  ## Testing Authentication
86
88
 
@@ -90,7 +92,13 @@ Restart your server:
90
92
  bin/dev
91
93
  ```
92
94
 
93
- Visit `http://localhost:3000/admin/blogging/posts`. You'll be redirected to the login page.
95
+ Visit `http://localhost:3000/admin/blogging/posts`. You'll be redirected to the login page:
96
+
97
+ ![Admin login page](/images/tutorial/03-login.png)
98
+
99
+ The "Create a New Account" link goes to the same Rodauth-rendered account creation form:
100
+
101
+ ![Create account page](/images/tutorial/03-create-account.png)
94
102
 
95
103
  ### Creating an Admin Account
96
104
 
@@ -79,10 +79,15 @@ end
79
79
 
80
80
  ## Testing the Action
81
81
 
82
- 1. Create an unpublished post
83
- 2. View the post details
84
- 3. Click the "Publish" action button
85
- 4. The post is now published
82
+ Open any unpublished post and click **Actions** in the top-right — the "Publish Post" item appears with its Tabler icon:
83
+
84
+ ![Publish action in the show page menu](/images/tutorial/05-actions-menu.png)
85
+
86
+ It also shows on each table row's `⋮` menu — same action, available wherever the record is rendered:
87
+
88
+ ![Publish action in the row menu](/images/tutorial/05-row-actions.png)
89
+
90
+ Click "Publish Post" and the post is updated; the flash banner confirms success.
86
91
 
87
92
  ## Actions with User Input
88
93
 
@@ -69,7 +69,13 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
69
69
  end
70
70
  ```
71
71
 
72
- The panel links to the nested comments route and shows "Add Comment" if the user has permission.
72
+ The post show page now has tabs **Details** and **Comments** driven by the associations you permit:
73
+
74
+ ![Post show page with Details and Comments tabs](/images/tutorial/06-post-with-comments.png)
75
+
76
+ Clicking **Comments** opens the nested index for that post — a complete sub-resource view with its own paginated table, "New" button, and row actions:
77
+
78
+ ![Nested comments index](/images/tutorial/06-comments-tab.png)
73
79
 
74
80
  ## Comment Policy
75
81
 
@@ -168,6 +168,14 @@ Now you have two portals:
168
168
  | Admin | `/admin` | Admin | All posts |
169
169
  | Author | `/author` | User | Own posts only |
170
170
 
171
+ Log in at `/users/login` with the user account and you land on the Author Portal dashboard — the same chrome as the Admin Portal but mounted at `/author`, gated by `Rodauth::Rails.authenticate(:user)`:
172
+
173
+ ![Author Portal dashboard](/images/tutorial/07-author-dashboard.png)
174
+
175
+ The posts list lives at `/author/blogging/posts` — same `Blogging::Post` resource, different portal context (and once you add the scoping policy below, scoped to the logged-in author):
176
+
177
+ ![Author Portal posts index](/images/tutorial/07-author-portal.png)
178
+
171
179
  ### Test the difference:
172
180
 
173
181
  1. **Create an Admin account** at `/admin/register`
@@ -117,6 +117,10 @@ class Blogging::PostDefinition < Blogging::ResourceDefinition
117
117
  end
118
118
  ```
119
119
 
120
+ The default "Posts" heading becomes your branded title and description:
121
+
122
+ ![Customized index page title](/images/tutorial/08-customized-index.png)
123
+
120
124
  For more advanced customization, you can create custom page classes that inherit from Plutonium's page components:
121
125
 
122
126
  ```ruby
@@ -22,10 +22,10 @@ rails db:migrate
22
22
  # (when you run `pu:pkg:portal admin --auth=user`, this happens automatically)
23
23
  ```
24
24
 
25
- Then mount your portal with the auth constraint:
25
+ If you generated the portal with `--auth=user`, the engine is already mounted with the `Rodauth::Rails.authenticate(:user)` constraint — open `packages/admin_portal/config/routes.rb` to see it. The wiring looks like:
26
26
 
27
27
  ```ruby
28
- # config/routes.rb
28
+ # packages/admin_portal/config/routes.rb (generated)
29
29
  Rails.application.routes.draw do
30
30
  constraints Rodauth::Rails.authenticate(:user) do
31
31
  mount AdminPortal::Engine, at: "/admin"
@@ -33,6 +33,8 @@ Rails.application.routes.draw do
33
33
  end
34
34
  ```
35
35
 
36
+ If you generated the portal as `--public` and need to switch it to authenticated later, re-run with `--auth=user --force` (or edit the constraint into the routes file by hand).
37
+
36
38
  For accounts with more features, options, and admin patterns: see [Reference › Auth › Accounts](/reference/auth/accounts).
37
39
 
38
40
  ## Common variations
@@ -47,18 +49,21 @@ Then enable in the user-facing security section (see [User profile](./user-profi
47
49
 
48
50
  ### Hardened admin account
49
51
 
50
- For an admin role with 2FA required, lockout, audit logging, and no public signup:
52
+ For an admin role with 2FA, lockout, audit logging, and no public signup, use the dedicated `pu:rodauth:admin` generator (a preset of `pu:rodauth:account` with hardened defaults):
51
53
 
52
54
  ```bash
53
55
  rails generate pu:rodauth:admin admin
54
56
  ```
55
57
 
56
- Create the first admin with the rake task:
58
+ Create the first admin with the rake task generated alongside the account:
57
59
 
58
60
  ```bash
59
- rails rodauth_admin:create[admin@example.com,password123]
61
+ EMAIL=admin@example.com rails rodauth:admin
62
+ # (run without EMAIL to prompt)
60
63
  ```
61
64
 
65
+ The task creates the account and triggers a verification email; the admin sets their own password through that flow. No password is passed on the command line.
66
+
62
67
  ### Multi-tenant SaaS — user + entity + membership in one shot
63
68
 
64
69
  ```bash
@@ -18,7 +18,7 @@ Every policy controls three things:
18
18
 
19
19
  - **`create?` and `read?` default to `false`.** Always override them explicitly. Derived methods (`update?`, `show?`, `index?`) inherit automatically.
20
20
  - **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detects; production raises.
21
- - **`relation_scope` must call `default_relation_scope(relation)` explicitly** never `super`. See [Reference › Behavior › Policies](/reference/behavior/policies).
21
+ - **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly in your override. `super` is fine when extending a parent policy (e.g., a package-level base) that itself calls `default_relation_scope`. See [Reference › Behavior › Policies](/reference/behavior/policies).
22
22
  - **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy. Undefined methods return `false` → action silently disappears.
23
23
 
24
24
  ## Steps
@@ -93,7 +93,7 @@ relation_scope do |relation|
93
93
  end
94
94
  ```
95
95
 
96
- 🚨 Always call `default_relation_scope(relation)` explicitly not `super`. Bypassing it triggers `verify_default_relation_scope_applied!` at runtime.
96
+ 🚨 `default_relation_scope(relation)` must be called somewhere in the chain — otherwise `verify_default_relation_scope_applied!` raises at runtime. Calling it explicitly here is safest. `super` works only when the parent policy also calls it.
97
97
 
98
98
  ## Common patterns
99
99
 
@@ -244,7 +244,7 @@ end
244
244
  - **Undefined custom action policy method** — the button silently disappears (undefined returns `false`). Add `def my_action?` to the policy.
245
245
  - **`record.X` crashes during index** — `record` is `nil` on index. Add an explicit `permitted_attributes_for_index` that doesn't depend on `record`.
246
246
  - **`verify_default_relation_scope_applied!` raises** — your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
247
- - **`super` in `relation_scope` doesn't behave as expected** use `default_relation_scope(relation)` explicitly; `super`'s semantics depend on how ActionPolicy registered the scope.
247
+ - **`super` in `relation_scope`** — works when you're extending a parent policy that itself calls `default_relation_scope`. If you're not sure (or you're inheriting from `Plutonium::Resource::Policy` directly), call `default_relation_scope(relation)` explicitly. The runtime check verifies `default_relation_scope` was hit somewhere not that you wrote it in this class.
248
248
 
249
249
  ## Related
250
250
 
@@ -13,7 +13,7 @@ Domain code (models, policies, definitions, interactions) lives in **feature pac
13
13
  | **Feature** | Business logic | `pu:pkg:package NAME` | `blogging`, `billing`, `inventory` |
14
14
  | **Portal** | Web interface | `pu:pkg:portal NAME` | `admin_portal`, `customer_portal`, `public_portal` |
15
15
 
16
- 🚨 Don't mix the two. Feature packages have NO routes, views, or controllers. Portal packages have NO models or interactions.
16
+ 🚨 Don't mix the two. Feature packages own the **domain code** — models, interactions, policies/definitions for resources owned by that feature. Portal packages own the **web surface** — controllers, routes, auth, and portal-specific policy/definition *overrides* for resources they expose.
17
17
 
18
18
  ## Feature package
19
19
 
@@ -64,21 +64,18 @@ Options:
64
64
  - `--byo` — bring your own auth.
65
65
  - `--scope=CLASS` — entity class for multi-tenancy.
66
66
 
67
- ### 2. Mount it
67
+ The generator mounts the engine for you — at `/admin` in this case, wrapped in `constraints Rodauth::Rails.authenticate(:user)` because you passed `--auth=user`. Open `packages/admin_portal/config/routes.rb` to see the generated mount.
68
68
 
69
- ```ruby
70
- # config/routes.rb
71
- Rails.application.routes.draw do
72
- constraints Rodauth::Rails.authenticate(:user) do
73
- mount AdminPortal::Engine, at: "/admin"
74
- end
75
- end
69
+ ### 2. Connect resources
70
+
71
+ ```bash
72
+ rails g pu:res:conn Blogging::Post --dest=admin_portal
76
73
  ```
77
74
 
78
- ### 3. Connect resources
75
+ You can connect multiple resources in one command:
79
76
 
80
77
  ```bash
81
- rails g pu:res:conn Post Blogging::Post --dest=admin_portal
78
+ rails g pu:res:conn Blogging::Post Blogging::Comment --dest=admin_portal
82
79
  ```
83
80
 
84
81
  See [Reference › App › Portals](/reference/app/portals) for the full portal surface.
@@ -139,7 +139,12 @@ def bulk_archive?
139
139
  end
140
140
  ```
141
141
 
142
- The UI only shows bulk actions ALL selected records support.
142
+ Two related behaviors:
143
+
144
+ - A row gets a `✕` instead of a checkbox when **no** bulk action applies to it (no `*_bulk?` policy method on that record returns true).
145
+ - A bulk action only appears in the toolbar when **every selected row** supports it. Mixing one unsupported row hides the action until you deselect.
146
+
147
+ ![Bulk action toolbar with selected drafts](/images/guides/custom-actions-bulk.png)
143
148
 
144
149
  ## Resource action (no specific record)
145
150