diff --git a/react/src/index.ts b/react/src/index.ts index 090f41f4..6880b1c5 100644 --- a/react/src/index.ts +++ b/react/src/index.ts @@ -9,6 +9,8 @@ export * from './screens/TransferScreen'; export * from './screens/BurnScreen'; export * from './screens/MintScreen'; export * from './screens/RolesScreen'; +export * from './screens/GrantRoleModal'; +export * from './screens/RevokeRoleModal'; export { theme } from './theme'; // Design tokens and the CSS-variable bridge (#1140). export { diff --git a/react/src/screens/GrantRoleModal.tsx b/react/src/screens/GrantRoleModal.tsx new file mode 100644 index 00000000..339e1044 --- /dev/null +++ b/react/src/screens/GrantRoleModal.tsx @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +import React, { useState } from "react"; +import { Role } from "@bc-forge/sdk"; +import type { TransactionResult } from "@bc-forge/sdk"; +import { ConfirmationDialog } from "../components/ConfirmationDialog"; +import { Dropdown } from "../components/Dropdown"; +import { Alert } from "../components/Alert"; + +/** Roles the screen can grant through the SDK (#1227). */ +export type GrantableRole = Role.Minter | Role.SuperAdmin; + +const GRANTABLE_ROLES: Array<{ label: string; value: GrantableRole }> = [ + { label: "Minter", value: Role.Minter }, + { label: "Super Admin", value: Role.SuperAdmin }, +]; + +export interface GrantRoleModalProps { + /** Whether the modal is shown. */ + open: boolean; + /** Address that will receive the role. */ + target: string; + /** Called on cancel or after a successful grant. */ + onClose: () => void; + /** Executes the grant for the chosen role; rejections are shown inline. */ + onGrant: (role: GrantableRole, target: string) => Promise; + /** Called with the transaction result after a successful grant. */ + onGranted?: (role: GrantableRole, result: TransactionResult) => void; +} + +/** + * Dialog for granting a role to `target` (#1227). The user picks a role from + * a dropdown; submit calls `onGrant`. State: `role` (selection), `busy` + * (in-flight tx), `error` (rejection or failed tx message). + */ +export const GrantRoleModal: React.FC = ({ + open, + target, + onClose, + onGrant, + onGranted, +}) => { + const [role, setRole] = useState(Role.Minter); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async () => { + setError(null); + setBusy(true); + try { + const result = await onGrant(role, target); + if (result.success) { + onGranted?.(role, result); + onClose(); + } else { + setError(`Grant failed with transaction hash: ${result.hash}`); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( + { + setError(null); + onClose(); + }} + onConfirm={() => { + void submit(); + }} + title="Grant role" + description={`Grant a role to ${target}.`} + variant="warning" + confirmationMode="none" + confirmLabel="Grant" + busyLabel="Granting…" + busy={busy} + > +
+ setRole(item.value as GrantableRole)} + disabled={busy} + placeholder="Select role" + /> + {error && ( + + {error} + + )} +
+
+ ); +}; diff --git a/react/src/screens/RevokeRoleModal.tsx b/react/src/screens/RevokeRoleModal.tsx new file mode 100644 index 00000000..9884aa07 --- /dev/null +++ b/react/src/screens/RevokeRoleModal.tsx @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +import React, { useState } from "react"; +import { Role } from "@bc-forge/sdk"; +import type { TransactionResult } from "@bc-forge/sdk"; +import { ConfirmationDialog } from "../components/ConfirmationDialog"; +import { Alert } from "../components/Alert"; + +/** Roles the screen can revoke through the SDK (#1228). */ +export type RevocableRole = Role.Minter | Role.SuperAdmin; + +export interface RevokeRoleModalProps { + /** Whether the modal is shown. */ + open: boolean; + /** Role being revoked. */ + role: RevocableRole; + /** Address losing the role. */ + target: string; + /** Called on cancel or after a successful revoke. */ + onClose: () => void; + /** Executes the revoke; rejections are shown inline. */ + onRevoke: (role: RevocableRole, target: string) => Promise; + /** Called with the transaction result after a successful revoke. */ + onRevoked?: (role: RevocableRole, result: TransactionResult) => void; +} + +const IMPACT: Record = { + [Role.Minter]: + "This address will immediately lose the ability to mint tokens.", + [Role.SuperAdmin]: + "This address will immediately lose all super admin privileges, including managing other roles. Never revoke the last active super admin: the contract could be left without governance.", +}; + +/** + * Confirmation dialog for revoking a role (#1228). Shows an impact warning + * and requires the user to tick an acknowledgement before `onRevoke` runs. + * State: `busy` (in-flight tx), `error` (rejection or failed tx message). + */ +export const RevokeRoleModal: React.FC = ({ + open, + role, + target, + onClose, + onRevoke, + onRevoked, +}) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const confirm = async () => { + setError(null); + setBusy(true); + try { + const result = await onRevoke(role, target); + if (result.success) { + onRevoked?.(role, result); + onClose(); + } else { + setError(`Revoke failed with transaction hash: ${result.hash}`); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( + { + setError(null); + onClose(); + }} + onConfirm={() => void confirm()} + title={`Revoke ${role}`} + description={`Revoke the ${role} role from ${target}. ${IMPACT[role]}`} + variant="danger" + confirmationMode="checkbox" + confirmationLabel="I understand this address will lose these permissions." + confirmLabel="Revoke" + busyLabel="Revoking…" + busy={busy} + > + {error && ( + + {error} + + )} + + ); +}; diff --git a/react/src/screens/RolesScreen.test.tsx b/react/src/screens/RolesScreen.test.tsx index f03f7f30..98837e0f 100644 --- a/react/src/screens/RolesScreen.test.tsx +++ b/react/src/screens/RolesScreen.test.tsx @@ -1,13 +1,20 @@ // SPDX-License-Identifier: MIT import "@testing-library/jest-dom"; import React from "react"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from "@testing-library/react"; import { Role } from "@bc-forge/sdk"; import { BcForgeProvider, WalletProvider } from "../context"; import { useWallet } from "../hooks"; import { RolesScreen } from "./RolesScreen"; -const TARGET = "GAVTHAJHFEHX4PZ7GR6O5V2DSJ6G6J4U5XQZ4XK7X5XQZ4XK7X5XQZ"; +const TARGET = "GAVTHAJHFEHX4PZ7GR6O5V2DSJ6G6J4U5XQZ4XK7X5XQZ4XK7X5XQZ4A"; +const CALLER = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT"; const mockHasRole = jest.fn(); const mockGrantMinter = jest.fn(); @@ -97,7 +104,7 @@ const renderWithWallet = (connected: boolean) => render( - + , @@ -105,52 +112,17 @@ const renderWithWallet = (connected: boolean) => describe("RolesScreen", () => { beforeEach(() => { - mockHasRole.mockReset(); - mockGrantMinter.mockReset(); - mockRevokeMinter.mockReset(); - mockGrantSuperAdmin.mockReset(); - mockRevokeSuperAdmin.mockReset(); - (globalThis as typeof globalThis & { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: async () => ({ - events: [ - { - topic: [ - "role_granted", - { value: TARGET }, - { - value: - "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT", - }, - ], - txHash: "EVENTHASH1", - ledger: 101, - ledgerClosedAt: "2024-01-01T00:00:00.000Z", - }, - { - topic: [ - "role_revoked", - { - value: - "GAVTHAJHFEHX4PZ7GR6O5V2DSJ6G6J4U5XQZ4XK7X5XQZ4XK7X5XQZ", - }, - { - value: - "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT", - }, - ], - txHash: "EVENTHASH2", - ledger: 102, - ledgerClosedAt: "2024-01-01T00:01:00.000Z", - }, - ], - }), - }); - - // Minter granted; everything else not granted. - mockHasRole.mockImplementation(async (role: Role) => role === Role.Minter); + [ + mockHasRole, + mockGrantMinter, + mockRevokeMinter, + mockGrantSuperAdmin, + mockRevokeSuperAdmin, + ].forEach((m) => m.mockReset()); + // Caller is SuperAdmin; target holds Minter only. + mockHasRole.mockImplementation(async (role: Role, addr: string) => + addr === CALLER ? role === Role.SuperAdmin : role === Role.Minter, + ); mockGrantMinter.mockResolvedValue({ success: true, hash: "GRANTHASH1" }); mockRevokeMinter.mockResolvedValue({ success: true, hash: "REVOKEHASH1" }); mockGrantSuperAdmin.mockResolvedValue({ success: true, hash: "SAGRANT1" }); @@ -160,131 +132,143 @@ describe("RolesScreen", () => { }); }); - it("renders a badge per role from the mocked hasRole result", async () => { - renderWithWallet(false); - + const typeTarget = (value: string) => fireEvent.change(screen.getByPlaceholderText(/target/i), { - target: { value: TARGET }, + target: { value }, }); - await waitFor(() => { + const connectedWithTarget = async () => { + renderWithWallet(true); + await waitFor(() => + expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( + "GBRP…YOXT", + ), + ); + typeTarget(TARGET); + await waitFor(() => expect(screen.getByTestId("role-badge-Minter")).toHaveTextContent( "Minter: Granted", - ); - }); + ), + ); + await waitFor(() => expect(screen.getByTestId("open-grant")).toBeEnabled()); + }; + it("renders a card per role and badges from hasRole", async () => { + renderWithWallet(false); + expect(screen.getAllByTestId(/^role-card-/)).toHaveLength(4); + typeTarget(TARGET); + await waitFor(() => + expect(screen.getByTestId("role-badge-Minter")).toHaveTextContent( + "Minter: Granted", + ), + ); expect(screen.getByTestId("role-badge-Admin")).toHaveTextContent( "Admin: Not granted", ); - expect(screen.getByTestId("role-badge-SuperAdmin")).toHaveTextContent( - "SuperAdmin: Not granted", + expect(screen.getByTestId("role-count")).toHaveTextContent( + "Roles held: 1 of 4", ); - expect(screen.getByTestId("role-badge-Pauser")).toHaveTextContent( - "Pauser: Not granted", + [Role.Admin, Role.SuperAdmin, Role.Minter, Role.Pauser].forEach((r) => + expect(mockHasRole).toHaveBeenCalledWith(r, TARGET), ); - - expect(mockHasRole).toHaveBeenCalledWith(Role.Admin, TARGET); - expect(mockHasRole).toHaveBeenCalledWith(Role.SuperAdmin, TARGET); - expect(mockHasRole).toHaveBeenCalledWith(Role.Minter, TARGET); - expect(mockHasRole).toHaveBeenCalledWith(Role.Pauser, TARGET); - }); - - it("renders recent role_granted and role_revoked events in the RBAC audit table", async () => { - renderWithWallet(true); - - await waitFor(() => { - expect(screen.getByText("Role Audit Log")).toBeInTheDocument(); - }); - - expect(await screen.findByText("granted")).toBeInTheDocument(); - expect(screen.getByText("revoked")).toBeInTheDocument(); - expect( - screen.getByRole("link", { name: /EVEN.*ASH1/i }), - ).toBeInTheDocument(); }); - it("grant-minter click calls grantMinter for the typed address and refreshes badges", async () => { - renderWithWallet(true); - - await waitFor(() => { - expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( - "GBRP…YOXT", - ); - }); - - fireEvent.change(screen.getByPlaceholderText(/target/i), { - target: { value: TARGET }, - }); - - await waitFor(() => { + it("shows a validation error and clears the matrix for an invalid address", async () => { + renderWithWallet(false); + typeTarget(TARGET); + await waitFor(() => expect(screen.getByTestId("role-badge-Minter")).toHaveTextContent( - "Minter: Granted", - ); - }); - - fireEvent.click(screen.getByTestId("grant-minter")); - - await waitFor(() => { - expect(mockGrantMinter).toHaveBeenCalledTimes(1); - }); - expect(mockGrantMinter).toHaveBeenCalledWith(TARGET); - - expect(await screen.findByRole("status")).toHaveTextContent("GRANTHASH1"); + "Granted", + ), + ); + typeTarget("not-an-address"); + expect(screen.getByTestId("target-error")).toBeInTheDocument(); + expect(screen.queryByTestId("role-count")).not.toBeInTheDocument(); + expect(screen.getByTestId("role-badge-Minter")).toHaveTextContent( + "Minter: —", + ); }); - it("disables grant/revoke actions when the wallet is disconnected", async () => { + it("alerts when the lookup fails", async () => { + mockHasRole.mockRejectedValue(new Error("rpc timeout")); renderWithWallet(false); + typeTarget(TARGET); + expect(await screen.findByRole("alert")).toHaveTextContent("rpc timeout"); + }); - fireEvent.change(screen.getByPlaceholderText(/target/i), { - target: { value: TARGET }, - }); - - await waitFor(() => { + it("disables actions when the wallet is disconnected", async () => { + renderWithWallet(false); + typeTarget(TARGET); + await waitFor(() => expect(screen.getByTestId("role-badge-Minter")).toHaveTextContent( - "Minter: Granted", - ); - }); - - expect(screen.getByTestId("grant-minter")).toBeDisabled(); + "Granted", + ), + ); + expect(screen.getByTestId("open-grant")).toBeDisabled(); expect(screen.getByTestId("revoke-minter")).toBeDisabled(); - expect(screen.getByTestId("grant-super-admin")).toBeDisabled(); - expect(screen.getByTestId("revoke-super-admin")).toBeDisabled(); - expect(mockGrantMinter).not.toHaveBeenCalled(); }); - it("disables actions while the target address is empty", async () => { + it("disables actions when the caller is not an admin", async () => { + mockHasRole.mockResolvedValue(false); renderWithWallet(true); - - await waitFor(() => { - expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( - "GBRP…YOXT", - ); - }); - - expect(screen.getByTestId("grant-minter")).toBeDisabled(); + typeTarget(TARGET); + expect(await screen.findByTestId("not-admin")).toBeInTheDocument(); + expect(screen.getByTestId("open-grant")).toBeDisabled(); }); - it("surfaces an Alert when the grant transaction fails", async () => { - mockGrantMinter.mockResolvedValue({ success: false, hash: "FAILHASH" }); - renderWithWallet(true); + it("grants Minter through the modal and shows success", async () => { + await connectedWithTarget(); + fireEvent.click(screen.getByTestId("open-grant")); + expect(screen.getByTestId("grant-role-modal")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Grant" })); + await waitFor(() => expect(mockGrantMinter).toHaveBeenCalledWith(TARGET)); + expect(await screen.findByRole("status")).toHaveTextContent("GRANTHASH1"); + }); - await waitFor(() => { - expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( - "GBRP…YOXT", - ); - }); + it("grants SuperAdmin when selected in the dropdown", async () => { + await connectedWithTarget(); + fireEvent.click(screen.getByTestId("open-grant")); + fireEvent.click( + within(screen.getByTestId("grant-role-modal")).getByRole("button", { + expanded: false, + }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Super Admin" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Grant" })); + await waitFor(() => + expect(mockGrantSuperAdmin).toHaveBeenCalledWith(TARGET), + ); + }); - fireEvent.change(screen.getByPlaceholderText(/target/i), { - target: { value: TARGET }, - }); - await waitFor(() => { - expect(screen.getByTestId("role-badge-Minter")).toHaveTextContent( - "Minter: Granted", - ); - }); + it("shows an error inside the grant modal when the grant is rejected", async () => { + mockGrantMinter.mockRejectedValue(new Error("user rejected")); + await connectedWithTarget(); + fireEvent.click(screen.getByTestId("open-grant")); + fireEvent.click(screen.getByRole("button", { name: "Grant" })); + expect(await screen.findByText("user rejected")).toBeInTheDocument(); + expect(screen.getByTestId("grant-role-modal")).toBeInTheDocument(); + }); - fireEvent.click(screen.getByTestId("grant-minter")); + it("requires confirmation before revoking Minter", async () => { + await connectedWithTarget(); + fireEvent.click(screen.getByTestId("revoke-minter")); + const confirm = screen.getByRole("button", { name: "Revoke" }); + expect(confirm).toBeDisabled(); + expect(mockRevokeMinter).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(confirm); + await waitFor(() => expect(mockRevokeMinter).toHaveBeenCalledWith(TARGET)); + expect(await screen.findByRole("status")).toHaveTextContent("REVOKEHASH1"); + }); - expect(await screen.findByRole("alert")).toHaveTextContent("FAILHASH"); + it("shows an alert when the revoke transaction fails", async () => { + mockRevokeMinter.mockResolvedValue({ success: false, hash: "FAILHASH" }); + await connectedWithTarget(); + fireEvent.click(screen.getByTestId("revoke-minter")); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + expect(await screen.findByText(/FAILHASH/)).toBeInTheDocument(); }); }); diff --git a/react/src/screens/RolesScreen.tsx b/react/src/screens/RolesScreen.tsx index 5d2c8c0b..4d8604c0 100644 --- a/react/src/screens/RolesScreen.tsx +++ b/react/src/screens/RolesScreen.tsx @@ -1,98 +1,27 @@ // SPDX-License-Identifier: MIT import React, { useState, useEffect } from "react"; import { Role } from "@bc-forge/sdk"; -import type { bcForgeClient } from "@bc-forge/sdk"; +import type { bcForgeClient, TransactionResult } from "@bc-forge/sdk"; import { useBcForgeClient, truncatePublicKey } from "../context"; import { useWallet } from "../hooks"; import { Alert } from "../components/Alert"; import { Badge } from "../components/Badge"; - -interface AuditEventRow { - eventType: "role_granted" | "role_revoked"; - target: string; - admin: string; - timestamp: string; - txHash: string; - ledger: number; -} - -function coerceAuditValue(value: unknown): string { - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "bigint") - return String(value); - if (typeof value === "object" && value !== null) { - const record = value as Record; - if (typeof record.value === "string") return record.value; - if (typeof record.value === "number" || typeof record.value === "bigint") - return String(record.value); - if (Array.isArray(record.value) && record.value.length === 1) - return coerceAuditValue(record.value[0]); - if (typeof record.symbol === "string") return record.symbol; - if (typeof record.name === "string") return record.name; - if (typeof record.toString === "function") { - const text = String(record.toString()); - if (text && text !== "[object Object]") return text; - } - } - return ""; -} - -function parseAuditEvent(event: unknown): AuditEventRow | null { - const candidate = event as { - topic?: unknown[]; - txHash?: string; - ledger?: number; - ledgerClosedAt?: string | Date; - value?: unknown; - }; - if ( - !candidate || - !Array.isArray(candidate.topic) || - typeof candidate.ledger !== "number" - ) { - return null; - } - - const eventType = candidate.topic[0]; - const eventName = coerceAuditValue(eventType); - if (eventName !== "role_granted" && eventName !== "role_revoked") { - return null; - } - - const target = coerceAuditValue( - candidate.topic[1] ?? - (Array.isArray(candidate.value) ? candidate.value[0] : undefined), - ); - const admin = coerceAuditValue( - candidate.topic[2] ?? - (Array.isArray(candidate.value) ? candidate.value[1] : undefined), - ); - if (!target || !admin) return null; - - const timestamp = - typeof candidate.ledgerClosedAt === "string" || - candidate.ledgerClosedAt instanceof Date - ? new Date(candidate.ledgerClosedAt).toISOString() - : new Date(candidate.ledger * 5000).toISOString(); - - return { - eventType: eventName, - target, - admin, - timestamp, - txHash: String(candidate.txHash ?? "unknown"), - ledger: candidate.ledger, - }; -} +import { theme } from "../theme"; +import { GrantRoleModal } from "./GrantRoleModal"; +import type { GrantableRole } from "./GrantRoleModal"; +import { RevokeRoleModal } from "./RevokeRoleModal"; +import type { RevocableRole } from "./RevokeRoleModal"; export interface RolesScreenProps { /** Optional container style */ style?: React.CSSProperties; /** Optional container CSS class */ className?: string; + /** Debounce delay in ms before a typed address is looked up. @default 400 */ + debounceMs?: number; } -/** All RBAC roles rendered as badges (#906). */ +/** All RBAC roles rendered as cards and badges (#906, #1224). */ const ALL_ROLES: Role[] = [ Role.Admin, Role.SuperAdmin, @@ -100,8 +29,37 @@ const ALL_ROLES: Role[] = [ Role.Pauser, ]; +/** Description and permission summary shown on each role card (#1224). */ +const ROLE_INFO: Record = + { + [Role.Admin]: { + description: "Contract administrator.", + permissions: "Configure the contract and manage roles.", + }, + [Role.SuperAdmin]: { + description: "Highest authority.", + permissions: "Grant and revoke every role, including other super admins.", + }, + [Role.Minter]: { + description: "Token issuer.", + permissions: "Mint new tokens.", + }, + [Role.Pauser]: { + description: "Emergency operator.", + permissions: "Pause and unpause the contract.", + }, + }; + +/** Stellar public key (G... strkey) shape check. */ +const STELLAR_ADDRESS = /^G[A-Z2-7]{55}$/; + +/** Returns true when `value` is a well-formed Stellar `G...` address. */ +function isValidStellarAddress(value: string): boolean { + return STELLAR_ADDRESS.test(value); +} + /** Fetches `hasRole` for every role against `target`. */ -async function fetchRoleGrants( +export async function fetchRoleGrants( client: bcForgeClient, target: string, ): Promise> { @@ -113,39 +71,70 @@ async function fetchRoleGrants( return Object.fromEntries(entries); } +const buttonStyle = ( + disabled: boolean, + danger = false, +): React.CSSProperties => ({ + padding: "8px 14px", + backgroundColor: disabled ? "#6b7280" : danger ? theme.danger : theme.primary, + color: theme.surface, + border: "none", + borderRadius: "6px", + fontWeight: 500, + cursor: disabled ? "not-allowed" : "pointer", +}); + /** - * Role management screen (#906). Shows `hasRole` badges for a typed target - * address and offers grant/revoke actions for Minter and SuperAdmin, signed - * by the connected wallet. Admin and Pauser are read-only badges because - * `bcForgeClient` exposes no `grantAdmin` / grantPauser entry point here. + * Role management dashboard (#906, #1224, #1225, #1227, #1228). + * + * - Role cards: description, permission summary and, for the inspected + * address, whether the role is held plus a held-roles count. + * - Inspection search: a valid `G...` address triggers a debounced + * `hasRole` lookup for every role; an invalid one shows a validation error + * and clears the matrix. + * - Grant/revoke go through {@link GrantRoleModal} and {@link RevokeRoleModal} + * and are enabled only for a connected wallet that holds Admin or SuperAdmin. + * + * State: `target` (input), `debouncedTarget` (address being looked up), + * `roleState` (lookup result or null), `adminAccount` (account verified as admin of the + * connected wallet), `grantOpen` / `revokeRole` (open modals). */ export const RolesScreen: React.FC = ({ style, className, + debounceMs = 400, }) => { const client = useBcForgeClient(); const { publicKey, connected } = useWallet(); const [target, setTarget] = useState(""); + const [debouncedTarget, setDebouncedTarget] = useState(""); const [roleState, setRoleState] = useState | null>( null, ); + const [adminAccount, setAdminAccount] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [busy, setBusy] = useState(false); + const [grantOpen, setGrantOpen] = useState(false); + const [revokeRole, setRevokeRole] = useState(null); const [reloadKey, setReloadKey] = useState(0); - const [auditEvents, setAuditEvents] = useState([]); - const [auditLoading, setAuditLoading] = useState(false); const trimmedTarget = target.trim(); - const targetValid = trimmedTarget.length > 0; + const targetValid = isValidStellarAddress(trimmedTarget); + const targetInvalid = trimmedTarget.length > 0 && !targetValid; useEffect(() => { if (!targetValid) return; + const id = setTimeout(() => setDebouncedTarget(trimmedTarget), debounceMs); + return () => clearTimeout(id); + }, [targetValid, trimmedTarget, debounceMs]); + + useEffect(() => { + if (!debouncedTarget || debouncedTarget !== trimmedTarget) return; let cancelled = false; async function load() { try { - const grants = await fetchRoleGrants(client, trimmedTarget); + const grants = await fetchRoleGrants(client, debouncedTarget); if (!cancelled) setRoleState(grants); } catch (err) { if (!cancelled) { @@ -158,115 +147,48 @@ export const RolesScreen: React.FC = ({ return () => { cancelled = true; }; - }, [client, targetValid, trimmedTarget, reloadKey]); + }, [client, debouncedTarget, trimmedTarget, reloadKey]); useEffect(() => { + if (!connected || !publicKey) return; let cancelled = false; - async function loadAuditEvents() { - setAuditLoading(true); + async function check(caller: string) { try { - const contractId = - typeof (client as unknown as { getContractId?: () => string }) - .getContractId === "function" - ? ( - client as unknown as { getContractId: () => string } - ).getContractId() - : (client as unknown as { contractId?: string }).contractId; - const rpcUrl = (client as unknown as { rpcUrl?: string }).rpcUrl; - if (!contractId || !rpcUrl) { - if (!cancelled) setAuditEvents([]); - return; - } - - const response = await fetch(rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "get_events", - params: [ - { - filters: [{ type: "contract", contractIds: [contractId] }], - limit: 25, - }, - ], - }), - }); - - if (!response.ok) { - throw new Error(`RPC request failed (${response.status})`); - } - - const payload = (await response.json()) as { events?: unknown[] }; - const parsed = (payload.events ?? []) - .map((event: unknown) => parseAuditEvent(event)) - .filter( - (event: AuditEventRow | null): event is AuditEventRow => - event !== null, - ); - if (!cancelled) setAuditEvents(parsed); + const [admin, superAdmin] = await Promise.all([ + client.hasRole(Role.Admin, caller), + client.hasRole(Role.SuperAdmin, caller), + ]); + if (!cancelled) setAdminAccount(admin || superAdmin ? caller : null); } catch { - if (!cancelled) setAuditEvents([]); - } finally { - if (!cancelled) setAuditLoading(false); + if (!cancelled) setAdminAccount(null); } } - void loadAuditEvents(); + void check(publicKey); return () => { cancelled = true; }; - }, [client]); + }, [client, connected, publicKey, reloadKey]); - const runAction = async ( - label: string, - action: () => Promise<{ success: boolean; hash: string }>, - ) => { - setError(null); - setSuccess(null); - setBusy(true); - try { - const result = await action(); - if (result.success) { - setSuccess(`${label} succeeded. Tx: ${result.hash}`); - setReloadKey((key) => key + 1); - } else { - setError(`${label} failed with transaction hash: ${result.hash}`); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } + const onDone = (label: string, result: TransactionResult) => { + setSuccess(`${label} succeeded. Tx: ${result.hash}`); + setReloadKey((key) => key + 1); }; - const actions: Array<{ - label: string; - run: () => Promise<{ success: boolean; hash: string }>; - }> = [ - { label: "Grant Minter", run: () => client.grantMinter(trimmedTarget) }, - { label: "Revoke Minter", run: () => client.revokeMinter(trimmedTarget) }, - { - label: "Grant Super Admin", - run: () => client.grantSuperAdmin(trimmedTarget), - }, - { - label: "Revoke Super Admin", - run: () => client.revokeSuperAdmin(trimmedTarget), - }, - ]; - - const actionsDisabled = !connected || !targetValid || busy; - - const actionButtonStyle = (disabled: boolean): React.CSSProperties => ({ - padding: "8px 14px", - backgroundColor: disabled ? "#9ca3af" : "#2563eb", - color: "#ffffff", - border: "none", - borderRadius: "6px", - fontWeight: 500, - cursor: disabled ? "not-allowed" : "pointer", - }); + const grant = (role: GrantableRole, address: string) => + role === Role.Minter + ? client.grantMinter(address) + : client.grantSuperAdmin(address); + const revoke = (role: RevocableRole, address: string) => + role === Role.Minter + ? client.revokeMinter(address) + : client.revokeSuperAdmin(address); + + const callerIsAdmin = connected && !!publicKey && adminAccount === publicKey; + const canAct = connected && callerIsAdmin && targetValid; + const heldCount = + targetValid && roleState + ? ALL_ROLES.filter((r) => roleState[r]).length + : null; return (
= ({

Role Management

-

+

Inspect and manage RBAC roles for a target address.

-
+
Signing account:{" "} = ({ name="target" placeholder="Target G... address" value={target} + aria-invalid={targetInvalid} onChange={(e) => { setTarget(e.target.value); - if (!e.target.value.trim()) setRoleState(null); + setError(null); + setRoleState(null); }} style={{ padding: "8px 12px", - border: "1px solid #d1d5db", + border: `1px solid ${targetInvalid ? theme.danger : theme.border}`, borderRadius: "6px", fontSize: "14px", outline: "none", }} /> - - -
- {!targetValid && ( - - Enter a target address to view roles. + {targetInvalid && ( + + Enter a valid Stellar address starting with G (56 characters). )} - {targetValid && - ALL_ROLES.map((role) => ( - - {role}:{" "} - {roleState === null - ? "…" - : roleState[role] - ? "Granted" - : "Not granted"} - - ))} -
- -
- {actions.map((action) => ( - - ))} -
+
-
-

- Role Audit Log -

- {auditLoading && ( - Loading… - )} -
- -
- ( +
-
- {role} + + {ROLE_INFO[role]?.description} + + + {ROLE_INFO[role]?.permissions} + +
+ -
- - - - - - - - {auditEvents.length === 0 ? ( - - - - ) : ( - auditEvents.map((entry) => ( - - - - - - - - )) - )} - -
EventTargetAdminTimestampTx
- {auditLoading - ? "Loading recent role events…" - : "No role_granted or role_revoked events found."} -
- - {entry.eventType.replace("role_", "")} - - - {truncatePublicKey(entry.target)} - - {truncatePublicKey(entry.admin)} - - {new Date(entry.timestamp).toLocaleString()} - - - {truncatePublicKey(entry.txHash)} - -
-
+ {role}:{" "} + {!targetValid + ? "—" + : roleState === null + ? "…" + : roleState[role] + ? "Granted" + : "Not granted"} + +
+
+ ))} +
+ + {heldCount !== null && ( + + Roles held: {heldCount} of {ALL_ROLES.length} + + )} + {!trimmedTarget && ( + + Enter a target address to view roles. + + )} + +
+ + {([Role.Minter, Role.SuperAdmin] as const).map((role) => { + const disabled = !canAct || !roleState?.[role]; + return ( + + ); + })}
{!connected && ( - + Connect a wallet to grant or revoke roles. )} + {connected && !callerIsAdmin && ( + + The connected account is not an Admin or SuperAdmin, so role changes + are disabled. + + )} + + setGrantOpen(false)} + onGrant={grant} + onGranted={(role, result) => onDone(`Grant ${role}`, result)} + /> + {revokeRole && ( + setRevokeRole(null)} + onRevoke={revoke} + onRevoked={(role, result) => onDone(`Revoke ${role}`, result)} + /> + )} ); };