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
2 changes: 2 additions & 0 deletions react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions react/src/screens/GrantRoleModal.tsx
Original file line number Diff line number Diff line change
@@ -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<TransactionResult>;
/** 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<GrantRoleModalProps> = ({
open,
target,
onClose,
onGrant,
onGranted,
}) => {
const [role, setRole] = useState<GrantableRole>(Role.Minter);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<ConfirmationDialog
open={open}
onClose={() => {
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}
>
<div
data-testid="grant-role-modal"
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
<Dropdown
aria-label="Role"
data-testid="grant-role-dropdown"
items={GRANTABLE_ROLES}
value={role}
onChange={(item) => setRole(item.value as GrantableRole)}
disabled={busy}
placeholder="Select role"
/>
{error && (
<Alert variant="danger" title="Error">
{error}
</Alert>
)}
</div>
</ConfirmationDialog>
);
};
91 changes: 91 additions & 0 deletions react/src/screens/RevokeRoleModal.tsx
Original file line number Diff line number Diff line change
@@ -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<TransactionResult>;
/** Called with the transaction result after a successful revoke. */
onRevoked?: (role: RevocableRole, result: TransactionResult) => void;
}

const IMPACT: Record<RevocableRole, string> = {
[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<RevokeRoleModalProps> = ({
open,
role,
target,
onClose,
onRevoke,
onRevoked,
}) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<ConfirmationDialog
open={open}
onClose={() => {
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 && (
<Alert variant="danger" title="Error">
{error}
</Alert>
)}
</ConfirmationDialog>
);
};
Loading
Loading