transition_buttons

Render one button per state-machine event the current user may perform on a record right now — the intersection of legal from the current state (the state machine's guards) and authorized for this user (your policy) — each button POSTing to a member transition route that re-checks both server-side before firing.

The buttons are a convenience for the view. The endpoint is the security boundary: it never trusts the submitted event, re-deriving the legal-and-authorized set independently.

Ships with adapters for state_machines and ActionPolicy; both are swappable.

Install

# Gemfile
gem "transition_buttons"
gem "state_machines-activerecord"
gem "action_policy"

Usage

1. A model with one or more state machines

class Post < ApplicationRecord
  state_machine :state, initial: :draft do
    event(:submit)  { transition draft: :pending_review }
    event(:publish) { transition pending_review: :published }
    event(:reject)  { transition pending_review: :draft }
  end

  # A second machine on the same record is fully supported.
  state_machine :moderation_state, initial: :ok do
    event(:flag)   { transition ok: :flagged }
    event(:unflag) { transition flagged: :ok }
  end
end

2. A policy whose rule names match the event names

class PostPolicy < ApplicationPolicy
  def submit?  = owner?
  def publish? = editor?
  def reject?  = editor?
  def flag?    = editor?
  def unflag?  = editor?
end

An event with no matching rule is denied by default — adding an event can never silently expose it.

3. Route + controller

# config/routes.rb
resources :posts, only: [:index, :show] do
  transitionable        # POST /posts/:id/transition  (transition_post)
end
class PostsController < ApplicationController
  include TransitionButtons::Transitionable
  # `transition` is provided. The record defaults to Post.find(params[:id]);
  # override #transition_buttons_record for custom scoping.
end

4. The view

<%= transition_buttons_for(@post) %>                       <%# machine: :state %>
<%= transition_buttons_for(@post, machine: :moderation_state) %>
<%= transition_buttons_for(@post, user: some_user, class: "btn") %>

<%# Or render your own UI from the raw permitted list: %>
<% available_events(@post).each do |event| %> ... <% end %>

user: defaults to current_user when the view exposes it. Extra keyword options are forwarded to each button_to.

Recording who triggered a transition

If your model exposes current_actor=, the controller sets it to current_user before firing, so a transition log (e.g. state_machines-audit_trail) can record the actor:

class Post < ApplicationRecord
  attr_accessor :current_actor
  state_machine :state, initial: :draft do
    audit_trail context: :audited_by
    # ...
  end
  def audited_by = current_actor&.email
end

Swapping backends

TransitionButtons.configure do |c|
  c.state_machine_adapter = MyAdapter.new   # #available_events/#current_state/#fire/#event_label
  c.authorizer            = MyAuthorizer.new # #permitted?(user:, record:, event:, machine:)
  c.default_machine       = :state
end

An AASM adapter (record.aasm(machine).events(permitted: true) / record.public_send("#{event}!")) and a Pundit authorizer are natural additions and the seams are designed for them.

Out of scope (v1)

Events that need arguments — those want a real form, not a one-click button.