From caf0492fbee9305c6339eaf72343a07c868c170a Mon Sep 17 00:00:00 2001 From: panditdhamdhere Date: Thu, 30 Jul 2026 05:40:20 +0530 Subject: [PATCH] Add admin action receipt view for privileged operations --- README.md | 1 + docs/README.md | 1 + docs/admin-action-receipts.md | 92 ++++++++ docs/architecture.md | 6 +- docs/transaction-components.md | 8 + .../TransactionFixtureGallery.test.tsx | 2 + .../transactions/TransactionReceipt.tsx | 41 +++- .../transactions/explorerLink.test.ts | 23 ++ src/components/transactions/fixtures.ts | 9 + .../transactions/statusMapper.test.ts | 53 +++++ src/components/transactions/types.ts | 6 +- src/features/admin/components/AdminPanel.tsx | 21 +- .../components/ComplianceUpdateModal.tsx | 7 + .../receipts/AdminActionReceiptView.test.tsx | 75 +++++++ .../admin/receipts/AdminActionReceiptView.tsx | 37 ++++ src/features/admin/receipts/fixtures.ts | 73 +++++++ src/features/admin/receipts/index.ts | 10 + .../receipts/mapAdminActionReceipt.test.ts | 104 +++++++++ .../admin/receipts/mapAdminActionReceipt.ts | 205 ++++++++++++++++++ src/features/admin/receipts/types.ts | 50 +++++ .../components/AssetCreationWizard.tsx | 43 ++-- .../components/WhitelistActionModal.tsx | 44 ++-- .../minting/components/MintWorkflow.tsx | 27 ++- 23 files changed, 887 insertions(+), 51 deletions(-) create mode 100644 docs/admin-action-receipts.md create mode 100644 src/components/transactions/explorerLink.test.ts create mode 100644 src/components/transactions/statusMapper.test.ts create mode 100644 src/features/admin/receipts/AdminActionReceiptView.test.tsx create mode 100644 src/features/admin/receipts/AdminActionReceiptView.tsx create mode 100644 src/features/admin/receipts/fixtures.ts create mode 100644 src/features/admin/receipts/index.ts create mode 100644 src/features/admin/receipts/mapAdminActionReceipt.test.ts create mode 100644 src/features/admin/receipts/mapAdminActionReceipt.ts create mode 100644 src/features/admin/receipts/types.ts diff --git a/README.md b/README.md index 047f44d..28b333b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Key resources for contributors: - [Bulk Compliance Review](docs/bulk-compliance-review.md) — Bulk compliance review table with action confirmation modal - [Compliance Status Panel](docs/compliance-status-panel.md) — Address-level compliance status for investor and admin views (Issue #175) - [Transaction Review Modal](docs/transaction-review-modal.md) — Pre-signature review modal, operation summary mapper, and risk notes (Issue #177) +- [Admin Action Receipts](docs/admin-action-receipts.md) — Privileged action status, target, hash, explorer link, and next-step guidance (Issue #179) - [Environment Mismatch Blocking Screen](docs/environment-mismatch-blocking.md) — Full-page blocking screen when the wallet network does not match the dashboard target network - [Investor Onboarding Eligibility](docs/investor-onboarding-eligibility.md) — Investor onboarding eligibility page, evaluation precedence, and SDK mapping - [Performance Budget Review](docs/performance-budget-review.md) — Typed budget threshold evaluation, edge cases, and reviewer checklist diff --git a/docs/README.md b/docs/README.md index 92da8a9..84dc499 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ Reference material for contributors implementing new functionality. |---|---| | [transaction-components.md](transaction-components.md) | Transfer modal, history table, and operation-type mapping | | [transaction-review-modal.md](transaction-review-modal.md) | Pre-signature review modal, operation summary mapper, risk notes (Issue #177) | +| [admin-action-receipts.md](admin-action-receipts.md) | Admin operation receipts, explorer links, next actions, limitations (Issue #179) | | [transaction-history.md](transaction-history.md) | Normalised transaction model, supported operation types, fixture coverage | | [table-filtering.md](table-filtering.md) | Reusable table filtering, sorting, search, and saved-views pattern | | [empty-state-components.md](empty-state-components.md) | Reusable `EmptyState` component — variants, props, and usage examples | diff --git a/docs/admin-action-receipts.md b/docs/admin-action-receipts.md new file mode 100644 index 0000000..7a184f4 --- /dev/null +++ b/docs/admin-action-receipts.md @@ -0,0 +1,92 @@ +# Admin Action Receipts + +Issue #179 adds a consistent receipt view for privileged dashboard actions. +After an action resolves, admins see its status, operation, target, transaction +hash, explorer link (when available), and a context-specific next action. + +## Architecture + +The feature lives in `src/features/admin/receipts/`: + +| File | Purpose | +| --- | --- | +| `types.ts` | Typed admin operations and normalized receipt model | +| `mapAdminActionReceipt.ts` | Maps provider/local outcomes into receipt data | +| `AdminActionReceiptView.tsx` | Admin view composed from shared `TransactionReceipt` | +| `fixtures.ts` | Major operations and success/failure/pending/unknown states | + +The mapper reuses: + +- `mapToTransactionResult` for SDK/RPC status normalization +- `getExplorerUrl` for trusted Stellar Expert links +- `TransactionReceipt` for consistent status, hash, and detail rendering + +## Represented operations + +| Operation | Current integration | Chain evidence | +| --- | --- | --- | +| Whitelist add / revoke | `WhitelistActionModal` | Provider hash and explorer link when returned | +| Mint | `MintWorkflow` and legacy admin mint | Provider hash and explorer link when returned | +| Asset registration | `AssetCreationWizard` | Local issuance request only; no hash | +| Role change | Typed fixture / expected view | No dashboard SDK action is wired yet | +| Bulk compliance update | `ComplianceUpdateModal` | Local success today; explorer link only when a future provider returns a hash | + +`AdminActionOperation` intentionally distinguishes whitelist add from revoke even +though both use the shared `whitelist` transaction presentation label. + +## Receipt states + +The view supports the shared transaction statuses: + +- `success` — action confirmed or local request accepted +- `failure` — provider rejected the action or returned an error +- `pending` — submitted but not confirmed +- `unknown` — the outcome cannot be confirmed + +Pending and unknown receipts tell the admin to verify network state before +retrying. This reduces duplicate privileged actions when the original submission +may still complete. + +## Explorer links + +Explorer links are shown only when both are true: + +1. The provider returned a transaction hash. +2. The wallet network maps to a supported Stellar Expert network (`TESTNET` or + public/mainnet aliases). + +A hash on an unsupported network remains visible, but the link is omitted and a +limitation note explains why. Missing hashes are never fabricated. + +## Next actions + +Each operation maps to a useful follow-up: + +- Whitelist add/revoke → **Back to whitelist** +- Mint → **Mint another** +- Asset registration → **Create another** +- Role change → **Review role assignments** + +Failure uses **Review action**. Pending/unknown uses **Check transaction status**. +The caller owns navigation/reset behavior; the mapper owns labels and guidance. + +## Limitations + +- Asset creation currently creates a local `IssuanceRequest` and does not submit + an on-chain registration transaction. Its receipt therefore has no hash or + explorer link. +- Role-change provider/UI support does not exist yet. Fixtures document the + expected receipt contract so future SDK integration does not require a new + view. +- Mock provider hashes are synthetic and intended only for dashboard testing. +- An absent hash does not prove an action failed. Admins should check the list or + transaction history before retrying. + +## Tests and fixtures + +- `mapAdminActionReceipt.test.ts` covers status mapping, explorer behavior, + limitations, major operations, and receipt states. +- `AdminActionReceiptView.test.tsx` covers status/operation/target/hash display, + explorer links, next actions, and local-action limitations. +- Existing whitelist, mint, admin, and asset-creation flow tests exercise the + integrated view. diff --git a/docs/architecture.md b/docs/architecture.md index 3254cca..8749f03 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,4 +30,8 @@ The UI is strictly separated into pages and domain-specific features: - `src/components/transactions/` provides the shared review-before-sign UI: - `TransactionReview` / `TransactionReviewModal` - `operationSummary` mapper for transfer, mint, whitelist, and compliance updates - - progress / receipt / status mapping used by all sensitive signing flows \ No newline at end of file + - progress / receipt / status mapping used by all sensitive signing flows +- `src/features/admin/receipts/` encapsulates: + - admin operation receipt types for whitelist, mint, asset registration, and role changes + - SDK/local-outcome mapping onto shared transaction status and explorer helpers + - next-action guidance and fixtures for all receipt states \ No newline at end of file diff --git a/docs/transaction-components.md b/docs/transaction-components.md index baec96d..bf292ac 100644 --- a/docs/transaction-components.md +++ b/docs/transaction-components.md @@ -94,6 +94,11 @@ const outcome = await transfer(recipient, amount, setState); details={details} // same object used for the review onClose={reset} explorerUrl={getExplorerUrl(result.txHash, network)} + nextAction={{ // optional — used by admin receipts + label: 'Mint another', + description: 'Review the confirmed amount before starting another mint.', + onClick: reset, + }} /> ``` @@ -108,6 +113,9 @@ Handles all four outcomes with its own icon, colour and badge: The transaction hash row and the explorer link are only rendered when available, so a failure that never reached the network shows neither. +`nextAction` and `limitation` are optional. The admin receipt feature uses them +for operation-specific follow-up guidance and to explain missing chain evidence. +See [admin-action-receipts.md](admin-action-receipts.md). ## Status mapping diff --git a/src/components/transactions/TransactionFixtureGallery.test.tsx b/src/components/transactions/TransactionFixtureGallery.test.tsx index 8b4e632..a5abe52 100644 --- a/src/components/transactions/TransactionFixtureGallery.test.tsx +++ b/src/components/transactions/TransactionFixtureGallery.test.tsx @@ -14,6 +14,8 @@ describe('TransactionFixtureGallery', () => { // expect(screen.getByText(/submitting to the network/i)).toBeInTheDocument(); expect(screen.getAllByText(/submitting to the network/i),).toHaveLength(2); expect(screen.getByText(/transaction confirmed/i)).toBeInTheDocument(); + expect(screen.getByText(/pending receipt/i)).toBeInTheDocument(); + expect(screen.getByText(/transaction submitted/i)).toBeInTheDocument(); expect(screen.getByText(/transaction failed/i)).toBeInTheDocument(); expect(screen.getByText(/transaction status unknown/i)).toBeInTheDocument(); }); diff --git a/src/components/transactions/TransactionReceipt.tsx b/src/components/transactions/TransactionReceipt.tsx index 8db978e..fbe2e40 100644 --- a/src/components/transactions/TransactionReceipt.tsx +++ b/src/components/transactions/TransactionReceipt.tsx @@ -14,6 +14,12 @@ import { type TransactionStatus, } from './types'; +export interface TransactionReceiptAction { + label: string; + onClick: () => void; + description?: string; +} + interface TransactionReceiptProps { result: TransactionResult; details: TransactionDetails; @@ -23,6 +29,10 @@ interface TransactionReceiptProps { * `getExplorerUrl` — `null` simply hides the link. */ explorerUrl?: string | null; + /** Optional operation-specific action shown above the generic close button. */ + nextAction?: TransactionReceiptAction; + /** Explains why a hash or explorer link may not be available. */ + limitation?: string; } const STATUS_STYLES: Record< @@ -65,6 +75,8 @@ export default function TransactionReceipt({ details, onClose, explorerUrl, + nextAction, + limitation, }: TransactionReceiptProps) { const { Icon, iconClass, badgeClass, label } = STATUS_STYLES[result.status]; @@ -122,10 +134,37 @@ export default function TransactionReceipt({ )} + {limitation && ( +

+ {limitation} +

+ )} + + {nextAction && ( +
+ {nextAction.description && ( +

+ {nextAction.description} +

+ )} + +
+ )} + diff --git a/src/components/transactions/explorerLink.test.ts b/src/components/transactions/explorerLink.test.ts new file mode 100644 index 0000000..ea2e203 --- /dev/null +++ b/src/components/transactions/explorerLink.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { getExplorerUrl } from './explorerLink'; + +describe('getExplorerUrl', () => { + it('builds stellar.expert links for supported networks', () => { + expect(getExplorerUrl('abc123', 'TESTNET')).toBe( + 'https://stellar.expert/explorer/testnet/tx/abc123', + ); + expect(getExplorerUrl('abc123', 'PUBLIC')).toBe( + 'https://stellar.expert/explorer/public/tx/abc123', + ); + expect(getExplorerUrl('abc123', 'MAINNET')).toBe( + 'https://stellar.expert/explorer/public/tx/abc123', + ); + }); + + it('returns null when the hash or network is missing/unsupported', () => { + expect(getExplorerUrl(undefined, 'TESTNET')).toBeNull(); + expect(getExplorerUrl('abc123', null)).toBeNull(); + expect(getExplorerUrl('abc123', 'FUTURENET')).toBeNull(); + expect(getExplorerUrl(' ', 'TESTNET')).toBeNull(); + }); +}); diff --git a/src/components/transactions/fixtures.ts b/src/components/transactions/fixtures.ts index b9f2956..cfa5df6 100644 --- a/src/components/transactions/fixtures.ts +++ b/src/components/transactions/fixtures.ts @@ -152,6 +152,15 @@ export const transactionFixtureGalleryEntries: TransactionFixtureGalleryEntry[] result: successResultFixture, explorerUrl: 'https://stellar.expert/explorer/testnet/tx/b9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e', }, + { + id: 'receipt-pending', + kind: 'receipt', + title: 'Pending receipt', + description: 'Preview the submitted-but-unconfirmed receipt state.', + details: transferDetailsFixture, + result: pendingResultFixture, + explorerUrl: 'https://stellar.expert/explorer/testnet/tx/c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f50', + }, { id: 'receipt-failure', kind: 'receipt', diff --git a/src/components/transactions/statusMapper.test.ts b/src/components/transactions/statusMapper.test.ts new file mode 100644 index 0000000..bd12f4e --- /dev/null +++ b/src/components/transactions/statusMapper.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { mapToTransactionResult } from './statusMapper'; + +describe('mapToTransactionResult', () => { + it('maps successful RPC outcomes with a transaction hash', () => { + expect( + mapToTransactionResult({ + status: 'SUCCESS', + hash: 'abc123', + }), + ).toMatchObject({ + status: 'success', + txHash: 'abc123', + message: 'Transaction confirmed', + }); + }); + + it('maps pending and unknown statuses', () => { + expect(mapToTransactionResult({ status: 'PENDING', txHash: 'pending-hash' })).toMatchObject({ + status: 'pending', + txHash: 'pending-hash', + message: 'Transaction submitted', + }); + + expect(mapToTransactionResult({ status: 'not_a_real_status', hash: 'x' })).toMatchObject({ + status: 'unknown', + txHash: 'x', + message: 'Transaction status unknown', + }); + }); + + it('treats thrown errors and error fields as failures', () => { + expect(mapToTransactionResult(new Error('Wallet rejected'))).toMatchObject({ + status: 'failure', + detail: 'Wallet rejected', + }); + + expect( + mapToTransactionResult({ + status: 'SUCCESS', + errorMessage: 'Recipient account is not authorised to hold this asset.', + }), + ).toMatchObject({ + status: 'failure', + detail: 'Recipient account is not authorised to hold this asset.', + }); + }); + + it('maps bare status strings and nullish outcomes', () => { + expect(mapToTransactionResult('CONFIRMED')).toMatchObject({ status: 'success' }); + expect(mapToTransactionResult(null)).toMatchObject({ status: 'unknown' }); + }); +}); diff --git a/src/components/transactions/types.ts b/src/components/transactions/types.ts index 73d930f..d2d369e 100644 --- a/src/components/transactions/types.ts +++ b/src/components/transactions/types.ts @@ -35,7 +35,9 @@ export type TransactionAction = | 'transfer' | 'mint' | 'whitelist' - | 'compliance-update'; + | 'compliance-update' + | 'asset-registration' + | 'role-change'; /** Human-readable label for each action, shared by review and receipt. */ export const TRANSACTION_ACTION_LABELS: Record = { @@ -43,6 +45,8 @@ export const TRANSACTION_ACTION_LABELS: Record = { mint: 'Mint', whitelist: 'Whitelist', 'compliance-update': 'Compliance update', + 'asset-registration': 'Asset registration', + 'role-change': 'Role change', }; /** A single label/value line in the review and receipt summaries. */ diff --git a/src/features/admin/components/AdminPanel.tsx b/src/features/admin/components/AdminPanel.tsx index 3877a71..99f64de 100644 --- a/src/features/admin/components/AdminPanel.tsx +++ b/src/features/admin/components/AdminPanel.tsx @@ -4,10 +4,12 @@ import { useWallet } from '@/hooks/useWallet'; import { useFeatureFlags } from '@/hooks/useFeatureFlags'; import TransactionReview from '@/components/transactions/TransactionReview'; import TransactionProgress from '@/components/transactions/TransactionProgress'; -import TransactionReceipt from '@/components/transactions/TransactionReceipt'; import { mapToTransactionResult } from '@/components/transactions/statusMapper'; -import { getExplorerUrl } from '@/components/transactions/explorerLink'; import { buildMintSummary } from '@/components/transactions/operationSummary'; +import { + AdminActionReceiptView, + mapAdminActionReceipt, +} from '@/features/admin/receipts'; import { CheckCircle } from 'lucide-react'; import type { TransactionResult, @@ -61,12 +63,19 @@ function LegacyMintPanel() { }; if (result) { + const receipt = mapAdminActionReceipt({ + operation: 'mint', + target: cleanAddress, + outcome: result, + network, + metadata: { amount: MINT_AMOUNT.toLocaleString('en-US') }, + }); + return ( - ); } diff --git a/src/features/admin/components/ComplianceUpdateModal.tsx b/src/features/admin/components/ComplianceUpdateModal.tsx index a2c8405..4182926 100644 --- a/src/features/admin/components/ComplianceUpdateModal.tsx +++ b/src/features/admin/components/ComplianceUpdateModal.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import TransactionReceipt from '@/components/transactions/TransactionReceipt'; import TransactionReviewModal from '@/components/transactions/TransactionReviewModal'; import { buildComplianceUpdateSummary } from '@/components/transactions/operationSummary'; +import { getExplorerUrl } from '@/components/transactions/explorerLink'; import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview'; import type { ComplianceSubject, BulkAction } from '@/lib/complianceReview'; import type { TransactionResult } from '@/components/transactions/types'; @@ -78,6 +79,12 @@ export default function ComplianceUpdateModal({ result={result} details={details} onClose={onClose} + explorerUrl={getExplorerUrl(result.txHash, network)} + limitation={ + result.txHash + ? undefined + : 'Bulk compliance updates are applied locally in this dashboard build. No on-chain transaction hash or explorer link is available until a provider-backed write lands.' + } /> )} diff --git a/src/features/admin/receipts/AdminActionReceiptView.test.tsx b/src/features/admin/receipts/AdminActionReceiptView.test.tsx new file mode 100644 index 0000000..32d4254 --- /dev/null +++ b/src/features/admin/receipts/AdminActionReceiptView.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import AdminActionReceiptView from './AdminActionReceiptView'; +import { + assetRegistrationReceiptFixture, + mintReceiptFixture, + whitelistRemoveReceiptFixture, +} from './fixtures'; + +describe('AdminActionReceiptView', () => { + it('shows status, operation, target, hash, explorer link, and next action', () => { + const onNextAction = vi.fn(); + const onClose = vi.fn(); + + render( + , + ); + + expect(screen.getByText(/mint · success/i)).toBeInTheDocument(); + expect(screen.getByText('Mint')).toBeInTheDocument(); + expect(screen.getByText(mintReceiptFixture.target)).toBeInTheDocument(); + expect(screen.getByText(/b9d0e\.\.\.4d5e/i)).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /view on stellar expert/i }), + ).toHaveAttribute( + 'href', + mintReceiptFixture.explorerUrl, + ); + + fireEvent.click(screen.getByRole('button', { name: /mint another/i })); + expect(onNextAction).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: /^close$/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('shows failure status and missing-hash limitation', () => { + render( + , + ); + + expect(screen.getByText(/whitelist · failed/i)).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.getByText(/did not return a transaction hash/i)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /review action/i }), + ).toBeInTheDocument(); + }); + + it('explains local asset-registration receipt limitations', () => { + render( + , + ); + + expect(screen.getByText(/asset registration · success/i)).toBeInTheDocument(); + expect(screen.getByText('ISS-005')).toBeInTheDocument(); + expect(screen.getByText(/local issuance request/i)).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /create another/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/admin/receipts/AdminActionReceiptView.tsx b/src/features/admin/receipts/AdminActionReceiptView.tsx new file mode 100644 index 0000000..6896cff --- /dev/null +++ b/src/features/admin/receipts/AdminActionReceiptView.tsx @@ -0,0 +1,37 @@ +import TransactionReceipt from '@/components/transactions/TransactionReceipt'; +import type { AdminActionReceipt } from './types'; + +export interface AdminActionReceiptViewProps { + receipt: AdminActionReceipt; + /** Performs the operation-specific next step described by the receipt. */ + onNextAction: () => void; + /** Dismisses the receipt without starting another action. */ + onClose: () => void; +} + +/** + * Receipt view for privileged dashboard actions. + * + * The mapper owns status/operation/target/hash/explorer/next-action semantics; + * this component reuses the shared transaction receipt presentation so admin + * and investor outcomes remain visually and behaviorally consistent. + */ +export default function AdminActionReceiptView({ + receipt, + onNextAction, + onClose, +}: AdminActionReceiptViewProps) { + return ( + + ); +} diff --git a/src/features/admin/receipts/fixtures.ts b/src/features/admin/receipts/fixtures.ts new file mode 100644 index 0000000..0296aaf --- /dev/null +++ b/src/features/admin/receipts/fixtures.ts @@ -0,0 +1,73 @@ +import { mapAdminActionReceipt } from './mapAdminActionReceipt'; +import type { AdminActionReceipt } from './types'; + +const TARGET = + 'GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3'; + +const HASH = + 'b9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e'; + +export const whitelistAddReceiptFixture = mapAdminActionReceipt({ + operation: 'whitelist-add', + target: TARGET, + network: 'TESTNET', + outcome: { status: 'SUCCESS', hash: HASH }, + metadata: { note: 'KYC case ref-001' }, +}); + +export const whitelistRemoveReceiptFixture = mapAdminActionReceipt({ + operation: 'whitelist-remove', + target: TARGET, + network: 'TESTNET', + outcome: { status: 'FAILED', errorMessage: 'Admin authorization failed.' }, +}); + +export const assetRegistrationReceiptFixture = mapAdminActionReceipt({ + operation: 'asset-registration', + target: 'NY-CRE', + outcome: { status: 'SUCCESS' }, + metadata: { + asset: 'Manhattan Commercial Real Estate (NY-CRE)', + amount: '100,000.00', + requestId: 'ISS-005', + }, +}); + +export const roleChangeReceiptFixture = mapAdminActionReceipt({ + operation: 'role-change', + target: TARGET, + network: 'TESTNET', + outcome: { status: 'PENDING', hash: HASH }, + metadata: { role: 'Issuer' }, +}); + +export const mintReceiptFixture = mapAdminActionReceipt({ + operation: 'mint', + target: TARGET, + network: 'TESTNET', + outcome: { status: 'SUCCESS', hash: HASH }, + metadata: { asset: 'NY-CRE', amount: '1,000.00 NY-CRE' }, +}); + +/** Major admin operations required by Issue #179. */ +export const adminOperationReceiptFixtures: AdminActionReceipt[] = [ + whitelistAddReceiptFixture, + whitelistRemoveReceiptFixture, + assetRegistrationReceiptFixture, + roleChangeReceiptFixture, + mintReceiptFixture, +]; + +/** Every receipt status rendered by the view. */ +export const adminReceiptStateFixtures: AdminActionReceipt[] = [ + mintReceiptFixture, + whitelistRemoveReceiptFixture, + roleChangeReceiptFixture, + mapAdminActionReceipt({ + operation: 'mint', + target: TARGET, + network: 'TESTNET', + outcome: { status: 'not-a-known-status', hash: HASH }, + metadata: { asset: 'NY-CRE', amount: '1,000.00 NY-CRE' }, + }), +]; diff --git a/src/features/admin/receipts/index.ts b/src/features/admin/receipts/index.ts new file mode 100644 index 0000000..e6a292d --- /dev/null +++ b/src/features/admin/receipts/index.ts @@ -0,0 +1,10 @@ +export { default as AdminActionReceiptView } from './AdminActionReceiptView'; +export { mapAdminActionReceipt } from './mapAdminActionReceipt'; +export * from './fixtures'; +export type { + AdminActionOperation, + AdminActionReceipt, + AdminActionReceiptInput, + AdminActionReceiptMetadata, + AdminReceiptNextAction, +} from './types'; diff --git a/src/features/admin/receipts/mapAdminActionReceipt.test.ts b/src/features/admin/receipts/mapAdminActionReceipt.test.ts new file mode 100644 index 0000000..3c33584 --- /dev/null +++ b/src/features/admin/receipts/mapAdminActionReceipt.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { + adminOperationReceiptFixtures, + adminReceiptStateFixtures, +} from './fixtures'; +import { mapAdminActionReceipt } from './mapAdminActionReceipt'; + +const TARGET = + 'GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3'; +const HASH = + 'b9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e'; + +describe('mapAdminActionReceipt', () => { + it('normalizes SDK status, target, hash, explorer URL, and next action', () => { + const receipt = mapAdminActionReceipt({ + operation: 'mint', + target: TARGET, + network: 'TESTNET', + outcome: { status: 'SUCCESS', hash: HASH }, + metadata: { asset: 'NY-CRE', amount: '1,000.00 NY-CRE' }, + }); + + expect(receipt.result).toMatchObject({ + status: 'success', + txHash: HASH, + }); + expect(receipt.details.rows).toEqual( + expect.arrayContaining([ + { label: 'Operation', value: 'Mint' }, + { label: 'Target', value: TARGET, mono: true }, + { label: 'Asset', value: 'NY-CRE' }, + { label: 'Network', value: 'TESTNET' }, + ]), + ); + expect(receipt.explorerUrl).toBe( + `https://stellar.expert/explorer/testnet/tx/${HASH}`, + ); + expect(receipt.nextAction.label).toBe('Mint another'); + expect(receipt.limitation).toBeUndefined(); + }); + + it('explains when a provider returns no transaction hash', () => { + const receipt = mapAdminActionReceipt({ + operation: 'whitelist-remove', + target: TARGET, + network: 'TESTNET', + outcome: { status: 'FAILED', errorMessage: 'Not authorized' }, + }); + + expect(receipt.result.status).toBe('failure'); + expect(receipt.explorerUrl).toBeNull(); + expect(receipt.nextAction.label).toBe('Review action'); + expect(receipt.limitation).toMatch(/did not return a transaction hash/i); + }); + + it('documents asset registration as local without chain evidence', () => { + const receipt = mapAdminActionReceipt({ + operation: 'asset-registration', + target: 'NY-CRE', + outcome: { status: 'SUCCESS' }, + metadata: { requestId: 'ISS-005', asset: 'New York CRE (NY-CRE)' }, + }); + + expect(receipt.result.status).toBe('success'); + expect(receipt.result.message).toBe('Request submitted'); + expect(receipt.explorerUrl).toBeNull(); + expect(receipt.limitation).toMatch(/local issuance request/i); + expect(receipt.nextAction.label).toBe('Create another'); + }); + + it('does not create an explorer link for unsupported networks', () => { + const receipt = mapAdminActionReceipt({ + operation: 'role-change', + target: TARGET, + network: 'FUTURENET', + outcome: { status: 'PENDING', hash: HASH }, + metadata: { role: 'Issuer' }, + }); + + expect(receipt.result.status).toBe('pending'); + expect(receipt.explorerUrl).toBeNull(); + expect(receipt.limitation).toMatch(/not wired/i); + }); +}); + +describe('admin receipt fixtures', () => { + it('covers all major admin operations', () => { + expect( + adminOperationReceiptFixtures.map((receipt) => receipt.operation), + ).toEqual([ + 'whitelist-add', + 'whitelist-remove', + 'asset-registration', + 'role-change', + 'mint', + ]); + }); + + it('covers every receipt status', () => { + expect( + adminReceiptStateFixtures.map((receipt) => receipt.result.status), + ).toEqual(['success', 'failure', 'pending', 'unknown']); + }); +}); diff --git a/src/features/admin/receipts/mapAdminActionReceipt.ts b/src/features/admin/receipts/mapAdminActionReceipt.ts new file mode 100644 index 0000000..df7e521 --- /dev/null +++ b/src/features/admin/receipts/mapAdminActionReceipt.ts @@ -0,0 +1,205 @@ +import { getExplorerUrl } from '@/components/transactions/explorerLink'; +import { mapToTransactionResult } from '@/components/transactions/statusMapper'; +import type { + TransactionAction, + TransactionDetailRow, + TransactionResult, + TransactionStatus, +} from '@/components/transactions/types'; +import type { + AdminActionOperation, + AdminActionReceipt, + AdminActionReceiptInput, + AdminReceiptNextAction, +} from './types'; + +interface OperationPresentation { + action: TransactionAction; + label: string; + title: string; +} + +const OPERATION_PRESENTATION: Record< + AdminActionOperation, + OperationPresentation +> = { + 'whitelist-add': { + action: 'whitelist', + label: 'Whitelist add', + title: 'Whitelist update receipt', + }, + 'whitelist-remove': { + action: 'whitelist', + label: 'Whitelist revoke', + title: 'Whitelist update receipt', + }, + 'asset-registration': { + action: 'asset-registration', + label: 'Asset registration', + title: 'Asset registration receipt', + }, + 'role-change': { + action: 'role-change', + label: 'Role change', + title: 'Role change receipt', + }, + mint: { + action: 'mint', + label: 'Mint', + title: 'Mint receipt', + }, +}; + +const SUCCESS_NEXT_ACTION: Record< + AdminActionOperation, + AdminReceiptNextAction +> = { + 'whitelist-add': { + label: 'Back to whitelist', + description: 'Review the updated address in whitelist management.', + }, + 'whitelist-remove': { + label: 'Back to whitelist', + description: 'Review the revoked address in whitelist management.', + }, + 'asset-registration': { + label: 'Create another', + description: + 'The request is pending compliance review; no asset was minted.', + }, + 'role-change': { + label: 'Review role assignments', + description: 'Verify the account now has the intended protocol role.', + }, + mint: { + label: 'Mint another', + description: 'Review the confirmed amount before starting another mint.', + }, +}; + +const RECEIPT_STATUSES = new Set([ + 'success', + 'failure', + 'pending', + 'unknown', +]); + +function isTransactionResult(value: unknown): value is TransactionResult { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + return ( + typeof candidate.status === 'string' && + RECEIPT_STATUSES.has(candidate.status as TransactionStatus) && + typeof candidate.message === 'string' + ); +} + +function nextActionFor( + operation: AdminActionOperation, + status: TransactionStatus, +): AdminReceiptNextAction { + if (status === 'success') return SUCCESS_NEXT_ACTION[operation]; + + if (status === 'failure') { + return { + label: 'Review action', + description: + 'Check the target and permissions before attempting this action again.', + }; + } + + return { + label: 'Check transaction status', + description: + 'Verify the latest network state before retrying to avoid a duplicate action.', + }; +} + +function detailRows(input: AdminActionReceiptInput): TransactionDetailRow[] { + const presentation = OPERATION_PRESENTATION[input.operation]; + const metadata = input.metadata; + + return [ + { label: 'Operation', value: presentation.label }, + { label: 'Target', value: input.target, mono: true }, + ...(metadata?.asset + ? [{ label: 'Asset', value: metadata.asset }] + : []), + ...(metadata?.amount + ? [{ label: 'Amount', value: metadata.amount }] + : []), + ...(metadata?.role ? [{ label: 'Role', value: metadata.role }] : []), + ...(metadata?.requestId + ? [{ label: 'Request ID', value: metadata.requestId, mono: true }] + : []), + ...(metadata?.note ? [{ label: 'Note', value: metadata.note }] : []), + { label: 'Network', value: input.network?.trim() || 'Not applicable' }, + ]; +} + +function limitationFor( + operation: AdminActionOperation, + txHash: string | undefined, + explorerUrl: string | null, +): string | undefined { + if (operation === 'asset-registration') { + return 'Asset registration is currently a local issuance request. It does not submit an on-chain transaction, so no transaction hash or explorer link is available.'; + } + + if (operation === 'role-change') { + return 'Role-change SDK submission is not wired in this dashboard yet. Fixture receipts document the expected view; live hash and explorer support depend on the provider response.'; + } + + if (!txHash) { + return 'The provider did not return a transaction hash. Confirm the action in the admin list or transaction history before retrying.'; + } + + if (!explorerUrl) { + return 'A transaction hash was returned, but this wallet network is not supported by the configured explorer link.'; + } + + return undefined; +} + +/** + * Maps SDK/provider outcomes and local admin requests into one receipt model. + * Status normalization and explorer URL construction reuse the shared + * transaction helpers so admin receipts follow the same semantics as investor + * receipts. + */ +export function mapAdminActionReceipt( + input: AdminActionReceiptInput, +): AdminActionReceipt { + const presentation = OPERATION_PRESENTATION[input.operation]; + const mappedResult = isTransactionResult(input.outcome) + ? input.outcome + : mapToTransactionResult(input.outcome); + const result = { + ...mappedResult, + message: + mappedResult.status === 'success' && + input.operation === 'asset-registration' + ? 'Request submitted' + : mappedResult.message, + }; + const explorerUrl = getExplorerUrl(result.txHash, input.network); + + return { + operation: input.operation, + target: input.target, + result, + explorerUrl, + nextAction: nextActionFor(input.operation, result.status), + limitation: limitationFor( + input.operation, + result.txHash, + explorerUrl, + ), + details: { + action: presentation.action, + title: presentation.title, + rows: detailRows(input), + network: input.network ?? undefined, + }, + }; +} diff --git a/src/features/admin/receipts/types.ts b/src/features/admin/receipts/types.ts new file mode 100644 index 0000000..d0e9bae --- /dev/null +++ b/src/features/admin/receipts/types.ts @@ -0,0 +1,50 @@ +import type { + TransactionDetails, + TransactionResult, +} from '@/components/transactions/types'; + +/** Major privileged operations represented by the admin receipt view. */ +export type AdminActionOperation = + | 'whitelist-add' + | 'whitelist-remove' + | 'asset-registration' + | 'role-change' + | 'mint'; + +/** Extra operation data used to build receipt rows. */ +export interface AdminActionReceiptMetadata { + asset?: string; + amount?: string; + role?: string; + requestId?: string; + note?: string; +} + +/** Raw input from an admin flow or SDK/provider outcome. */ +export interface AdminActionReceiptInput { + operation: AdminActionOperation; + target: string; + outcome: unknown; + network?: string | null; + metadata?: AdminActionReceiptMetadata; +} + +export interface AdminReceiptNextAction { + label: string; + description: string; +} + +/** Normalized model consumed by the admin receipt view. */ +export interface AdminActionReceipt { + operation: AdminActionOperation; + target: string; + details: TransactionDetails; + result: TransactionResult; + explorerUrl: string | null; + nextAction: AdminReceiptNextAction; + /** + * Explains missing chain evidence for local/mock actions or outcomes where a + * hash was not returned. + */ + limitation?: string; +} diff --git a/src/features/asset-creation/components/AssetCreationWizard.tsx b/src/features/asset-creation/components/AssetCreationWizard.tsx index ad61480..09f5d1d 100644 --- a/src/features/asset-creation/components/AssetCreationWizard.tsx +++ b/src/features/asset-creation/components/AssetCreationWizard.tsx @@ -7,6 +7,10 @@ import { type AssetCreationErrorCode, } from '@/lib/assetCreationRequest'; import { useFormErrors, FormFieldError, FormError } from '@/features/forms/validation'; +import { + AdminActionReceiptView, + mapAdminActionReceipt, +} from '@/features/admin/receipts'; import type { IssuanceRequest } from '@/fixtures/issuer'; type WizardStep = 'form' | 'review' | 'success'; @@ -143,31 +147,24 @@ export default function AssetCreationWizard({ }; if (step === 'success' && lastCreated) { + const receipt = mapAdminActionReceipt({ + operation: 'asset-registration', + target: lastCreated.ticker, + outcome: { status: 'SUCCESS' }, + metadata: { + asset: `${lastCreated.assetName} (${lastCreated.ticker})`, + amount: lastCreated.amount.toLocaleString('en-US'), + requestId: lastCreated.id, + }, + }); + return (
-

Request submitted

-

- {lastCreated.ticker} has been - submitted for compliance review with status{' '} - pending. It will appear as - mintable once approved. -

-
- - -
+
); } diff --git a/src/features/compliance/components/WhitelistActionModal.tsx b/src/features/compliance/components/WhitelistActionModal.tsx index b104948..8e7255c 100644 --- a/src/features/compliance/components/WhitelistActionModal.tsx +++ b/src/features/compliance/components/WhitelistActionModal.tsx @@ -1,14 +1,14 @@ import { useState } from 'react'; import TransactionReviewModal from '@/components/transactions/TransactionReviewModal'; import TransactionProgress from '@/components/transactions/TransactionProgress'; -import TransactionReceipt from '@/components/transactions/TransactionReceipt'; -import { mapToTransactionResult } from '@/components/transactions/statusMapper'; import { buildWhitelistSummary } from '@/components/transactions/operationSummary'; +import { + AdminActionReceiptView, + mapAdminActionReceipt, + type AdminActionReceipt, +} from '@/features/admin/receipts'; import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview'; -import type { - TransactionPhase, - TransactionResult, -} from '@/components/transactions/types'; +import type { TransactionPhase } from '@/components/transactions/types'; export type WhitelistAction = 'add' | 'remove'; @@ -42,7 +42,7 @@ export default function WhitelistActionModal({ onClose, }: WhitelistActionModalProps) { const [phase, setPhase] = useState('review'); - const [result, setResult] = useState(null); + const [receipt, setReceipt] = useState(null); const details = buildWhitelistSummary({ action, address, note, network }); @@ -50,15 +50,31 @@ export default function WhitelistActionModal({ setPhase('signing'); try { const outcome = await onSubmit((nextPhase) => setPhase(nextPhase)); - setResult(mapToTransactionResult(outcome)); + setReceipt( + mapAdminActionReceipt({ + operation: action === 'add' ? 'whitelist-add' : 'whitelist-remove', + target: address, + outcome, + network, + metadata: { note }, + }), + ); } catch (err) { - setResult(mapToTransactionResult(err)); + setReceipt( + mapAdminActionReceipt({ + operation: action === 'add' ? 'whitelist-add' : 'whitelist-remove', + target: address, + outcome: err, + network, + metadata: { note }, + }), + ); } finally { setPhase('receipt'); } }; - const succeeded = result?.status === 'success'; + const succeeded = receipt?.result.status === 'success'; if (phase === 'review') { return ( @@ -84,10 +100,10 @@ export default function WhitelistActionModal({ )} - {phase === 'receipt' && result && ( - onClose(succeeded)} onClose={() => onClose(succeeded)} /> )} diff --git a/src/features/minting/components/MintWorkflow.tsx b/src/features/minting/components/MintWorkflow.tsx index 45188f9..03f96e2 100644 --- a/src/features/minting/components/MintWorkflow.tsx +++ b/src/features/minting/components/MintWorkflow.tsx @@ -3,10 +3,13 @@ import { useAegis } from '@/hooks/useAegis'; import { useWallet } from '@/hooks/useWallet'; import TransactionReview from '@/components/transactions/TransactionReview'; import TransactionProgress from '@/components/transactions/TransactionProgress'; -import TransactionReceipt from '@/components/transactions/TransactionReceipt'; import { mapToTransactionResult } from '@/components/transactions/statusMapper'; import { getExplorerUrl } from '@/components/transactions/explorerLink'; import { buildMintSummary } from '@/components/transactions/operationSummary'; +import { + AdminActionReceiptView, + mapAdminActionReceipt, +} from '@/features/admin/receipts'; import type { RawTransactionOutcome, TransactionResult, @@ -206,12 +209,26 @@ export default function MintWorkflow({ } if (result) { + const receipt = mapAdminActionReceipt({ + operation: 'mint', + target: cleanRecipient, + outcome: result, + network, + metadata: { + asset: selectedAsset + ? `${selectedAsset.name} (${selectedAsset.ticker})` + : undefined, + amount: selectedAsset + ? `${numericAmount.toLocaleString('en-US')} ${selectedAsset.ticker}` + : numericAmount.toLocaleString('en-US'), + }, + }); + return ( - ); }