diff --git a/react/src/screens/GrantRoleModal.tsx b/react/src/screens/GrantRoleModal.tsx index 339e1044..da3aca6f 100644 --- a/react/src/screens/GrantRoleModal.tsx +++ b/react/src/screens/GrantRoleModal.tsx @@ -61,6 +61,8 @@ export const GrantRoleModal: React.FC = ({ } }; + const isSuperAdmin = role === Role.SuperAdmin; + return ( = ({ onConfirm={() => { void submit(); }} - title="Grant role" - description={`Grant a role to ${target}.`} - variant="warning" - confirmationMode="none" - confirmLabel="Grant" + title={isSuperAdmin ? "Grant SuperAdmin access?" : "Grant role"} + description={ + isSuperAdmin + ? "This transfers full governance control over role assignments for the contract." + : `Grant a role to ${target}.` + } + variant={isSuperAdmin ? "danger" : "warning"} + confirmationMode={isSuperAdmin ? "checkbox" : "none"} + confirmationLabel={ + isSuperAdmin + ? "I verified the target address and reviewed the security checklist." + : undefined + } + confirmLabel={isSuperAdmin ? "Grant SuperAdmin" : "Grant"} busyLabel="Granting…" busy={busy} > @@ -92,6 +103,15 @@ export const GrantRoleModal: React.FC = ({ disabled={busy} placeholder="Select role" /> + {isSuperAdmin && ( +
    +
  • The recipient can grant and revoke privileged roles.
  • +
  • + Only proceed after verifying the target address with the + recipient. +
  • +
+ )} {error && ( {error} diff --git a/react/src/screens/RolesScreen.test.tsx b/react/src/screens/RolesScreen.test.tsx index 2ae12a11..b6371626 100644 --- a/react/src/screens/RolesScreen.test.tsx +++ b/react/src/screens/RolesScreen.test.tsx @@ -12,6 +12,7 @@ import { Role } from "@bc-forge/sdk"; import { BcForgeProvider, WalletProvider } from "../context"; import { useWallet } from "../hooks"; import { RolesScreen } from "./RolesScreen"; +import { exportRbacReport } from "../utils"; const TARGET = "GAVTHAJHFEHX4PZ7GR6O5V2DSJ6G6J4U5XQZ4XK7X5XQZ4XK7X5XQZ4A"; const CALLER = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT"; @@ -21,6 +22,7 @@ const mockGrantMinter = jest.fn(); const mockRevokeMinter = jest.fn(); const mockGrantSuperAdmin = jest.fn(); const mockRevokeSuperAdmin = jest.fn(); +const mockGetEvents = jest.fn().mockResolvedValue([]); jest.mock("@bc-forge/sdk", () => ({ Role: { @@ -43,6 +45,7 @@ jest.mock("@bc-forge/sdk", () => ({ revokeMinter: (...args: unknown[]) => mockRevokeMinter(...args), grantSuperAdmin: (...args: unknown[]) => mockGrantSuperAdmin(...args), revokeSuperAdmin: (...args: unknown[]) => mockRevokeSuperAdmin(...args), + getEvents: (...args: unknown[]) => mockGetEvents(...args), setWalletAdapter: jest.fn(), getContractId: () => config?.contractId ?? "", }; @@ -236,7 +239,11 @@ describe("RolesScreen", () => { fireEvent.click( await screen.findByRole("menuitem", { name: "Super Admin" }), ); - fireEvent.click(screen.getByRole("button", { name: "Grant" })); + const confirm = screen.getByRole("button", { name: "Grant SuperAdmin" }); + expect(confirm).toBeDisabled(); + fireEvent.click(screen.getByRole("checkbox")); + expect(confirm).toBeEnabled(); + fireEvent.click(confirm); await waitFor(() => expect(mockGrantSuperAdmin).toHaveBeenCalledWith(TARGET), ); @@ -308,5 +315,46 @@ describe("RolesScreen", () => { "not-granted", ); }); + + it("exports role assignments in the selected format", async () => { + await connectedWithTarget(); + const createObjectURL = jest.fn(() => "blob:mock"); + const revokeObjectURL = jest.fn(); + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + + fireEvent.change(screen.getByLabelText("Export format"), { + target: { value: "json" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Export permissions" })); + + await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); + + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = revokeObjectURL; + }); }); +describe("exportRbacReport", () => { + it("creates timestamped JSON and CSV reports with address and audit data", () => { + const roles = { Admin: true, Minter: false }; + const events = [{ type: "role_granted", ledger: 42n }]; + const timestamp = "2026-10-19T12:30:00.000Z"; + + const json = exportRbacReport(TARGET, roles, events, "json", timestamp); + expect(json.filename).toBe("rbac-report-2026-10-19T12-30-00-000Z.json"); + expect(JSON.parse(json.content)).toEqual({ + exportedAt: timestamp, + roleAssignments: [{ address: TARGET, roles }], + auditHistory: [{ type: "role_granted", ledger: "42" }], + }); + + const csv = exportRbacReport(TARGET, roles, events, "csv", timestamp); + expect(csv.content).toContain('"role","' + TARGET + '","Admin","true"'); + expect(csv.content).toContain( + '"audit","","","","{""type"":""role_granted"",""ledger"":""42""}"', + ); + }); +}); diff --git a/react/src/screens/RolesScreen.tsx b/react/src/screens/RolesScreen.tsx index a233c2f2..0805a59f 100644 --- a/react/src/screens/RolesScreen.tsx +++ b/react/src/screens/RolesScreen.tsx @@ -11,6 +11,8 @@ import { GrantRoleModal } from "./GrantRoleModal"; import type { GrantableRole } from "./GrantRoleModal"; import { RevokeRoleModal } from "./RevokeRoleModal"; import type { RevocableRole } from "./RevokeRoleModal"; +import { exportRbacReport } from "../utils"; +import type { RbacReportFormat } from "../utils"; export interface RolesScreenProps { /** Optional container style */ @@ -127,6 +129,39 @@ export const RolesScreen: React.FC = ({ const [grantOpen, setGrantOpen] = useState(false); const [revokeRole, setRevokeRole] = useState(null); const [reloadKey, setReloadKey] = useState(0); + const [reportFormat, setReportFormat] = useState("csv"); + + const handleExport = async () => { + if (!roleState || !targetValid) return; + setError(null); + try { + const events = + typeof (client as unknown as { getEvents?: () => Promise }) + .getEvents === "function" + ? await ( + client as unknown as { getEvents: () => Promise } + ).getEvents() + : []; + const report = exportRbacReport( + trimmedTarget, + roleState, + events, + reportFormat, + ); + const url = URL.createObjectURL( + new Blob([report.content], { type: report.mimeType }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = report.filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; const trimmedTarget = target.trim(); const targetValid = isValidStellarAddress(trimmedTarget); @@ -403,6 +438,36 @@ export const RolesScreen: React.FC = ({ )} +
+ + +
+ { ); }); + it("refreshes vault metrics every 30 seconds", async () => { + (calculateApy as jest.Mock).mockResolvedValue(null); + jest.useFakeTimers(); + const { unmount } = render(); + + act(() => jest.advanceTimersByTime(0)); + expect(mockClient.getTotalAssets).toHaveBeenCalledTimes(1); + act(() => jest.advanceTimersByTime(30_000)); + expect(mockClient.getTotalAssets).toHaveBeenCalledTimes(2); + + unmount(); + jest.useRealTimers(); + }); + it("disables deposit and withdraw forms when no wallet is connected", async () => { (calculateApy as jest.Mock).mockResolvedValue(null); diff --git a/react/src/screens/VaultsScreen.tsx b/react/src/screens/VaultsScreen.tsx index 96827307..2e67f212 100644 --- a/react/src/screens/VaultsScreen.tsx +++ b/react/src/screens/VaultsScreen.tsx @@ -10,7 +10,6 @@ import { import { useOptionalBcForgeClient, useWalletContext } from "../context"; import { isPositiveInteger } from "../utils"; - export interface VaultsScreenProps { /** Optional pre-instantiated VaultClient instance */ client?: VaultClient; @@ -126,7 +125,6 @@ export const VaultsScreen: React.FC = ({ ? null : quoteUnderlying(shareBalance, totalAssets, totalShares); - const activeClient = useMemo(() => { if (client) return client; if (rpcUrl && networkPassphrase && contractId) { @@ -203,9 +201,11 @@ export const VaultsScreen: React.FC = ({ ]); useEffect(() => { - void (async () => { - await loadData(); - })(); + void loadData(); + const interval = window.setInterval(() => { + void loadData(); + }, 30_000); + return () => window.clearInterval(interval); }, [loadData]); const handleDeposit = async (e: React.FormEvent) => { @@ -242,7 +242,8 @@ export const VaultsScreen: React.FC = ({ const result = await activeClient.deposit( connectedAddress, amountBigInt, - (effectiveAdapter ?? walletAdapter) as unknown as import("@stellar/stellar-sdk").Keypair, + (effectiveAdapter ?? + walletAdapter) as unknown as import("@stellar/stellar-sdk").Keypair, minimumAfterSlippage(estimatedDepositShares, slippageBps), ); if (result && result.success) { @@ -302,7 +303,8 @@ export const VaultsScreen: React.FC = ({ const result = await activeClient.withdraw( connectedAddress, sharesBigInt, - (effectiveAdapter ?? walletAdapter) as unknown as import("@stellar/stellar-sdk").Keypair, + (effectiveAdapter ?? + walletAdapter) as unknown as import("@stellar/stellar-sdk").Keypair, minimumAfterSlippage(quote, slippageBps), ); if (result && result.success) { diff --git a/react/src/utils.ts b/react/src/utils.ts index fe9976ce..6245e865 100644 --- a/react/src/utils.ts +++ b/react/src/utils.ts @@ -55,26 +55,31 @@ export function formatTokenAmount( decimals: number | undefined, options: FormatTokenAmountOptions = {}, ): string { - const { trim = true, placeholder = '—' } = options; + const { trim = true, placeholder = "—" } = options; if (amount === null || amount === undefined) return placeholder; if (decimals === undefined || decimals <= 0) return amount.toString(); - const sign = amount < 0n ? '-' : ''; + const sign = amount < 0n ? "-" : ""; const digits = (amount < 0n ? -amount : amount).toString(); - const padded = digits.padStart(decimals + 1, '0'); + const padded = digits.padStart(decimals + 1, "0"); const whole = padded.slice(0, padded.length - decimals); const fraction = padded.slice(padded.length - decimals); if (!trim) return `${sign}${whole}.${fraction}`; - const trimmedFraction = fraction.replace(/0+$/, ''); - return trimmedFraction ? `${sign}${whole}.${trimmedFraction}` : `${sign}${whole}`; + const trimmedFraction = fraction.replace(/0+$/, ""); + return trimmedFraction + ? `${sign}${whole}.${trimmedFraction}` + : `${sign}${whole}`; } /** * Percentage string for a decimal fraction, e.g. `0.1234` -> `'12.34%'`. */ -export function formatApy(apy: number | null | undefined, fractionDigits = 2): string { - if (apy === null || apy === undefined || !Number.isFinite(apy)) return '—'; +export function formatApy( + apy: number | null | undefined, + fractionDigits = 2, +): string { + if (apy === null || apy === undefined || !Number.isFinite(apy)) return "—"; return `${(apy * 100).toFixed(fractionDigits)}%`; } @@ -94,7 +99,7 @@ export interface TruncateMiddleOptions { */ export function truncateMiddle( value: string, - { lead = 4, tail = 4, separator = '…' }: TruncateMiddleOptions = {}, + { lead = 4, tail = 4, separator = "…" }: TruncateMiddleOptions = {}, ): string { if (value.length <= lead + tail) return value; return `${value.slice(0, lead)}${separator}${value.slice(-tail)}`; @@ -103,38 +108,52 @@ export function truncateMiddle( /** * Known error patterns and their user-friendly descriptions. */ -const WALLET_ERROR_PATTERNS: Array<{ test: (msg: string) => boolean; message: string }> = [ +const WALLET_ERROR_PATTERNS: Array<{ + test: (msg: string) => boolean; + message: string; +}> = [ { // Matches Freighter, Albedo, and standard wallet rejection messages: // "User declined", "User rejected", "User cancelled", "transaction was rejected", etc. test: (msg) => /user\s*(declined|rejected|cancelled|canceled|denied)/i.test(msg) || - /transaction\s*(was\s*)?(declined|rejected|cancelled|canceled)/i.test(msg) || + /transaction\s*(was\s*)?(declined|rejected|cancelled|canceled)/i.test( + msg, + ) || /rejected\s*by\s*user/i.test(msg) || /declined\s*by\s*user/i.test(msg) || /signature\s*rejected/i.test(msg) || /request\s*rejected/i.test(msg), - message: 'Transaction cancelled by user', + message: "Transaction cancelled by user", }, { test: (msg) => - /not\s*connected|disconnected|no\s*wallet\s*connected|unauthenticated/i.test(msg), - message: 'Wallet is not connected. Please connect your wallet first.', + /not\s*connected|disconnected|no\s*wallet\s*connected|unauthenticated/i.test( + msg, + ), + message: "Wallet is not connected. Please connect your wallet first.", }, { test: (msg) => - /wrong\s*network|different\s*stellar\s*network|network\s*mismatch/i.test(msg), - message: 'Wallet is connected to a different Stellar network than expected.', + /wrong\s*network|different\s*stellar\s*network|network\s*mismatch/i.test( + msg, + ), + message: + "Wallet is connected to a different Stellar network than expected.", }, { test: (msg) => - /timeout|timed\s*out|network\s*error|econnrefused|failed\s*to\s*fetch/i.test(msg), - message: 'Network request timed out or failed. Please check your connection and try again.', + /timeout|timed\s*out|network\s*error|econnrefused|failed\s*to\s*fetch/i.test( + msg, + ), + message: + "Network request timed out or failed. Please check your connection and try again.", }, { test: (msg) => /hardware\s*wallet|ledger.*(timed?\s*out|disconnected)/i.test(msg), - message: 'Hardware wallet confirmation timed out or device was disconnected.', + message: + "Hardware wallet confirmation timed out or device was disconnected.", }, ]; @@ -147,19 +166,19 @@ const WALLET_ERROR_PATTERNS: Array<{ test: (msg: string) => boolean; message: st * @returns Human-readable, actionable error message suitable for toasts and alert dialogs. */ export function parseWalletError(error: unknown): string { - if (!error) return 'An unexpected error occurred.'; + if (!error) return "An unexpected error occurred."; const rawMessage = error instanceof Error ? error.message - : typeof error === 'string' + : typeof error === "string" ? error - : typeof error === 'object' && error !== null && 'message' in error + : typeof error === "object" && error !== null && "message" in error ? String((error as { message: unknown }).message) : String(error); const trimmed = rawMessage.trim(); - if (!trimmed) return 'An unexpected error occurred.'; + if (!trimmed) return "An unexpected error occurred."; for (const { test, message } of WALLET_ERROR_PATTERNS) { if (test(trimmed)) { @@ -170,3 +189,55 @@ export function parseWalletError(error: unknown): string { return trimmed; } +export type RbacReportFormat = "csv" | "json"; + +const stringifyReportValue = (value: unknown, space?: number) => + JSON.stringify( + value, + (_key, nestedValue: unknown) => + typeof nestedValue === "bigint" ? nestedValue.toString() : nestedValue, + space, + ); + +/** Creates a timestamped role assignment and audit-history download. */ +export function exportRbacReport( + address: string, + roles: Record, + auditHistory: unknown[], + format: RbacReportFormat, + exportedAt = new Date().toISOString(), +): { filename: string; mimeType: string; content: string } { + const timestamp = exportedAt.replace(/[:.]/g, "-"); + if (format === "json") { + return { + filename: `rbac-report-${timestamp}.json`, + mimeType: "application/json", + content: stringifyReportValue( + { exportedAt, roleAssignments: [{ address, roles }], auditHistory }, + 2, + ), + }; + } + + const rows = Object.entries(roles).map(([role, granted]) => [ + "role", + address, + role, + String(granted), + "", + ]); + for (const event of auditHistory) + rows.push(["audit", "", "", "", stringifyReportValue(event)]); + const csvCell = (value: string) => { + const safeValue = /^[=+\-@]/.test(value) ? `'${value}` : value; + return `"${safeValue.replace(/"/g, '""')}"`; + }; + + return { + filename: `rbac-report-${timestamp}.csv`, + mimeType: "text/csv", + content: [["kind", "address", "role", "granted", "event"], ...rows] + .map((row) => row.map(csvCell).join(",")) + .join("\r\n"), + }; +}