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
66 changes: 57 additions & 9 deletions app/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -49,26 +60,50 @@ export default function MembersPage() {

const [addr, setAddr] = useState('')
const [role, setRole] = useState<Role>('member')
const [pendingAssignment, setPendingAssignment] = useState<AssignRoleInput | null>(null)
const [successMessage, setSuccessMessage] = useState('')
const [rollbackMessage, setRollbackMessage] = useState('')

const {
mutate,
isPending,
isError: mutateError,
error: mutateErrorValue,
reset: resetMutation
} = useMutation({
mutationFn: () => getApi(address, authSession?.token).assignRole(addr, role),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['members'] })
} = useMutation<void, unknown, AssignRoleInput, AssignRoleRollback>({
mutationFn: (input) =>
getApi(address, authSession?.token).assignRole(input.address, input.role),
onMutate: async (input) => {
await qc.cancelQueries({ queryKey: ['members'] })
const previousMembers = qc.getQueryData<MemberRow[]>(['members'])

setPendingAssignment(input)
setSuccessMessage('')
setRollbackMessage('')
setSessionExpired(false)

qc.setQueryData<MemberRow[]>(['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 (
Expand Down Expand Up @@ -100,17 +135,27 @@ export default function MembersPage() {
</select>
<Button
id="assign-role-btn"
onClick={() => mutate()}
onClick={() => mutate({ address: addr, role })}
disabled={!addr || isPending}
>
{isPending ? 'Assigning…' : 'Assign'}
</Button>
</div>
{successMessage && (
<div className="text-sm text-green-700 dark:text-green-400" role="status">
{successMessage}
</div>
)}
{rollbackMessage && (
<div className="text-sm text-destructive" role="alert">
{rollbackMessage}
</div>
)}
{mutateError && (
<ErrorState
title="Failed to assign role"
message={safeErrorMessage(mutateErrorValue)}
onRetry={() => mutate()}
onRetry={() => mutate({ address: addr, role })}
/>
)}
</CardContent>
Expand All @@ -137,8 +182,11 @@ export default function MembersPage() {
className="flex items-center justify-between border rounded-md p-2"
>
<div className="text-sm">{m.address}</div>
<div className="text-xs text-muted-foreground">
Tier: {m.tier} • Roles: {m.roles.join(', ')}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Tier: {m.tier} • Roles: {m.roles.join(', ')}</span>
{pendingAssignment?.address.toLowerCase() === m.address.toLowerCase() && (
<Badge variant="warning">Saving</Badge>
)}
</div>
</div>
))}
Expand Down
154 changes: 97 additions & 57 deletions app/admin/policies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -41,6 +45,9 @@ export default function PoliciesPage() {
const { authSession } = useSiweAuth()
const qc = useQueryClient()
const [sessionExpired, setSessionExpired] = useState(false)
const [pendingPolicyId, setPendingPolicyId] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState('')
const [rollbackMessage, setRollbackMessage] = useState('')

const { data: policies, isLoading, isError, error, refetch } = useQuery<AccessPolicy[]>({
queryKey: ['policies'],
Expand All @@ -50,23 +57,42 @@ export default function PoliciesPage() {

const {
mutate,
isPending,
isError: mutateError,
error: mutateErrorValue,
reset: resetMutation
} = useMutation({
} = useMutation<void, unknown, AccessPolicy, PolicyRollback>({
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<AccessPolicy[]>(['policies'])

setPendingPolicyId(policy.resourceId)
setSuccessMessage('')
setRollbackMessage('')
setSessionExpired(false)

qc.setQueryData<AccessPolicy[]>(['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 (
Expand All @@ -77,56 +103,70 @@ export default function PoliciesPage() {

{sessionExpired && <SessionExpiredBanner />}

<Card>
<CardHeader><CardTitle>Resources</CardTitle></CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<LoadingState message="Loading policies…" />
) : isError ? (
<ErrorState
title="Failed to load policies"
message={safeErrorMessage(error)}
onRetry={() => refetch()}
/>
) : !policies?.length ? (
<EmptyState message="No resources configured." />
) : (
policies.map((p) => (
<div key={p.resourceId} className="flex items-center gap-2">
<div className="w-40 text-sm">{p.resourceId}</div>
<select
id={`policy-tier-${p.resourceId}`}
className="border rounded-md h-9 px-2 text-sm"
value={p.minTier ?? 'free'}
onChange={(e) => mutate({ ...p, minTier: e.target.value as AccessPolicy['minTier'] })}
disabled={isPending}
>
<option value="free">free</option>
<option value="standard">standard</option>
<option value="pro">pro</option>
</select>
<Button
id={`policy-save-${p.resourceId}`}
variant="outline"
size="sm"
onClick={() => mutate({ ...p })}
disabled={isPending}
>
{isPending ? 'Saving…' : 'Save'}
</Button>
<Card>
<CardHeader><CardTitle>Resources</CardTitle></CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<LoadingState message="Loading policies…" />
) : isError ? (
<ErrorState
title="Failed to load policies"
message={safeErrorMessage(error)}
onRetry={() => refetch()}
/>
) : !policies?.length ? (
<EmptyState message="No resources configured." />
) : (
policies.map((p) => (
<div key={p.resourceId} className="flex items-center gap-2">
<div className="flex w-40 items-center gap-2 text-sm">
<span>{p.resourceId}</span>
{pendingPolicyId === p.resourceId && (
<Badge variant="warning">Saving</Badge>
)}
</div>
))
)}
{mutateError && (
<ErrorState
title="Failed to save policy"
message={safeErrorMessage(mutateErrorValue)}
/>
)}
</CardContent>
</Card>
</div>
</AdminGuard>
</FeatureGate>
<select
id={`policy-tier-${p.resourceId}`}
className="border rounded-md h-9 px-2 text-sm"
value={p.minTier ?? 'free'}
onChange={(e) => mutate({ ...p, minTier: e.target.value as AccessPolicy['minTier'] })}
disabled={Boolean(pendingPolicyId)}
>
<option value="free">free</option>
<option value="standard">standard</option>
<option value="pro">pro</option>
</select>
<Button
id={`policy-save-${p.resourceId}`}
variant="outline"
size="sm"
onClick={() => mutate({ ...p })}
disabled={Boolean(pendingPolicyId)}
>
{pendingPolicyId === p.resourceId ? 'Saving…' : 'Save'}
</Button>
</div>
))
)}
{successMessage && (
<div className="text-sm text-green-700 dark:text-green-400" role="status">
{successMessage}
</div>
)}
{rollbackMessage && (
<div className="text-sm text-destructive" role="alert">
{rollbackMessage}
</div>
)}
{mutateError && (
<ErrorState
title="Failed to save policy"
message={safeErrorMessage(mutateErrorValue)}
/>
)}
</CardContent>
</Card>
</div>
</AdminGuard>
)
}
Loading