Skip to content

feat(api,riexchange): symmetric dual-auth + Approve button for approveRIExchange (mirror of #286) #300

Description

@cristim

Background

PR for #286 added in-dashboard purchase approval via the approve-{any,own}:purchases RBAC matrix and a session-first dispatch in internal/api/handler_purchases.go::approvePurchase. The companion approveRIExchange at internal/api/handler_ri_exchange.go:580 has the same shape gap (token-only auth, no session-authed branch) but mutates a state-machine and triggers downstream cloud-side execution — substantially larger surface than fits in #286's PR.

This issue tracks the symmetric work.

What needs to happen

Apply the same dual-auth shape approvePurchase now uses, adapted to the RI-exchange flow:

  1. Reuse the existing approve-{any,own}:purchases verbs — the RI-exchange approval is conceptually still "approving a purchase action", just on a different resource type. Don't add new RI-specific verbs unless an operational reason emerges. (Alternative if you want stricter isolation: add approve-{any,own}:ri-exchanges and gate separately. Default to reuse to keep the matrix small.)

  2. Refactor approveRIExchange into a three-mode dispatch like approvePurchase:

    • Session present + RBAC matches → session-authed approve.
    • token != "" → legacy email-link, unchanged.
    • token == "" → session-authed branch.
      Permission-denied (403) on the session check falls through to the token branch (legacy operators retain their email-link path).
  3. approveRIExchangeViaSession that:

    • Calls validateExchangeApproval minus the token-equality check (session is the auth) but keeping the existing-record + state-machine state guards.
    • Calls TransitionRIExchangeStatus (pending → processing). The atomic transition there already handles concurrent cancel/expire — that protection remains.
    • Calls the existing executeApprovedExchange with the session-supplied actor.
    • Stamps approver attribution somewhere on the RI-exchange row (mirror of purchase_executions.approved_by TEXT). If the schema doesn't have a column today, add one in a small migration.
  4. Frontend Approve button on the RI-exchange "pending" row UI, mirroring the History page's button. Find where pending RI-exchanges render today (riexchange.ts or similar) and add the same predicate + click + confirmDialog → api.approveRIExchange(id) → reload + toast pattern. The cancel/approve predicates can share created_by_user_id-based own-ness logic.

  5. Tests:

    • Backend: copy the cancel/approve precedent test pattern. Cover admin-session, approve-own-with-creator-match, approve-own-without-match (rejected), legacy email-token path still works (backwards-compat).
    • Frontend: button render + click handler tests mirroring history-approve-button.test.ts.
  6. Cross-cutting check: ensure the RI-exchange executeApprovedExchange is safe to call from a logged-in session context (it might assume token-bearer-only — verify it doesn't read state that's only present on the email-link path).

Acceptance

  • A logged-in admin (or approve-any holder) can approve a pending RI-exchange directly from the dashboard.
  • Token-based email-link approval continues to work for non-session approvers.
  • A test seeds an admin session + a pending RI-exchange, hits the route with no token, asserts the row transitions and the cloud-side execution kicks off (mockable).
  • Pre-existing RI-exchange tests on the token-only path continue to pass — backwards-compat mandatory.

References

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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