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
@@ -28,7 +28,7 @@ All declared in the definition.
28
28
 
29
29
  ```ruby
30
30
  class PostDefinition < ResourceDefinition
31
- # Search box — searches title and body
31
+ # Search box: searches title and body
32
32
  search do |scope, query|
33
33
  scope.where("title ILIKE :q OR body ILIKE :q", q: "%#{query}%")
34
34
  end
@@ -81,7 +81,7 @@ end
81
81
 
82
82
  When an association input targets this resource, the dropdown's autocomplete calls the resource's `search` block. Same code, two surfaces.
83
83
 
84
- ### Without a `search` block — typeahead fallback
84
+ ### Without a `search` block: typeahead fallback
85
85
 
86
86
  The framework falls back to a case-insensitive `LIKE` on the first column it finds, in priority order:
87
87
 
@@ -89,7 +89,7 @@ The framework falls back to a case-insensitive `LIKE` on the first column it fin
89
89
  2. Otherwise the first match from `[name, title, label, slug, display_name, email]`.
90
90
  3. Otherwise the relation is returned unfiltered (capped).
91
91
 
92
- For large tables, write an explicit `search` block — the leading-wildcard `LIKE` can't use a b-tree index. See [Reference › Resource › Query › Search](/reference/resource/query#search).
92
+ For large tables, write an explicit `search` block: the leading-wildcard `LIKE` can't use a b-tree index. See [Reference › Resource › Query › Search](/reference/resource/query#search).
93
93
 
94
94
  ## Filters
95
95
 
@@ -154,7 +154,7 @@ class PostDefinition < ResourceDefinition
154
154
  scope :published # uses Post.published
155
155
  scope :draft # uses Post.draft
156
156
 
157
- # Inline scope — block runs with scope as argument
157
+ # Inline scope: block runs with scope as argument
158
158
  scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
159
159
 
160
160
  # Scope with controller context
@@ -206,9 +206,9 @@ Query params are namespaced under `q`:
206
206
  ## Performance tips
207
207
 
208
208
  - **Add indexes** for filtered and sorted columns.
209
- - **Use `.distinct`** when joining associations in search — duplicate rows otherwise.
209
+ - **Use `.distinct`** when joining associations in search: duplicate rows otherwise.
210
210
  - **Prefer scopes over filters** for queries used often (no input parsing).
211
- - **`LIKE '%q%'` can't use a b-tree index** — for large tables, use `pg_search` or a trigram/GIN/full-text index.
211
+ - **`LIKE '%q%'` can't use a b-tree index**: for large tables, use `pg_search` or a trigram/GIN/full-text index.
212
212
 
213
213
  ## Full-text search with `pg_search`
214
214
 
@@ -227,13 +227,13 @@ end
227
227
 
228
228
  ## Common issues
229
229
 
230
- - **Filter not showing up** — make sure the attribute is in `permitted_attributes_for_index` on the policy.
231
- - **Slow search on large tables** — `LIKE '%q%'` can't be indexed by a b-tree. Switch to FTS or trigram.
232
- - **Duplicate rows in results** — add `.distinct` when joining associations.
233
- - **Typeahead works on small dev tables but slows in production** — same b-tree issue. Write an explicit `search` block backed by a proper index.
230
+ - **Filter not showing up**: make sure the attribute is in `permitted_attributes_for_index` on the policy.
231
+ - **Slow search on large tables**: `LIKE '%q%'` can't be indexed by a b-tree. Switch to FTS or trigram.
232
+ - **Duplicate rows in results**: add `.distinct` when joining associations.
233
+ - **Typeahead works on small dev tables but slows in production**: same b-tree issue. Write an explicit `search` block backed by a proper index.
234
234
 
235
235
  ## Related
236
236
 
237
- - [Reference › Resource › Query](/reference/resource/query) — full surface
238
- - [Adding resources](./adding-resources) — basic resource setup
239
- - [Authorization](./authorization) — `permitted_attributes_for_index` gates which fields can be filtered
237
+ - [Reference › Resource › Query](/reference/resource/query): full surface
238
+ - [Adding resources](./adding-resources): basic resource setup
239
+ - [Authorization](./authorization): `permitted_attributes_for_index` gates which fields can be filtered
@@ -1,6 +1,6 @@
1
1
  # Testing
2
2
 
3
- Plutonium ships `Plutonium::Testing` — opt-in Minitest concerns that give your app default test coverage for resources, policies, definitions, interactions, models, nested scoping, portal access, and authentication.
3
+ Plutonium ships `Plutonium::Testing`, opt-in Minitest concerns that give your app default test coverage for resources, policies, definitions, interactions, models, nested scoping, portal access, and authentication.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -75,7 +75,7 @@ resource_tests_for ResourceClass,
75
75
  has_cents: %i[price] # ResourceModel only
76
76
  ```
77
77
 
78
- The **portal symbol** drives path prefix, default auth strategy, and scoping expectations. The resolver walks `Rails.application.routes.routes` for the engine mount — no manual configuration.
78
+ The **portal symbol** drives path prefix, default auth strategy, and scoping expectations. The resolver walks `Rails.application.routes.routes` for the engine mount, with no manual configuration.
79
79
 
80
80
  ## Concerns
81
81
 
@@ -84,15 +84,17 @@ The **portal symbol** drives path prefix, default auth strategy, and scoping exp
84
84
  | `ResourceCrud` | index/show/new/create/edit/update/destroy | `create_resource!`, `valid_create_params`, `valid_update_params` |
85
85
  | `ResourcePolicy` | permit? × role × action matrix + relation_scope smoke | `policy_roles`, `policy_record`, `policy_matrix` |
86
86
  | `ResourceDefinition` | definition class + defineable prop smoke | none |
87
- | `ResourceInteraction` | `assert_interaction_success/failure` helpers | `interaction_class`, `valid_interaction_input` |
87
+ | `ResourceInteraction` | `assert_interaction_success/failure` helpers | none |
88
88
  | `ResourceModel` | `associated_with`, SGID, `has_cents` | `model_test_record` |
89
89
  | `NestedResource` | nested CRUD + sibling-tenant boundaries | `parent_record!`, `other_parent_record!`, `create_resource!(parent:)` |
90
90
  | `PortalAccess` | cross-portal access matrix | `login_as_role`, `portal_root_path` |
91
91
 
92
- Mix and match — `include` only what you want.
92
+ Mix and match: `include` only what you want.
93
93
 
94
94
  ## Auth helpers
95
95
 
96
+ `login_as` and friends come from `Plutonium::Testing::AuthHelpers`. Only `ResourceCrud`, `NestedResource` and `PortalAccess` include it; the policy, definition, model and interaction concerns don't. A bare `login_as(account)` takes its portal from `resource_tests_for`, so in a `PortalAccess` class (or a hand-written test that adds `include Plutonium::Testing::AuthHelpers` itself) pass `portal:` every time.
97
+
96
98
  ```ruby
97
99
  login_as(account) # uses portal from DSL
98
100
  login_as(account, portal: :admin) # explicit override
@@ -130,7 +132,7 @@ Idempotent. Adds the require line and creates the override stub.
130
132
  |---|---|---|
131
133
  | `--portals=admin,org` | required | Emit one file per portal |
132
134
  | `--concerns=...` | `crud,policy,definition` | Subset of concerns to include |
133
- | `--parent=organization` | none | Wires `NestedResource` parent |
135
+ | `--parent=organization` | none | Adds `parent:` to `resource_tests_for`; the `NestedResource` include and stubs also need `nested` in `--concerns` (`--concerns=crud,nested --parent=organization`) |
134
136
  | `--dest=main_app\|<package>` | `main_app` | Output destination |
135
137
 
136
138
  Output: `test/integration/<portal>_portal/<resource>_test.rb`.
@@ -144,16 +146,18 @@ Output: `test/integration/<portal>_portal/<resource>_test.rb`.
144
146
 
145
147
  ## Common pitfalls
146
148
 
147
- - **Forgotten stubs raise `NotImplementedError`** with the stub name — look for the missing method.
149
+ - **Forgotten stubs raise `NotImplementedError`** with the stub name: look for the missing method.
148
150
  - **Portal mismatch:** `:admin` expects `AdminPortal::Engine`. Pass `path_prefix:` if your engine is named differently.
149
151
  - **Tenant leakage in stubs:** for an org portal, `create_resource!` must return a record bound to the test's `@org`.
150
- - **`policy_record` for tenant-scoped resources** must belong to a tenant the role can access — otherwise even allowed roles see `false`.
151
- - **Nested resources need `parent:` in the DSL AND a parent record** from `parent_record!`. Both are required for path interpolation.
152
+ - **`policy_record` for tenant-scoped resources** must belong to a tenant the role can access, otherwise even allowed roles see `false`.
153
+ - **Nested paths come from `parent_record!.id`**, so it must return the same persisted tenant on every call (e.g. `@org`). `parent:` in the DSL documents the relationship; the concern doesn't read it.
154
+ - **Entity-scoped (`:path`) portals need two classes** for CRUD and tenant isolation, since `ResourceCrud` needs the tenant in `current_path_prefix` and `NestedResource` adds it itself. See [Reference › Testing](/reference/testing/#entity-scoped-portals-crud-tenant-isolation).
155
+ - **`valid_update_params` is compared literally** after the PATCH: use strings for enums and leave association SGIDs out.
152
156
  - **`PortalAccess` uses `portal_access_for`**, not `resource_tests_for`. Don't mix them on the same class.
153
157
 
154
158
  ## Related
155
159
 
156
- - [Reference › Testing](/reference/testing/) — full DSL reference, all concern stubs, override hooks
157
- - [Authorization](./authorization) — write the policy this concern verifies
158
- - [Multi-tenancy](./multi-tenancy) — entity scoping that drives nested-resource tests
159
- - [Authentication](./authentication) — Rodauth setup behind the default login flow
160
+ - [Reference › Testing](/reference/testing/): full DSL reference, all concern stubs, override hooks
161
+ - [Authorization](./authorization): write the policy this concern verifies
162
+ - [Multi-tenancy](./multi-tenancy): entity scoping that drives nested-resource tests
163
+ - [Authentication](./authentication): Rodauth setup behind the default login flow
@@ -18,11 +18,12 @@ Adapt Plutonium's defaults to match your brand: primary color, fonts, logo, dark
18
18
 
19
19
  ## 🚨 Critical
20
20
 
21
- - **Always register Stimulus controllers** — `registerControllers(application)`. Without it, the entire interactive layer is dead.
22
- - **Use `plutoniumTailwindConfig.merge`** when overriding Tailwind theme — plain object spread drops Plutonium's defaults.
23
- - **Tokens are CSS variables, not Tailwind keys** — `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
24
- - **Dark mode is `selector`, not `class`** — toggle by adding/removing `dark` on `<html>`.
25
- - **Prefer `.pu-*` classes and `var(--pu-*)` tokens** over hardcoded `gray-X/dark:gray-Y` pairs — they switch with dark mode automatically.
21
+ - **Run `pu:core:assets` before any CSS or brand-color change.** Out of the box the app serves the gem's prebuilt `plutonium.css` / `plutonium.min.js`, so app-side Tailwind classes, palette changes and token overrides have nowhere to compile. Don't hand-write the Tailwind/PostCSS pipeline.
22
+ - **Once the app owns its JS bundle, register Stimulus controllers** with `registerControllers(application)` (`pu:core:assets` adds it). Your bundle replaces the gem's, so without it the entire interactive layer is dead.
23
+ - **Use `plutoniumTailwindConfig.merge`** when overriding Tailwind theme: plain object spread drops Plutonium's defaults.
24
+ - **Tokens are CSS variables, not Tailwind keys**: `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
25
+ - **Dark mode is `selector`, not `class`**: toggle by adding/removing `dark` on `<html>`.
26
+ - **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs last.** Banners, badges, cards and buttons all have a `.pu-*` class that carries its own `.dark` rule; a hand-written `bg-warning-50 dark:bg-warning-950/30` pair duplicates that and drifts from the theme.
26
27
 
27
28
  ## Step 1: Run the assets generator
28
29
 
@@ -30,7 +31,9 @@ Adapt Plutonium's defaults to match your brand: primary color, fonts, logo, dark
30
31
  rails generate pu:core:assets
31
32
  ```
32
33
 
33
- This installs npm packages, creates `tailwind.config.js`, imports Plutonium CSS, registers Stimulus controllers, and points `Plutonium.configure` at your asset files. Run once per app.
34
+ This installs npm packages, creates `tailwind.config.js` and `postcss.config.js`, imports Plutonium CSS, registers Stimulus controllers, and points `Plutonium.configure` at your asset files. Run once per app.
35
+
36
+ It aborts unless `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` exist (an app created with `-j esbuild -c tailwind` plus Stimulus). For an app without them, run `bin/rails javascript:install:esbuild`, `css:install:tailwind` and `stimulus:install` first. See [Reference › UI › Assets › Generator](/reference/ui/assets#generator).
34
37
 
35
38
  ## Step 2: Asset configuration
36
39
 
@@ -75,6 +78,8 @@ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
75
78
  })
76
79
  ```
77
80
 
81
+ These palettes are compiled into the CSS at build time (`.pu-btn-primary` is `@apply bg-primary-600 ...`), so a brand color change needs this `merge` plus a rebuild (the `build:css` script, or the running `bin/dev` watcher). A `--pu-*` override won't recolor `primary`.
82
+
78
83
  ### Default palette
79
84
 
80
85
  | Color | Use |
@@ -105,7 +110,15 @@ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
105
110
  }
106
111
  ```
107
112
 
108
- Tokens auto-switch when the user toggles dark mode. See [Reference › UI › Assets › Design tokens](/reference/ui/assets#design-tokens) for the full token catalog.
113
+ Tokens auto-switch when the user toggles dark mode.
114
+
115
+ ::: warning Mirror every `:root` override in `.dark`
116
+ Your stylesheet loads after Plutonium's and `:root` / `.dark` have equal specificity, so a token overridden only in `:root` wins in dark mode too and ships your light value there. Re-assert every customized color token in `.dark`, shadows included: `--pu-shadow-sm/md/lg` have their own dark values in `src/css/tokens.css`.
117
+
118
+ Put dark values in a `.dark { ... }` block, not `@media (prefers-color-scheme: dark)`. Dark mode is the `dark` class on `<html>`, so a media query ignores the user's toggle.
119
+ :::
120
+
121
+ See [Reference › UI › Assets › Design tokens](/reference/ui/assets#design-tokens) for the full token catalog.
109
122
 
110
123
  ## Using tokens in your code
111
124
 
@@ -145,6 +158,8 @@ Pre-styled ready-to-use components:
145
158
  | Buttons | `.pu-btn`, `.pu-btn-md/-sm/-xs`, `.pu-btn-primary/-secondary/-danger/-success/-warning/-info/-accent`, `.pu-btn-ghost/-outline`, `.pu-btn-soft-*` |
146
159
  | Inputs | `.pu-input/-invalid/-valid`, `.pu-label/-required`, `.pu-hint`, `.pu-error`, `.pu-checkbox` |
147
160
  | Cards | `.pu-card`, `.pu-card-body`, `.pu-panel-header`, `.pu-panel-title`, `.pu-panel-description` |
161
+ | Badges | `.pu-badge`, `.pu-badge-neutral/-primary/-secondary/-success/-danger/-warning/-info/-accent` |
162
+ | Alerts (inline banners) | `.pu-alert`, `.pu-alert-success/-warning/-danger/-info`, `.pu-alert-message`, `.pu-alert-close` |
148
163
  | Tables | `.pu-table-wrapper`, `.pu-table`, `-header`, `-header-cell`, `-body-row`, `-body-row-selected`, `-body-cell`, `.pu-selection-cell` |
149
164
  | Toolbars / empty states | `.pu-toolbar`, `-text`, `-actions`; `.pu-empty-state`, `-icon`, `-title`, `-description` |
150
165
 
@@ -184,7 +199,7 @@ end
184
199
  ```
185
200
 
186
201
  ::: warning Always `super.merge(...)`
187
- Don't replace the theme wholesale — Plutonium's defaults handle invalid states, focus rings, and dark mode. `super.merge` keeps them.
202
+ Don't replace the theme wholesale (Plutonium's defaults handle invalid states, focus rings, and dark mode). `super.merge` keeps them.
188
203
  :::
189
204
 
190
205
  Full theme key catalog: [Reference › UI › Assets › Phlexi component themes](/reference/ui/assets#phlexi-component-themes).
@@ -225,7 +240,7 @@ document.documentElement.classList.toggle('dark')
225
240
 
226
241
  If you've overridden tokens via `:root` and `.dark`, both modes Just Work.
227
242
 
228
- ## Per-portal chrome — eject the shell
243
+ ## Per-portal chrome: eject the shell
229
244
 
230
245
  For per-portal headers/sidebars:
231
246
 
@@ -245,7 +260,7 @@ Copies `layouts/resource.html.erb` for layout-level edits.
245
260
 
246
261
  ```ruby
247
262
  Plutonium.configure do |config|
248
- config.shell = :modern # default — topbar + icon rail
263
+ config.shell = :modern # default: topbar + icon rail
249
264
  # config.shell = :classic # legacy header + sidebar (only when upgrading)
250
265
  end
251
266
  ```
@@ -268,13 +283,13 @@ Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fi
268
283
 
269
284
  ## Common issues
270
285
 
271
- - **Stimulus controllers silently fail** — if `registerControllers(application)` isn't called, the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error — just no behavior.
272
- - **`plutoniumTailwindConfig.merge` is mandatory** — plain spread drops defaults silently.
273
- - **Tokens not switching in dark mode** — you used `bg-pu-surface` instead of `bg-[var(--pu-surface)]`. Tokens are CSS variables, not Tailwind keys.
274
- - **`.pu-btn` styles not applying** — check that Plutonium CSS is imported BEFORE Tailwind: `@import "gem:plutonium/src/css/plutonium.css";` then `@import "tailwindcss";`.
286
+ - **Stimulus controllers silently fail**: once the app serves its own JS bundle, if `registerControllers(application)` isn't called, the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error: just no behavior.
287
+ - **`plutoniumTailwindConfig.merge` is mandatory**: plain spread drops defaults silently.
288
+ - **Tokens not switching in dark mode**: you used `bg-pu-surface` instead of `bg-[var(--pu-surface)]`. Tokens are CSS variables, not Tailwind keys.
289
+ - **`.pu-btn` styles not applying**: check that Plutonium CSS is imported BEFORE Tailwind: `@import "gem:plutonium/src/css/plutonium.css";` then `@import "tailwindcss";`.
275
290
 
276
291
  ## Related
277
292
 
278
- - [Reference › UI › Assets](/reference/ui/assets) — full Tailwind / Stimulus / design tokens / component classes surface
279
- - [Reference › UI › Layouts](/reference/ui/layouts) — shell, eject, ResourceLayout
280
- - [Reference › UI › Forms › Theming](/reference/ui/forms#theming) — Form theme keys
293
+ - [Reference › UI › Assets](/reference/ui/assets): full Tailwind / Stimulus / design tokens / component classes surface
294
+ - [Reference › UI › Layouts](/reference/ui/layouts): shell, eject, ResourceLayout
295
+ - [Reference › UI › Forms › Theming](/reference/ui/forms#theming): Form theme keys
@@ -79,6 +79,6 @@ If you encounter an issue not covered here, please [open an issue](https://githu
79
79
 
80
80
  - [Nested resources](./nested-resources)
81
81
  - [Adding resources](./adding-resources)
82
- - [Reference › Behavior › Controllers](/reference/behavior/controllers) — `controller_for`, `resource_url_for`, `current_parent`
83
- - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources) — nested URL generation
82
+ - [Reference › Behavior › Controllers](/reference/behavior/controllers): `controller_for`, `resource_url_for`, `current_parent`
83
+ - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources): nested URL generation
84
84
  - [Rails Inflections](https://api.rubyonrails.org/classes/ActiveSupport/Inflector/Inflections.html)
@@ -8,7 +8,7 @@ An admin enters an email, the user gets an invite link, clicks it, signs up (or
8
8
 
9
9
  ## Prerequisites
10
10
 
11
- You need a user model, an entity model, and a membership model. The fastest path is `pu:saas:setup` — it creates all three and runs `pu:invites:install` automatically:
11
+ You need a user model, an entity model, and a membership model. The fastest path is `pu:saas:setup`: it creates all three and runs `pu:invites:install` automatically:
12
12
 
13
13
  ```bash
14
14
  rails g pu:saas:setup --user Customer --entity Organization
@@ -43,7 +43,7 @@ rails g pu:invites:install \
43
43
  | `--enforce-domain` | `false` | Require email domain to match entity |
44
44
 
45
45
  ::: info Roles come from the membership model
46
- `pu:invites:install` reads the role list from the membership model's `enum :role` — it does not accept a `--roles=` flag. Define roles when you generate the membership model (`pu:saas:membership --roles=...`), or edit the enum directly. **Index 0 is the most privileged** (typically `owner`); the invite interaction excludes `owner` from selectable choices and defaults new invitees to the second role.
46
+ `pu:invites:install` reads the role list from the membership model's `enum :role`; it does not accept a `--roles=` flag. Define roles when you generate the membership model (`pu:saas:membership --roles=...`), or edit the enum directly. **Index 0 is the most privileged** (typically `owner`); the invite interaction excludes `owner` from selectable choices and defaults new invitees to the second role.
47
47
  :::
48
48
 
49
49
  ### 2. Migrate
@@ -112,7 +112,7 @@ Users land on `/welcome` where pending invites are shown. Including `Plutonium::
112
112
  include Plutonium::Invites::PendingInviteCheck
113
113
  ```
114
114
 
115
- ## Invitables — app models notified on acceptance
115
+ ## Invitables: app models notified on acceptance
116
116
 
117
117
  An invitable is a model that gets notified when its invitation is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
118
118
 
@@ -137,7 +137,7 @@ end
137
137
  ```
138
138
 
139
139
  ::: warning Without `on_invite_accepted`
140
- The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
140
+ The invitable never learns about the new user: the invite is consumed but your app doesn't update its state.
141
141
  :::
142
142
 
143
143
  ## Multiple invite flows in one app
@@ -158,7 +158,7 @@ rails g pu:invites:install \
158
158
 
159
159
  Each invocation creates an independent flow: model, controller, route, helper all named for the invite-model.
160
160
 
161
- The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array — `pending_invite` checks all flows in priority order (first-match wins).
161
+ The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array; `pending_invite` checks all flows in priority order (first-match wins).
162
162
 
163
163
  See [Reference › Tenancy › Invites › Multiple invite flows](/reference/tenancy/invites#multiple-invite-flows).
164
164
 
@@ -222,27 +222,27 @@ entity.user_invites.pending # list pending
222
222
 
223
223
  ## Security
224
224
 
225
- - **Token security** — `SecureRandom.urlsafe_base64(32)` — 256 bits, URL-safe. Stored hashed, raw token shown only at creation.
226
- - **Email validation** — `enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
227
- - **Rate limiting** — use Rack::Attack or similar to throttle invite creation per admin and acceptance attempts per IP.
225
+ - **Token security**: `SecureRandom.urlsafe_base64(32)`, 256 bits, URL-safe. Stored hashed, raw token shown only at creation.
226
+ - **Email validation**: `enforce_email?` is `true` by default. The accepting user's email must match the invited email; this prevents account hijacking via invite forwarding.
227
+ - **Rate limiting**: use Rack::Attack or similar to throttle invite creation per admin and acceptance attempts per IP.
228
228
 
229
229
  ::: danger Don't disable enforce_email?
230
230
  ```ruby
231
231
  def enforce_email? = false # ← only if you fully understand the trade-off
232
232
  ```
233
- Without this, anyone with the token can sign up — defeats the purpose of an invitation system.
233
+ Without this, anyone with the token can sign up, which defeats the purpose of an invitation system.
234
234
  :::
235
235
 
236
236
  ## Common issues
237
237
 
238
- - **"Invitation not found or expired"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
239
- - **Email mismatch error** — the accepting user's email doesn't match the invited email. This is by design (security).
240
- - **Rodauth redirect after login doesn't go to `/welcome`** — check `login_redirect "/welcome"` in the rodauth plugin's `configure` block.
241
- - **`on_invite_accepted` not called** — ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
238
+ - **"Invitation not found or expired"**: token expired (default 1 week), invite cancelled, or no longer `pending`.
239
+ - **Email mismatch error**: the accepting user's email doesn't match the invited email. This is by design (security).
240
+ - **Rodauth redirect after login doesn't go to `/welcome`**: check `login_redirect "/welcome"` in the rodauth plugin's `configure` block.
241
+ - **`on_invite_accepted` not called**: ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
242
242
 
243
243
  ## Related
244
244
 
245
- - [Reference › Tenancy › Invites](/reference/tenancy/invites) — full surface, multi-flow apps, customization
246
- - [Multi-tenancy](./multi-tenancy) — entity scoping (invites are entity-scoped automatically)
247
- - [Authentication](./authentication) — Rodauth setup
248
- - [User profile](./user-profile) — account-settings page
245
+ - [Reference › Tenancy › Invites](/reference/tenancy/invites): full surface, multi-flow apps, customization
246
+ - [Multi-tenancy](./multi-tenancy): entity scoping (invites are entity-scoped automatically)
247
+ - [Authentication](./authentication): Rodauth setup
248
+ - [User profile](./user-profile): account-settings page
@@ -8,15 +8,15 @@ A `/profile` URL that shows the user's personal fields plus a "Security" section
8
8
 
9
9
  ## 🚨 Critical
10
10
 
11
- - **Profile association is always `:profile`** regardless of the model class — `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
12
- - **Profile needs `pu:profile:conn` to be visible** — without it, no `/profile` route, no `profile_url` helper.
13
- - **Every user needs a profile row.** Add an `after_create :create_profile!` callback to the user model. Without it, `current_user.profile` is nil.
11
+ - **Profile association is always `:profile`** regardless of the model class: `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
12
+ - **Profile needs `pu:profile:conn` to be visible.** Without it there is no `/profile` route, and `profile_url` stays `nil` so the user menu has no Profile link.
13
+ - **A user without a profile row is fine.** `profile_url` sends them to the new-profile form. Creating the row up front is optional (see step 4).
14
14
 
15
15
  The show page renders the user's fields, then the **Security Settings** block with Rodauth-backed actions:
16
16
 
17
17
  ![User profile show page with SecuritySection](/images/guides/user-profile-show.png)
18
18
 
19
- Editing produces a regular Plutonium form — same form generator the rest of your resources use:
19
+ Editing produces a regular Plutonium form, same form generator the rest of your resources use:
20
20
 
21
21
  ![User profile edit form](/images/guides/user-profile-edit.png)
22
22
 
@@ -28,7 +28,7 @@ rails g pu:profile:setup date_of_birth:date bio:text \
28
28
  --portal=competition_portal
29
29
  ```
30
30
 
31
- `pu:profile:setup` is a meta-generator — runs `pu:profile:install` + `pu:profile:conn` in one shot.
31
+ `pu:profile:setup` is a meta-generator: runs `pu:profile:install` + `pu:profile:conn` in one shot.
32
32
 
33
33
  ## Step-by-step
34
34
 
@@ -64,31 +64,38 @@ rails db:prepare
64
64
  rails g pu:profile:conn --dest=customer_portal
65
65
  ```
66
66
 
67
- This registers the profile as a **singular** resource — exposes `/profile` (no `:id`) and the `profile_url` helper.
67
+ This registers the profile as a **singular** resource: exposes `/profile` (no `:id`) and the `profile_url` helper.
68
68
 
69
- ### 4. Add the auto-create callback
69
+ It also generates a portal policy scoped to the current user: `create?` is `user.profile.nil?` (one profile per user), `update?` is `true`, and `destroy?` is `false`. The controller sets `user` from `current_user`, so `:user` is not a permitted attribute.
70
+
71
+ ::: tip Profile can't be edited?
72
+ Policies generated by older versions of `pu:profile:conn` lack `update?`, so it falls back to `create?` and the profile becomes read-only once it exists. Add `def update? = true` to the portal's profile policy.
73
+ :::
74
+
75
+ ### 4. (Optional) Create the row up front
76
+
77
+ Without a profile row, the first visit to the profile is the "create profile" form, and the Security section appears once it is saved. To land users on the show page from the first click, create the row when the user is created:
70
78
 
71
79
  ```ruby
72
- # app/models/user.rb (modified by pu:profile:install)
80
+ # app/models/user.rb (has_one added by pu:profile:install)
73
81
  class User < ApplicationRecord
74
82
  has_one :profile, class_name: "UserProfile", dependent: :destroy
75
83
 
76
- after_create :create_profile!
77
-
78
- private
79
- def create_profile! = create_profile
84
+ after_create :create_profile
80
85
  end
81
86
  ```
82
87
 
83
- For existing users at migration time:
88
+ and backfill existing users:
84
89
 
85
90
  ```bash
86
- rails runner "User.find_each(&:create_profile)"
91
+ rails runner "User.find_each { |u| u.profile || u.create_profile }"
87
92
  ```
88
93
 
94
+ Skip this when the profile has required fields the user must fill in, or when other code already creates profiles.
95
+
89
96
  ## What you get
90
97
 
91
- The generated definition injects a custom `ShowPage` that renders `SecuritySection` — dynamically lists Rodauth security links based on which features are enabled:
98
+ The generated definition injects a custom `ShowPage` that renders `SecuritySection`: dynamically lists Rodauth security links based on which features are enabled:
92
99
 
93
100
  | Feature enabled | Link rendered |
94
101
  |---|---|
@@ -100,15 +107,34 @@ The generated definition injects a custom `ShowPage` that renders `SecuritySecti
100
107
  | `active_sessions` | Active Sessions |
101
108
  | `close_account` | Close Account |
102
109
 
103
- If a feature isn't enabled, its link doesn't render — no configuration needed.
110
+ If a feature isn't enabled, its link doesn't render; no configuration needed.
104
111
 
105
112
  ## Linking to the profile
106
113
 
114
+ The topbar avatar menu already shows a "Profile" entry once `pu:profile:conn` has run. `Plutonium::Auth::Rodauth` defines `profile_url` as `nil`, and the generator overrides it in the portal's controller concern. To link from your own views:
115
+
107
116
  ```ruby
108
- link_to("Profile", profile_url) if respond_to?(:profile_url)
117
+ link_to("Profile", profile_url) if profile_url
118
+ ```
119
+
120
+ ## Account features on the profile page
121
+
122
+ Per-user settings that aren't profile fields (API tokens, connected accounts, notification preferences) belong on the profile page. Link them from the same `ShowPage` hook, next to `SecuritySection`:
123
+
124
+ ```ruby
125
+ class ShowPage < ShowPage
126
+ private
127
+
128
+ def render_after_content
129
+ render Plutonium::Profile::SecuritySection.new
130
+ div(class: "mt-8") do
131
+ a(href: resource_url_for(ApiToken), class: "font-medium text-[var(--pu-text)] hover:underline") { "API tokens" }
132
+ end
133
+ end
134
+ end
109
135
  ```
110
136
 
111
- The `respond_to?` guard is defensive — only portals that ran `pu:profile:conn` have the helper.
137
+ See [Reference › Auth › Profile](/reference/auth/profile#account-features-on-the-profile-page) for scoping such a resource to the user.
112
138
 
113
139
  ## Customizing the definition
114
140
 
@@ -152,12 +178,13 @@ rails g pu:profile:conn StaffUserProfile --dest=admin_portal
152
178
 
153
179
  ## Common issues
154
180
 
155
- - **`current_user.profile` is nil** — every user needs a profile row. Add `after_create :create_profile!` to the user model.
156
- - **`profile_url` is undefined** — the profile isn't connected to this portal. Run `pu:profile:conn --dest=<portal>`.
157
- - **`SecuritySection` shows nothing** — none of the relevant Rodauth features are enabled. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
181
+ - **First visit shows a "create profile" form.** The user has no profile row yet. That's expected; see step 4 if they should land on the show page.
182
+ - **Profile is read-only after creation.** The policy predates the generated `update?`. Add `def update? = true`.
183
+ - **No Profile link in the user menu.** The profile isn't connected to this portal, so `profile_url` returns `nil`. Run `pu:profile:conn --dest=<portal>`.
184
+ - **`SecuritySection` shows nothing**: none of the relevant Rodauth features are enabled. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
158
185
 
159
186
  ## Related
160
187
 
161
- - [Reference › Auth › Profile](/reference/auth/profile) — full surface
162
- - [Reference › Auth › Accounts](/reference/auth/accounts) — Rodauth feature flags that gate SecuritySection
163
- - [Authentication](./authentication) — the underlying auth setup
188
+ - [Reference › Auth › Profile](/reference/auth/profile): full surface
189
+ - [Reference › Auth › Accounts](/reference/auth/accounts): Rodauth feature flags that gate SecuritySection
190
+ - [Authentication](./authentication): the underlying auth setup