Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 78 additions & 21 deletions app/[communitySlug]/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ function VirtualList<T>({

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();
Expand Down Expand Up @@ -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) => [
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
},
Expand All @@ -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) => [
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
},
Expand Down
39 changes: 38 additions & 1 deletion app/[communitySlug]/admin/policies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(null);
const [successMessage, setSuccessMessage] = useState("");
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -489,6 +525,7 @@ export default function PoliciesPage() {
<FeatureGate enabled={features.adminPolicies} name="Access Policies">
<AdminGuard>
<div className="space-y-4">
<ToastViewport toasts={toasts} onDismiss={dismissToast} />
{/* Developer Testing Tools (Mock Mode Only) */}
{config.apiMode === 'mock' && (
<ScenarioSelector />
Expand Down
67 changes: 67 additions & 0 deletions lib/wallet/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
useRef,
useState,
} from "react";
import type { PendingRetryCallback } from "@/lib/wallet/siwe-context";
import {
WagmiProvider,
createConfig,
Expand Down Expand Up @@ -148,6 +149,19 @@ export interface SiweAuthContextValue {
logout: () => Promise<void>;
/** 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({
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<RetryEntry[]>([]);

const registerPendingRetry = useCallback(
(callback: PendingRetryCallback, options?: { onRetryFailure?: (err: unknown) => void }) => {
pendingRetriesRef.current = [
...pendingRetriesRef.current,
{ callback, onRetryFailure: options?.onRetryFailure },
];
},
[],
);

// ── Sign-in ─────────────────────────────────────────────────────────────────

const signIn = useCallback(async () => {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -598,6 +663,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
login: signIn, // backward-compat alias
logout,
markExpired,
registerPendingRetry,
}),
[
state.authSession,
Expand All @@ -611,6 +677,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
signIn,
logout,
markExpired,
registerPendingRetry,
],
);

Expand Down
17 changes: 17 additions & 0 deletions lib/wallet/siwe-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

/**
* 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
Expand All @@ -15,6 +23,15 @@ export interface SiweAuthContextType {
warningThresholdSeconds?: number;
login: () => Promise<void>;
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<SiweAuthContextType | undefined>(
Expand Down
1 change: 1 addition & 0 deletions test/admin-guard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const mockProviders = {
login: async () => {},
logout: async () => {},
markExpired: () => {},
registerPendingRetry: () => {},
...mockAuthState,
}),
}
Expand Down
1 change: 1 addition & 0 deletions test/admin-members-empty-state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ require.cache[providersPath] = {
login: async () => {},
logout: async () => {},
markExpired: () => {},
registerPendingRetry: () => {},
}),
},
} as any
Expand Down
Loading