Skip to content

Registration & launch

A wizard reaches a user one of two ways: as a resource action (the wizard macro on a definition) or as a route-mounted entry (register_wizard) — inside a portal or on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth — see Hosting & the controller override hook).

On a resource — the wizard macro

A wizard macro in a resource definition registers a wizard and synthesizes its launching action — sugar over the Action system.

ruby
class CompanyDefinition < Plutonium::Resource::Definition
  wizard :configure, ConfigureCompanyWizard            # anchored → record action
  wizard :onboard,   CompanyOnboardingWizard           # no anchor → resource action
  wizard :archive,   ArchiveWithReasonWizard, record_action: true   # override placement
end
  • Placement is dictated by the wizard, not chosen — an anchored wizard is a record action (it needs a record, the anchor); a non-anchored wizard is a collection-level resource action (index header). A record-action wizard surfaces on BOTH the record's show page and each list row (collection_record_action, scoped to that row's record), exactly like edit/destroy. The only thing you configure is where a record action shows (see the table); a flag that doesn't apply to the wizard's kind (e.g. resource_action: on an anchored wizard) raises.
  • Auto-mounted on the resource controller — the wizard macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
  • The anchor is IDOR-safe — an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated resource_record!. A record outside the portal's authorized scope (another tenant's, or a non-existent id) 404s; it is never loaded via an unscoped find_by.
  • Bulk wizards are not supported — wizards are inherently per-instance flows. Use a bulk interaction instead.
  • Authorization mirrors actions — a resource policy predicate named after the wizard key gates it (def configure? = update?, def onboard? = create?).
OptionMeaning
record_action:(Record wizards only) show on the record's show page. Default true; false removes it from there.
collection_record_action:(Record wizards only) show on each list row (scoped to that row's record). Default true; false keeps it on the show page but off the list.
label: / icon: / position: / category: / confirmation: / turbo_frame:Standard action chrome — any action option passes through.

Placement isn't an option — it follows anchored?. Passing a flag that doesn't apply (resource_action: on a record wizard, or record_action:/collection_record_action: on a resource wizard) raises.

Synthesized routes (resource-mounted)

# anchored → member route (anchor = the scoped resource_record!)
GET       /companies/:id/wizards/:wizard_name                 → launch (redirect to step)
GET/POST  /companies/:id/wizards/:wizard_name(/:token)/:step

# non-anchored → collection route (create flow)
GET       /companies/wizards/:wizard_name                     → launch (redirect to step)
GET/POST  /companies/wizards/:wizard_name(/:token)/:step

The synthesized launch action points at the bare wizard URL (no step). A GET there resolves the run (minting the per-run :token for a tokened wizard, or resolving the keyed identity) and redirects to its current step: the resumed cursor for an in-progress keyed run, else the first step, with the token already in the URL. So clicking the launch button resumes where the user left off (rather than jumping back to step 1) and never forks a fresh run on a first-step reload.

Route-mounted — register_wizard

For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with register_wizardinside a portal engine's routes (most common) or on the main application's routes, alongside register_resource:

ruby
# packages/admin_portal/config/routes.rb
AdminPortal::Engine.routes.draw do
  register_wizard ::OnboardOrganizationWizard, at: "onboarding"               # in-shell (portal default)
  register_wizard ::SetupOrgWizard, at: "setup", layout: :basic               # bare (BasicLayout)

  register_resource ::Company
end

# config/routes.rb — a main-app (portal-less) wizard
Rails.application.routes.draw do
  register_wizard ::AppOnboardingWizard, at: "onboarding"                     # main-app default → :basic
end
ArgumentMeaning
at: (required)The host-relative base path for the wizard's steps.
as:Override the route helper name prefix (defaults to at:, then the wizard's name, e.g. OnboardOrganizationWizardonboarding).
public:Mount on a public (unauthenticated) route for an anonymous wizard. Defaults to the wizard's own anonymous? flag.
layout:The Rails layout to render in (a layout name, like the controller layout macro): :basic (bare), :resource (shell), or any app layout. Defaults by host — portal → the resource shell, main-app → :basic.

This draws the wizard's step routes within the host (so a portal mount inherits the portal's scope/auth/layout) and dispatches them to a wizard controller. It provides <name>_wizard_path / _url helpers.

Hosting & the controller override hook

register_wizard resolves the controller it dispatches to override-first: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as register_resource — there is no hand-written file unless you want to customize.

HostControllerBase / auth
Portal<Portal>::WizardsController if defined, else synthesizedthe portal's PlutoniumController (inherits its auth/scope/layout)
Main app, authenticated::WizardsControlleryou define ityours: a base + include Plutonium::Auth::Rodauth(:account)
Main app, public (anonymous)::PublicWizardsController (synthesized)a bare base + Plutonium::Auth::Public (guest)

The synthesized main-app controller is bare — rooted in ApplicationController/ActionController::Base, deliberately not in ::PlutoniumController (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has no current_user, so an authenticated main-app wizard requires you to define ::WizardsController yourself with the auth concern:

ruby
# app/controllers/wizards_controller.rb
class WizardsController < ApplicationController
  include Plutonium::Wizard::Controller          # the complete include surface
  include Plutonium::Auth::Rodauth(:user)        # supplies current_user
end

Plutonium::Wizard::Controller is the whole mechanism: including it on any base yields a renderable wizard controller. It pulls in Plutonium::Core::Controller (asset/layout machinery) and contributes the "plutonium" view-lookup prefix itself, so even a bare ActionController::Base host resolves the gem's shared partials (plutonium/_flash, …). For an app that needs no custom auth base, subclass the ready-made Plutonium::Wizard::BaseController (< ActionController::Base with the module already included). The module is the contract; the class is sugar.

Layout

layout: is the Rails layout the wizard renders in — a layout name, exactly like the controller layout macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the Page component):

layout:AppearanceLayout
:basicno sidebar/topbar — e.g. "set up your organization"basic (BasicLayout)
:resourcesidebar + topbar + wizard (in-app)the resource shell layout
(any app layout)whatever that layout renderspassed straight to Rails
(omitted)host default — see below
  • Defaults by host: portal → the resource shell; main-app → :basic (a bare main-app host has no shell to embed in). Pass layout: to override.
  • Turbo-frame requests are always layout-less regardless of the setting — that's the embedded/modal path (how resource-anchored wizard-macro launches render).
  • layout: is a register_wizard option only; it travels with the mount, not the wizard class. Resource-defined wizards take no layout: (always embedded).

Synthesized routes

GET  /onboarding                  → launch: resolve/mint the run, redirect to its step
GET  /onboarding(/:token)/:step   → renders the step
POST /onboarding(/:token)/:step   → advances (the `_direction` param carries next/back/cancel)

The bare /onboarding is the canonical entry point (helper onboarding_wizard_launch_path). A GET there resolves the run (minting the per-run :token for a tokened wizard, or resolving the keyed/guest identity) and 303-redirects to its entry step: the resumed cursor for an in-progress keyed/guest run, else the first visible step. Because the redirect target already carries the token, the address bar shows a stable, shareable run URL from the first paint (the token no longer "appears" only after the first submit, and reloading the first step can't fork a second run). Link to /onboarding from menus/dashboards; the stepped /onboarding(/:token)/:step URLs are built for you by the engine.

The POST _direction param carries next / back / cancel. Where Cancel sends the user is captured at launch from a ?return_to= query param (or the referer), sanitized to a same-host local path that isn't the wizard's own mount (open-redirect-safe); Cancel returns there, falling back to the host root. So linking to /onboarding?return_to=/dashboard lands a cancelled run back on the dashboard.

scope_gid (folded into the instance key) comes from the portal's scoping entity when the portal is entity-scoped.

Entry authorization

A portal-level wizard has no resource policy, so gate entry with an authorize? instance method on the wizard — checked before each request:

ruby
class WelcomeWizard < Plutonium::Wizard::Base
  def authorize?
    current_user.present? && !current_user.onboarded?
  end
end

A falsy return → ActionPolicy::Unauthorized (403). Resource-attached wizards instead use their action's policy predicate (def onboard? etc.).

A portal-level wizard with no authorize? is open to ANY authenticated portal user

A portal-level wizard (registered with register_wizard) has no resource policy and defaults to allowed — so if you omit authorize?, every authenticated user of that portal can run it. Always define def authorize? for anything privileged (admin-only flows, per-user gating, tenant checks). The default-allow is only safe for flows that are genuinely fine for any signed-in portal user (e.g. self-service onboarding).

As-built: authorize? is an instance method

Define def authorize? on the wizard. The controller checks it only when the wizard responds to it (default: allowed).

Public mount for anonymous wizards

Wizards require authentication by default. A guest (anonymous) wizard needs a route reachable before login. Because a portal engine is mounted inside the host's authentication constraint (constraints Rodauth::Rails.authenticate(:user) { mount ... }), a route drawn in the portal is unreachable pre-login. So register_wizard draws an anonymous wizard's route on the main application's route set, outside that constraint:

ruby
# in the portal engine's routes (register_wizard is available there)
register_wizard ::GuestSignupWizard, at: "signup", public: true
#   → GET/POST /signup(/:token)/:step  (a top-level, unauthenticated route)
  • public: true is the default for an anonymous wizard; you can pass it explicitly for clarity.
  • A non-anonymous wizard may not be mounted public (raises) — and an anonymous wizard may not be mounted authenticated (it would be unreachable pre-login).
  • The public route dispatches to a synthesized top-level ::PublicWizardsController (a distinct const from an authenticated main-app ::WizardsController, so the two never collapse onto each other) that renders full-page with a standalone layout (no resource sidebar / user menu) and treats the request as a guest via Plutonium::Auth::Public.

One-time gating

To make a wizard run once and gate a controller behind it, see One-time wizards — a concurrency_key + one_time + the Plutonium::Wizard::Gate concern (ensure_wizard_completed).

For a one-time wizard, the launch action this macro synthesizes also hides itself once the current user has completed it (via a render-time action condition:); a custom condition: composes with that check. See The launch action hides itself once completed.

Constraints

  • with:-anchored wizards mount on the resource, not route-level. Register a with:-anchored wizard on the anchored resource's definition with the wizard macro (it auto-mounts a member action whose anchor is the scoped resource_record!). Passing a with:-anchored wizard to register_wizard raises: a route-level mount has no resource record to anchor to. A via:-anchored (context-anchored) wizard does mount route-level; its anchor is resolved by a controller method, not a URL :id.
  • An authenticated main-app wizard needs an app-defined controller. The synthesized main-app controller is bare (no current_user); supply your own ::WizardsController with an auth concern (see Hosting & the controller override hook). Portal mounts and anonymous public mounts need nothing.
  • Route-helper names must be unique across public mounts. A public (anonymous) wizard's route is drawn on the main app, keyed by its helper name (as:at: → class-derived). Two distinct public wizards resolving to the same helper name raise at draw time — give one an explicit as:. (Re-drawing the same wizard on a route reload is a no-op, not a collision.)

Released under the MIT License.