From 28d80b0c4cafb18587bfe2cc73acf4cb464479b4 Mon Sep 17 00:00:00 2001 From: EngrEOOnoja Date: Sun, 26 Jul 2026 08:38:35 +0100 Subject: [PATCH 1/4] feat: implement optimistic role assignment with audit trail and SIWE 401 retry --- app/[communitySlug]/admin/members/page.tsx | 126 ++++++++++++++- test/role-assignment-audit.test.ts | 171 +++++++++++++++++++++ 2 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 test/role-assignment-audit.test.ts diff --git a/app/[communitySlug]/admin/members/page.tsx b/app/[communitySlug]/admin/members/page.tsx index c2ff3e1..66481fe 100644 --- a/app/[communitySlug]/admin/members/page.tsx +++ b/app/[communitySlug]/admin/members/page.tsx @@ -44,6 +44,16 @@ type AssignRoleRollback = { previousMembers?: MemberRow[]; }; +type AuditLogEntry = { + id: string; + address: string; + role: Role; + action: "assign" | "remove"; + status: "pending" | "success" | "error"; + timestamp: Date; + error?: string; +}; + function SessionExpiredBanner() { const { signIn, isSigningIn } = useSiweAuth(); return ( @@ -225,6 +235,7 @@ export default function MembersPage() { useState(null); const [rollbackMessage, setRollbackMessage] = useState(""); const [successMessage, setSuccessMessage] = useState(""); + const [auditLog, setAuditLog] = useState([]); // ── Bulk selection state ────────────────────────────────────────── const [selectedAddresses, setSelectedAddresses] = useState>( new Set(), @@ -286,10 +297,30 @@ export default function MembersPage() { isError: mutateError, error: mutateErrorValue, reset: resetMutation, - } = useMutation({ + } = useMutation({ 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, onMutate: async (input) => { + const auditId = Date.now().toString() + Math.random().toString(); + setAuditLog((prev) => [ + { + id: auditId, + address: input.address, + role: input.role, + action: "assign", + status: "pending", + timestamp: new Date(), + }, + ...prev, + ]); + await qc.cancelQueries({ queryKey: queryKeys.members.all(communitySlug) }); const previousQueries = qc.getQueriesData({ queryKey: queryKeys.members.all(communitySlug) }); @@ -314,9 +345,14 @@ export default function MembersPage() { return old; }); - return { previousQueries }; + return { previousQueries, auditId }; }, - onSuccess: (_data, input) => { + onSuccess: (_data, input, context) => { + setAuditLog((prev) => + prev.map((entry) => + entry.id === context?.auditId ? { ...entry, status: "success" } : entry + ) + ); reconcileMemberRoleCache(qc, { address: input.address, role: input.role, @@ -343,6 +379,16 @@ export default function MembersPage() { ? "Session expired. Use the re-authentication banner to sign in again." : safeErrorMessage(err); + if (context?.auditId) { + setAuditLog((prev) => + prev.map((entry) => + entry.id === context.auditId + ? { ...entry, status: "error", error: message } + : entry + ) + ); + } + setRollbackMessage(`Change reverted: ${message}`); addToast({ tone: isExpiredSession ? "warning" : "error", @@ -365,11 +411,31 @@ export default function MembersPage() { void, unknown, AssignRoleInput, - { previousQueries?: [any, any][] } + { previousQueries?: [any, any][]; auditId: string } >({ 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, onMutate: async (input) => { + const auditId = Date.now().toString() + Math.random().toString(); + setAuditLog((prev) => [ + { + id: auditId, + address: input.address, + role: input.role, + action: "remove", + status: "pending", + timestamp: new Date(), + }, + ...prev, + ]); + await qc.cancelQueries({ queryKey: queryKeys.members.all(communitySlug) }); const previousQueries = qc.getQueriesData({ queryKey: queryKeys.members.all(communitySlug) }); setPendingAssignment(input); @@ -391,9 +457,14 @@ export default function MembersPage() { } return old; }); - return { previousQueries }; + return { previousQueries, auditId }; }, - onSuccess: (_data, input) => { + onSuccess: (_data, input, context) => { + setAuditLog((prev) => + prev.map((entry) => + entry.id === context?.auditId ? { ...entry, status: "success" } : entry + ) + ); reconcileMemberRoleCache(qc, { address: input.address, role: input.role, @@ -419,6 +490,16 @@ export default function MembersPage() { ? "Session expired. Use the re-authentication banner to sign in again." : safeErrorMessage(err); + if (context?.auditId) { + setAuditLog((prev) => + prev.map((entry) => + entry.id === context.auditId + ? { ...entry, status: "error", error: message } + : entry + ) + ); + } + setRollbackMessage(`Change reverted: ${message}`); addToast({ tone: isExpiredSession ? "warning" : "error", @@ -677,6 +758,39 @@ export default function MembersPage() { + {auditLog.length > 0 && ( + + + Recent Role Changes (This Session) + + +
+ {auditLog.map((log) => ( +
+
+ + + {log.action === 'assign' ? 'assigned' : 'removed'} role + + {log.role} +
+
+ + {log.timestamp.toLocaleTimeString()} + + {log.status === 'pending' && Pending} + {log.status === 'success' && Success} + {log.status === 'error' && ( + Failed + )} +
+
+ ))} +
+
+
+ )} + {hasAnyMembers && hasVisibleMembers && ( diff --git a/test/role-assignment-audit.test.ts b/test/role-assignment-audit.test.ts new file mode 100644 index 0000000..76cd560 --- /dev/null +++ b/test/role-assignment-audit.test.ts @@ -0,0 +1,171 @@ +import './setup-env' +import { describe, test, beforeEach } from 'node:test' +import * as assert from 'node:assert/strict' +import { QueryClient } from '@tanstack/react-query' +import { MockAccessApi, resetMockData, setMockRoleMutationFailure } from '../lib/api/mock' +import { isApiError, ApiError } from '../lib/api/errors' +import { applyOptimisticRole } from '../lib/api/optimistic' +import { reconcileMemberRoleCache } from '../lib/query/member-cache' +import { queryKeys } from '../lib/query/query-keys' +import type { MemberRow, Role } from '../lib/api/types' + +const ADDRESS = '0xAbC0000000000000000000000000000000000001' + +function membersKey(searchQuery = '') { + return [...queryKeys.members.all(), { searchQuery }] as const +} + +function seedInfinitePage(members: MemberRow[]) { + return { + pages: [{ members, nextCursor: undefined, isFallback: true }], + pageParams: [undefined], + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +type AuditLogEntry = { + id: string + address: string + role: Role + action: 'assign' | 'remove' + status: 'pending' | 'success' | 'error' + timestamp: Date + error?: string +} + +describe('Role Assignment Mutation Logic & Audit Trail', () => { + beforeEach(async () => { + await resetMockData() + setMockRoleMutationFailure(false) + }) + + test('successful assignment updates optimistic state and audit log', async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + qc.setQueryData(membersKey(), seedInfinitePage([ + { address: ADDRESS, roles: ['member'], tier: 'standard', active: true }, + ])) + + const auditLog: AuditLogEntry[] = [] + + // Simulate onMutate + const auditId = 'test-audit-id' + auditLog.unshift({ + id: auditId, + address: ADDRESS, + role: 'moderator', + action: 'assign', + status: 'pending', + timestamp: new Date() + }) + + await qc.cancelQueries({ queryKey: queryKeys.members.all() }) + const previousQueries = qc.getQueriesData({ queryKey: queryKeys.members.all() }) + + qc.setQueriesData({ queryKey: queryKeys.members.all() }, (old: any) => { + return { + ...old, + pages: old.pages.map((page: any) => ({ + ...page, + members: applyOptimisticRole(page.members, ADDRESS, 'moderator'), + })), + } + }) + + // Verify optimistic state + const cached = qc.getQueryData(membersKey()) + assert.deepEqual(cached.pages[0].members[0].roles, ['member', 'moderator']) + + // Simulate onSuccess + const entryIndex = auditLog.findIndex(e => e.id === auditId) + auditLog[entryIndex].status = 'success' + reconcileMemberRoleCache(qc, { address: ADDRESS, role: 'moderator', action: 'assign' }) + + assert.equal(auditLog[0].status, 'success') + assert.deepEqual(qc.getQueryData(membersKey()).pages[0].members[0].roles, ['member', 'moderator']) + }) + + test('failure rolls back optimistic state and updates audit log with error', async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const initialCache = seedInfinitePage([ + { address: ADDRESS, roles: ['member'], tier: 'standard', active: true }, + ]) + qc.setQueryData(membersKey(), initialCache) + + const auditLog: AuditLogEntry[] = [] + const auditId = 'test-audit-id-2' + + // Simulate onMutate + auditLog.unshift({ + id: auditId, + address: ADDRESS, + role: 'admin', + action: 'assign', + status: 'pending', + timestamp: new Date() + }) + + await qc.cancelQueries({ queryKey: queryKeys.members.all() }) + const previousQueries = qc.getQueriesData({ queryKey: queryKeys.members.all() }) + + qc.setQueriesData({ queryKey: queryKeys.members.all() }, (old: any) => { + return { + ...old, + pages: old.pages.map((page: any) => ({ + ...page, + members: applyOptimisticRole(page.members, ADDRESS, 'admin'), + })), + } + }) + + // Simulate onError + const err = new ApiError({ status: 500, code: 'server_error', safeMessage: 'Test error' }) + + for (const [key, data] of previousQueries) { + qc.setQueryData(key, data) + } + + const entryIndex = auditLog.findIndex(e => e.id === auditId) + auditLog[entryIndex].status = 'error' + auditLog[entryIndex].error = err.safeMessage + + assert.equal(auditLog[0].status, 'error') + assert.equal(auditLog[0].error, 'Test error') + assert.deepEqual(qc.getQueryData(membersKey()).pages[0].members[0].roles, ['member']) + }) + + test('401 retry logic allows mutation to succeed on second attempt', async () => { + let attempt = 0 + const mockMutationFn = async () => { + attempt++ + if (attempt === 1) { + throw new ApiError({ status: 401, code: 'unauthorized', safeMessage: 'Session expired' }) + } + return Promise.resolve() + } + + const retryLogic = (failureCount: number, error: unknown) => { + if (error instanceof ApiError && error.code === 'unauthorized' && failureCount < 1) { + return true + } + return false + } + + // Try attempt 1 + let errorRef: unknown = null + try { + await mockMutationFn() + } catch (err) { + errorRef = err + } + + assert.equal(attempt, 1) + assert.ok(retryLogic(0, errorRef)) + + // Try attempt 2 + await mockMutationFn() + assert.equal(attempt, 2) + }) +}) From 27bd9805d91cffe8ba92415d15400155061e7365 Mon Sep 17 00:00:00 2001 From: EngrEOOnoja Date: Sun, 26 Jul 2026 17:05:38 +0100 Subject: [PATCH 2/4] feat(admin): implement Pending Actions and Multi-Admin Approval workflow --- app/[communitySlug]/admin/approvals/page.tsx | 148 +++++++++++++++++++ app/[communitySlug]/admin/members/page.tsx | 43 +++++- app/[communitySlug]/admin/policies/page.tsx | 15 +- app/[communitySlug]/admin/settings/page.tsx | 71 ++++++++- lib/api/live.ts | 27 +++- lib/api/mock.ts | 109 +++++++++++++- lib/api/types.ts | 37 ++++- lib/query/query-keys.ts | 8 + test/pending-actions.test.ts | 92 ++++++++++++ 9 files changed, 528 insertions(+), 22 deletions(-) create mode 100644 app/[communitySlug]/admin/approvals/page.tsx create mode 100644 test/pending-actions.test.ts diff --git a/app/[communitySlug]/admin/approvals/page.tsx b/app/[communitySlug]/admin/approvals/page.tsx new file mode 100644 index 0000000..86d8f3f --- /dev/null +++ b/app/[communitySlug]/admin/approvals/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { getApi, PendingAction } from "@/lib/api"; +import { queryKeys } from "@/lib/query"; +import { useParams } from "next/navigation"; +import { AdminGuard } from "@/components/admin-guard"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useToasts, ToastViewport } from "@/components/ui/toast"; +import { useSiweAuth } from "@/lib/wallet/providers"; +import { AddressText } from "@/components/wallet/address-text"; + +export default function ApprovalsPage() { + const params = useParams(); + const communitySlug = (params?.communitySlug as string) || 'guildpass-demo'; + const queryClient = useQueryClient(); + const { addToast } = useToasts(); + const { authSession } = useSiweAuth(); + const currentAddress = authSession?.address; + + const { data: pendingActions, isLoading } = useQuery({ + queryKey: queryKeys.pendingActions.all(communitySlug), + queryFn: () => getApi(authSession?.token, undefined, communitySlug).getPendingActions(), + }); + + const approveMutation = useMutation({ + mutationFn: (id: string) => getApi(authSession?.token, undefined, communitySlug).approveAction(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.pendingActions.all(communitySlug) }); + queryClient.invalidateQueries({ queryKey: queryKeys.members.all(communitySlug) }); + queryClient.invalidateQueries({ queryKey: queryKeys.policies.all(communitySlug) }); + addToast({ title: "Approved", description: "Action approved successfully.", variant: "success" }); + }, + onError: (error: any) => { + addToast({ title: "Error", description: error.message || "Failed to approve action.", variant: "destructive" }); + } + }); + + const rejectMutation = useMutation({ + mutationFn: (id: string) => getApi(authSession?.token, undefined, communitySlug).rejectAction(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.pendingActions.all(communitySlug) }); + addToast({ title: "Rejected", description: "Action rejected successfully.", variant: "default" }); + }, + onError: (error: any) => { + addToast({ title: "Error", description: error.message || "Failed to reject action.", variant: "destructive" }); + } + }); + + // Sort so pending actions are at the top + const sortedActions = pendingActions ? [...pendingActions].sort((a, b) => { + if (a.status === 'pending' && b.status !== 'pending') return -1; + if (a.status !== 'pending' && b.status === 'pending') return 1; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }) : []; + + return ( + +
+

Pending Approvals

+

+ Review and approve sensitive administrative actions proposed by other admins. +

+ + {isLoading ? ( +
Loading pending actions...
+ ) : sortedActions.length === 0 ? ( + + + No pending actions require approval. + + + ) : ( +
+ {sortedActions.map((action: PendingAction) => { + // Note: using toLowerCase() for address comparisons as a best practice for Ethereum addresses + const currentAddressLower = currentAddress?.toLowerCase(); + const canApprove = currentAddressLower && + !action.currentApprovals.map(a => a.toLowerCase()).includes(currentAddressLower) && + action.status === 'pending'; + const isProposer = currentAddressLower && action.proposer.toLowerCase() === currentAddressLower; + + return ( + + + + {action.type === 'assignRole' && 'Assign Role'} + {action.type === 'removeRole' && 'Remove Role'} + {action.type === 'updatePolicy' && 'Update Policy'} + + + {action.status.toUpperCase()} + + + +
+
+ Proposer: + +
+
+ Approvals: + {action.currentApprovals.length} / {action.requiredApprovals} +
+ +
+
+                          {JSON.stringify(action.payload, null, 2)}
+                        
+
+ + {action.status === 'pending' && ( +
+ + +
+ )} + + {action.status === 'pending' && isProposer && !canApprove && ( +

+ Waiting for other admins to approve. +

+ )} +
+
+
+ ); + })} +
+ )} +
+ +
+ ); +} diff --git a/app/[communitySlug]/admin/members/page.tsx b/app/[communitySlug]/admin/members/page.tsx index 66481fe..cd3011a 100644 --- a/app/[communitySlug]/admin/members/page.tsx +++ b/app/[communitySlug]/admin/members/page.tsx @@ -297,7 +297,7 @@ export default function MembersPage() { isError: mutateError, error: mutateErrorValue, reset: resetMutation, - } = useMutation({ + } = 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) => { @@ -347,7 +347,25 @@ export default function MembersPage() { return { previousQueries, auditId }; }, - onSuccess: (_data, input, context) => { + onSuccess: (data, input, context) => { + if (data.status === 'pending') { + // Rollback optimistic update + if (context?.previousQueries) { + for (const [key, qData] of context.previousQueries) { + qc.setQueryData(key, qData); + } + } + setPendingAssignment(null); + addToast({ + tone: "default", + title: "Approval Required", + description: `Assignment of ${input.role} to ${input.address.slice(0, 6)}…${input.address.slice(-4)} has been proposed for approval.`, + }); + setAddr(""); + resetMutation(); + return; + } + setAuditLog((prev) => prev.map((entry) => entry.id === context?.auditId ? { ...entry, status: "success" } : entry @@ -408,7 +426,7 @@ export default function MembersPage() { mutateErrorValue instanceof AuthError && mutateErrorValue.code === "unauthorized"; const removeRoleMutation = useMutation< - void, + { status: 'executed' | 'pending'; pendingActionId?: string }, unknown, AssignRoleInput, { previousQueries?: [any, any][]; auditId: string } @@ -459,7 +477,24 @@ export default function MembersPage() { }); return { previousQueries, auditId }; }, - onSuccess: (_data, input, context) => { + onSuccess: (data, input, context) => { + if (data.status === 'pending') { + // Rollback optimistic update + if (context?.previousQueries) { + for (const [key, qData] of context.previousQueries) { + qc.setQueryData(key, qData); + } + } + setPendingAssignment(null); + addToast({ + tone: "default", + title: "Approval Required", + description: `Removal of ${input.role} from ${input.address.slice(0, 6)}…${input.address.slice(-4)} has been proposed for approval.`, + }); + 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 2490a9b..b69a695 100644 --- a/app/[communitySlug]/admin/policies/page.tsx +++ b/app/[communitySlug]/admin/policies/page.tsx @@ -337,7 +337,7 @@ export default function PoliciesPage() { isError: mutateError, error: mutateErrorValue, reset: resetMutation, - } = useMutation({ + } = useMutation<{ status: 'executed' | 'pending'; pendingActionId?: string }, unknown, AccessPolicy, PolicyRollback>({ mutationFn: (policy: AccessPolicy) => getApi(address, authSession?.token, communitySlug).updatePolicy(policy), @@ -359,7 +359,18 @@ export default function PoliciesPage() { return { previousPolicies }; }, - onSuccess: (_data, policy) => { + onSuccess: (data, policy, context) => { + if (data.status === 'pending') { + qc.setQueryData(queryKeys.policies.all(communitySlug), context?.previousPolicies); + setSuccessMessage(`Policy update for "${policy.resourceId}" proposed for approval.`); + clearPolicyDraft(policy.resourceId); + clearPolicyDraft(""); + setEditingResourceId(null); + setShowCreateForm(false); + resetMutation(); + return; + } + setSuccessMessage(`Policy saved for ${policy.resourceId}.`); setFormErrors((current) => ({ ...current, diff --git a/app/[communitySlug]/admin/settings/page.tsx b/app/[communitySlug]/admin/settings/page.tsx index 85677cb..7f2f895 100644 --- a/app/[communitySlug]/admin/settings/page.tsx +++ b/app/[communitySlug]/admin/settings/page.tsx @@ -7,9 +7,10 @@ import { AdminGuard } from "@/components/admin-guard"; import { FeatureGate } from "@/components/feature-gate"; import { features } from "@/lib/features"; import { useParams } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; -import { getApi } from "@/lib/api"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { getApi, ApprovalConfig } from "@/lib/api"; import { queryKeys } from "@/lib/query"; +import { useToasts, ToastViewport } from "@/components/ui/toast"; export default function SettingsPage() { const params = useParams(); @@ -20,13 +21,30 @@ export default function SettingsPage() { queryFn: () => getApi(undefined, undefined, communitySlug).getCommunity(), }); + const queryClient = useQueryClient(); + const { addToast } = useToasts(); const [name, setName] = useState(""); + const [approvalConfig, setApprovalConfig] = useState({ assignRole: 1, removeRole: 1, updatePolicy: 1 }); + + const updateConfigMutation = useMutation({ + mutationFn: (config: ApprovalConfig) => getApi(undefined, undefined, communitySlug).updateApprovalConfig(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.community.all(communitySlug) }); + addToast({ title: "Configuration saved", description: "Approval thresholds have been updated.", variant: "success" }); + }, + onError: () => { + addToast({ title: "Error", description: "Failed to update configuration.", variant: "destructive" }); + } + }); useEffect(() => { if (community?.name) { setName(community.name); } - }, [community?.name]); + if (community?.approvalConfig) { + setApprovalConfig(community.approvalConfig); + } + }, [community?.name, community?.approvalConfig]); return ( @@ -53,8 +71,55 @@ export default function SettingsPage() {
+ + + Workflow & Approvals + + +

+ Configure the number of admin approvals required before sensitive actions take effect. (1 = instant execution) +

+
+
+ + setApprovalConfig({ ...approvalConfig, assignRole: parseInt(e.target.value) || 1 })} + /> +
+
+ + setApprovalConfig({ ...approvalConfig, removeRole: parseInt(e.target.value) || 1 })} + /> +
+
+ + setApprovalConfig({ ...approvalConfig, updatePolicy: parseInt(e.target.value) || 1 })} + /> +
+
+
+ +
+
+ ); diff --git a/lib/api/live.ts b/lib/api/live.ts index 1a1feee..f8a5fcf 100644 --- a/lib/api/live.ts +++ b/lib/api/live.ts @@ -42,6 +42,8 @@ import { ModerationReport, ModerationReportSchema, ModerationState, + PendingAction, + ApprovalConfig, } from './types' import { checkVersionCompatibility, type VersionCompatibility } from './version' import { @@ -1004,15 +1006,32 @@ export class LiveAccessApi implements AccessApi { }) } - async assignRole(address: string, role: Role): Promise { + async getPendingActions(): Promise { + throw new Error("Pending actions are not yet supported by guildpass-core.") + } + + async approveAction(id: string): Promise { + throw new Error("Pending actions are not yet supported by guildpass-core.") + } + + async rejectAction(id: string): Promise { + throw new Error("Pending actions are not yet supported by guildpass-core.") + } + + async updateApprovalConfig(config: ApprovalConfig): Promise { + throw new Error("Pending actions are not yet supported by guildpass-core.") + } + + async assignRole(address: string, role: Role): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { await getJson(`/v1/members/${encodeURIComponent(address)}/roles`, { method: 'POST', headers: this.authHeaders(), body: JSON.stringify({ role }), }) + return { status: 'executed' } } - async removeRole(address: string, role: Role): Promise { + async removeRole(address: string, role: Role): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { await getJson( `/v1/members/${encodeURIComponent(address)}/roles/${encodeURIComponent(role)}`, { @@ -1020,9 +1039,10 @@ export class LiveAccessApi implements AccessApi { headers: this.authHeaders(), }, ) + return { status: 'executed' } } - async updatePolicy(policy: AccessPolicy): Promise { + async updatePolicy(policy: AccessPolicy): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { const result = validatePolicy(policy) if (!result.valid) { @@ -1039,6 +1059,7 @@ export class LiveAccessApi implements AccessApi { updated_at: result.value.updatedAt, }), }) + return { status: 'executed' } } async getNonce(address: string): Promise { diff --git a/lib/api/mock.ts b/lib/api/mock.ts index b58ea4a..75433a5 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -53,6 +53,10 @@ import { Paginated, WebhookEvent, EXPECTED_API_VERSION, + PendingAction, + ApprovalConfig, + PendingActionType, + PendingActionPayload, } from './types' import { ApiError } from './errors' import { @@ -391,6 +395,7 @@ export interface CommunityState { policies: AccessPolicy[] webhookEvents: WebhookEventLog[] memberStore: Record + pendingActions: PendingAction[] } export let communityStates: Record = {} @@ -403,7 +408,8 @@ export function getCommunityState(communityId: string = 'guildpass-demo'): Commu resources: [...(MOCK_RESOURCES[normalizedId] ?? [])], policies: [...(MOCK_POLICIES[normalizedId] ?? [])], webhookEvents: [...DEFAULT_WEBHOOK_EVENTS], - memberStore: { ...(MOCK_MEMBER_STORES[normalizedId] ?? {}) } + memberStore: { ...(MOCK_MEMBER_STORES[normalizedId] ?? {}) }, + pendingActions: [], } } return communityStates[normalizedId] @@ -1093,28 +1099,114 @@ export class MockAccessApi implements AccessApi { ) } - async assignRole(address: string, role: Role): Promise { + async getPendingActions(): Promise { + await initPromise + return getCommunityState(this.communityId).pendingActions + } + + async approveAction(id: string): Promise { + await initPromise + const state = getCommunityState(this.communityId) + const action = state.pendingActions.find(a => a.id === id) + if (!action || action.status !== 'pending') return + + if (!action.currentApprovals.includes(MOCK_ADMIN_ADDRESS)) { + action.currentApprovals.push(MOCK_ADMIN_ADDRESS) + } + + if (action.currentApprovals.length >= action.requiredApprovals) { + if (action.type === 'assignRole') { + const data = ensureAddress(action.payload.address!, this.communityId) + if (data && !data.roles.includes(action.payload.role! as Role)) data.roles.push(action.payload.role! as Role) + } else if (action.type === 'removeRole') { + const data = state.memberStore[action.payload.address!] + if (data) data.roles = data.roles.filter(r => r !== action.payload.role!) + } else if (action.type === 'updatePolicy') { + const result = validatePolicy(action.payload.policy!) + if (result.valid) { + const idx = state.policies.findIndex(p => p.resourceId === result.value.resourceId) + const updatedPolicy = { ...result.value, updatedAt: new Date().toISOString() } + if (idx >= 0) state.policies[idx] = updatedPolicy + else state.policies.push(updatedPolicy) + } + } + action.status = 'executed' + } + schedulePersist() + } + + async rejectAction(id: string): Promise { + await initPromise + const state = getCommunityState(this.communityId) + const action = state.pendingActions.find(a => a.id === id) + if (action && action.status === 'pending') { + action.status = 'rejected' + schedulePersist() + } + } + + async updateApprovalConfig(config: ApprovalConfig): Promise { + await initPromise + const state = getCommunityState(this.communityId) + state.community.approvalConfig = config + schedulePersist() + } + + private _checkApproval(type: PendingActionType, payload: PendingActionPayload): { status: 'executed' | 'pending'; pendingActionId?: string } { + const state = getCommunityState(this.communityId) + const config = state.community.approvalConfig + const required = config ? config[type] || 1 : 1 + + if (required > 1) { + const pendingActionId = `pa_${Date.now()}_${Math.random().toString(36).substr(2, 5)}` + state.pendingActions.push({ + id: pendingActionId, + type, + payload, + proposer: MOCK_ADMIN_ADDRESS, + requiredApprovals: required, + currentApprovals: [MOCK_ADMIN_ADDRESS], + status: 'pending', + createdAt: new Date().toISOString() + }) + schedulePersist() + return { status: 'pending', pendingActionId } + } + return { status: 'executed' } + } + + async assignRole(address: string, role: Role): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { await initPromise if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() if (mockRoleMutationShouldFail) throwMockRoleMutationFailure() + + const check = this._checkApproval('assignRole', { address, role }) + if (check.status === 'pending') return check + const data = ensureAddress(address, this.communityId) - if (!data) return + if (!data) return { status: 'executed' } if (!data.roles.includes(role)) data.roles.push(role) schedulePersist() + return { status: 'executed' } } - async removeRole(address: string, role: Role): Promise { + async removeRole(address: string, role: Role): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { await initPromise if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() if (mockRoleMutationShouldFail) throwMockRoleMutationFailure() + + const check = this._checkApproval('removeRole', { address, role }) + if (check.status === 'pending') return check + const state = getCommunityState(this.communityId) const data = state.memberStore[address] - if (!data) return + if (!data) return { status: 'executed' } data.roles = data.roles.filter((r) => r !== role) schedulePersist() + return { status: 'executed' } } - async updatePolicy(policy: AccessPolicy): Promise { + async updatePolicy(policy: AccessPolicy): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { await initPromise if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() const result = validatePolicy(policy) @@ -1130,7 +1222,6 @@ export class MockAccessApi implements AccessApi { if (idx >= 0 && policy.updatedAt) { const existingPolicy = state.policies[idx] if (existingPolicy.updatedAt && existingPolicy.updatedAt !== policy.updatedAt) { - // Policy has been modified by another admin - return 409 Conflict throw new ApiError({ status: 409, code: 'conflict', @@ -1143,6 +1234,9 @@ export class MockAccessApi implements AccessApi { } } + const check = this._checkApproval('updatePolicy', { policy }) + if (check.status === 'pending') return check + // Update policy with new timestamp const updatedPolicy = { ...result.value, @@ -1152,6 +1246,7 @@ export class MockAccessApi implements AccessApi { if (idx >= 0) state.policies[idx] = updatedPolicy else state.policies.push(updatedPolicy) schedulePersist() + return { status: 'executed' } } async listAdminEvents(params?: AdminEventFilterParams): Promise> { diff --git a/lib/api/types.ts b/lib/api/types.ts index 1ac2a6f..706c992 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -47,11 +47,37 @@ export const WebhookEventLogSchema = z.object({ payloadSummary: WebhookPayloadSummarySchema, }) +export interface ApprovalConfig { + assignRole: number + removeRole: number + updatePolicy: number +} + +export type PendingActionType = 'assignRole' | 'removeRole' | 'updatePolicy' + +export interface PendingActionPayload { + address?: string + role?: string + policy?: AccessPolicy +} + +export interface PendingAction { + id: string + type: PendingActionType + payload: PendingActionPayload + proposer: string + requiredApprovals: number + currentApprovals: string[] // List of admin addresses who approved + status: 'pending' | 'approved' | 'rejected' | 'executed' + createdAt: string +} + export interface Community { id: string name: string description?: string tiers: MembershipTier[] + approvalConfig?: ApprovalConfig } export const CommunitySchema = z.object({ @@ -663,9 +689,14 @@ export interface AdminAccessApi { * guildpass-core. Contract tracked in issue #157; pending backend confirmation. */ getAnalyticsSummary(signal?: AbortSignal): Promise - assignRole(address: string, role: Role): Promise - removeRole(address: string, role: Role): Promise - updatePolicy(policy: AccessPolicy): Promise + getPendingActions(): Promise + approveAction(id: string): Promise + rejectAction(id: string): Promise + updateApprovalConfig(config: ApprovalConfig): Promise + + assignRole(address: string, role: Role): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> + removeRole(address: string, role: Role): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> + updatePolicy(policy: AccessPolicy): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> // ── Moderation Queue ── listReports(signal?: AbortSignal): Promise diff --git a/lib/query/query-keys.ts b/lib/query/query-keys.ts index 52aca96..475e384 100644 --- a/lib/query/query-keys.ts +++ b/lib/query/query-keys.ts @@ -111,4 +111,12 @@ export const queryKeys = { all: ['moderationReports'] as const, detail: (id: string) => ['moderationReport', id] as const, }, + + // Pending Actions + pendingActions: { + all: (community: string = 'guildpass-demo') => + features.multiCommunity + ? ['pendingActions', community] as const + : ['pendingActions'] as const, + }, } diff --git a/test/pending-actions.test.ts b/test/pending-actions.test.ts new file mode 100644 index 0000000..8d36b97 --- /dev/null +++ b/test/pending-actions.test.ts @@ -0,0 +1,92 @@ +import './setup-env.ts' +import { describe, test, beforeEach } from 'node:test' +import * as assert from 'node:assert/strict' +import { MockAccessApi, resetMockData } from '../lib/api/mock.ts' +import type { Role } from '../lib/api/types.ts' + +const ADDRESS = '0x0000000000000000000000000000000000000001' + +describe('Pending Actions & Multi-Admin Approval Workflow', () => { + beforeEach(async () => { + await resetMockData() + }) + + test('direct execution when threshold is 1', async () => { + const api = new MockAccessApi('guildpass-demo') + + // Set threshold to 1 + await api.updateApprovalConfig({ assignRole: 1, removeRole: 1, updatePolicy: 1 }) + + const result = await api.assignRole(ADDRESS, 'admin' as Role) + assert.equal(result.status, 'executed') + + const pending = await api.getPendingActions() + assert.equal(pending.length, 0) + }) + + test('creates pending action when threshold > 1', async () => { + const api = new MockAccessApi('guildpass-demo') + + // Set threshold to 2 + await api.updateApprovalConfig({ assignRole: 2, removeRole: 1, updatePolicy: 1 }) + + const result = await api.assignRole(ADDRESS, 'admin' as Role) + assert.equal(result.status, 'pending') + assert.ok(result.pendingActionId) + + const pending = await api.getPendingActions() + assert.equal(pending.length, 1) + assert.equal(pending[0].id, result.pendingActionId) + assert.equal(pending[0].type, 'assignRole') + assert.equal(pending[0].status, 'pending') + assert.equal(pending[0].requiredApprovals, 2) + }) + + test('approval workflow executes mutation upon reaching required count', async () => { + const api = new MockAccessApi('guildpass-demo') + await api.updateApprovalConfig({ assignRole: 2, removeRole: 1, updatePolicy: 1 }) + + const result = await api.assignRole(ADDRESS, 'admin' as Role) + assert.equal(result.status, 'pending') + + const actionId = result.pendingActionId! + + // Approve action (simulating second approval) + await api.approveAction(actionId) + + const pending = await api.getPendingActions() + assert.equal(pending.length, 1) + assert.equal(pending[0].status, 'executed') + + // Verify member actually got the role + const membersRes = await api.listMembers({}) + const members = Array.isArray(membersRes) ? membersRes : membersRes.members + const targetMember = members.find(m => m.address === ADDRESS) + assert.ok(targetMember) + assert.ok(targetMember.roles.includes('admin')) + }) + + test('rejection workflow prevents execution', async () => { + const api = new MockAccessApi('guildpass-demo') + await api.updateApprovalConfig({ assignRole: 2, removeRole: 1, updatePolicy: 1 }) + + const result = await api.assignRole(ADDRESS, 'admin' as Role) + assert.equal(result.status, 'pending') + + const actionId = result.pendingActionId! + + // Reject action + await api.rejectAction(actionId) + + const pending = await api.getPendingActions() + assert.equal(pending.length, 1) + assert.equal(pending[0].status, 'rejected') + + // Verify member did NOT get the role + const membersRes = await api.listMembers({}) + const members = Array.isArray(membersRes) ? membersRes : membersRes.members + const targetMember = members.find(m => m.address === ADDRESS) + assert.ok(targetMember) + assert.ok(!targetMember.roles.includes('admin')) + }) +}) From 7bc91aec3078bb433448916667117ff905615456 Mon Sep 17 00:00:00 2001 From: EngrEOOnoja Date: Sun, 26 Jul 2026 17:19:46 +0100 Subject: [PATCH 3/4] docs(admin): add design specification for multi-admin approval engine --- docs/admin-approvals-workflow.md | 70 ++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/admin-approvals-workflow.md diff --git a/docs/admin-approvals-workflow.md b/docs/admin-approvals-workflow.md new file mode 100644 index 0000000..a783654 --- /dev/null +++ b/docs/admin-approvals-workflow.md @@ -0,0 +1,70 @@ +# Multi-Admin Approval & Pending Actions Workflow Design + +## Overview +This document outlines the architecture and design of the Multi-Admin Approval Workflow engine in `guildpass-integrations`. It details the client-side pending action queue, approval threshold configuration, mutation interception mechanics, and the explicit boundary between the client-side MVP implementation and future `guildpass-core` backend support. + +--- + +## Background & Problem Statement +Previously, sensitive administrative actions—such as assigning/removing member roles (`POST /v1/members/:address/roles`, `DELETE /v1/members/:address/roles/:role`) and updating resource access policies (`PUT /v1/policies/:resourceId`)—took effect unilaterally and immediately upon execution by any single admin. + +For security-conscious communities, unilateral execution poses significant operational risks (e.g., rogue admins or compromised keys immediately revoking other admins or opening resource access). Multi-admin approval ensures sensitive actions require $N$-of-$M$ admin sign-offs before the mutation is committed. + +--- + +## Architectural Architecture & Implementation + +### 1. Data Models (`lib/api/types.ts`) +- **`PendingActionType`**: Identifies supported multi-approval operations (`'assignRole' | 'removeRole' | 'updatePolicy'`). +- **`ApprovalConfig`**: Community configuration mapping each action type to its required approval threshold ($N \ge 1$). +- **`PendingActionPayload`**: Encloses the parameters of the proposed mutation (`address`, `role`, `policy`). +- **`PendingAction`**: Represents an active or historical proposal: + ```typescript + export interface PendingAction { + id: string; + type: PendingActionType; + payload: PendingActionPayload; + proposer: string; + requiredApprovals: number; + currentApprovals: string[]; // List of admin addresses who approved + status: 'pending' | 'executed' | 'rejected'; + createdAt: string; + } + ``` + +### 2. Mutation Interception & Execution (`lib/api/mock.ts`) +When an admin initiates a sensitive mutation: +1. `_checkApproval(type, payload)` checks the community's `ApprovalConfig`. +2. If `requiredApprovals == 1` (default), the mutation executes immediately. +3. If `requiredApprovals > 1`, a `PendingAction` is instantiated with `currentApprovals = [proposerAddress]`, stored in `pendingActions`, and saved to state. The mutation method returns `{ status: 'pending', pendingActionId }`. +4. When another admin approves the pending action via `approveAction(id)`, their address is recorded. Once `currentApprovals.length >= requiredApprovals`, `status` changes to `'executed'` and the underlying state mutation is performed. +5. If an admin invokes `rejectAction(id)`, the status changes to `'rejected'` and no state changes occur. + +### 3. UI Layer & Optimistic Rollbacks +- **Settings Surface ([`app/[communitySlug]/admin/settings/page.tsx`](file:///c:/Users/DELL/Downloads/Rogut%20Omni%20Channel%20Mock%20API/guildpass-integrations/app/%5BcommunitySlug%5D/admin/settings/page.tsx))**: Allows admins to configure threshold requirements ($1$ to $5$) per action type. +- **Approvals Queue ([`app/[communitySlug]/admin/approvals/page.tsx`](file:///c:/Users/DELL/Downloads/Rogut%20Omni%20Channel%20Mock%20API/guildpass-integrations/app/%5BcommunitySlug%5D/admin/approvals/page.tsx))**: Displays active pending proposals, approval progress, payload details, and action controls for approving/rejecting. +- **Optimistic UI Handling**: In [`members/page.tsx`](file:///c:/Users/DELL/Downloads/Rogut%20Omni%20Channel%20Mock%20API/guildpass-integrations/app/%5BcommunitySlug%5D/admin/members/page.tsx) and [`policies/page.tsx`](file:///c:/Users/DELL/Downloads/Rogut%20Omni%20Channel%20Mock%20API/guildpass-integrations/app/%5BcommunitySlug%5D/admin/policies/page.tsx), when a mutation returns `status === 'pending'`, the optimistic UI state is cleanly rolled back, and an informational banner/toast notifies the admin that the proposal was submitted for approval. + +--- + +## Client-Side MVP vs. `guildpass-core` Security Boundaries + +> [!IMPORTANT] +> **Client-Side Boundary Notice**: +> The current implementation is client-side and in-memory/mock-persisted (`lib/api/mock.ts`). `lib/api/live.ts` explicitly throws an unsupported error for pending action approval methods until `guildpass-core` implements backend multi-sig endpoints. + +### Comparison Table + +| Capability | Client-Side MVP (`guildpass-integrations`) | Production Backend (`guildpass-core` Target) | +| :--- | :--- | :--- | +| **State Persistence** | Local / In-memory state (`communityStates`) | Distributed DB (`postgres` / `redis`) | +| **Identity & Authentication** | Mock Admin Address / Local SIWE session | Cryptographic SIWE signature per approval | +| **Security Guarantee** | Client UX simulation; bypassed if client calls API directly | Enforced at backend API layer before DB write | +| **Auditing & History** | Local session event logs | Immutable tamper-proof audit trail | + +### Required `guildpass-core` Endpoints for Production Integration +To transition from client-side MVP to full production security, `guildpass-core` will need to implement: +- `GET /v1/pending-actions` - List pending action queue. +- `POST /v1/pending-actions/:id/approve` - Submit signed approval payload. +- `POST /v1/pending-actions/:id/reject` - Submit signed rejection payload. +- `PUT /v1/communities/:slug/approval-config` - Persist approval policy configurations. From da57082bffb7156c1be406687a63b1b4a532d264 Mon Sep 17 00:00:00 2001 From: EngrEOOnoja Date: Tue, 28 Jul 2026 05:28:09 +0100 Subject: [PATCH 4/4] feat(analytics): add PortfolioChart, AssetBreakdown, YieldPerformanceSummary, and Analytics Dashboard page --- app/analytics/page.tsx | 280 +++++++++++++ components/analytics/AssetBreakdown.tsx | 203 +++++++++ components/analytics/PortfolioChart.tsx | 393 ++++++++++++++++++ .../analytics/YieldPerformanceSummary.tsx | 132 ++++++ components/nav.tsx | 1 + package.json | 1 + src/app/analytics/page.tsx | 1 + src/components/analytics/AssetBreakdown.tsx | 1 + src/components/analytics/PortfolioChart.tsx | 1 + test/portfolio-analytics.test.ts | 51 +++ test/tsconfig.json | 4 +- 11 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 app/analytics/page.tsx create mode 100644 components/analytics/AssetBreakdown.tsx create mode 100644 components/analytics/PortfolioChart.tsx create mode 100644 components/analytics/YieldPerformanceSummary.tsx create mode 100644 src/app/analytics/page.tsx create mode 100644 src/components/analytics/AssetBreakdown.tsx create mode 100644 src/components/analytics/PortfolioChart.tsx create mode 100644 test/portfolio-analytics.test.ts diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx new file mode 100644 index 0000000..5c185b9 --- /dev/null +++ b/app/analytics/page.tsx @@ -0,0 +1,280 @@ +"use client"; + +import React, { useState } from "react"; +import { Nav } from "@/components/nav"; +import { PortfolioChart, type Timeframe } from "@/components/analytics/PortfolioChart"; +import { AssetBreakdown } from "@/components/analytics/AssetBreakdown"; +import { YieldPerformanceSummary } from "@/components/analytics/YieldPerformanceSummary"; +import { + BarChart3, + TrendingUp, + Sparkles, + RefreshCw, + Coins, + ArrowUpRight, + ShieldAlert, + Sliders, + CheckCircle2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface StrategyPool { + id: string; + name: string; + category: string; + stakedBalance: number; + apy: number; + return30D: number; + unclaimedYield: number; + status: "Active" | "Boosting" | "Paused"; +} + +const INITIAL_POOLS: StrategyPool[] = [ + { + id: "pool-1", + name: "ETH-USDC Concentrated Vault", + category: "Uniswap v3 LP", + stakedBalance: 12850.5, + apy: 14.8, + return30D: 385.2, + unclaimedYield: 142.8, + status: "Boosting", + }, + { + id: "pool-2", + name: "Lido Liquid Staking (stETH)", + category: "ETH Liquid Staking", + stakedBalance: 8460.2, + apy: 4.2, + return30D: 29.5, + unclaimedYield: 12.4, + status: "Active", + }, + { + id: "pool-3", + name: "GuildPass Revenue Share Pool", + category: "Protocol Governance", + stakedBalance: 5440.0, + apy: 22.5, + return30D: 102.0, + unclaimedYield: 85.6, + status: "Boosting", + }, + { + id: "pool-4", + name: "USDC Stablecoin Vault", + category: "Aave v3 Core", + stakedBalance: 3480.1, + apy: 8.5, + return30D: 24.6, + unclaimedYield: 9.2, + status: "Active", + }, +]; + +export default function AnalyticsPage() { + const [isEmptyState, setIsEmptyState] = useState(false); + const [timeframe, setTimeframe] = useState("1M"); + const [pools, setPools] = useState(INITIAL_POOLS); + const [claimNotification, setClaimNotification] = useState(null); + const [isClaiming, setIsClaiming] = useState(false); + + const formatCurrency = (val: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(val); + + const totalUnclaimed = pools.reduce((sum, p) => sum + p.unclaimedYield, 0); + + const handleClaimAll = () => { + if (totalUnclaimed === 0) return; + setIsClaiming(true); + setTimeout(() => { + setPools((prev) => prev.map((p) => ({ ...p, unclaimedYield: 0 }))); + setIsClaiming(false); + setClaimNotification(`Successfully claimed ${formatCurrency(totalUnclaimed)} in yield rewards!`); + setTimeout(() => setClaimNotification(null), 5000); + }, 1200); + }; + + const handleClaimPool = (id: string) => { + const target = pools.find((p) => p.id === id); + if (!target || target.unclaimedYield === 0) return; + + setPools((prev) => + prev.map((p) => (p.id === id ? { ...p, unclaimedYield: 0 } : p)) + ); + setClaimNotification(`Claimed ${formatCurrency(target.unclaimedYield)} from ${target.name}!`); + setTimeout(() => setClaimNotification(null), 5000); + }; + + return ( +
+
+ ); +} diff --git a/components/analytics/AssetBreakdown.tsx b/components/analytics/AssetBreakdown.tsx new file mode 100644 index 0000000..7338766 --- /dev/null +++ b/components/analytics/AssetBreakdown.tsx @@ -0,0 +1,203 @@ +"use client"; + +import React from "react"; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; +import { PieChart as PieIcon, Layers, ShieldCheck, ArrowUpRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface AssetItem { + name: string; + symbol: string; + value: number; + allocationPercent: number; + color: string; + apy: number; + strategy: string; +} + +export const MOCK_ASSETS: AssetItem[] = [ + { + name: "ETH-USDC LP Vault", + symbol: "ETH-USDC", + value: 12850.5, + allocationPercent: 42.5, + color: "#6366F1", // Indigo + apy: 14.8, + strategy: "Concentrated Liquidity", + }, + { + name: "Lido Staked ETH", + symbol: "stETH", + value: 8460.2, + allocationPercent: 28.0, + color: "#10B981", // Emerald + apy: 4.2, + strategy: "Liquid Staking", + }, + { + name: "GuildPass Governance Pool", + symbol: "gPASS", + value: 5440.0, + allocationPercent: 18.0, + color: "#F59E0B", // Amber + apy: 22.5, + strategy: "Protocol Revenue Share", + }, + { + name: "USDC Stable Vault", + symbol: "USDC", + value: 3480.1, + allocationPercent: 11.5, + color: "#3B82F6", // Blue + apy: 8.5, + strategy: "Single-Sided Lending", + }, +]; + +interface AssetBreakdownProps { + assets?: AssetItem[]; + isEmpty?: boolean; + className?: string; +} + +export function AssetBreakdown({ assets = MOCK_ASSETS, isEmpty = false, className }: AssetBreakdownProps) { + const totalValue = assets.reduce((sum, item) => sum + item.value, 0); + + const formatCurrency = (val: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(val); + + return ( +
+
+
+

+ + Asset Allocation +

+

+ Distribution across active yield strategies & vaults +

+
+ + {!isEmpty && ( + + {assets.length} Active Vaults + + )} +
+ + {isEmpty ? ( +
+ +
No Asset Allocation
+

+ Once you deposit into a yield pool or vault, your portfolio composition will be charted here. +

+
+ ) : ( +
+ {/* Donut Chart */} +
+ + + + {assets.map((entry, index) => ( + + ))} + + { + if (active && payload && payload.length) { + const item = payload[0].payload as AssetItem; + return ( +
+
{item.name}
+
+ {formatCurrency(item.value)} ({item.allocationPercent}%) +
+
+ {item.apy}% APY +
+
+ ); + } + return null; + }} + /> +
+
+ + {/* Center Total Summary */} +
+ Total + + {formatCurrency(totalValue)} + +
+
+ + {/* Allocation List */} +
+ {assets.map((asset) => ( +
+
+
+ + + {asset.name} + + + {asset.symbol} + +
+
+ + {formatCurrency(asset.value)} + + {asset.allocationPercent}% +
+
+ + {/* Progress bar */} +
+
+
+ +
+ Strategy: {asset.strategy} + + {asset.apy}% APY + +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/components/analytics/PortfolioChart.tsx b/components/analytics/PortfolioChart.tsx new file mode 100644 index 0000000..00ae93d --- /dev/null +++ b/components/analytics/PortfolioChart.tsx @@ -0,0 +1,393 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { TrendingUp, TrendingDown, Calendar, BarChart2, DollarSign, Info } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type Timeframe = "1D" | "1W" | "1M" | "1Y" | "ALL"; +export type MetricView = "value" | "yield" | "combined"; + +export interface DataPoint { + timestamp: string; + dateLabel: string; + fullDate: string; + portfolioValue: number; + yieldEarned: number; + pnlPercentage: number; +} + +interface PortfolioChartProps { + timeframe?: Timeframe; + onTimeframeChange?: (tf: Timeframe) => void; + isEmpty?: boolean; + onStartStaking?: () => void; + className?: string; +} + +// Generate realistic mock historical performance data per timeframe +export function generateHistoricalData(timeframe: Timeframe): DataPoint[] { + const pointsCount = timeframe === "1D" ? 24 : timeframe === "1W" ? 7 : timeframe === "1M" ? 30 : timeframe === "1Y" ? 12 : 36; + const baseValue = 25000; + const data: DataPoint[] = []; + + let currentValue = baseValue; + let cumulativeYield = 120; + + const now = new Date(); + + for (let i = pointsCount - 1; i >= 0; i--) { + let dateLabel = ""; + let fullDate = ""; + + const date = new Date(now); + + if (timeframe === "1D") { + date.setHours(now.getHours() - i); + dateLabel = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + fullDate = date.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + } else if (timeframe === "1W") { + date.setDate(now.getDate() - i); + dateLabel = date.toLocaleDateString([], { weekday: "short" }); + fullDate = date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); + } else if (timeframe === "1M") { + date.setDate(now.getDate() - i); + dateLabel = date.toLocaleDateString([], { month: "short", day: "numeric" }); + fullDate = date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); + } else if (timeframe === "1Y") { + date.setMonth(now.getMonth() - i); + dateLabel = date.toLocaleDateString([], { month: "short" }); + fullDate = date.toLocaleDateString([], { month: "long", year: "numeric" }); + } else { + date.setMonth(now.getMonth() - i); + dateLabel = date.toLocaleDateString([], { month: "short", year: "2-digit" }); + fullDate = date.toLocaleDateString([], { month: "long", year: "numeric" }); + } + + // Add controlled stochastic growth + const changePercent = (Math.sin(i * 0.5) * 0.015) + (Math.random() * 0.02 - 0.008); + currentValue = Math.max(10000, currentValue * (1 + changePercent)); + cumulativeYield += Math.max(2, Math.random() * 25 + 5); + const pnl = ((currentValue - baseValue) / baseValue) * 100; + + data.push({ + timestamp: date.toISOString(), + dateLabel, + fullDate, + portfolioValue: Math.round(currentValue * 100) / 100, + yieldEarned: Math.round(cumulativeYield * 100) / 100, + pnlPercentage: Math.round(pnl * 100) / 100, + }); + } + + return data; +} + +export function PortfolioChart({ + timeframe: externalTimeframe, + onTimeframeChange, + isEmpty = false, + onStartStaking, + className, +}: PortfolioChartProps) { + const [internalTimeframe, setInternalTimeframe] = useState("1M"); + const [metricView, setMetricView] = useState("value"); + const [hoveredPoint, setHoveredPoint] = useState(null); + + const activeTimeframe = externalTimeframe ?? internalTimeframe; + + const handleTimeframeSelect = (tf: Timeframe) => { + setInternalTimeframe(tf); + if (onTimeframeChange) { + onTimeframeChange(tf); + } + }; + + const chartData = useMemo(() => { + if (isEmpty) return []; + return generateHistoricalData(activeTimeframe); + }, [activeTimeframe, isEmpty]); + + const latestPoint = chartData[chartData.length - 1]; + const startPoint = chartData[0]; + + const overallChange = useMemo(() => { + if (!latestPoint || !startPoint || startPoint.portfolioValue === 0) return { diff: 0, percent: 0 }; + const diff = latestPoint.portfolioValue - startPoint.portfolioValue; + const percent = (diff / startPoint.portfolioValue) * 100; + return { diff, percent }; + }, [latestPoint, startPoint]); + + const displayPoint = hoveredPoint || latestPoint; + + const formatCurrency = (val: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(val); + + return ( +
+ {/* Header & Controls */} +
+
+
+ + Portfolio Performance + + + Live Subgraph + +
+ + {!isEmpty && displayPoint ? ( +
+ + {formatCurrency(displayPoint.portfolioValue)} + +
= 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-rose-600 dark:text-rose-400" + )} + > + {overallChange.percent >= 0 ? ( + + ) : ( + + )} + + {overallChange.percent >= 0 ? "+" : ""} + {overallChange.percent.toFixed(2)}% ({formatCurrency(overallChange.diff)}) + + in {activeTimeframe} +
+
+ ) : ( +
+ $0.00 +
+ )} +
+ + {/* Timeframe & Metric View Controls */} +
+ {/* Timeframe Buttons */} +
+ {(["1D", "1W", "1M", "1Y", "ALL"] as Timeframe[]).map((tf) => ( + + ))} +
+ + {/* Metric View Toggle */} + {!isEmpty && ( +
+ + +
+ )} +
+
+ + {/* Chart Canvas / Empty State */} + {isEmpty ? ( +
+
+ +
+

+ No Historical Data Available +

+

+ You do not have any active yield positions or historical snapshots recorded yet. + Deposit into a vault or stake tokens to track your portfolio profitability. +

+ {onStartStaking && ( + + )} +
+ ) : ( +
+ + {metricView === "yield" ? ( + { + if (e && e.activePayload && e.activePayload.length > 0) { + setHoveredPoint(e.activePayload[0].payload as DataPoint); + } + }} + onMouseLeave={() => setHoveredPoint(null)} + > + + + `$${val}`} + className="text-zinc-500 dark:text-zinc-400" + /> + } /> + + + ) : ( + { + if (e && e.activePayload && e.activePayload.length > 0) { + setHoveredPoint(e.activePayload[0].payload as DataPoint); + } + }} + onMouseLeave={() => setHoveredPoint(null)} + > + + + + + + + + + + + + + `$${(val / 1000).toFixed(0)}k`} + domain={["auto", "auto"]} + className="text-zinc-500 dark:text-zinc-400" + /> + } /> + + + )} + +
+ )} +
+ ); +} + +function CustomTooltip({ active, payload, formatCurrency }: any) { + if (active && payload && payload.length) { + const data = payload[0].payload as DataPoint; + return ( +
+
+ {data.fullDate} +
+
+
+ Portfolio Value: + + {formatCurrency(data.portfolioValue)} + +
+
+ Yield Earned: + + +{formatCurrency(data.yieldEarned)} + +
+
+ PnL: + = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400" + )} + > + {data.pnlPercentage >= 0 ? "+" : ""} + {data.pnlPercentage.toFixed(2)}% + +
+
+
+ ); + } + return null; +} diff --git a/components/analytics/YieldPerformanceSummary.tsx b/components/analytics/YieldPerformanceSummary.tsx new file mode 100644 index 0000000..9e7afca --- /dev/null +++ b/components/analytics/YieldPerformanceSummary.tsx @@ -0,0 +1,132 @@ +"use client"; + +import React from "react"; +import { TrendingUp, Coins, DollarSign, Percent, Sparkles, Award } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SummaryMetrics { + totalValue: number; + totalYieldEarned: number; + allTimePnlPercent: number; + allTimePnlValue: number; + averageApy: number; + bestStrategy: string; +} + +const DEFAULT_METRICS: SummaryMetrics = { + totalValue: 30230.8, + totalYieldEarned: 2430.5, + allTimePnlPercent: 18.4, + allTimePnlValue: 4710.2, + averageApy: 12.6, + bestStrategy: "ETH-USDC LP Vault (14.8% APY)", +}; + +interface YieldPerformanceSummaryProps { + metrics?: SummaryMetrics; + isEmpty?: boolean; + className?: string; +} + +export function YieldPerformanceSummary({ + metrics = DEFAULT_METRICS, + isEmpty = false, + className, +}: YieldPerformanceSummaryProps) { + const formatCurrency = (val: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(val); + + if (isEmpty) { + return ( +
+ {[ + { label: "Staked Balance", value: "$0.00", icon: DollarSign }, + { label: "Total Yield Earned", value: "$0.00", icon: Coins }, + { label: "All-Time PnL", value: "0.00%", icon: TrendingUp }, + { label: "Avg Yield Rate", value: "0.00%", icon: Percent }, + ].map((item, idx) => ( +
+
+ {item.label} + +
+
{item.value}
+
+ ))} +
+ ); + } + + return ( +
+
+ {/* Total Value */} +
+
+ Total Portfolio Value +
+ +
+
+
+ {formatCurrency(metrics.totalValue)} +
+ + +5.4% from last week + +
+ + {/* Total Yield */} +
+
+ Total Yield Harvested +
+ +
+
+
+ +{formatCurrency(metrics.totalYieldEarned)} +
+ + Auto-compounded daily + +
+ + {/* All-Time PnL */} +
+
+ All-Time Net PnL +
+ +
+
+
+ +{metrics.allTimePnlPercent}% +
+ + +{formatCurrency(metrics.allTimePnlValue)} total gain + +
+ + {/* Avg APY */} +
+
+ Weighted APY +
+ +
+
+
+ {metrics.averageApy}% +
+ + Best: {metrics.bestStrategy} + +
+
+
+ ); +} diff --git a/components/nav.tsx b/components/nav.tsx index 4b347de..f204c14 100644 --- a/components/nav.tsx +++ b/components/nav.tsx @@ -164,6 +164,7 @@ export function Nav() { const items = [ { href: `${prefix}/dashboard` as Route, label: "Dashboard", enabled: true }, + { href: `/analytics` as Route, label: "Analytics", enabled: true }, ...adminNavItems.map((item) => ({ ...item, enabled: true })), { href: `${prefix}/resources/alpha` as Route, diff --git a/package.json b/package.json index cae9028..654ead9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "postcss": "^8.4.38", "react": "18.2.0", "react-dom": "18.2.0", + "recharts": "^3.10.1", "tailwind-merge": "^2.3.0", "tailwindcss": "^3.4.3", "viem": "^2.17.0", diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx new file mode 100644 index 0000000..71b451a --- /dev/null +++ b/src/app/analytics/page.tsx @@ -0,0 +1 @@ +export { default } from "@/app/analytics/page"; diff --git a/src/components/analytics/AssetBreakdown.tsx b/src/components/analytics/AssetBreakdown.tsx new file mode 100644 index 0000000..a328a73 --- /dev/null +++ b/src/components/analytics/AssetBreakdown.tsx @@ -0,0 +1 @@ +export { AssetBreakdown, MOCK_ASSETS, type AssetItem } from "@/components/analytics/AssetBreakdown"; diff --git a/src/components/analytics/PortfolioChart.tsx b/src/components/analytics/PortfolioChart.tsx new file mode 100644 index 0000000..ca291e3 --- /dev/null +++ b/src/components/analytics/PortfolioChart.tsx @@ -0,0 +1 @@ +export { PortfolioChart, generateHistoricalData, type Timeframe, type DataPoint } from "@/components/analytics/PortfolioChart"; diff --git a/test/portfolio-analytics.test.ts b/test/portfolio-analytics.test.ts new file mode 100644 index 0000000..d645f9a --- /dev/null +++ b/test/portfolio-analytics.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { generateHistoricalData, type Timeframe } from '../components/analytics/PortfolioChart' +import { MOCK_ASSETS } from '../components/analytics/AssetBreakdown' + +test('generateHistoricalData creates correct number of data points for each timeframe', () => { + const tf1D = generateHistoricalData('1D') + assert.equal(tf1D.length, 24) + + const tf1W = generateHistoricalData('1W') + assert.equal(tf1W.length, 7) + + const tf1M = generateHistoricalData('1M') + assert.equal(tf1M.length, 30) + + const tf1Y = generateHistoricalData('1Y') + assert.equal(tf1Y.length, 12) + + const tfALL = generateHistoricalData('ALL') + assert.equal(tfALL.length, 36) +}) + +test('generateHistoricalData populates required properties on each data point', () => { + const data = generateHistoricalData('1M') + for (const point of data) { + assert.equal(typeof point.timestamp, 'string') + assert.equal(typeof point.dateLabel, 'string') + assert.equal(typeof point.fullDate, 'string') + assert.equal(typeof point.portfolioValue, 'number') + assert.equal(typeof point.yieldEarned, 'number') + assert.equal(typeof point.pnlPercentage, 'number') + assert.ok(point.portfolioValue >= 0) + assert.ok(point.yieldEarned >= 0) + } +}) + +test('MOCK_ASSETS allocation percents sum approximately to 100%', () => { + const totalPercent = MOCK_ASSETS.reduce((sum, asset) => sum + asset.allocationPercent, 0) + assert.ok(Math.abs(totalPercent - 100) < 1, `Expected total allocation ~100%, got ${totalPercent}%`) +}) + +test('MOCK_ASSETS contains required metadata for strategy breakdown', () => { + for (const asset of MOCK_ASSETS) { + assert.ok(asset.name && asset.name.length > 0) + assert.ok(asset.symbol && asset.symbol.length > 0) + assert.ok(asset.value > 0) + assert.ok(asset.apy > 0) + assert.ok(asset.color.startsWith('#')) + assert.ok(asset.strategy && asset.strategy.length > 0) + } +}) diff --git a/test/tsconfig.json b/test/tsconfig.json index 146f165..b6f8439 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -46,6 +46,8 @@ "../lib/api/role-removal.ts", "../lib/validation/policy.ts", "../lib/validation/profile.ts", - "../lib/api/analytics.ts" + "../lib/api/analytics.ts", + "../components/analytics/*.tsx", + "../app/analytics/*.tsx" ] }