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
9 changes: 7 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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}
/>
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/hooks/__tests__/useRoles.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
171 changes: 120 additions & 51 deletions frontend/src/hooks/useRoles.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -36,9 +32,6 @@ const ROLE_LABELS: Record<WalletRole, string> = {
Viewer: "Viewer",
Guardian: "Guardian",
SpendingLimit: "Spending Limit",
Proposer: "Proposer",
Approver: "Approver",
Executor: "Executor",
};

function normalizeAddress(address: string): string {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<Role[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(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 };
}
69 changes: 41 additions & 28 deletions frontend/src/pages/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -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(<DashboardPage {...baseProps} walletRoles={["Viewer"]} />);
function renderDashboard(roleBanner: RoleAccessBanner | null) {
return render(<DashboardPage {...baseProps} roleBanner={roleBanner} />);
}

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(<DashboardPage {...baseProps} walletRoles={["Proposer"]} />);
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(
<DashboardPage
{...baseProps}
roleBanner={{
key: "unrecognized:GCONNECTED",
variant: "unrecognized",
title: "Unrecognized wallet",
message: "This wallet is not assigned a role in this Accord.",
}}
/>
);

expect(screen.getByText("Unrecognized wallet")).toBeTruthy();
});
});
Loading
Loading