Skip to content

feat(api,history): add Approve button + approve-{any,own} RBAC for in-dashboard purchase approval #286

@cristim

Description

@cristim

Background

PR #145 (closed #46) added an inline Cancel button on per-purchase rows in the History view, gated by the cancel-any / cancel-own RBAC matrix. The Cancel path is dual-authed in the backend (internal/api/handler_purchases.go:308-332):

  1. Session present AND RBAC-authorized (admin / cancel-any / cancel-own match) → session-authed cancel
  2. Token present (email link) → fallback path

The Approve path doesn't have the equivalent shape. approvePurchase (handler_purchases.go:267) is AuthPublic — token-only — and the only way for an admin to approve a pending purchase is to open the SES email and click the link. The session-authed admin / approve-any / approve-own branch is missing both server-side and from the History UI.

This forces an unnecessary dependency on the SES delivery pipeline (which is the entire reason --no-ses and equivalent dev-mode flags exist) and means an admin who's already logged in still has to context-switch to email to approve their own pending plan. Friction, not security — admins already have the authority to do everything else from the dashboard.

What needs to happen

Backend

  1. Add approve-any and approve-own verbs to internal/auth/types.go (around line 284 where cancel-own / retry-own defaults live). Default grants: RoleUser gets approve-own:purchases; RoleAdmin gets approve-any:purchases automatically (per the existing admin-implies-everything pattern).
  2. Add a dual-auth branch to approvePurchase mirroring the cancel-path shape: if a valid session is present AND it satisfies the approve-any / approve-own RBAC matrix (analogous to cancelPurchaseViaSession at :419 — same approver-email co-check at :1351-1391), accept and skip the email-token requirement. Token-only auth stays as a fallback so existing email links keep working.
  3. Persist approved_by_user_id the same way cancel persists cancelled_by_user_id — for audit + the same rationale as the cancel-own UUID-pointer at :993-997.

Frontend

frontend/src/history.ts:300-340 already wires the Cancel + Retry button rendering by RBAC verb. Add a third sibling — Approve — rendered when:

  • purchaseRow.status === 'pending_approval' (or whatever the current pending-state name is — confirm against handler_history.go)
  • AND session.permissions.includes('approve-any:purchases') OR (approve-own:purchases AND purchaseRow.created_by_user_id === session.user_id)

The button POSTs to a new authenticated route /api/purchases/{id}/approve (or extends the existing route to accept session auth — depends on which is cleaner; the cancel pattern uses one route with branching internal auth).

RI exchange

approveRIExchange (handler_ri_exchange.go:579) has the same shape and the same gap. Same fix applied symmetrically.

Acceptance

  • An admin (or approve-any holder) clicks Approve on a pending purchase in the History view and the row transitions to approved without leaving the dashboard.
  • A non-admin with approve-own can approve their own pending purchases.
  • Token-based email approval continues to work for the people who don't have sessions.
  • A test seeds an admin session + a pending row + clicks Approve via the API; asserts the row's status flips and approved_by_user_id is set.

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions