plutonium 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -41
  3. data/.claude/skills/plutonium-app/SKILL.md +115 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +168 -0
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +236 -145
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +171 -95
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +80 -0
  15. data/CLAUDE.md +282 -210
  16. data/README.md +9 -9
  17. data/SECURITY.md +1 -1
  18. data/app/assets/plutonium-charts.js +20604 -0
  19. data/app/assets/plutonium-charts.js.map +7 -0
  20. data/app/assets/plutonium-charts.min.js +41 -0
  21. data/app/assets/plutonium-charts.min.js.map +7 -0
  22. data/app/assets/plutonium.css +1 -1
  23. data/app/assets/plutonium.js +794 -629
  24. data/app/assets/plutonium.js.map +4 -4
  25. data/app/assets/plutonium.min.js +48 -48
  26. data/app/assets/plutonium.min.js.map +4 -4
  27. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  28. data/app/views/plutonium/_resource_header.html.erb +2 -2
  29. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  30. data/app/views/plutonium/_toast.html.erb +2 -2
  31. data/app/views/rodauth/_login_form.html.erb +1 -1
  32. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  33. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  34. data/app/views/rodauth/change_login.html.erb +2 -2
  35. data/app/views/rodauth/create_account.html.erb +4 -4
  36. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  37. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  38. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  39. data/config/brakeman.ignore +4 -4
  40. data/config/initializers/rabl.rb +0 -40
  41. data/config/locales/en/api_client.yml +22 -0
  42. data/config/locales/en/async.yml +34 -0
  43. data/config/locales/en/dashboard.yml +25 -0
  44. data/config/locales/en/invites.yml +37 -0
  45. data/config/locales/en/js.yml +40 -0
  46. data/config/locales/en/kanban.yml +28 -0
  47. data/config/locales/en/plutonium.yml +26 -0
  48. data/config/locales/en/positioning.yml +14 -0
  49. data/config/locales/en/profile.yml +29 -0
  50. data/config/locales/en/resource.yml +60 -0
  51. data/config/locales/en/ui.yml +93 -0
  52. data/config/locales/en/wizard.yml +46 -0
  53. data/docs/.vitepress/config.ts +34 -1
  54. data/docs/.vitepress/sync-skills.mjs +6 -3
  55. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  56. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  57. data/docs/.vitepress/theme/custom.css +34 -377
  58. data/docs/blog/introducing-plutonium-dashboards.md +174 -0
  59. data/docs/blog/introducing-plutonium-i18n.md +90 -0
  60. data/docs/blog/introducing-plutonium.md +4 -5
  61. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  62. data/docs/getting-started/installation.md +5 -5
  63. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  64. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  65. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  66. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  67. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  68. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  69. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  70. data/docs/getting-started/tutorial/index.md +1 -1
  71. data/docs/guides/adding-resources.md +10 -7
  72. data/docs/guides/authentication.md +25 -25
  73. data/docs/guides/authorization.md +24 -24
  74. data/docs/guides/creating-packages.md +17 -17
  75. data/docs/guides/custom-actions.md +32 -32
  76. data/docs/guides/customizing-ui.md +29 -26
  77. data/docs/guides/dashboards.md +300 -0
  78. data/docs/guides/index.md +4 -3
  79. data/docs/guides/kanban.md +56 -56
  80. data/docs/guides/multi-tenancy.md +35 -22
  81. data/docs/guides/nested-resources.md +21 -21
  82. data/docs/guides/performance.md +3 -3
  83. data/docs/guides/search-filtering.md +13 -13
  84. data/docs/guides/testing.md +16 -12
  85. data/docs/guides/theming.md +32 -17
  86. data/docs/guides/troubleshooting.md +2 -2
  87. data/docs/guides/user-invites.md +17 -17
  88. data/docs/guides/user-profile.md +51 -24
  89. data/docs/guides/wizards.md +55 -55
  90. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  91. data/docs/public/images/blog/dashboards-loading.png +0 -0
  92. data/docs/public/images/blog/dashboards-overview.png +0 -0
  93. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  94. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  95. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  96. data/docs/public/images/guides/dashboard-loading.png +0 -0
  97. data/docs/public/images/guides/dashboard-overview.png +0 -0
  98. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  99. data/docs/reference/app/generators.md +24 -24
  100. data/docs/reference/app/index.md +15 -18
  101. data/docs/reference/app/packages.md +8 -8
  102. data/docs/reference/app/portals.md +77 -29
  103. data/docs/reference/auth/accounts.md +15 -15
  104. data/docs/reference/auth/index.md +12 -12
  105. data/docs/reference/auth/profile.md +67 -29
  106. data/docs/reference/behavior/async-interactions.md +24 -24
  107. data/docs/reference/behavior/controllers.md +28 -28
  108. data/docs/reference/behavior/index.md +5 -5
  109. data/docs/reference/behavior/interactions.md +44 -44
  110. data/docs/reference/behavior/policies.md +48 -28
  111. data/docs/reference/configuration.md +88 -31
  112. data/docs/reference/dashboard/dsl.md +123 -0
  113. data/docs/reference/dashboard/index.md +35 -0
  114. data/docs/reference/dashboard/registration.md +76 -0
  115. data/docs/reference/generators/lite.md +7 -7
  116. data/docs/reference/i18n.md +262 -0
  117. data/docs/reference/index.md +11 -2
  118. data/docs/reference/kanban/authorization.md +9 -9
  119. data/docs/reference/kanban/dsl.md +33 -33
  120. data/docs/reference/kanban/index.md +1 -1
  121. data/docs/reference/kanban/positioning.md +18 -16
  122. data/docs/reference/resource/actions.md +51 -51
  123. data/docs/reference/resource/definition.md +166 -88
  124. data/docs/reference/resource/export.md +11 -7
  125. data/docs/reference/resource/index.md +16 -14
  126. data/docs/reference/resource/model.md +24 -24
  127. data/docs/reference/{positioning.md → resource/positioning.md} +78 -75
  128. data/docs/reference/resource/query.md +14 -12
  129. data/docs/reference/tenancy/entity-scoping.md +65 -35
  130. data/docs/reference/tenancy/index.md +11 -11
  131. data/docs/reference/tenancy/invites.md +20 -20
  132. data/docs/reference/tenancy/nested-resources.md +13 -13
  133. data/docs/reference/testing/index.md +116 -22
  134. data/docs/reference/ui/assets.md +65 -25
  135. data/docs/reference/ui/components.md +20 -20
  136. data/docs/reference/ui/displays.md +19 -22
  137. data/docs/reference/ui/forms.md +35 -35
  138. data/docs/reference/ui/index.md +17 -15
  139. data/docs/reference/ui/layouts.md +21 -21
  140. data/docs/reference/ui/pages.md +22 -22
  141. data/docs/reference/ui/tables.md +7 -7
  142. data/docs/reference/wizard/anchoring-resume.md +33 -32
  143. data/docs/reference/wizard/dsl.md +45 -45
  144. data/docs/reference/wizard/index.md +6 -6
  145. data/docs/reference/wizard/one-time.md +18 -18
  146. data/docs/reference/wizard/registration-launch.md +32 -32
  147. data/docs/reference/wizard/storage-config.md +23 -23
  148. data/esbuild.config.js +5 -2
  149. data/gemfiles/rails_7.gemfile.lock +2 -2
  150. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  151. data/lib/active_model/validations/array_validator.rb +1 -1
  152. data/lib/active_model/validations/attached_validator.rb +1 -1
  153. data/lib/active_model/validations/url_validator.rb +2 -2
  154. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  155. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  156. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  157. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  158. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  159. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  160. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  161. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  162. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  163. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  164. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  165. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  166. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  167. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  168. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  169. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  170. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  171. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  172. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  173. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  174. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  175. data/lib/plutonium/action/base.rb +27 -3
  176. data/lib/plutonium/action/interactive.rb +17 -4
  177. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  178. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  179. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  180. data/lib/plutonium/configuration.rb +5 -0
  181. data/lib/plutonium/core/controller.rb +25 -1
  182. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  183. data/lib/plutonium/dashboard/base.rb +114 -0
  184. data/lib/plutonium/dashboard/card.rb +200 -0
  185. data/lib/plutonium/dashboard/controller.rb +101 -0
  186. data/lib/plutonium/dashboard/dsl.rb +104 -0
  187. data/lib/plutonium/dashboard/register.rb +40 -0
  188. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  189. data/lib/plutonium/dashboard.rb +42 -0
  190. data/lib/plutonium/definition/actions.rb +6 -1
  191. data/lib/plutonium/definition/base.rb +41 -3
  192. data/lib/plutonium/definition/index_views.rb +1 -1
  193. data/lib/plutonium/definition/positioning.rb +1 -1
  194. data/lib/plutonium/definition/presentable.rb +2 -2
  195. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  196. data/lib/plutonium/helpers/display_helper.rb +10 -2
  197. data/lib/plutonium/interaction/async/context.rb +6 -8
  198. data/lib/plutonium/interaction/async/executor.rb +6 -7
  199. data/lib/plutonium/interaction/async/run.rb +2 -2
  200. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  201. data/lib/plutonium/interaction/base.rb +2 -0
  202. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  203. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  204. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  205. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  206. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  207. data/lib/plutonium/invites/controller.rb +11 -11
  208. data/lib/plutonium/kanban/column.rb +13 -3
  209. data/lib/plutonium/kanban/dsl.rb +7 -4
  210. data/lib/plutonium/models/has_cents.rb +1 -1
  211. data/lib/plutonium/profile/security_section.rb +10 -18
  212. data/lib/plutonium/query/filter.rb +10 -1
  213. data/lib/plutonium/query/filters/association.rb +27 -2
  214. data/lib/plutonium/query/filters/boolean.rb +4 -4
  215. data/lib/plutonium/query/filters/date.rb +4 -17
  216. data/lib/plutonium/query/filters/date_range.rb +2 -2
  217. data/lib/plutonium/query/filters/select.rb +10 -1
  218. data/lib/plutonium/query/filters/text.rb +9 -12
  219. data/lib/plutonium/railtie.rb +17 -0
  220. data/lib/plutonium/resource/controller.rb +10 -5
  221. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  222. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  223. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  224. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  225. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  226. data/lib/plutonium/resource/query_object.rb +30 -3
  227. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  228. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  229. data/lib/plutonium/routing/resource_registration.rb +5 -0
  230. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  231. data/lib/plutonium/translation.rb +227 -0
  232. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  233. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  234. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  235. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  236. data/lib/plutonium/ui/component/methods.rb +4 -0
  237. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  238. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  239. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  240. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  241. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  242. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  243. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  244. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  245. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  246. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  247. data/lib/plutonium/ui/display/resource.rb +23 -16
  248. data/lib/plutonium/ui/export_button.rb +3 -3
  249. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  250. data/lib/plutonium/ui/form/components/password.rb +2 -2
  251. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  252. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  253. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  254. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  255. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  256. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  257. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  258. data/lib/plutonium/ui/form/query.rb +8 -8
  259. data/lib/plutonium/ui/form/resource.rb +54 -26
  260. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  261. data/lib/plutonium/ui/grid/card.rb +3 -3
  262. data/lib/plutonium/ui/grid/resource.rb +3 -3
  263. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  264. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  265. data/lib/plutonium/ui/kanban/column.rb +6 -6
  266. data/lib/plutonium/ui/layout/base.rb +19 -1
  267. data/lib/plutonium/ui/layout/header.rb +1 -1
  268. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  269. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  270. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  271. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  272. data/lib/plutonium/ui/modal/base.rb +4 -4
  273. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  274. data/lib/plutonium/ui/nav_user.rb +2 -2
  275. data/lib/plutonium/ui/page/base.rb +10 -2
  276. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  277. data/lib/plutonium/ui/page/edit.rb +3 -3
  278. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  279. data/lib/plutonium/ui/page/new.rb +3 -3
  280. data/lib/plutonium/ui/page/show.rb +2 -2
  281. data/lib/plutonium/ui/page/wizard.rb +22 -16
  282. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  283. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  284. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  285. data/lib/plutonium/ui/table/base.rb +2 -2
  286. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  287. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  288. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  289. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  290. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  291. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  292. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  293. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  294. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  295. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  296. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  297. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  298. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  299. data/lib/plutonium/ui/table/resource.rb +53 -17
  300. data/lib/plutonium/ui/wizard/review.rb +7 -7
  301. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  302. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  303. data/lib/plutonium/version.rb +1 -1
  304. data/lib/plutonium/wizard/base.rb +2 -0
  305. data/lib/plutonium/wizard/driving.rb +8 -2
  306. data/lib/plutonium/wizard/dsl.rb +4 -2
  307. data/lib/plutonium/wizard/field_importer.rb +1 -8
  308. data/lib/plutonium/wizard/review_step.rb +8 -1
  309. data/lib/plutonium/wizard/step.rb +14 -3
  310. data/package.json +4 -2
  311. data/plutonium.gemspec +4 -1
  312. data/postcss-gem-import.cjs +34 -0
  313. data/postcss-gem-import.js +3 -28
  314. data/src/css/components.css +101 -10
  315. data/src/css/slim_select.css +0 -38
  316. data/src/css/tokens.css +24 -0
  317. data/src/js/controllers/attachment_input_controller.js +15 -6
  318. data/src/js/controllers/chart_controller.js +162 -0
  319. data/src/js/controllers/clipboard_controller.js +3 -2
  320. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  321. data/src/js/controllers/easymde_controller.js +3 -0
  322. data/src/js/controllers/filter_panel_controller.js +15 -6
  323. data/src/js/controllers/flatpickr_controller.js +8 -0
  324. data/src/js/controllers/frame_refresh_controller.js +59 -0
  325. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  326. data/src/js/controllers/register_controllers.js +4 -0
  327. data/src/js/controllers/slim_select_controller.js +79 -176
  328. data/src/js/core.js +9 -1
  329. data/src/js/i18n.js +69 -0
  330. data/src/js/plutonium-charts.js +15 -0
  331. data/src/js/turbo/turbo_confirm.js +5 -3
  332. metadata +64 -16
  333. data/.yarnrc.yml +0 -8
  334. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  335. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  336. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  337. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  338. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  339. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  340. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  341. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  342. data/public/plutonium-assets/application.js +0 -31419
  343. data/public/plutonium-assets/plutonium.ico +0 -0
  344. data/yarn.lock +0 -6858
@@ -1,6 +1,6 @@
1
1
  # Packages
2
2
 
3
- Plutonium apps are organized into **packages** — Rails engines with stricter conventions. Two flavors, hard split:
3
+ Plutonium apps are organized into **packages**: Rails engines with stricter conventions. Two flavors, hard split:
4
4
 
5
5
  | Type | Purpose | Generator | Examples |
6
6
  |---|---|---|---|
@@ -60,7 +60,7 @@ Each feature package gets its own base classes:
60
60
  - `Blogging::ResourceDefinition`
61
61
  - `Blogging::ResourceInteraction`
62
62
 
63
- These inherit from the main app's base classes — extend them for package-wide defaults.
63
+ These inherit from the main app's base classes; extend them for package-wide defaults.
64
64
 
65
65
  ### Creating resources inside a feature package
66
66
 
@@ -111,13 +111,13 @@ This is loaded from `config/application.rb`. Migrations from all packages are pi
111
111
 
112
112
  ## When to use which
113
113
 
114
- **Feature packages** — domain logic that:
114
+ **Feature packages**: domain logic that:
115
115
 
116
116
  - Could be reused across multiple portals (admin and customer both edit `Blogging::Post`).
117
117
  - Has no inherent UI / auth (it's just behavior).
118
118
  - You want isolated from other domains (`billing` should not depend on `blogging`).
119
119
 
120
- **Portal packages** — user-facing surfaces that:
120
+ **Portal packages**: user-facing surfaces that:
121
121
 
122
122
  - Have a specific auth flow (admin vs customer vs public).
123
123
  - Render different views of the same underlying resources.
@@ -137,10 +137,10 @@ packages/
137
137
  └── controllers, views, routes
138
138
  ```
139
139
 
140
- The portals expose the features. A single feature can be exposed by multiple portals — usually with different policies and definitions per portal.
140
+ The portals expose the features. A single feature can be exposed by multiple portals, usually with different policies and definitions per portal.
141
141
 
142
142
  ## Related
143
143
 
144
- - [Portals](./portals) — portal-specific configuration (mounting, auth, route registration)
145
- - [Generators](./generators) — `pu:pkg:package` and `pu:pkg:portal` flags
146
- - [Guide: Creating Packages](/guides/creating-packages) — task-oriented walkthrough
144
+ - [Portals](./portals): portal-specific configuration (mounting, auth, route registration)
145
+ - [Generators](./generators): `pu:pkg:package` and `pu:pkg:portal` flags
146
+ - [Guide: Creating Packages](/guides/creating-packages): task-oriented walkthrough
@@ -5,7 +5,7 @@ A portal is a Rails engine mixing in `Plutonium::Portal::Engine`. It defines its
5
5
  ## 🚨 Critical
6
6
 
7
7
  - **Use `pu:pkg:portal` for everything.** Never hand-write the engine file, controller concern, or layout.
8
- - **Pass `--auth=<name>`, `--public`, or `--byo`** for unattended runs — without one of these flags, the generator prompts.
8
+ - **Pass `--auth=<name>`, `--public`, or `--byo`** for unattended runs; without one of these flags, the generator prompts.
9
9
  - **Always connect resources with `pu:res:conn`.** Until connected, a resource has no portal routes and is invisible.
10
10
  - **For custom routes on a registered resource, pass `as:`.** Without it, `resource_url_for` can't build URLs.
11
11
 
@@ -20,7 +20,7 @@ rails g pu:pkg:portal <name>
20
20
  | Option | Description |
21
21
  |---|---|
22
22
  | `--auth=NAME` | Rodauth account to authenticate with (e.g. `--auth=user`) |
23
- | `--public` | Public access — no authentication |
23
+ | `--public` | Public access, no authentication |
24
24
  | `--byo` | Bring your own authentication |
25
25
  | `--scope=CLASS` | Entity class for multi-tenancy (e.g. `--scope=Organization`) |
26
26
 
@@ -51,7 +51,9 @@ end
51
51
 
52
52
  ## Controller concern (auth)
53
53
 
54
- Every portal has a `Concerns::Controller` mixed into its `ResourceController`. The generator wires this up; you customize for auth flow and shared before_action hooks.
54
+ Every portal has a `Concerns::Controller`, included by both its `ResourceController` (resource pages) and its `PlutoniumController` (dashboard and other non-resource pages). The generator wires this up; you customize for auth flow and shared before_action hooks.
55
+
56
+ Portal-wide helpers the layout calls belong here, declared with `helper_method`, so they work on the dashboard as well as resource pages. The common case is `profile_url`: `Plutonium::Auth::Rodauth` defines it as `nil`, and the avatar menu only shows a Profile link when it returns a URL. `pu:profile:conn --dest=<portal>` writes the override into this concern (see [Auth › Profile](/reference/auth/profile)).
55
57
 
56
58
  ### Rodauth
57
59
 
@@ -89,19 +91,63 @@ end
89
91
 
90
92
  ## Mounting
91
93
 
94
+ `pu:pkg:portal` writes the mount at the bottom of the portal's own `packages/<name>_portal/config/routes.rb`, at `/<name>`, wrapped in an auth constraint when `--auth` is given:
95
+
92
96
  ```ruby
93
- # config/routes.rb
97
+ # packages/admin_portal/config/routes.rb (after the engine's routes.draw block)
94
98
  Rails.application.routes.draw do
95
- # Authenticated mount
96
99
  constraints Rodauth::Rails.authenticate(:user) do
97
100
  mount AdminPortal::Engine, at: "/admin"
98
101
  end
102
+ end
103
+ ```
104
+
105
+ With `--public` or `--byo` the mount is unconstrained and the portal handles its own auth.
106
+
107
+ To change the path, edit `at:` in place. Don't mount the engine again in `config/routes.rb`: the second `mount` reuses the route name (`admin_portal`) and Rails raises `ArgumentError: Invalid route name, already in use`.
99
108
 
100
- # Unconstrained — the portal handles its own auth
101
- mount PublicPortal::Engine, at: "/public"
109
+ Route order matters: the app's `config/routes.rb` is drawn first, then each package's routes file, then gem engines (Active Storage, Turbo).
110
+
111
+ ### Mounting a portal at `/`
112
+
113
+ Two things change when a portal is mounted at `"/"`.
114
+
115
+ **Drop the `Rodauth::Rails.authenticate` constraint and authenticate in the controller concern instead.** The constraint does not fail the match for an anonymous visitor; it calls `rodauth.require_account`, which redirects to login. A constrained mount at `/` matches every path the app's own routes did not claim, so it redirects anonymous requests meant for routes drawn after it (other portals, Active Storage) and turns unknown URLs into login redirects.
116
+
117
+ ```ruby
118
+ # packages/desk_portal/config/routes.rb
119
+ Rails.application.routes.draw do
120
+ mount DeskPortal::Engine, at: "/"
121
+ end
122
+
123
+ # packages/desk_portal/app/controllers/desk_portal/concerns/controller.rb
124
+ module DeskPortal
125
+ module Concerns
126
+ module Controller
127
+ extend ActiveSupport::Concern
128
+ include Plutonium::Portal::Controller
129
+ include Plutonium::Auth::Rodauth(:user)
130
+
131
+ included do
132
+ before_action { rodauth.require_account }
133
+ end
134
+ end
135
+ end
102
136
  end
103
137
  ```
104
138
 
139
+ **Move the engine's root off `/` but keep the name.** The app's `root` is drawn first, so the generated `root to: "dashboard#index"` is unreachable and the portal's `root_path` points at the app's home page. Plutonium's header, icon rail, breadcrumbs and wizard exits all link to `root_path`, so the portal still needs a route named `root`:
140
+
141
+ ```ruby
142
+ DeskPortal::Engine.routes.draw do
143
+ get "dashboard", to: "dashboard#index", as: :root
144
+ register_resource ::Comment
145
+ # register resources above.
146
+ end
147
+ ```
148
+
149
+ The same applies to `register_dashboard ..., at: "/"`. Confirm with `Rails.application.routes.recognize_path("/")` (still the app's home) and `recognize_path("/dashboard")`.
150
+
105
151
  ## Routes & `register_resource`
106
152
 
107
153
  Portal routes live in `packages/<name>_portal/config/routes.rb`:
@@ -126,7 +172,7 @@ For each call, Plutonium auto-generates:
126
172
  - Nested routes for every registered `has_many` / `has_one` parent (prefixed `nested_`)
127
173
  - Route names that `resource_url_for` can resolve
128
174
 
129
- You list every resource the portal exposes. If a resource isn't registered, it has no URLs in that portal — `resource_url_for` will fail.
175
+ You list every resource the portal exposes. If a resource isn't registered, it has no URLs in that portal, so `resource_url_for` will fail.
130
176
 
131
177
  ### Choosing nested associations
132
178
 
@@ -136,11 +182,11 @@ Name the associations that get nested routes, and the rest are not drawn:
136
182
  register_resource ::Post, associations: %i[comments post_detail]
137
183
  ```
138
184
 
139
- `associations: []` draws none, and a name that is not a routable association fails the boot. Set `config.nested_association_routes = :declared` to make naming them the rule — see [Tenancy › Nested resources](../tenancy/nested-resources#declaring-which-associations-get-routes).
185
+ `associations: []` draws none, and a name that is not a routable association fails the boot. Set `config.nested_association_routes = :declared` to make naming them the rule; see [Tenancy › Nested resources](../tenancy/nested-resources#declaring-which-associations-get-routes).
140
186
 
141
187
  ### Singular (singleton) resources
142
188
 
143
- For resources with no collection — a single per-user `Profile`, app-wide `Settings`, etc.:
189
+ For resources with no collection: a single per-user `Profile`, app-wide `Settings`, etc.:
144
190
 
145
191
  ```ruby
146
192
  register_resource ::Profile, singular: true
@@ -178,12 +224,12 @@ end
178
224
  ```
179
225
 
180
226
  ::: warning Always pass `as:`
181
- Without `as:`, `resource_url_for(@post, action: :preview)` fails because there's no named route — especially critical for nested resources.
227
+ Without `as:`, `resource_url_for(@post, action: :preview)` fails because there's no named route, which is especially critical for nested resources.
182
228
  :::
183
229
 
184
- For most operations with business logic, prefer **interactive actions** (definition + interaction — see [Resource › Actions](/reference/resource/actions)) over custom controller routes. Action routes wire automatically with no `register_resource` block needed.
230
+ For most operations with business logic, prefer **interactive actions** (definition + interaction; see [Resource › Actions](/reference/resource/actions)) over custom controller routes. Action routes wire automatically with no `register_resource` block needed.
185
231
 
186
- ## Connecting resources — `pu:res:conn`
232
+ ## Connecting resources: `pu:res:conn`
187
233
 
188
234
  A resource is invisible until connected to at least one portal. The generator wires up the portal-specific controller, policy, definition, and route registration.
189
235
 
@@ -191,7 +237,7 @@ A resource is invisible until connected to at least one portal. The generator wi
191
237
  rails g pu:res:conn RESOURCE [RESOURCE...] --dest=PORTAL_NAME [--singular]
192
238
  ```
193
239
 
194
- Pass resources directly — avoids interactive prompts. No `--src` needed.
240
+ Pass resources directly to avoid interactive prompts. No `--src` needed.
195
241
 
196
242
  ```bash
197
243
  # Main app resources
@@ -286,13 +332,13 @@ end
286
332
  ## Per-portal overrides
287
333
 
288
334
  ```ruby
289
- # Definition — how fields render per portal
335
+ # Definition: how fields render per portal
290
336
  class AdminPortal::PostDefinition < ::PostDefinition
291
337
  scope :pending_review
292
338
  input :internal_notes, hint: "Not shown to the author"
293
339
  end
294
340
 
295
- # Policy — which fields exist, and who may act
341
+ # Policy: which fields exist, and who may act
296
342
  # `internal_notes` appears for admins because THIS permits it,
297
343
  # not because the definition above mentions it.
298
344
  class AdminPortal::PostPolicy < ::PostPolicy
@@ -302,7 +348,7 @@ class AdminPortal::PostPolicy < ::PostPolicy
302
348
  def permitted_attributes_for_create = %i[title content featured internal_notes]
303
349
  end
304
350
 
305
- # Controller — different redirect after submit
351
+ # Controller: different redirect after submit
306
352
  module AdminPortal
307
353
  class PostsController < ResourceController
308
354
  private
@@ -321,20 +367,22 @@ config.after_initialize do
321
367
  end
322
368
  ```
323
369
 
324
- Strategies: `:path` (entity id in URL — default) or a custom method name on the portal controller concern.
370
+ Strategies: `:path` (entity id in URL, the default) or a custom method name on the portal controller concern.
325
371
 
326
372
  For the full multi-tenancy story, see [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
327
373
 
328
374
  ## Dashboard / non-resource pages
329
375
 
376
+ The generated `dashboard#index` is a plain page listing the registered resources. To replace it with a [dashboard](/guides/dashboards) of metric and chart cards, run `rails g pu:dashboard Home --dest=<portal> --at=/`, which swaps the `root to:` line for a `register_dashboard ... at: "/"` registration.
377
+
330
378
  ```ruby
331
- # config/routes.rb
379
+ # packages/admin_portal/config/routes.rb
332
380
  AdminPortal::Engine.routes.draw do
333
381
  root to: "dashboard#index"
334
382
  get "settings", to: "settings#index"
335
383
  end
336
384
 
337
- # Controller — inherit from PlutoniumController, NOT ResourceController
385
+ # Controller: inherit from PlutoniumController, NOT ResourceController
338
386
  module AdminPortal
339
387
  class DashboardController < PlutoniumController
340
388
  def index
@@ -349,7 +397,7 @@ See [UI › Pages](/reference/ui/pages) for custom Phlex page classes.
349
397
  ## Multiple portals
350
398
 
351
399
  ```ruby
352
- # Admin — full access, entity-scoped
400
+ # Admin: full access, entity-scoped
353
401
  module AdminPortal
354
402
  class Engine < Rails::Engine
355
403
  include Plutonium::Portal::Engine
@@ -360,7 +408,7 @@ module AdminPortal
360
408
  end
361
409
  end
362
410
 
363
- # Customer dashboard — entity-scoped to the customer's organization
411
+ # Customer dashboard: entity-scoped to the customer's organization
364
412
  module DashboardPortal
365
413
  class Engine < Rails::Engine
366
414
  include Plutonium::Portal::Engine
@@ -371,7 +419,7 @@ module DashboardPortal
371
419
  end
372
420
  end
373
421
 
374
- # Public — no auth, no entity scoping
422
+ # Public: no auth, no entity scoping
375
423
  module PublicPortal
376
424
  class Engine < Rails::Engine
377
425
  include Plutonium::Portal::Engine
@@ -381,9 +429,9 @@ end
381
429
 
382
430
  ## Related
383
431
 
384
- - [Packages](./packages) — feature vs portal split, structure, namespacing
385
- - [Generators](./generators) — full `pu:pkg:portal` / `pu:res:conn` option reference
386
- - [Behavior › Controllers](/reference/behavior/controllers) — controller key methods, hooks, customizations
387
- - [Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — multi-tenancy mechanics
388
- - [Auth](/reference/auth/) — Rodauth account types referenced by `--auth=`
389
- - [UI › Layouts](/reference/ui/layouts) — customizing portal chrome
432
+ - [Packages](./packages): feature vs portal split, structure, namespacing
433
+ - [Generators](./generators): full `pu:pkg:portal` / `pu:res:conn` option reference
434
+ - [Behavior › Controllers](/reference/behavior/controllers): controller key methods, hooks, customizations
435
+ - [Tenancy › Entity scoping](/reference/tenancy/entity-scoping): multi-tenancy mechanics
436
+ - [Auth](/reference/auth/): Rodauth account types referenced by `--auth=`
437
+ - [UI › Layouts](/reference/ui/layouts): customizing portal chrome
@@ -1,8 +1,8 @@
1
1
  # Accounts
2
2
 
3
- Rodauth account types. Pick one (or several — apps can have multiple side-by-side).
3
+ Rodauth account types. Pick one (or several; apps can have multiple side-by-side).
4
4
 
5
- ## Basic account — `pu:rodauth:account`
5
+ ## Basic account: `pu:rodauth:account`
6
6
 
7
7
  ```bash
8
8
  rails generate pu:rodauth:account user [options]
@@ -59,7 +59,7 @@ rails g pu:rodauth:account api_user --api_only --jwt --jwt_refresh
59
59
  rails g pu:rodauth:account user --kitchen_sink
60
60
  ```
61
61
 
62
- ## Admin account — `pu:rodauth:admin`
62
+ ## Admin account: `pu:rodauth:admin`
63
63
 
64
64
  Pre-configured secure admin with multi-phase login, **required** TOTP, recovery codes, lockout, active session tracking, audit logging, role-based access, invite interaction, and **no public signup**.
65
65
 
@@ -74,7 +74,7 @@ rails g pu:rodauth:admin admin --extra-attributes=name:string,department:string
74
74
  | `--roles` | `super_admin,admin` | Comma-separated roles (positional enum) |
75
75
  | `--extra_attributes` | | Additional model attributes (e.g. `name:string`) |
76
76
 
77
- **Role-ordering convention:** index 0 is the most privileged. Generated invite interaction defaults new invitees to `roles[1]` — the order in `--roles=` matters.
77
+ **Role-ordering convention:** index 0 is the most privileged. Generated invite interaction defaults new invitees to `roles[1]`, so the order in `--roles=` matters.
78
78
 
79
79
  ```ruby
80
80
  enum :role, super_admin: 0, admin: 1
@@ -82,12 +82,12 @@ enum :role, super_admin: 0, admin: 1
82
82
 
83
83
  **Invite + resend.** The admin resource gets two actions:
84
84
 
85
- - **Invite** — invite a new admin by email; Rodauth sends a verification link and the invitee sets their own password through the verify flow.
86
- - **Resend invitation** — re-send the verification email. Only shown for admins who haven't verified yet.
85
+ - **Invite**: invite a new admin by email; Rodauth sends a verification link and the invitee sets their own password through the verify flow.
86
+ - **Resend invitation**: re-send the verification email. Only shown for admins who haven't verified yet.
87
87
 
88
88
  This uses Rodauth account verification, separate from the [Tenancy › Invites](/reference/tenancy/invites) system.
89
89
 
90
- Rake task for direct admin creation (generated alongside the account — namespace is `rodauth`, task name is the account name):
90
+ Rake task for direct admin creation (generated alongside the account; namespace is `rodauth`, task name is the account name):
91
91
 
92
92
  ```bash
93
93
  EMAIL=admin@example.com rails rodauth:admin
@@ -96,7 +96,7 @@ EMAIL=admin@example.com rails rodauth:admin
96
96
 
97
97
  The task creates the account and triggers a verification email; the admin sets their own password via that flow. No password is passed on the command line.
98
98
 
99
- ## SaaS setup — `pu:saas:setup` (meta-generator) {#saas-setup}
99
+ ## SaaS setup: `pu:saas:setup` (meta-generator) {#saas-setup}
100
100
 
101
101
  Creates the User + Entity + Membership trio AND runs:
102
102
 
@@ -152,7 +152,7 @@ class OrganizationCustomer < ApplicationRecord
152
152
  end
153
153
  ```
154
154
 
155
- ## API client — `pu:saas:api_client`
155
+ ## API client: `pu:saas:api_client`
156
156
 
157
157
  For machine-to-machine authentication. HTTP Basic Auth with auto-generated password.
158
158
 
@@ -233,12 +233,12 @@ end
233
233
  Emitted by the generators; both parts are required for concurrent portal logins.
234
234
 
235
235
  ```ruby
236
- # app/rodauth/rodauth_plugin.rb — the shared base, once
236
+ # app/rodauth/rodauth_plugin.rb: the shared base, once
237
237
  enable :session_isolation
238
238
  ```
239
239
 
240
240
  ```ruby
241
- # app/rodauth/<name>_rodauth_plugin.rb — once per account type
241
+ # app/rodauth/<name>_rodauth_plugin.rb: once per account type
242
242
  session_key_prefix "admin_" # namespaces EVERY key, account id included
243
243
  remember_cookie_key "_admin_remember"
244
244
  ```
@@ -249,7 +249,7 @@ Do **not** also set `session_key`: explicit values bypass `convert_session_key`
249
249
 
250
250
  ## Related
251
251
 
252
- - [Profile](./profile) — profile resource + SecuritySection component
253
- - [App › Portals › Controller concern (auth)](/reference/app/portals#controller-concern-auth) — wiring accounts into portal controllers
254
- - [Tenancy › Invites](/reference/tenancy/invites) — invitation system on top of Rodauth signup
255
- - [App › Generators › Authentication generators](/reference/app/generators#authentication-generators) — full generator catalog
252
+ - [Profile](./profile): profile resource + SecuritySection component
253
+ - [App › Portals › Controller concern (auth)](/reference/app/portals#controller-concern-auth): wiring accounts into portal controllers
254
+ - [Tenancy › Invites](/reference/tenancy/invites): invitation system on top of Rodauth signup
255
+ - [App › Generators › Authentication generators](/reference/app/generators#authentication-generators): full generator catalog
@@ -4,18 +4,18 @@ Plutonium uses [Rodauth](http://rodauth.jeremyevans.net/) via [rodauth-rails](ht
4
4
 
5
5
  ## Sub-pages
6
6
 
7
- - [Accounts](./accounts) — Rodauth install, basic accounts, admin accounts, SaaS setup, account customization
8
- - [Profile](./profile) — profile resource generator, the SecuritySection component
7
+ - [Accounts](./accounts): Rodauth install, basic accounts, admin accounts, SaaS setup, account customization
8
+ - [Profile](./profile): profile resource generator, the SecuritySection component
9
9
 
10
10
  ## 🚨 Critical
11
11
 
12
12
  - **Use the generators.** `pu:rodauth:install`, `pu:rodauth:account`, `pu:rodauth:admin`, `pu:saas:setup`, `pu:profile:install`, `pu:profile:conn`. Never hand-write Rodauth plugin files, account models, or profile resources.
13
- - **Role index 0 is the most privileged** (`owner`, `super_admin`). Invite interactions default new invitees to **index 1** — the order in `--roles=` matters.
13
+ - **Role index 0 is the most privileged** (`owner`, `super_admin`). Invite interactions default new invitees to **index 1**, so the order in `--roles=` matters.
14
14
  - **`pu:saas:setup --roles=...` always prepends `owner` as index 0.** Don't include `owner` in the option.
15
15
  - **`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.
16
- - **Profile association is always `:profile`** regardless of the model class — `current_user.profile`, `build_profile`, `params.require(:profile)`.
17
- - **Profile needs `pu:profile:conn` to be visible** — without it, the singular `/profile` route and `profile_url` helper don't exist.
18
- - **Every user needs a profile row.** Add an `after_create` callback or `find_or_create_by` — otherwise `current_user.profile` is nil.
16
+ - **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`.
17
+ - **Profile needs `pu:profile:conn` to be visible.** Without it, there is no singular `/profile` route and `profile_url` stays `nil` (no menu link).
18
+ - **Check an existing profile policy for `update?`.** Older `pu:profile:conn` output lacks `def update? = true`, so the profile can't be edited once it exists. See [Profile](./profile#what-pu-profile-conn-generates).
19
19
 
20
20
  ## Install Rodauth
21
21
 
@@ -34,7 +34,7 @@ class ResourceController < PlutoniumController
34
34
  end
35
35
  ```
36
36
 
37
- Multiple account types — include the matching `:name`:
37
+ Multiple account types: include the matching `:name`:
38
38
 
39
39
  ```ruby
40
40
  class AdminController < PlutoniumController
@@ -80,9 +80,9 @@ Authorization: Bearer <access_token>
80
80
 
81
81
  ## Related
82
82
 
83
- - [Accounts](./accounts) — account types and feature flags
84
- - [Profile](./profile) — profile resource + SecuritySection
85
- - [Tenancy › Invites](/reference/tenancy/invites) — invitation system on top of Rodauth signup
86
- - [App › Portals › Controller concern (auth)](/reference/app/portals#controller-concern-auth) — portal-side wiring
87
- - [Guides › Authentication](/guides/authentication) — task-oriented walkthrough
83
+ - [Accounts](./accounts): account types and feature flags
84
+ - [Profile](./profile): profile resource + SecuritySection
85
+ - [Tenancy › Invites](/reference/tenancy/invites): invitation system on top of Rodauth signup
86
+ - [App › Portals › Controller concern (auth)](/reference/app/portals#controller-concern-auth): portal-side wiring
87
+ - [Guides › Authentication](/guides/authentication): task-oriented walkthrough
88
88
  - [Guides › User profile](/guides/user-profile)
@@ -1,12 +1,12 @@
1
1
  # Profile
2
2
 
3
- Manages Rodauth account settings as a Plutonium resource — users view/edit personal fields and access Rodauth security features (change password, 2FA, etc.) on one page.
3
+ Manages Rodauth account settings as a Plutonium resource: users view/edit personal fields and access Rodauth security features (change password, 2FA, etc.) on one page.
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
- - **Profile association is always `:profile`** regardless of the model class — `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
8
- - **Profile needs `pu:profile:conn`** — without it, no route, no `profile_url` helper, no user-menu link.
9
- - **Every user needs a profile row** — add an `after_create` callback (or `find_or_create_by`). Without it, `current_user.profile` is nil and the profile route errors.
7
+ - **Profile association is always `:profile`** regardless of the model class: `current_user.profile`, `build_profile`, `params.require(:profile)` always work. `pu:profile:conn` hardcodes `current_user.profile` in the code it generates.
8
+ - **Profile needs `pu:profile:conn`.** Without it: no route, no `profile_url` override, no user-menu link.
9
+ - **Check an existing profile policy for `update?`.** Policies generated by older versions lack `def update? = true`, which leaves the profile read-only once it exists. See [What `pu:profile:conn` generates](#what-pu-profile-conn-generates).
10
10
 
11
11
  ## Quick setup
12
12
 
@@ -42,7 +42,7 @@ rails g pu:profile:install AccountSettings bio:text --dest=main_app
42
42
 
43
43
  ## What gets created
44
44
 
45
- By default the model is `{UserModel}Profile` — `UserProfile`, `StaffUserProfile`, etc. — derived from `--user-model`.
45
+ By default the model is `{UserModel}Profile` (`UserProfile`, `StaffUserProfile`, etc.), derived from `--user-model`.
46
46
 
47
47
  ```
48
48
  app/models/[package/]user_profile.rb
@@ -59,10 +59,10 @@ has_one :profile, class_name: "UserProfile", dependent: :destroy
59
59
  ```
60
60
 
61
61
  ::: warning Association name is fixed
62
- Even when the class is `StaffUserProfile`, the association is `:profile`. Don't rename it — `current_user.profile`, `build_profile`, `params.require(:profile)` all assume this.
62
+ Even when the class is `StaffUserProfile`, the association is `:profile`. Don't rename it: `current_user.profile`, `build_profile`, `params.require(:profile)` all assume this.
63
63
  :::
64
64
 
65
- The generated definition injects a custom `ShowPage` that renders the `SecuritySection` component.
65
+ If you reuse a profile model whose user association has a different name (e.g. `has_one :user_profile`), add `has_one :profile, class_name: "UserProfile"` to the user model rather than editing `current_user.profile` out of the generated policy and `profile_url`. Then connect it with `pu:profile:conn UserProfile --dest=<portal>`.
66
66
 
67
67
  ## The `SecuritySection` component
68
68
 
@@ -78,7 +78,7 @@ Dynamically lists Rodauth security links based on which features are enabled on
78
78
  | `active_sessions` | Active Sessions |
79
79
  | `close_account` | Close Account |
80
80
 
81
- If a feature isn't enabled on the account, its link doesn't render — no configuration needed.
81
+ If a feature isn't enabled on the account, its link doesn't render; no configuration needed.
82
82
 
83
83
  To customize (e.g. add chrome, reorder), override `ShowPage#render_after_content`:
84
84
 
@@ -96,40 +96,77 @@ end
96
96
 
97
97
  See [UI › Pages](/reference/ui/pages) for page-class customization.
98
98
 
99
- ## Required: every user gets a profile
99
+ ## Connecting to a portal
100
+
101
+ `pu:profile:conn` registers the profile as a **singular** resource: `/profile` (no `:id`), and overrides the `profile_url` helper:
102
+
103
+ ```bash
104
+ rails g pu:profile:conn --dest=customer_portal
105
+ ```
106
+
107
+ This is what makes the profile visible. Without it, the model exists but has no route in any portal.
108
+
109
+ ### What `pu:profile:conn` generates
110
+
111
+ It runs `pu:res:conn` with `--singular --policy --definition`, then injects:
112
+
113
+ - **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`.
114
+ - **Controller:** `resource_params` merged with `user: current_user`, so the owner is set server-side.
115
+ - **Definition:** a `ShowPage` whose `render_after_content` renders `Plutonium::Profile::SecuritySection`.
116
+ - **Portal controller concern:** `profile_url`, which points at the profile or, when the user has none yet, at the new-profile form.
117
+
118
+ 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:
100
119
 
101
120
  ```ruby
102
- class User < ApplicationRecord
103
- after_create :create_profile!
121
+ def update?
122
+ true
123
+ end
124
+ ```
104
125
 
105
- private
106
- def create_profile! = create_profile
126
+ ## Users without a profile row
127
+
128
+ 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` only appears after it is saved. If you want the show page from the first click, create the row up front:
129
+
130
+ ```ruby
131
+ class User < ApplicationRecord
132
+ after_create :create_profile
107
133
  end
108
134
  ```
109
135
 
110
- Without this, `current_user.profile` returns nil and the profile route errors. For existing users at migration time, run a one-off backfill:
136
+ and backfill existing users:
111
137
 
112
138
  ```bash
113
- rails runner "User.find_each(&:create_profile)"
139
+ rails runner "User.find_each { |u| u.profile || u.create_profile }"
114
140
  ```
115
141
 
116
- ## Connecting to a portal
142
+ 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).
117
143
 
118
- `pu:profile:conn` registers the profile as a **singular** resource — `/profile` (no `:id`), and exposes the `profile_url` helper:
144
+ ## Linking to the profile
119
145
 
120
- ```bash
121
- rails g pu:profile:conn --dest=customer_portal
146
+ 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:
147
+
148
+ ```ruby
149
+ link_to("Profile", profile_url) if profile_url
122
150
  ```
123
151
 
124
- This is what makes the profile visible. Without it, the model exists but has no route in any portal.
152
+ ## Account features on the profile page
125
153
 
126
- ## Linking to the profile
154
+ 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:
127
155
 
128
156
  ```ruby
129
- link_to("Profile", profile_url) if respond_to?(:profile_url)
157
+ class ShowPage < ShowPage
158
+ private
159
+
160
+ def render_after_content
161
+ render Plutonium::Profile::SecuritySection.new
162
+ div(class: "mt-8") do
163
+ a(href: resource_url_for(ApiToken), class: "font-medium text-[var(--pu-text)] hover:underline") { "API tokens" }
164
+ end
165
+ end
166
+ end
130
167
  ```
131
168
 
132
- The `respond_to?` guard is defensive — only portals that ran `pu:profile:conn` have the helper.
169
+ A resource like `ApiToken` belongs to the user, not the portal's entity, so connect it with `pu:res:conn ApiToken --dest=<portal> --policy` and give it the same shape the profile has: `skip_default_relation_scope!` plus `relation.where(user: user)` in the policy, and `resource_params` merged with `user: current_user` in the controller.
133
170
 
134
171
  ## Customizing the definition
135
172
 
@@ -173,13 +210,14 @@ rails g pu:profile:conn StaffUserProfile --dest=admin_portal
173
210
 
174
211
  ## Gotchas
175
212
 
176
- - **`current_user.profile` is nil** — every user needs a profile row. Add `after_create :create_profile!` to the user model.
177
- - **`profile_url` is undefined** — the profile isn't connected to this portal. Run `pu:profile:conn --dest=<portal>`.
178
- - **Custom resource name** — pass it as the first positional argument to `pu:profile:install`. The association is still `:profile`.
179
- - **SecuritySection shows nothing** — none of the relevant Rodauth features are enabled on the account. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
213
+ - **Profile is read-only after creation.** The policy predates the generated `update?`, so `update?` falls back to `create?` (`user.profile.nil?`). Add `def update? = true`.
214
+ - **First visit shows a "create profile" form.** The user has no profile row yet. Not an error; add `after_create :create_profile` plus a backfill if they should land on the show page.
215
+ - **No Profile link in the user menu.** `profile_url` still returns the default `nil` because the profile isn't connected to this portal. Run `pu:profile:conn --dest=<portal>`.
216
+ - **Custom resource name**: pass it as the first positional argument to `pu:profile:install`. The association is still `:profile`.
217
+ - **SecuritySection shows nothing**: none of the relevant Rodauth features are enabled on the account. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
180
218
 
181
219
  ## Related
182
220
 
183
- - [Accounts](./accounts) — Rodauth feature flags that gate SecuritySection links
184
- - [Resource › Definition](/reference/resource/definition) — customizing the profile definition
221
+ - [Accounts](./accounts): Rodauth feature flags that gate SecuritySection links
222
+ - [Resource › Definition](/reference/resource/definition): customizing the profile definition
185
223
  - [App › Generators › Profile generators](/reference/app/generators#profile-generators)