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
30 changes: 25 additions & 5 deletions react/src/screens/GrantRoleModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const GrantRoleModal: React.FC<GrantRoleModalProps> = ({
}
};

const isSuperAdmin = role === Role.SuperAdmin;

return (
<ConfirmationDialog
open={open}
Expand All @@ -71,11 +73,20 @@ export const GrantRoleModal: React.FC<GrantRoleModalProps> = ({
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}
>
Expand All @@ -92,6 +103,15 @@ export const GrantRoleModal: React.FC<GrantRoleModalProps> = ({
disabled={busy}
placeholder="Select role"
/>
{isSuperAdmin && (
<ul>
<li>The recipient can grant and revoke privileged roles.</li>
<li>
Only proceed after verifying the target address with the
recipient.
</li>
</ul>
)}
{error && (
<Alert variant="danger" title="Error">
{error}
Expand Down
50 changes: 49 additions & 1 deletion react/src/screens/RolesScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: {
Expand All @@ -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 ?? "",
};
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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""}"',
);
});
});
65 changes: 65 additions & 0 deletions react/src/screens/RolesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -127,6 +129,39 @@ export const RolesScreen: React.FC<RolesScreenProps> = ({
const [grantOpen, setGrantOpen] = useState(false);
const [revokeRole, setRevokeRole] = useState<RevocableRole | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [reportFormat, setReportFormat] = useState<RbacReportFormat>("csv");

const handleExport = async () => {
if (!roleState || !targetValid) return;
setError(null);
try {
const events =
typeof (client as unknown as { getEvents?: () => Promise<unknown[]> })
.getEvents === "function"
? await (
client as unknown as { getEvents: () => Promise<unknown[]> }
).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);
Expand Down Expand Up @@ -403,6 +438,36 @@ export const RolesScreen: React.FC<RolesScreenProps> = ({
</span>
)}

<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "14px",
}}
>
Export format
<select
aria-label="Export format"
value={reportFormat}
onChange={(event) =>
setReportFormat(event.target.value as RbacReportFormat)
}
>
<option value="csv">CSV</option>
<option value="json">JSON</option>
</select>
</label>
<button
type="button"
disabled={!roleState || !targetValid}
onClick={() => void handleExport()}
>
Export permissions
</button>
</div>

<GrantRoleModal
open={grantOpen}
target={trimmedTarget}
Expand Down
22 changes: 21 additions & 1 deletion react/src/screens/VaultsScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// SPDX-License-Identifier: MIT
import "@testing-library/jest-dom";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import {
act,
render,
screen,
fireEvent,
waitFor,
} from "@testing-library/react";
import { VaultClient, calculateApy } from "@bc-forge/sdk";
import type { WalletAdapter } from "@bc-forge/sdk";
import { VaultsScreen, EMPTY_STATE_APY_MESSAGE } from "./VaultsScreen";
Expand Down Expand Up @@ -116,6 +122,20 @@ describe("VaultsScreen", () => {
);
});

it("refreshes vault metrics every 30 seconds", async () => {
(calculateApy as jest.Mock).mockResolvedValue(null);
jest.useFakeTimers();
const { unmount } = render(<VaultsScreen client={mockClient} />);

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);

Expand Down
16 changes: 9 additions & 7 deletions react/src/screens/VaultsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
import { useOptionalBcForgeClient, useWalletContext } from "../context";
import { isPositiveInteger } from "../utils";


export interface VaultsScreenProps {
/** Optional pre-instantiated VaultClient instance */
client?: VaultClient;
Expand Down Expand Up @@ -126,7 +125,6 @@ export const VaultsScreen: React.FC<VaultsScreenProps> = ({
? null
: quoteUnderlying(shareBalance, totalAssets, totalShares);


const activeClient = useMemo(() => {
if (client) return client;
if (rpcUrl && networkPassphrase && contractId) {
Expand Down Expand Up @@ -203,9 +201,11 @@ export const VaultsScreen: React.FC<VaultsScreenProps> = ({
]);

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) => {
Expand Down Expand Up @@ -242,7 +242,8 @@ export const VaultsScreen: React.FC<VaultsScreenProps> = ({
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) {
Expand Down Expand Up @@ -302,7 +303,8 @@ export const VaultsScreen: React.FC<VaultsScreenProps> = ({
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) {
Expand Down
Loading
Loading