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):
- Session present AND RBAC-authorized (admin / cancel-any / cancel-own match) → session-authed cancel
- 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
- 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).
- 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.
- 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
Background
PR #145 (closed #46) added an inline Cancel button on per-purchase rows in the History view, gated by the
cancel-any/cancel-ownRBAC matrix. The Cancel path is dual-authed in the backend (internal/api/handler_purchases.go:308-332):The Approve path doesn't have the equivalent shape.
approvePurchase(handler_purchases.go:267) isAuthPublic— 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-ownbranch 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-sesand 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
approve-anyandapprove-ownverbs tointernal/auth/types.go(around line 284 wherecancel-own/retry-owndefaults live). Default grants:RoleUsergetsapprove-own:purchases;RoleAdmingetsapprove-any:purchasesautomatically (per the existing admin-implies-everything pattern).approvePurchasemirroring the cancel-path shape: if a valid session is present AND it satisfies theapprove-any/approve-ownRBAC matrix (analogous tocancelPurchaseViaSessionat: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.approved_by_user_idthe same way cancel persistscancelled_by_user_id— for audit + the same rationale as thecancel-ownUUID-pointer at:993-997.Frontend
frontend/src/history.ts:300-340already 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 againsthandler_history.go)session.permissions.includes('approve-any:purchases')OR (approve-own:purchasesANDpurchaseRow.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
approve-anyholder) clicks Approve on a pending purchase in the History view and the row transitions toapprovedwithout leaving the dashboard.approve-owncan approve their own pending purchases.statusflips andapproved_by_user_idis set.References
retry-any/retry-ownexactly parallel)internal/api/handler_purchases.go:267(approvePurchase— needs dual-auth branch)internal/api/handler_purchases.go:308-332, 419-455(cancelPurchasedual-auth pattern to copy)internal/api/handler_purchases.go:1351-1391(approver-email check — reuse for approve-any/approve-own)internal/api/handler_ri_exchange.go:579(approveRIExchange— same fix symmetrically)internal/auth/types.go:284-300(default permission grants)frontend/src/history.ts:300-340(the Cancel/Retry button rendering site)