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
48 changes: 43 additions & 5 deletions app/[communitySlug]/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<MutationResult, unknown, AssignRoleInput, { previousQueries?: [any, any][]; auditId: string }>({
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down
81 changes: 55 additions & 26 deletions app/[communitySlug]/admin/policies/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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<PolicyConflictContext | null>(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<MutationResult["status"] | null>(null);

const {
data: policies,
isLoading,
Expand Down Expand Up @@ -339,11 +350,15 @@ export default function PoliciesPage() {
isError: mutateError,
error: mutateErrorValue,
reset: resetMutation,
} = useMutation<{ status: 'executed' | 'pending'; pendingActionId?: string }, unknown, AccessPolicy, PolicyRollback>({
} = useMutation<MutationResult, unknown, AccessPolicy, PolicyRollback>({
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<AccessPolicy[]>(
queryKeys.policies.all(communitySlug),
Expand All @@ -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({
Expand All @@ -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}"`,
Expand Down Expand Up @@ -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({
Expand All @@ -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) });
}
},
});

Expand Down
4 changes: 4 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -38,6 +39,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<BackendHealthCheck />
{/* Offline/Degraded status banner */}
<SyncStatusBanner className="mb-4 w-full" />
{/* Drains the durable offline mutation queue on reconnect and
hosts the conflict dialog for replayed policy updates */}
<MutationQueueSync />
<Nav />
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
</RootProviders>
Expand Down
Loading