diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 50d1fd2..fd6f165 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -149,7 +149,12 @@ export default function App() { ), [proposals] ); - const { roles: walletRolesList } = useRoles(wallet.address); + const walletRoles = useRoles({ + walletAddress: wallet.address, + ownerAddresses, + loading, + error, + }); const { address, connect } = wallet; @@ -468,7 +473,7 @@ export default function App() { onRevoke={handleRevoke} onCreateProposal={() => setShowCreate(true)} onCreateRecurringPayment={() => setShowCreateRecurring(true)} - walletRoles={walletRolesList} + roleBanner={walletRoles.banner} loading={loading} error={error} /> diff --git a/frontend/src/hooks/__tests__/useRoles.test.ts b/frontend/src/hooks/__tests__/useRoles.test.ts new file mode 100644 index 0000000..d858e53 --- /dev/null +++ b/frontend/src/hooks/__tests__/useRoles.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest"; +import { getRoleAccessBanner, getWalletRoles } from "../useRoles"; + +const ownerAddress = "GOWNER"; +const viewerAddress = "GVIEWER"; +const guardianAddress = "GGUARDIAN"; +const strangerAddress = "GSTRANGER"; + +describe("useRoles helpers", () => { + test("returns Owner from the current owner set", () => { + expect( + getWalletRoles({ + walletAddress: ownerAddress, + ownerAddresses: [ownerAddress], + }) + ).toEqual(["Owner"]); + }); + + test("returns configured Viewer and limited roles", () => { + expect( + getWalletRoles({ + walletAddress: viewerAddress, + ownerAddresses: [], + viewerAddresses: [viewerAddress], + }) + ).toEqual(["Viewer"]); + + expect( + getWalletRoles({ + walletAddress: guardianAddress, + ownerAddresses: [], + roleAssignments: { [guardianAddress]: ["Guardian"] }, + }) + ).toEqual(["Guardian"]); + }); + + test("classifies Viewer, limited-role, and unrecognized banner states", () => { + expect( + getRoleAccessBanner({ + walletAddress: viewerAddress, + roles: ["Viewer"], + })?.variant + ).toBe("viewer"); + + expect( + getRoleAccessBanner({ + walletAddress: guardianAddress, + roles: ["Guardian"], + })?.variant + ).toBe("role-holder"); + + expect( + getRoleAccessBanner({ + walletAddress: strangerAddress, + roles: [], + })?.variant + ).toBe("unrecognized"); + }); + + test("does not show a role banner for Owners or disconnected wallets", () => { + expect( + getRoleAccessBanner({ + walletAddress: ownerAddress, + roles: ["Owner"], + }) + ).toBeNull(); + + expect( + getRoleAccessBanner({ + walletAddress: null, + roles: [], + }) + ).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/useRoles.ts b/frontend/src/hooks/useRoles.ts index d874f11..69f57fb 100644 --- a/frontend/src/hooks/useRoles.ts +++ b/frontend/src/hooks/useRoles.ts @@ -1,10 +1,6 @@ import { useMemo } from "react"; -import { useState, useCallback, useEffect } from "react"; -import { getRoles } from "../lib/contract"; -import type { Role } from "../types/accord"; -import { useEventPolling } from "./useEventPolling"; -export type WalletRole = Role; +export type WalletRole = "Owner" | "Viewer" | "Guardian" | "SpendingLimit"; export type RoleBannerVariant = "viewer" | "role-holder" | "unrecognized"; @@ -36,9 +32,6 @@ const ROLE_LABELS: Record = { Viewer: "Viewer", Guardian: "Guardian", SpendingLimit: "Spending Limit", - Proposer: "Proposer", - Approver: "Approver", - Executor: "Executor", }; function normalizeAddress(address: string): string { @@ -66,12 +59,6 @@ function parseRole(role: string): WalletRole | null { case "spending_limit": case "spending-limit": return "SpendingLimit"; - case "proposer": - return "Proposer"; - case "approver": - return "Approver"; - case "executor": - return "Executor"; default: return null; } @@ -84,51 +71,133 @@ function parseRoleAssignments(raw: string | undefined): RoleAssignments { const [address, rolesValue] = entry.split(":"); const normalizedAddress = normalizeAddress(address ?? ""); if (!normalizedAddress || !rolesValue) return assignments; - const parsedRoles = rolesValue + + const roles = rolesValue .split("|") .map(parseRole) - .filter((r): r is WalletRole => r !== null); - if (parsedRoles.length > 0) { - assignments[normalizedAddress] = parsedRoles; + .filter((role): role is WalletRole => role !== null); + + if (roles.length > 0) { + assignments[normalizedAddress] = roles; } + return assignments; }, {}); } -export function useRoles(address: string | null) { - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); +const CONFIGURED_VIEWER_ADDRESSES = parseAddressList( + import.meta.env.VITE_VIEWER_ADDRESSES +); +const CONFIGURED_ROLE_ASSIGNMENTS = parseRoleAssignments( + import.meta.env.VITE_ROLE_ASSIGNMENTS +); - const fetchRoles = useCallback(async () => { - if (!address) { - setRoles([]); - return; - } - try { - const fetchedRoles = await getRoles(address); - setRoles(fetchedRoles); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch roles"); - } finally { - setLoading(false); - } - }, [address]); - - useEffect(() => { - // Reset/clear cached roles when the address changes - setRoles([]); - setError(null); - if (address) { - setLoading(true); - fetchRoles(); - } else { - setLoading(false); - } - }, [address, fetchRoles]); +function uniqueRoles(roles: WalletRole[]): WalletRole[] { + return Array.from(new Set(roles)); +} - useEventPolling(fetchRoles, 5000); +function formatRoleList(roles: WalletRole[]): string { + const labels = roles.map((role) => ROLE_LABELS[role]); + if (labels.length === 1) return labels[0]; + if (labels.length === 2) return `${labels[0]} and ${labels[1]}`; + return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`; +} + +export function getWalletRoles({ + walletAddress, + ownerAddresses, + viewerAddresses = CONFIGURED_VIEWER_ADDRESSES, + roleAssignments = CONFIGURED_ROLE_ASSIGNMENTS, +}: UseRolesArgs): WalletRole[] { + if (!walletAddress) return []; + + const normalizedWallet = normalizeAddress(walletAddress); + const roles: WalletRole[] = []; + + if (ownerAddresses.map(normalizeAddress).includes(normalizedWallet)) { + roles.push("Owner"); + } + + if (viewerAddresses.map(normalizeAddress).includes(normalizedWallet)) { + roles.push("Viewer"); + } + + const assignedRoles = roleAssignments[normalizedWallet] ?? []; + roles.push(...assignedRoles); + + return uniqueRoles(roles); +} + +export function getRoleAccessBanner({ + walletAddress, + roles, + loading = false, + error = null, +}: { + walletAddress: string | null; + roles: WalletRole[]; + loading?: boolean; + error?: string | null; +}): RoleAccessBanner | null { + if (!walletAddress || loading || error) return null; + if (roles.includes("Owner")) return null; + + const nonViewerRoles = roles.filter((role) => role !== "Viewer"); + + if (roles.includes("Viewer") && nonViewerRoles.length === 0) { + return { + key: `viewer:${walletAddress}`, + variant: "viewer", + title: "Viewer access", + message: + "This wallet is recognized as a Viewer. You can inspect activity, but you cannot create, approve, or execute proposals.", + }; + } + + if (nonViewerRoles.length > 0) { + const roleList = formatRoleList(nonViewerRoles); + return { + key: `role-holder:${walletAddress}:${nonViewerRoles.join("|")}`, + variant: "role-holder", + title: "Limited role access", + message: `This wallet holds ${roleList}. You can use role-specific permissions, but owner-only proposal actions are unavailable.`, + }; + } + + return { + key: `unrecognized:${walletAddress}`, + variant: "unrecognized", + title: "Unrecognized wallet", + message: + "This wallet is not assigned a role in this Accord. You can view public activity, but protected actions are unavailable.", + }; +} - return { roles, loading, error }; +export function useRoles(args: UseRolesArgs): WalletRoles { + const { + walletAddress, + ownerAddresses, + viewerAddresses = CONFIGURED_VIEWER_ADDRESSES, + roleAssignments = CONFIGURED_ROLE_ASSIGNMENTS, + loading = false, + error = null, + } = args; + + const roles = useMemo( + () => + getWalletRoles({ + walletAddress, + ownerAddresses, + viewerAddresses, + roleAssignments, + }), + [walletAddress, ownerAddresses, viewerAddresses, roleAssignments] + ); + + const banner = useMemo( + () => getRoleAccessBanner({ walletAddress, roles, loading, error }), + [walletAddress, roles, loading, error] + ); + + return { roles, banner }; } diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx index b0467f9..313f1f8 100644 --- a/frontend/src/pages/DashboardPage.test.tsx +++ b/frontend/src/pages/DashboardPage.test.tsx @@ -2,16 +2,7 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, test, vi } from "vitest"; import { DashboardPage } from "./DashboardPage"; -import type { Role } from "../types/accord"; - -vi.mock("../hooks/useOwnerWeights", () => ({ - useOwnerWeights: () => ({ weights: {}, totalWeight: 0, loading: false, error: null }), -})); - -vi.mock("../lib/contract", () => ({ - getDueRecurring: vi.fn().mockResolvedValue([]), - getOwnerWeightChangeEvents: vi.fn().mockResolvedValue([]), -})); +import type { RoleAccessBanner } from "../hooks/useRoles"; const baseProps = { activeProposals: [], @@ -23,32 +14,54 @@ const baseProps = { onRevoke: vi.fn(), onCreateProposal: vi.fn(), onCreateRecurringPayment: vi.fn(), - walletRoles: ["Proposer"], + roleBanner: null, loading: false, error: null, }; -describe("DashboardPage role-gated actions", () => { - test("disables create actions for a connected wallet without Proposer", () => { - render(); +function renderDashboard(roleBanner: RoleAccessBanner | null) { + return render(); +} - const newButton = screen.getByRole("button", { name: "New" }); - const recurringButton = screen.getByRole("button", { name: "Recurring" }); +describe("DashboardPage role access banner", () => { + test("renders Viewer messaging and dismisses it accessibly", () => { + renderDashboard({ + key: "viewer:GCONNECTED", + variant: "viewer", + title: "Viewer access", + message: "This wallet is recognized as a Viewer.", + }); - expect(newButton).toBeDisabled(); - expect(recurringButton).toBeDisabled(); - expect(newButton).toHaveAttribute( - "title", - "Creating proposals requires the Proposer role." - ); - }); + expect(screen.getByRole("status", { name: "Wallet role access" })).toBeTruthy(); + expect(screen.getByText("Viewer access")).toBeTruthy(); - test("keeps create actions enabled for a connected Proposer", () => { - render(); + fireEvent.click(screen.getByRole("button", { name: "Dismiss role access message" })); - expect(screen.getByRole("button", { name: "New" })).toBeEnabled(); - expect(screen.getByRole("button", { name: "Recurring" })).toBeEnabled(); + expect(screen.queryByText("Viewer access")).toBeNull(); }); -}); + test("renders limited-role and unrecognized messages distinctly", () => { + const { rerender } = renderDashboard({ + key: "role-holder:GCONNECTED:Guardian", + variant: "role-holder", + title: "Limited role access", + message: "This wallet holds Guardian.", + }); + expect(screen.getByText("Limited role access")).toBeTruthy(); + + rerender( + + ); + + expect(screen.getByText("Unrecognized wallet")).toBeTruthy(); + }); +}); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 2519069..2e41d2e 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,8 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Plus, Repeat2 } from "lucide-react"; -import type { RoleAccessBanner, WalletRole } from "../hooks/useRoles"; -import { useOwnerWeights } from "../hooks/useOwnerWeights"; -import type { WalletRole } from "../hooks/useRoles"; +import type { RoleAccessBanner } from "../hooks/useRoles"; import type { DashboardStat, Owner, Proposal } from "../types/accord"; import { ProposalCard } from "../components/ProposalCard"; import { StatCard } from "../components/StatCard"; @@ -38,7 +36,7 @@ type DashboardPageProps = { onRevoke: (id: number) => void; onCreateProposal: () => void; onCreateRecurringPayment: () => void; - walletRoles: WalletRole[]; + roleBanner: RoleAccessBanner | null; loading: boolean; error: string | null; }; @@ -53,16 +51,14 @@ export function DashboardPage({ onRevoke, onCreateProposal, onCreateRecurringPayment, - walletRoles, + roleBanner, loading, }: DashboardPageProps) { const readyCount = activeProposals.filter((p) => p.status === "ready").length; const [bannerDismissed, setBannerDismissed] = useState(false); const [sortByDeadline, setSortByDeadline] = useState(false); + const [dismissedError, setDismissedError] = useState(null); const [dismissedRoleBannerKey, setDismissedRoleBannerKey] = useState(null); - const [dueSchedules, setDueSchedules] = useState([]); - const [weightChanges, setWeightChanges] = useState([]); - const [weightChangesLoading, setWeightChangesLoading] = useState(true); const prevReadyCount = useRef(readyCount); const displayedProposals = [...activeProposals].sort((left, right) => { @@ -123,13 +119,13 @@ export function DashboardPage({ setDismissedError(null); }, [error]); + const showRoleBanner = Boolean( + roleBanner && dismissedRoleBannerKey !== roleBanner.key + ); - - const createDisabledReason = - walletAddress && !canCreate(walletRoles) - ? missingRoleTooltip("create") - : undefined; - const createDisabled = Boolean(createDisabledReason); + const roleBannerStyles = roleBanner?.variant === "unrecognized" + ? "border-amber-500/20 bg-amber-500/10 text-amber-100" + : "border-sky-500/20 bg-sky-500/10 text-sky-100"; return ( <> @@ -183,9 +179,7 @@ export function DashboardPage({ ))} - - )} - + )} {showRoleBanner && roleBanner && (
)} - {readyCount > 0 && !bannerDismissed && (