diff --git a/app/[communitySlug]/admin/members/page.tsx b/app/[communitySlug]/admin/members/page.tsx index 5cf6bda..b679d3f 100644 --- a/app/[communitySlug]/admin/members/page.tsx +++ b/app/[communitySlug]/admin/members/page.tsx @@ -2,7 +2,8 @@ import { useAccount } from "wagmi"; import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getApi, type MemberRow, type Role } from "@/lib/api"; +import { getApi, type MemberRow, type MutationResult, type Role } from "@/lib/api"; +import { withOfflineMutationQueue } from "@/lib/api/offline-mutations"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -248,9 +249,12 @@ export default function MembersPage() { isError: mutateError, error: mutateErrorValue, reset: resetMutation, - } = useMutation<{ status: 'executed' | 'pending'; pendingActionId?: string }, unknown, AssignRoleInput, { previousQueries?: [any, any][]; auditId: string }>({ + } = useMutation({ mutationFn: (input) => - getApi(address, authSession?.token, communitySlug).assignRole(input.address, input.role), + withOfflineMutationQueue( + getApi(address, authSession?.token, communitySlug), + communitySlug, + ).assignRole(input.address, input.role), // Auth errors are handled via registerPendingRetry — do not auto-retry 401s // to avoid racing with the re-auth banner flow. retry: false, @@ -313,6 +317,23 @@ export default function MembersPage() { return; } + if (data.status === 'queued') { + // Offline — keep the optimistic update applied by onMutate rather + // than rolling back; it reflects what will happen once + // components/offline/mutation-queue-sync.tsx replays this mutation. + // The audit log entry is left in its "pending" state (rather than + // marked "success") since the mutation hasn't actually happened yet; + // this page has no way to hear back once replay completes later. + addToast({ + tone: "warning", + title: "Action queued", + description: `Assigning ${input.role} to ${input.address.slice(0, 6)}…${input.address.slice(-4)} is queued. It will automatically sync when you're back online.`, + }); + setAddr(""); + resetMutation(); + return; + } + setAuditLog((prev) => prev.map((entry) => entry.id === context?.auditId ? { ...entry, status: "success" } : entry @@ -407,13 +428,16 @@ export default function MembersPage() { mutateErrorValue instanceof AuthError && mutateErrorValue.code === "unauthorized"; const removeRoleMutation = useMutation< - { status: 'executed' | 'pending'; pendingActionId?: string }, + MutationResult, unknown, AssignRoleInput, { previousQueries?: [any, any][]; auditId: string } >({ mutationFn: (input) => - getApi(address, authSession?.token, communitySlug).removeRole(input.address, input.role), + withOfflineMutationQueue( + getApi(address, authSession?.token, communitySlug), + communitySlug, + ).removeRole(input.address, input.role), // Auth errors are handled via registerPendingRetry — do not auto-retry 401s. retry: false, onMutate: async (input) => { @@ -471,6 +495,20 @@ export default function MembersPage() { return; } + if (data.status === 'queued') { + // Offline — keep the optimistic update applied by onMutate; the + // audit log entry stays "pending" since removal hasn't actually + // happened yet (see the assignRole mutation above for the same + // reasoning). + addToast({ + tone: "warning", + title: "Action queued", + description: `Removing ${input.role} from ${input.address.slice(0, 6)}…${input.address.slice(-4)} is queued. It will automatically sync when you're back online.`, + }); + resetMutation(); + return; + } + setAuditLog((prev) => prev.map((entry) => entry.id === context?.auditId ? { ...entry, status: "success" } : entry diff --git a/app/[communitySlug]/admin/policies/page.tsx b/app/[communitySlug]/admin/policies/page.tsx index 947d193..f9087a4 100644 --- a/app/[communitySlug]/admin/policies/page.tsx +++ b/app/[communitySlug]/admin/policies/page.tsx @@ -1,11 +1,12 @@ "use client"; -import { useEffect, useId, useState, useMemo } from "react"; +import { useEffect, useId, useRef, useState, useMemo } from "react"; import { useAccount } from "wagmi"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getApi, type AccessPolicy, + type MutationResult, type Resource, MembershipTier, Role, @@ -43,6 +44,11 @@ import { storePolicyDraft, } from "@/lib/policy-drafts"; import { PolicyConflictDialog } from "@/components/ui/policy-conflict-dialog"; +import { + buildPolicyConflictContext, + type PolicyConflictContext, +} from "@/lib/api/policy-conflict"; +import { withOfflineMutationQueue } from "@/lib/api/offline-mutations"; import { ScenarioSelector } from "@/components/developer/scenario-selector"; import { config } from "@/lib/config"; const ALL_ROLES: Role[] = ["member", "moderator", "admin"]; @@ -304,12 +310,17 @@ export default function PoliciesPage() { ); // Conflict detection state - const [conflictState, setConflictState] = useState<{ - attemptedPolicy: AccessPolicy; - currentPolicy?: AccessPolicy; - } | null>(null); + const [conflictState, setConflictState] = useState(null); const [isLoadingConflictData, setIsLoadingConflictData] = useState(false); + // Tracks the most recent mutation's result status so onSettled can skip + // invalidating the policies query when the mutation was only queued for + // offline replay — there's nothing new on the server yet, and refetching + // now would just overwrite the optimistic update with stale data (see + // components/offline/mutation-queue-sync.tsx for when the real + // invalidation happens, once replay actually succeeds). + const lastMutationStatusRef = useRef(null); + const { data: policies, isLoading, @@ -339,11 +350,15 @@ export default function PoliciesPage() { isError: mutateError, error: mutateErrorValue, reset: resetMutation, - } = useMutation<{ status: 'executed' | 'pending'; pendingActionId?: string }, unknown, AccessPolicy, PolicyRollback>({ + } = useMutation({ mutationFn: (policy: AccessPolicy) => - getApi(address, authSession?.token, communitySlug).updatePolicy(policy), + withOfflineMutationQueue( + getApi(address, authSession?.token, communitySlug), + communitySlug, + ).updatePolicy(policy), onMutate: async (policy) => { + lastMutationStatusRef.current = null; await qc.cancelQueries({ queryKey: queryKeys.policies.all(communitySlug) }); const previousPolicies = qc.getQueryData( queryKeys.policies.all(communitySlug), @@ -362,6 +377,7 @@ export default function PoliciesPage() { }, onSuccess: (data, policy, context) => { + lastMutationStatusRef.current = data.status; if (data.status === 'pending') { qc.setQueryData(queryKeys.policies.all(communitySlug), context?.previousPolicies); addToast({ @@ -378,6 +394,26 @@ export default function PoliciesPage() { return; } + if (data.status === 'queued') { + // Offline — the optimistic update already reflects the attempted + // change locally; leave it in place until replay confirms it + // (components/offline/mutation-queue-sync.tsx), rather than rolling + // back to the previous server state. + addToast({ + tone: "warning", + title: "Action queued", + description: `Your update to "${policy.resourceId}" is queued. It will automatically sync when you're back online.`, + }); + setSuccessMessage(""); + setRollbackMessage(""); + clearPolicyDraft(policy.resourceId); + clearPolicyDraft(""); + setEditingResourceId(null); + setShowCreateForm(false); + resetMutation(); + return; + } + addToast({ tone: "success", title: `Policy saved for "${policy.resourceId}"`, @@ -439,25 +475,13 @@ export default function PoliciesPage() { // Check for conflict error (409) if (isApiError(err) && err.status === 409) { - // Fetch the current version of the policy from the server setIsLoadingConflictData(true); - getApi(address, authSession?.token, communitySlug) - .getPolicy(policy.resourceId) - .then((currentPolicy) => { - setConflictState({ - attemptedPolicy: policy, - currentPolicy: currentPolicy ?? undefined, - }); - }) - .catch(() => { - // If we can't fetch the current policy, still show the dialog - setConflictState({ - attemptedPolicy: policy, - }); - }) - .finally(() => { - setIsLoadingConflictData(false); - }); + buildPolicyConflictContext( + getApi(address, authSession?.token, communitySlug), + policy, + ) + .then(setConflictState) + .finally(() => setIsLoadingConflictData(false)); } else if (!(err instanceof AuthError)) { // Non-auth, non-conflict errors — show a toast with the error message addToast({ @@ -480,7 +504,12 @@ export default function PoliciesPage() { onSettled: () => { setPendingPolicyId(null); - qc.invalidateQueries({ queryKey: queryKeys.policies.all(communitySlug) }); + // Skip invalidating when the mutation was only queued for offline + // replay — there's nothing new on the server yet (see + // lastMutationStatusRef above). + if (lastMutationStatusRef.current !== 'queued') { + qc.invalidateQueries({ queryKey: queryKeys.policies.all(communitySlug) }); + } }, }); diff --git a/app/layout.tsx b/app/layout.tsx index 38d30fa..bfee7a6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { Nav } from '@/components/nav' import { SwRegistrar } from '@/components/sw-registrar' import { BackendHealthCheck } from '@/components/backend-health-check' import { SyncStatusBanner } from '@/components/ui/sync-status-banner' +import { MutationQueueSync } from '@/components/offline/mutation-queue-sync' import { ThemeProvider } from '@/components/theme-provider' export const metadata: Metadata = { title: { @@ -38,6 +39,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {/* Offline/Degraded status banner */} + {/* Drains the durable offline mutation queue on reconnect and + hosts the conflict dialog for replayed policy updates */} +