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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-app/SKILL.md +2 -0
- data/.claude/skills/plutonium-auth/SKILL.md +6 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +1 -1
- data/.claude/skills/plutonium-tenancy/SKILL.md +25 -6
- data/.claude/skills/plutonium-testing/SKILL.md +3 -1
- data/.claude/skills/plutonium-ui/SKILL.md +3 -3
- data/CHANGELOG.md +17 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1 -0
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +1 -1
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +1 -2
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -50
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/guides/authentication.md +10 -5
- data/docs/guides/authorization.md +3 -3
- data/docs/guides/creating-packages.md +8 -11
- data/docs/guides/custom-actions.md +6 -1
- data/docs/guides/customizing-ui.md +258 -0
- data/docs/guides/index.md +49 -32
- data/docs/guides/multi-tenancy.md +10 -2
- data/docs/guides/nested-resources.md +69 -0
- data/docs/guides/search-filtering.md +6 -0
- data/docs/guides/testing.md +5 -1
- data/docs/guides/theming.md +13 -0
- data/docs/guides/user-invites.md +10 -4
- data/docs/guides/user-profile.md +8 -0
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- data/docs/reference/app/generators.md +4 -4
- data/docs/reference/auth/accounts.md +6 -7
- data/docs/reference/auth/index.md +1 -1
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +67 -55
- data/docs/reference/resource/definition.md +1 -1
- data/docs/reference/tenancy/entity-scoping.md +8 -1
- data/docs/reference/tenancy/index.md +1 -1
- data/docs/reference/tenancy/invites.md +12 -5
- data/docs/reference/ui/tables.md +8 -4
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
- data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/invites/install_generator.rb +44 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- data/lib/plutonium/helpers/turbo_helper.rb +19 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +4 -4
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- data/lib/plutonium/ui/component/methods.rb +1 -0
- data/lib/plutonium/ui/form/base.rb +17 -1
- data/lib/plutonium/ui/form/components/secure_association.rb +11 -6
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/theme.rb +1 -1
- data/lib/plutonium/ui/page/edit.rb +1 -1
- data/lib/plutonium/ui/page/new.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
- data/lib/plutonium/version.rb +1 -1
- data/package.json +4 -1
- data/src/js/controllers/form_controller.js +5 -4
- data/yarn.lock +108 -1
- 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
|
-
|
|
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 = "×"
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
150
|
+

|
|
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
|
+
|  |  |
|
|
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
|
+

|
|
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
|
-
##
|
|
15
|
+
## Installing Rodauth
|
|
16
16
|
|
|
17
|
-
|
|
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
|
|
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
|
-
##
|
|
64
|
+
## Gating the Portal with Authentication
|
|
61
65
|
|
|
62
|
-
|
|
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
|
-
```
|
|
65
|
-
|
|
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
|
|
72
|
+
This updates two files:
|
|
78
73
|
|
|
79
|
-
|
|
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
|
-
```
|
|
82
|
-
|
|
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
|
+

|
|
98
|
+
|
|
99
|
+
The "Create a New Account" link goes to the same Rodauth-rendered account creation form:
|
|
100
|
+
|
|
101
|
+

|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
Open any unpublished post and click **Actions** in the top-right — the "Publish Post" item appears with its Tabler icon:
|
|
83
|
+
|
|
84
|
+

|
|
85
|
+
|
|
86
|
+
It also shows on each table row's `⋮` menu — same action, available wherever the record is rendered:
|
|
87
|
+
|
|
88
|
+

|
|
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
|
|
72
|
+
The post show page now has tabs — **Details** and **Comments** — driven by the associations you permit:
|
|
73
|
+
|
|
74
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
🚨
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
+
You can connect multiple resources in one command:
|
|
79
76
|
|
|
80
77
|
```bash
|
|
81
|
-
rails g pu:res:conn Post Blogging::
|
|
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
|
-
|
|
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
|
+

|
|
143
148
|
|
|
144
149
|
## Resource action (no specific record)
|
|
145
150
|
|