From 1b34394591b7392e9c5a9c9905ff8e4169d9faa2 Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Tue, 28 Jul 2026 12:18:47 +0100 Subject: [PATCH] fix: auto-retry mutation after SIWE re-auth on 401 When a mutation (role assignment, policy update) fails with a 401 AuthError, the original request is now automatically retried once after the user successfully re-authenticates via the inline banner. Changes: - lib/wallet/siwe-context.ts: add PendingRetryCallback type and registerPendingRetry to SiweAuthContextType - lib/wallet/providers.tsx: implement pendingRetriesRef queue; drain it in both signIn() and performSilentRefresh() after a successful session recovery; add registerPendingRetry to context value and SiweAuthContextValue interface - admin/members/page.tsx: use registerPendingRetry in assignRole and removeRole onError handlers; remove React Query built-in retry for 401s to avoid racing with the re-auth banner flow - admin/policies/page.tsx: use registerPendingRetry in policy mutation onError; add useToasts/ToastViewport for retry feedback - test mocks: add registerPendingRetry: () => {} to all three useSiweAuth mock objects Acceptance criteria met: - 401 during mutation triggers inline re-auth banner - Successful re-auth automatically retries original mutation once - Second 401 on retry calls onRetryFailure (no infinite loop) - Success/failure toasts shown for the retried mutation --- app/[communitySlug]/admin/members/page.tsx | 99 ++++++++++++++++----- app/[communitySlug]/admin/policies/page.tsx | 39 +++++++- lib/wallet/providers.tsx | 67 ++++++++++++++ lib/wallet/siwe-context.ts | 17 ++++ test/admin-guard.test.tsx | 1 + test/admin-members-empty-state.test.tsx | 1 + test/analytics-flag.test.ts | 3 +- 7 files changed, 204 insertions(+), 23 deletions(-) diff --git a/app/[communitySlug]/admin/members/page.tsx b/app/[communitySlug]/admin/members/page.tsx index a7e67c0..9b82645 100644 --- a/app/[communitySlug]/admin/members/page.tsx +++ b/app/[communitySlug]/admin/members/page.tsx @@ -167,7 +167,7 @@ function VirtualList({ export default function MembersPage() { const { address } = useAccount(); - const { authSession, markExpired, sessionStatus } = useSiweAuth(); + const { authSession, markExpired, sessionStatus, registerPendingRetry } = useSiweAuth(); const qc = useQueryClient(); const { toasts, addToast, dismissToast } = useToasts(); const params = useParams(); @@ -300,13 +300,9 @@ export default function MembersPage() { } = useMutation<{ status: 'executed' | 'pending'; pendingActionId?: string }, unknown, AssignRoleInput, { previousQueries?: [any, any][]; auditId: string }>({ mutationFn: (input) => getApi(address, authSession?.token, communitySlug).assignRole(input.address, input.role), - retry: (failureCount, error) => { - if (error instanceof AuthError && error.code === "unauthorized" && failureCount < 1) { - return true; - } - return false; - }, - retryDelay: 1000, + // Auth errors are handled via registerPendingRetry — do not auto-retry 401s + // to avoid racing with the re-auth banner flow. + retry: false, onMutate: async (input) => { const auditId = Date.now().toString() + Math.random().toString(); setAuditLog((prev) => [ @@ -395,7 +391,7 @@ export default function MembersPage() { void qc.invalidateQueries({ queryKey: queryKeys.members.all(communitySlug) }); const isExpiredSession = err instanceof AuthError && err.code === "unauthorized"; const message = isExpiredSession - ? "Session expired. Use the re-authentication banner to sign in again." + ? "Session expired. Re-authenticating and retrying role assignment…" : safeErrorMessage(err); if (context?.auditId) { @@ -408,13 +404,47 @@ export default function MembersPage() { ); } - setRollbackMessage(`Change reverted: ${message}`); + setRollbackMessage(`Change reverted: ${safeErrorMessage(err)}`); addToast({ tone: isExpiredSession ? "warning" : "error", - title: isExpiredSession ? "Admin session expired" : "Failed to assign role", + title: isExpiredSession ? "Session expired — retrying after re-auth" : "Failed to assign role", description: message, }); + if (isExpiredSession) { + // Capture the input so the mutation can be replayed once re-auth succeeds. + const capturedInput = _input; + registerPendingRetry( + async (freshSession) => { + await getApi(address, freshSession.token, communitySlug).assignRole( + capturedInput.address, + capturedInput.role, + ); + // Reconcile cache and notify success + reconcileMemberRoleCache( + qc, + { address: capturedInput.address, role: capturedInput.role, action: "assign" }, + communitySlug, + ); + setSuccessAssignment(capturedInput); + setRollbackMessage(""); + addToast({ + tone: "success", + title: `Role assigned to ${capturedInput.address.slice(0, 6)}…${capturedInput.address.slice(-4)}`, + description: `The ${capturedInput.role} role was assigned successfully after re-authentication.`, + }); + }, + { + onRetryFailure: (retryErr) => { + setRollbackMessage(`Retry failed: ${safeErrorMessage(retryErr)}`); + addToast({ + tone: "error", + title: "Role assignment failed after re-auth", + description: safeErrorMessage(retryErr), + }); + }, + }, + ); markExpired(); } }, @@ -434,13 +464,8 @@ export default function MembersPage() { >({ mutationFn: (input) => getApi(address, authSession?.token, communitySlug).removeRole(input.address, input.role), - retry: (failureCount, error) => { - if (error instanceof AuthError && error.code === "unauthorized" && failureCount < 1) { - return true; - } - return false; - }, - retryDelay: 1000, + // Auth errors are handled via registerPendingRetry — do not auto-retry 401s. + retry: false, onMutate: async (input) => { const auditId = Date.now().toString() + Math.random().toString(); setAuditLog((prev) => [ @@ -524,7 +549,7 @@ export default function MembersPage() { void qc.invalidateQueries({ queryKey: queryKeys.members.all(communitySlug) }); const isExpiredSession = err instanceof AuthError && err.code === "unauthorized"; const message = isExpiredSession - ? "Session expired. Use the re-authentication banner to sign in again." + ? "Session expired. Re-authenticating and retrying role removal…" : safeErrorMessage(err); if (context?.auditId) { @@ -537,13 +562,45 @@ export default function MembersPage() { ); } - setRollbackMessage(`Change reverted: ${message}`); + setRollbackMessage(`Change reverted: ${safeErrorMessage(err)}`); addToast({ tone: isExpiredSession ? "warning" : "error", - title: isExpiredSession ? "Admin session expired" : "Failed to remove role", + title: isExpiredSession ? "Session expired — retrying after re-auth" : "Failed to remove role", description: message, }); + if (isExpiredSession) { + const capturedInput = _input; + registerPendingRetry( + async (freshSession) => { + await getApi(address, freshSession.token, communitySlug).removeRole( + capturedInput.address, + capturedInput.role, + ); + reconcileMemberRoleCache( + qc, + { address: capturedInput.address, role: capturedInput.role, action: "remove" }, + communitySlug, + ); + setSuccessMessage(`Role "${capturedInput.role}" removed from ${capturedInput.address}.`); + setRollbackMessage(""); + addToast({ + tone: "success", + title: `Role removed from ${capturedInput.address.slice(0, 6)}…${capturedInput.address.slice(-4)}`, + description: `The ${capturedInput.role} role was removed successfully after re-authentication.`, + }); + }, + { + onRetryFailure: (retryErr) => { + setRollbackMessage(`Retry failed: ${safeErrorMessage(retryErr)}`); + addToast({ + tone: "error", + title: "Role removal failed after re-auth", + description: safeErrorMessage(retryErr), + }); + }, + }, + ); markExpired(); } }, diff --git a/app/[communitySlug]/admin/policies/page.tsx b/app/[communitySlug]/admin/policies/page.tsx index b69a695..ed6cf75 100644 --- a/app/[communitySlug]/admin/policies/page.tsx +++ b/app/[communitySlug]/admin/policies/page.tsx @@ -23,6 +23,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; +import { useToasts, ToastViewport } from "@/components/ui/toast"; import { DeniedState, EmptyState, @@ -285,10 +286,11 @@ function SessionExpiredBanner() { export default function PoliciesPage() { const { address } = useAccount(); - const { authSession, markExpired, sessionStatus } = useSiweAuth(); + const { authSession, markExpired, sessionStatus, registerPendingRetry } = useSiweAuth(); const qc = useQueryClient(); const params = useParams(); const communitySlug = (params?.communitySlug as string) || 'guildpass-demo'; + const { toasts, addToast, dismissToast } = useToasts(); const [pendingPolicyId, setPendingPolicyId] = useState(null); const [successMessage, setSuccessMessage] = useState(""); @@ -388,6 +390,40 @@ export default function PoliciesPage() { setRollbackMessage(`Change reverted: ${safeErrorMessage(err)}`); if (err instanceof AuthError) { + // Capture the policy so we can replay it once re-auth succeeds. + const capturedPolicy = policy; + registerPendingRetry( + async (freshSession) => { + await getApi(address, freshSession.token, communitySlug).updatePolicy(capturedPolicy); + setSuccessMessage(`Policy saved for ${capturedPolicy.resourceId}.`); + setRollbackMessage(""); + clearPolicyDraft(capturedPolicy.resourceId); + clearPolicyDraft(""); + setEditingResourceId(null); + setShowCreateForm(false); + addToast({ + tone: "success", + title: `Policy updated for "${capturedPolicy.resourceId}"`, + description: "Policy saved successfully after re-authentication.", + }); + void qc.invalidateQueries({ queryKey: queryKeys.policies.all(communitySlug) }); + }, + { + onRetryFailure: (retryErr) => { + setRollbackMessage(`Retry failed: ${safeErrorMessage(retryErr)}`); + addToast({ + tone: "error", + title: "Policy update failed after re-auth", + description: safeErrorMessage(retryErr), + }); + }, + }, + ); + addToast({ + tone: "warning", + title: "Session expired — retrying after re-auth", + description: "Your admin session expired. Re-authenticate to automatically retry saving the policy.", + }); markExpired(); } @@ -489,6 +525,7 @@ export default function PoliciesPage() {
+ {/* Developer Testing Tools (Mock Mode Only) */} {config.apiMode === 'mock' && ( diff --git a/lib/wallet/providers.tsx b/lib/wallet/providers.tsx index 361f3c5..bfa52d9 100644 --- a/lib/wallet/providers.tsx +++ b/lib/wallet/providers.tsx @@ -54,6 +54,7 @@ import { useRef, useState, } from "react"; +import type { PendingRetryCallback } from "@/lib/wallet/siwe-context"; import { WagmiProvider, createConfig, @@ -148,6 +149,19 @@ export interface SiweAuthContextValue { logout: () => Promise; /** Mark the current session as expired (e.g. after a 401 from the backend). */ markExpired: () => void; + /** + * Register a callback to be automatically retried once after the user + * successfully re-authenticates following a 401. The callback receives the + * fresh session so it can supply the new token to its API call. + * + * Only one retry is attempted per registration — if the retried call also + * returns a 401 the callback is discarded and a failure toast is shown via + * the `onRetryFailure` handler passed in the registration options. + */ + registerPendingRetry: ( + callback: PendingRetryCallback, + options?: { onRetryFailure?: (err: unknown) => void } + ) => void; } const queryClient = new QueryClient({ @@ -232,6 +246,18 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { dispatch({ type: "refresh-success", session: refreshed }); broadcast({ type: "refreshed", session: refreshed }); scheduleRenewal(refreshed); + + // A silent refresh also counts as session recovery — drain any pending + // retry callbacks that were registered before the 401 was surfaced. + const retries = pendingRetriesRef.current; + pendingRetriesRef.current = []; + for (const entry of retries) { + try { + await entry.callback(refreshed); + } catch (retryErr) { + entry.onRetryFailure?.(retryErr); + } + } } catch { // 401 or network failure — session cannot be renewed clearAuthSession(); @@ -481,6 +507,30 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { } }, [chainId]); + // ── Pending-retry queue ────────────────────────────────────────────────────── + // + // When a mutation fails with 401, it may register a retry callback here + // before calling markExpired(). After a successful re-auth, signIn() drains + // this queue by invoking each callback with the fresh session. A second 401 + // on the retry call invokes the registered onRetryFailure handler instead of + // looping forever. + + type RetryEntry = { + callback: PendingRetryCallback; + onRetryFailure?: (err: unknown) => void; + }; + const pendingRetriesRef = useRef([]); + + const registerPendingRetry = useCallback( + (callback: PendingRetryCallback, options?: { onRetryFailure?: (err: unknown) => void }) => { + pendingRetriesRef.current = [ + ...pendingRetriesRef.current, + { callback, onRetryFailure: options?.onRetryFailure }, + ]; + }, + [], + ); + // ── Sign-in ───────────────────────────────────────────────────────────────── const signIn = useCallback(async () => { @@ -519,6 +569,21 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { dispatch({ type: "sign-in-success", session }); scheduleRenewal(session); broadcast({ type: "signed-in", session }); + + // Drain any pending retry callbacks registered before re-auth. + // We take the entire queue atomically so a second 401 inside a callback + // does not enqueue another retry and cause an infinite loop. + const retries = pendingRetriesRef.current; + pendingRetriesRef.current = []; + for (const entry of retries) { + try { + await entry.callback(session); + } catch (retryErr) { + // The retried call failed — invoke the registered failure handler + // rather than silently swallowing the error. + entry.onRetryFailure?.(retryErr); + } + } } catch (err) { dispatch({ type: "sign-in-error", @@ -598,6 +663,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { login: signIn, // backward-compat alias logout, markExpired, + registerPendingRetry, }), [ state.authSession, @@ -611,6 +677,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { signIn, logout, markExpired, + registerPendingRetry, ], ); diff --git a/lib/wallet/siwe-context.ts b/lib/wallet/siwe-context.ts index 5b4b9dc..e7cb23e 100644 --- a/lib/wallet/siwe-context.ts +++ b/lib/wallet/siwe-context.ts @@ -2,6 +2,14 @@ import { createContext, useContext } from 'react'; import type { SiweAuthSession } from '@/lib/api/types'; +/** + * Callback registered by a mutation when it fails with a 401. + * Called after the user successfully re-authenticates, with the fresh session + * so the mutation can re-invoke its API call with the new token. + * Must return a Promise that resolves/rejects based on the retried call. + */ +export type PendingRetryCallback = (freshSession: SiweAuthSession) => Promise; + /** * SIWE auth context, extracted from providers.tsx so it can be imported without * pulling in the wallet/wagmi stack. Tests and lightweight consumers depend on @@ -15,6 +23,15 @@ export interface SiweAuthContextType { warningThresholdSeconds?: number; login: () => Promise; logout: () => void; + /** + * Register a callback to be automatically retried once after the user + * successfully re-authenticates following a 401. The callback receives the + * fresh session so it can supply the new token to its API call. + * + * Only one retry is attempted per registration — if the retried call also + * returns a 401 the callback is discarded and an error is surfaced. + */ + registerPendingRetry: (callback: PendingRetryCallback) => void; } export const SiweAuthContext = createContext( diff --git a/test/admin-guard.test.tsx b/test/admin-guard.test.tsx index 5d663fb..b47ebca 100644 --- a/test/admin-guard.test.tsx +++ b/test/admin-guard.test.tsx @@ -36,6 +36,7 @@ const mockProviders = { login: async () => {}, logout: async () => {}, markExpired: () => {}, + registerPendingRetry: () => {}, ...mockAuthState, }), } diff --git a/test/admin-members-empty-state.test.tsx b/test/admin-members-empty-state.test.tsx index d53b87c..e3472f7 100644 --- a/test/admin-members-empty-state.test.tsx +++ b/test/admin-members-empty-state.test.tsx @@ -62,6 +62,7 @@ require.cache[providersPath] = { login: async () => {}, logout: async () => {}, markExpired: () => {}, + registerPendingRetry: () => {}, }), }, } as any diff --git a/test/analytics-flag.test.ts b/test/analytics-flag.test.ts index 689988d..4516dbb 100644 --- a/test/analytics-flag.test.ts +++ b/test/analytics-flag.test.ts @@ -100,7 +100,8 @@ const mockWalletProviders = { signIn: () => {}, logout: () => {}, error: null, - markExpired: () => {} + markExpired: () => {}, + registerPendingRetry: () => {}, }) } const providersPath = require.resolve('../lib/wallet/providers')