diff --git a/app/admin/members/page.tsx b/app/admin/members/page.tsx index 6d1ac4c..23e0f13 100644 --- a/app/admin/members/page.tsx +++ b/app/admin/members/page.tsx @@ -6,11 +6,22 @@ import { getApi, type MemberRow, type Role } from '@/lib/api' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' import { useState } from 'react' import { AdminGuard } from '@/components/admin-guard' import { useSiweAuth } from '@/lib/wallet/providers' import { AuthError } from '@/lib/api/live' import { LoadingState, ErrorState, EmptyState, safeErrorMessage } from '@/components/ui/api-states' +import { applyOptimisticRole } from '@/lib/api/optimistic' + +type AssignRoleInput = { + address: string + role: Role +} + +type AssignRoleRollback = { + previousMembers?: MemberRow[] +} function SessionExpiredBanner() { const { signIn, isSigningIn } = useSiweAuth() @@ -49,6 +60,9 @@ export default function MembersPage() { const [addr, setAddr] = useState('') const [role, setRole] = useState('member') + const [pendingAssignment, setPendingAssignment] = useState(null) + const [successMessage, setSuccessMessage] = useState('') + const [rollbackMessage, setRollbackMessage] = useState('') const { mutate, @@ -56,19 +70,40 @@ export default function MembersPage() { isError: mutateError, error: mutateErrorValue, reset: resetMutation - } = useMutation({ - mutationFn: () => getApi(address, authSession?.token).assignRole(addr, role), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['members'] }) + } = useMutation({ + mutationFn: (input) => + getApi(address, authSession?.token).assignRole(input.address, input.role), + onMutate: async (input) => { + await qc.cancelQueries({ queryKey: ['members'] }) + const previousMembers = qc.getQueryData(['members']) + + setPendingAssignment(input) + setSuccessMessage('') + setRollbackMessage('') setSessionExpired(false) + + qc.setQueryData(['members'], (currentMembers) => + applyOptimisticRole(currentMembers, input.address, input.role), + ) + + return { previousMembers } + }, + onSuccess: (_data, input) => { + setSuccessMessage(`Role "${input.role}" saved for ${input.address}.`) setAddr('') resetMutation() }, - onError: (err: unknown) => { + onError: (err: unknown, _input, context) => { + qc.setQueryData(['members'], context?.previousMembers) + setRollbackMessage(`Change reverted: ${safeErrorMessage(err)}`) if (err instanceof AuthError) { setSessionExpired(true) } }, + onSettled: () => { + setPendingAssignment(null) + qc.invalidateQueries({ queryKey: ['members'] }) + }, }) return ( @@ -100,17 +135,27 @@ export default function MembersPage() { + {successMessage && ( +
+ {successMessage} +
+ )} + {rollbackMessage && ( +
+ {rollbackMessage} +
+ )} {mutateError && ( mutate()} + onRetry={() => mutate({ address: addr, role })} /> )} @@ -137,8 +182,11 @@ export default function MembersPage() { className="flex items-center justify-between border rounded-md p-2" >
{m.address}
-
- Tier: {m.tier} • Roles: {m.roles.join(', ')} +
+ Tier: {m.tier} • Roles: {m.roles.join(', ')} + {pendingAssignment?.address.toLowerCase() === m.address.toLowerCase() && ( + Saving + )}
))} diff --git a/app/admin/policies/page.tsx b/app/admin/policies/page.tsx index 4958a9d..09d6c53 100644 --- a/app/admin/policies/page.tsx +++ b/app/admin/policies/page.tsx @@ -5,13 +5,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { getApi, type AccessPolicy } from '@/lib/api' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' import { AdminGuard } from '@/components/admin-guard' import { useSiweAuth } from '@/lib/wallet/providers' import { AuthError } from '@/lib/api/live' import { useState } from 'react' import { LoadingState, ErrorState, EmptyState, safeErrorMessage } from '@/components/ui/api-states' -import { FeatureGate } from '@/components/feature-gate' -import { features } from '@/lib/features' +import { applyOptimisticPolicy } from '@/lib/api/optimistic' + +type PolicyRollback = { + previousPolicies?: AccessPolicy[] +} function SessionExpiredBanner() { const { signIn, isSigningIn } = useSiweAuth() @@ -41,6 +45,9 @@ export default function PoliciesPage() { const { authSession } = useSiweAuth() const qc = useQueryClient() const [sessionExpired, setSessionExpired] = useState(false) + const [pendingPolicyId, setPendingPolicyId] = useState(null) + const [successMessage, setSuccessMessage] = useState('') + const [rollbackMessage, setRollbackMessage] = useState('') const { data: policies, isLoading, isError, error, refetch } = useQuery({ queryKey: ['policies'], @@ -50,23 +57,42 @@ export default function PoliciesPage() { const { mutate, - isPending, isError: mutateError, error: mutateErrorValue, reset: resetMutation - } = useMutation({ + } = useMutation({ mutationFn: (p: AccessPolicy) => getApi(address, authSession?.token).updatePolicy(p), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['policies'] }) + onMutate: async (policy) => { + await qc.cancelQueries({ queryKey: ['policies'] }) + const previousPolicies = qc.getQueryData(['policies']) + + setPendingPolicyId(policy.resourceId) + setSuccessMessage('') + setRollbackMessage('') setSessionExpired(false) + + qc.setQueryData(['policies'], (currentPolicies) => + applyOptimisticPolicy(currentPolicies, policy), + ) + + return { previousPolicies } + }, + onSuccess: (_data, policy) => { + setSuccessMessage(`Policy saved for ${policy.resourceId}.`) resetMutation() }, - onError: (err: unknown) => { + onError: (err: unknown, _policy, context) => { + qc.setQueryData(['policies'], context?.previousPolicies) + setRollbackMessage(`Change reverted: ${safeErrorMessage(err)}`) if (err instanceof AuthError) { setSessionExpired(true) } }, + onSettled: () => { + setPendingPolicyId(null) + qc.invalidateQueries({ queryKey: ['policies'] }) + }, }) return ( @@ -77,56 +103,70 @@ export default function PoliciesPage() { {sessionExpired && } - - Resources - - {isLoading ? ( - - ) : isError ? ( - refetch()} - /> - ) : !policies?.length ? ( - - ) : ( - policies.map((p) => ( -
-
{p.resourceId}
- - + + Resources + + {isLoading ? ( + + ) : isError ? ( + refetch()} + /> + ) : !policies?.length ? ( + + ) : ( + policies.map((p) => ( +
+
+ {p.resourceId} + {pendingPolicyId === p.resourceId && ( + Saving + )}
- )) - )} - {mutateError && ( - - )} - - -
- - + + +
+ )) + )} + {successMessage && ( +
+ {successMessage} +
+ )} + {rollbackMessage && ( +
+ {rollbackMessage} +
+ )} + {mutateError && ( + + )} +
+
+ + ) } diff --git a/lib/api/live.ts b/lib/api/live.ts index 0c2075c..f38b7fd 100644 --- a/lib/api/live.ts +++ b/lib/api/live.ts @@ -98,7 +98,11 @@ async function getJson(path: string, init?: RequestInit): Promise { // If the backend ever aligns its field names with the frontend types, the // mapper becomes a no-op and can be removed without touching call sites. -function mapCommunity(raw: any): Community { +function mapCommunity(raw: BackendSession['community']): Community { + if (!raw) { + throw new ApiError(500, 'Invalid API response', 'Missing community') + } + return { id: raw?.id ?? '', name: raw?.name ?? '', @@ -107,12 +111,19 @@ function mapCommunity(raw: any): Community { } } -function mapMembership(raw: any): Membership { +function requiredString(value: string | undefined, field: string): string { + if (!value) { + throw new ApiError(500, 'Invalid API response', `Missing ${field}`) + } + return value +} + +function mapMembership(raw: BackendMember): Membership { return { - address: raw?.address ?? raw?.wallet_address ?? '', - tier: raw?.tier ?? raw?.membership_tier ?? 'free', - active: raw?.active ?? raw?.is_active ?? false, - expiresAt: raw?.expiresAt ?? raw?.expires_at, + address: requiredString(raw.address ?? raw.wallet_address, 'member address'), + tier: raw.tier ?? raw.membership_tier ?? 'free', + active: raw.active ?? raw.is_active ?? false, + expiresAt: raw.expiresAt ?? raw.expires_at, } } @@ -127,28 +138,28 @@ function mapMemberProfile(raw: any, address: string): MemberProfile { function mapMemberRow(raw: any): MemberRow { return { - address: raw?.address ?? raw?.wallet_address ?? '', - roles: raw?.roles ?? [], - tier: raw?.tier ?? raw?.membership_tier ?? 'free', - active: raw?.active ?? raw?.is_active ?? false, + address: requiredString(raw.address ?? raw.wallet_address, 'member address'), + roles: raw.roles ?? [], + tier: raw.tier ?? raw.membership_tier ?? 'free', + active: raw.active ?? raw.is_active ?? false, } } function mapResource(raw: any): Resource { return { - id: raw?.id ?? '', - title: raw?.title ?? raw?.name ?? '', - description: raw?.description, - minTier: raw?.minTier ?? raw?.min_tier ?? 'free', - roles: raw?.roles, + id: raw.id, + title: raw.title ?? raw.name ?? raw.id, + description: raw.description, + minTier: raw.minTier ?? raw.min_tier, + roles: raw.roles, } } function mapPolicy(raw: any): AccessPolicy { return { - resourceId: raw?.resourceId ?? raw?.resource_id ?? '', - minTier: raw?.minTier ?? raw?.min_tier ?? 'free', - roles: raw?.roles ?? [], + resourceId: requiredString(raw.resourceId ?? raw.resource_id, 'policy resourceId'), + minTier: raw.minTier ?? raw.min_tier, + roles: raw.roles, } } diff --git a/lib/api/optimistic.ts b/lib/api/optimistic.ts new file mode 100644 index 0000000..2fdfaad --- /dev/null +++ b/lib/api/optimistic.ts @@ -0,0 +1,50 @@ +import type { AccessPolicy, MemberRow, Role } from './types' + +export function applyOptimisticRole( + members: MemberRow[] | undefined, + address: string, + role: Role, +): MemberRow[] { + const currentMembers = members ?? [] + const memberIndex = currentMembers.findIndex( + (member) => member.address.toLowerCase() === address.toLowerCase(), + ) + + if (memberIndex === -1) { + return [ + ...currentMembers, + { + address, + roles: [role], + tier: 'free', + active: true, + }, + ] + } + + return currentMembers.map((member, index) => { + if (index !== memberIndex || member.roles.includes(role)) return member + return { + ...member, + roles: [...member.roles, role], + } + }) +} + +export function applyOptimisticPolicy( + policies: AccessPolicy[] | undefined, + policy: AccessPolicy, +): AccessPolicy[] { + const currentPolicies = policies ?? [] + const policyIndex = currentPolicies.findIndex( + (currentPolicy) => currentPolicy.resourceId === policy.resourceId, + ) + + if (policyIndex === -1) { + return [...currentPolicies, policy] + } + + return currentPolicies.map((currentPolicy, index) => + index === policyIndex ? policy : currentPolicy, + ) +} diff --git a/package.json b/package.json index f589b5c..71b9d41 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "test": "tsc -p test/tsconfig.json && node --test test-dist/test/*.test.js", "typecheck": "tsc --noEmit", "sync-types": "node scripts/sync-api-types.js --write", "check-types": "node scripts/sync-api-types.js --check" @@ -42,4 +43,4 @@ "eslint-config-next": "14.2.5", "typescript": "^5.4.5" } -} \ No newline at end of file +} diff --git a/test/optimistic-updates.test.ts b/test/optimistic-updates.test.ts new file mode 100644 index 0000000..6c9cb71 --- /dev/null +++ b/test/optimistic-updates.test.ts @@ -0,0 +1,103 @@ +import { test } from 'node:test' +import * as assert from 'node:assert/strict' +import { applyOptimisticPolicy, applyOptimisticRole } from '../lib/api/optimistic' +import type { AccessPolicy, MemberRow } from '../lib/api/types' + +test('applies a role assignment optimistically without losing the rollback snapshot', () => { + const previousMembers: MemberRow[] = [ + { + address: '0xabc', + roles: ['member'], + tier: 'standard', + active: true, + }, + ] + + const optimisticMembers = applyOptimisticRole(previousMembers, '0xabc', 'moderator') + + assert.deepEqual(optimisticMembers, [ + { + address: '0xabc', + roles: ['member', 'moderator'], + tier: 'standard', + active: true, + }, + ]) + assert.deepEqual(previousMembers, [ + { + address: '0xabc', + roles: ['member'], + tier: 'standard', + active: true, + }, + ]) +}) + +test('rolls back an optimistic role assignment by restoring the previous members', () => { + const previousMembers: MemberRow[] = [ + { + address: '0xabc', + roles: ['member'], + tier: 'standard', + active: true, + }, + ] + const optimisticMembers = applyOptimisticRole(previousMembers, '0xabc', 'admin') + + assert.notDeepEqual(optimisticMembers, previousMembers) + assert.deepEqual(previousMembers, [ + { + address: '0xabc', + roles: ['member'], + tier: 'standard', + active: true, + }, + ]) +}) + +test('adds a missing member optimistically for a role assignment', () => { + assert.deepEqual(applyOptimisticRole([], '0xdef', 'member'), [ + { + address: '0xdef', + roles: ['member'], + tier: 'free', + active: true, + }, + ]) +}) + +test('applies a policy edit optimistically without mutating the rollback snapshot', () => { + const previousPolicies: AccessPolicy[] = [ + { resourceId: 'alpha', minTier: 'standard' }, + { resourceId: 'reports', minTier: 'pro' }, + ] + + const optimisticPolicies = applyOptimisticPolicy(previousPolicies, { + resourceId: 'alpha', + minTier: 'pro', + }) + + assert.deepEqual(optimisticPolicies, [ + { resourceId: 'alpha', minTier: 'pro' }, + { resourceId: 'reports', minTier: 'pro' }, + ]) + assert.deepEqual(previousPolicies, [ + { resourceId: 'alpha', minTier: 'standard' }, + { resourceId: 'reports', minTier: 'pro' }, + ]) +}) + +test('rolls back an optimistic policy edit by restoring the previous policies', () => { + const previousPolicies: AccessPolicy[] = [ + { resourceId: 'alpha', minTier: 'standard' }, + ] + const optimisticPolicies = applyOptimisticPolicy(previousPolicies, { + resourceId: 'alpha', + minTier: 'pro', + }) + + assert.notDeepEqual(optimisticPolicies, previousPolicies) + assert.deepEqual(previousPolicies, [ + { resourceId: 'alpha', minTier: 'standard' }, + ]) +}) diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..e19ed73 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "noEmit": false, + "outDir": "../test-dist", + "rootDir": "..", + "incremental": false, + "jsx": "react-jsx" + }, + "include": [ + "../lib/api/optimistic.ts", + "../lib/api/types.ts", + "./*.test.ts" + ] +}