diff --git a/apps/server/src/cli/ConfigParser.ts b/apps/server/src/cli/ConfigParser.ts index b8e9588..c1e54de 100644 --- a/apps/server/src/cli/ConfigParser.ts +++ b/apps/server/src/cli/ConfigParser.ts @@ -1,7 +1,7 @@ import fs from 'fs'; -import path from 'path'; -import os from 'os'; import yaml from 'js-yaml'; +import os from 'os'; +import path from 'path'; export interface SuiConfigEnv { alias: string; @@ -57,6 +57,20 @@ export class ConfigParser { } } + /** The active environment's fullnode RPC URL, or null if there's no config or no match. */ + public async getActiveRpcUrl(): Promise { + try { + const config = await this.getConfig(); + if (config) { + const activeEnv = config.envs.find((e) => e.alias === config.active_env); + return activeEnv?.rpc || null; + } + } catch { + // Ignore + } + return null; + } + public async saveConfig(updates: Partial): Promise { try { const currentContent = await fs.promises.readFile(this.configPath, 'utf8'); diff --git a/apps/server/src/cli/SuiCliExecutor.ts b/apps/server/src/cli/SuiCliExecutor.ts index bd50928..479e953 100644 --- a/apps/server/src/cli/SuiCliExecutor.ts +++ b/apps/server/src/cli/SuiCliExecutor.ts @@ -69,7 +69,10 @@ export class SuiCliExecutor { type: 'command_success', command: args[0], // e.g., 'client', 'move' duration, - metadata: { fullArgs: args }, + // Only the command + subcommand, never the tail: `keytool import` + // takes a mnemonic or private key as a positional argument, and + // logging the full argv would write it to analytics.jsonl in plaintext. + metadata: { fullArgs: args.slice(0, 2) }, }); if (stdout.trim()) { @@ -89,7 +92,7 @@ export class SuiCliExecutor { command: args[0], duration, error: errorOutput, - metadata: { fullArgs: args }, + metadata: { fullArgs: args.slice(0, 2) }, }); // Throw with the cleaned output so UI can display it properly @@ -110,7 +113,9 @@ export class SuiCliExecutor { } return JSON.parse(jsonMatch[0]) as T; } catch (error: any) { - throw new Error(`Failed to parse JSON output: ${error.message}\n\nOutput:\n${output.substring(0, 500)}`); + throw new Error( + `Failed to parse JSON output: ${error.message}\n\nOutput:\n${output.substring(0, 500)}` + ); } } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index dd3ec53..ff39f43 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -32,6 +32,7 @@ import { replayRoutes } from './routes/replay'; import { securityRoutes } from './routes/security'; import { transferRoutes } from './routes/transfer'; import { walrusMemoryRoutes } from './routes/walrusMemory'; +import { getOrCreateAuthToken } from './utils/authToken'; import { createRateLimitHook } from './utils/rateLimiter'; const require = createRequire(import.meta.url); @@ -40,14 +41,37 @@ const CURRENT_VERSION = pkg.version; const PACKAGE_NAME = pkg.name; const PORT = parseInt(process.env.PORT || '3001', 10); -// Automatically bind to 0.0.0.0 in Railway/Cloud platforms or when HOST is set -const isCloud = !!( - process.env.RAILWAY_STATIC_URL || - process.env.RAILWAY_SERVICE_ID || - process.env.PORT -); +// Only a real Railway/cloud signal implies 0.0.0.0 - a user picking a +// non-default PORT for their own local install is not a cloud deployment, +// and treating it as one used to bind the wallet-management API to every +// network interface on their machine. +const isCloud = !!(process.env.RAILWAY_STATIC_URL || process.env.RAILWAY_SERVICE_ID); const HOST = process.env.HOST || (isCloud ? '0.0.0.0' : '127.0.0.1'); +/** + * True when the Host header names this server's own loopback address (or, on a + * hosted deployment, its own public domain). + * + * CORS alone is not enough: a page on any domain can point a DNS record at + * 127.0.0.1 with a short TTL, and once the browser resolves it the request is + * same-origin as far as the browser's Origin header goes, bypassing the CORS + * check entirely. Validating Host closes that gap. + */ +function isAllowedHost(hostHeader: string | undefined): boolean { + if (!hostHeader) return false; + const hostname = hostHeader.startsWith('[') + ? hostHeader.slice(0, hostHeader.indexOf(']') + 1) + : hostHeader.split(':')[0]; + + const allowedHostnames = new Set(['localhost', '127.0.0.1', '[::1]']); + if (isCloud) { + for (const domain of [process.env.RAILWAY_PUBLIC_DOMAIN, process.env.RAILWAY_STATIC_URL]) { + if (domain) allowedHostnames.add(domain.replace(/^https?:\/\//, '')); + } + } + return allowedHostnames.has(hostname); +} + // Check for updates from npm registry async function checkForUpdates(): Promise<{ hasUpdate: boolean; latestVersion: string | null }> { try { @@ -147,6 +171,15 @@ export async function buildServer() { }, }); + // Reject requests aimed at a Host other than this server's own address, + // before CORS or any route runs - see isAllowedHost() for why this exists. + fastify.addHook('onRequest', async (request, reply) => { + if (!isAllowedHost(request.headers.host)) { + reply.status(421); + return reply.send({ error: 'Invalid Host header' }); + } + }); + // Register CORS - allow localhost and the one hosted UI origin await fastify.register(cors, { origin: (origin, cb) => { @@ -190,11 +223,17 @@ export async function buildServer() { ...envOrigins, ]; - // Regex patterns for dynamic origins + // Regex patterns for dynamic origins. Scoped to this server's own PORT + // (it needs to allow its own served UI) plus the web workspace's Vite + // dev port - not "any localhost port", which would let any other local + // process or page (an unrelated dev server, a malicious postinstall + // script) drive key export and transaction signing just by running on + // the same machine. const allowedPatterns = [ - // Local development (allow any localhost port) - /^http:\/\/localhost(:\d+)?$/, - /^http:\/\/127\.0\.0\.1(:\d+)?$/, + new RegExp(`^http://localhost:${PORT}$`), + new RegExp(`^http://127\\.0\\.0\\.1:${PORT}$`), + /^http:\/\/localhost:5174$/, + /^http:\/\/127\.0\.0\.1:5174$/, ]; // Check exact match @@ -690,6 +729,13 @@ async function main() { ║ Keep this terminal open while using the app. ║ ║ ║ ╚═══════════════════════════════════════════════════════════════╝ + + 🔑 Pairing token (required to export keys, sign, transfer, or pay): + + ${getOrCreateAuthToken()} + + Paste this into the web UI when prompted. It is never sent anywhere + except this browser tab, and is only ever shown here in this terminal. `); } catch (err: any) { if (err.code === 'EADDRINUSE') { diff --git a/apps/server/src/routes/filesystem.ts b/apps/server/src/routes/filesystem.ts index c8b49f1..f5243e5 100644 --- a/apps/server/src/routes/filesystem.ts +++ b/apps/server/src/routes/filesystem.ts @@ -1,8 +1,8 @@ +import type { ApiResponse } from '@sui-cli-web/shared'; import { FastifyInstance } from 'fastify'; import { promises as fs, realpathSync } from 'fs'; -import path from 'path'; import os from 'os'; -import type { ApiResponse } from '@sui-cli-web/shared'; +import path, { sep } from 'path'; import { handleRouteError } from '../utils/errorHandler'; interface DirectoryEntry { @@ -78,11 +78,17 @@ function isPathAllowed(targetPath: string): boolean { canonicalPath = path.normalize(path.resolve(targetPath)); } - // Check if canonical path starts with any allowed directory + // Check if canonical path is, or is inside, any allowed directory. The + // separator matters: without it, "/home/harry-evil" passes a startsWith + // check for "/home/harry". for (const allowedDir of allowedDirs) { - // Normalize the allowed dir too const normalizedAllowed = path.normalize(allowedDir); - if (canonicalPath.startsWith(normalizedAllowed)) { + if ( + canonicalPath === normalizedAllowed || + canonicalPath.startsWith( + normalizedAllowed.endsWith(sep) ? normalizedAllowed : normalizedAllowed + sep + ) + ) { return true; } } @@ -225,7 +231,8 @@ export async function filesystemRoutes(fastify: FastifyInstance) { for (const file of files) { if (!file.isDirectory()) continue; if (file.name.startsWith('.')) continue; // Skip hidden - if (file.name === 'node_modules' || file.name === 'target' || file.name === 'build') continue; // Skip common non-package dirs + if (file.name === 'node_modules' || file.name === 'target' || file.name === 'build') + continue; // Skip common non-package dirs const fullPath = path.join(dirPath, file.name); @@ -284,10 +291,7 @@ export async function filesystemRoutes(fastify: FastifyInstance) { // On Windows, add drive roots if (process.platform === 'win32') { - commonDirs.push( - { name: 'C: Drive', path: 'C:/' }, - { name: 'D: Drive', path: 'D:/' } - ); + commonDirs.push({ name: 'C: Drive', path: 'C:/' }, { name: 'D: Drive', path: 'D:/' }); } for (const dir of commonDirs) { diff --git a/apps/server/src/routes/key-management.ts b/apps/server/src/routes/key-management.ts index 5e0b7b8..041b6fd 100644 --- a/apps/server/src/routes/key-management.ts +++ b/apps/server/src/routes/key-management.ts @@ -1,12 +1,9 @@ -import { FastifyInstance } from 'fastify'; -import { KeyManagementService, EXPORT_WARNING } from '../services/KeyManagementService'; import type { ApiResponse } from '@sui-cli-web/shared'; -import { - validateAddress, - validateOptionalAlias, - validateKeyScheme, -} from '../utils/validation'; +import { FastifyInstance } from 'fastify'; +import { EXPORT_WARNING, KeyManagementService } from '../services/KeyManagementService'; +import { requireAuthToken } from '../utils/authToken'; import { handleRouteError } from '../utils/errorHandler'; +import { validateAddress, validateKeyScheme, validateOptionalAlias } from '../utils/validation'; const keyManagementService = new KeyManagementService(); @@ -34,7 +31,7 @@ export async function keyManagementRoutes(fastify: FastifyInstance) { publicKey: string; warning: string; }>; - }>('/keys/export', async (request, reply) => { + }>('/keys/export', { preHandler: requireAuthToken }, async (request, reply) => { try { // Validate address (can be address or alias) const address = request.body?.address; @@ -80,7 +77,7 @@ export async function keyManagementRoutes(fastify: FastifyInstance) { alias?: string; }; Reply: ApiResponse<{ address: string; alias?: string }>; - }>('/keys/import', async (request, reply) => { + }>('/keys/import', { preHandler: requireAuthToken }, async (request, reply) => { try { const { type, input, keyScheme, alias } = request.body || {}; @@ -100,7 +97,10 @@ export async function keyManagementRoutes(fastify: FastifyInstance) { const validatedKeyScheme = validateKeyScheme(keyScheme); if (!validatedKeyScheme) { reply.status(400); - return { success: false, error: 'Valid key scheme is required (ed25519, secp256k1, secp256r1)' }; + return { + success: false, + error: 'Valid key scheme is required (ed25519, secp256k1, secp256r1)', + }; } // Validate alias (optional) diff --git a/apps/server/src/routes/package.ts b/apps/server/src/routes/package.ts index 6a1ba3c..7764706 100644 --- a/apps/server/src/routes/package.ts +++ b/apps/server/src/routes/package.ts @@ -1,35 +1,21 @@ +import type { ApiResponse } from '@sui-cli-web/shared'; import { FastifyInstance } from 'fastify'; +import { ConfigParser } from '../cli/ConfigParser'; import { PackageService, PublishedPackageInfo } from '../services/dev/PackageService'; -import type { ApiResponse } from '@sui-cli-web/shared'; +import { handleRouteError } from '../utils/errorHandler'; +import { getPackageModulesViaGrpc, type PackageModulesViaGrpc } from '../utils/suiGrpcClient'; import { - validateObjectId, - validateOptionalGasBudget, - validateModuleName, validateFunctionName, + validateModuleName, validateMoveArgs, + validateObjectId, + validateOptionalGasBudget, validateTypeArgs, } from '../utils/validation'; -import { handleRouteError } from '../utils/errorHandler'; -import { ConfigParser } from '../cli/ConfigParser'; -import { - getPackageModulesViaGrpc, - type PackageModulesViaGrpc, -} from '../utils/suiGrpcClient'; const packageService = new PackageService(); const configParser = ConfigParser.getInstance(); -/** Resolve the active environment's fullnode URL for gRPC introspection. */ -async function getActiveRpcUrl(): Promise { - try { - const config = await configParser.getConfig(); - const activeEnv = config?.envs.find((e) => e.alias === config.active_env); - return activeEnv?.rpc || null; - } catch { - return null; - } -} - export async function packageRoutes(fastify: FastifyInstance) { // Get user's published packages (via UpgradeCap objects) fastify.get<{ @@ -64,7 +50,7 @@ export async function packageRoutes(fastify: FastifyInstance) { try { const packageId = validateObjectId(request.params.id, 'packageId'); - const rpcUrl = await getActiveRpcUrl(); + const rpcUrl = await configParser.getActiveRpcUrl(); if (!rpcUrl) { reply.status(503); return { success: false, error: 'No active Sui environment configured' }; diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts index f0b494e..0970742 100644 --- a/apps/server/src/routes/pay.ts +++ b/apps/server/src/routes/pay.ts @@ -2,8 +2,9 @@ * Pay Routes - Multi-recipient payments */ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { PayService, PayRequest, PayAllSuiRequest } from '../services/dev/PayService'; +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { PayAllSuiRequest, PayRequest, PayService } from '../services/dev/PayService'; +import { requireAuthToken } from '../utils/authToken'; import { handleRouteError } from '../utils/errorHandler'; import { validateAddress } from '../utils/validation'; @@ -13,7 +14,7 @@ export async function payRoutes(fastify: FastifyInstance) { // POST /api/pay - Pay using any coins fastify.post<{ Body: PayRequest; - }>('/pay', async (request, reply) => { + }>('/pay', { preHandler: requireAuthToken }, async (request, reply) => { try { // Validate recipients if (request.body.recipients) { @@ -37,7 +38,7 @@ export async function payRoutes(fastify: FastifyInstance) { // POST /api/pay/sui - Pay using SUI coins fastify.post<{ Body: PayRequest; - }>('/pay/sui', async (request, reply) => { + }>('/pay/sui', { preHandler: requireAuthToken }, async (request, reply) => { try { // Validate recipients if (request.body.recipients) { @@ -61,7 +62,7 @@ export async function payRoutes(fastify: FastifyInstance) { // POST /api/pay/all-sui - Pay all SUI to one recipient fastify.post<{ Body: PayAllSuiRequest; - }>('/pay/all-sui', async (request, reply) => { + }>('/pay/all-sui', { preHandler: requireAuthToken }, async (request, reply) => { try { if (request.body.recipient) { validateAddress(request.body.recipient, 'recipient'); diff --git a/apps/server/src/routes/ptb-builder.ts b/apps/server/src/routes/ptb-builder.ts index 6ea9aad..8143464 100644 --- a/apps/server/src/routes/ptb-builder.ts +++ b/apps/server/src/routes/ptb-builder.ts @@ -2,8 +2,9 @@ * PTB Builder Routes - Visual PTB construction and execution */ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { PtbBuilderService, PtbCommand, PtbBuildRequest } from '../services/dev/PtbBuilderService'; +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { PtbBuilderService, PtbBuildRequest, PtbCommand } from '../services/dev/PtbBuilderService'; +import { requireAuthToken } from '../utils/authToken'; import { handleRouteError } from '../utils/errorHandler'; export async function ptbBuilderRoutes(fastify: FastifyInstance) { @@ -24,7 +25,7 @@ export async function ptbBuilderRoutes(fastify: FastifyInstance) { // POST /api/ptb/build - Build and execute PTB fastify.post<{ Body: PtbBuildRequest; - }>('/inspector/ptb-builder/build', async (request, reply) => { + }>('/inspector/ptb-builder/build', { preHandler: requireAuthToken }, async (request, reply) => { try { const result = await service.executePtb(request.body); return result; diff --git a/apps/server/src/routes/transfer.ts b/apps/server/src/routes/transfer.ts index 7759fc9..4c7632c 100644 --- a/apps/server/src/routes/transfer.ts +++ b/apps/server/src/routes/transfer.ts @@ -1,12 +1,9 @@ +import type { ApiResponse } from '@sui-cli-web/shared'; import { FastifyInstance } from 'fastify'; import { TransferService } from '../services/TransferService'; -import type { ApiResponse } from '@sui-cli-web/shared'; -import { - validateAddress, - validateObjectId, - validateOptionalGasBudget, -} from '../utils/validation'; +import { requireAuthToken } from '../utils/authToken'; import { handleRouteError } from '../utils/errorHandler'; +import { validateAddress, validateObjectId, validateOptionalGasBudget } from '../utils/validation'; const transferService = new TransferService(); @@ -15,11 +12,13 @@ export async function transferRoutes(fastify: FastifyInstance) { fastify.post<{ Body: { to: string; amount: string; coinId?: string; gasBudget?: string }; Reply: ApiResponse<{ digest: string; gasUsed?: string }>; - }>('/transfers/sui', async (request, reply) => { + }>('/transfers/sui', { preHandler: requireAuthToken }, async (request, reply) => { try { const to = validateAddress(request.body?.to, 'to'); const amount = request.body?.amount; - const coinId = request.body?.coinId ? validateObjectId(request.body.coinId, 'coinId') : undefined; + const coinId = request.body?.coinId + ? validateObjectId(request.body.coinId, 'coinId') + : undefined; const gasBudget = validateOptionalGasBudget(request.body?.gasBudget); // Validate amount is a positive number @@ -56,7 +55,9 @@ export async function transferRoutes(fastify: FastifyInstance) { try { const to = validateAddress(request.body?.to, 'to'); const amount = request.body?.amount; - const coinId = request.body?.coinId ? validateObjectId(request.body.coinId, 'coinId') : undefined; + const coinId = request.body?.coinId + ? validateObjectId(request.body.coinId, 'coinId') + : undefined; const gasBudget = validateOptionalGasBudget(request.body?.gasBudget); // Validate amount is a positive number @@ -103,7 +104,7 @@ export async function transferRoutes(fastify: FastifyInstance) { fastify.post<{ Body: { to: string; objectId: string; gasBudget?: string }; Reply: ApiResponse<{ digest: string; gasUsed?: string }>; - }>('/transfers/object', async (request, reply) => { + }>('/transfers/object', { preHandler: requireAuthToken }, async (request, reply) => { try { const to = validateAddress(request.body?.to, 'to'); const objectId = validateObjectId(request.body?.objectId, 'objectId'); diff --git a/apps/server/src/services/AddressService.ts b/apps/server/src/services/AddressService.ts index ebba8f3..b30daa9 100644 --- a/apps/server/src/services/AddressService.ts +++ b/apps/server/src/services/AddressService.ts @@ -1,7 +1,7 @@ -import type { GasCoin, SuiAddress } from '@sui-cli-web/shared'; +import type { GasCoin, PublishedPackageInfo, SuiAddress } from '@sui-cli-web/shared'; import { ConfigParser } from '../cli/ConfigParser'; import { SuiCliExecutor } from '../cli/SuiCliExecutor'; -import type { PublishedPackageInfo } from '@sui-cli-web/shared'; +import { normalizeCliObjectShape } from '../utils/normalizeSuiObject'; import type { TransactionBalanceEffect } from '../utils/suiGrpcClient'; import { getBalanceViaGrpc, @@ -12,7 +12,6 @@ import { getTransactionBalanceEffectsViaGrpc, getTransactionTimestampsViaGrpc, } from '../utils/suiGrpcClient'; -import { normalizeCliObjectShape } from '../utils/normalizeSuiObject'; import { getAddressBalanceEffects, getObjectVersionHistory, @@ -367,16 +366,7 @@ export class AddressService { } private async getActiveRpcUrl(): Promise { - try { - const config = await this.configParser.getConfig(); - if (config) { - const activeEnv = config.envs.find((e) => e.alias === config.active_env); - return activeEnv?.rpc || null; - } - } catch { - // Ignore - } - return null; + return this.configParser.getActiveRpcUrl(); } private async fetchBalanceViaRpc(address: string, rpcUrl: string): Promise { diff --git a/apps/server/src/services/CoinService.ts b/apps/server/src/services/CoinService.ts index c6c1938..9855e0f 100644 --- a/apps/server/src/services/CoinService.ts +++ b/apps/server/src/services/CoinService.ts @@ -1,13 +1,13 @@ -import { SuiCliExecutor } from '../cli/SuiCliExecutor'; -import { ConfigParser } from '../cli/ConfigParser'; import type { - CoinInfo, CoinGroup, CoinGroupedResponse, + CoinInfo, CoinMetadata, CoinOperationResult, } from '@sui-cli-web/shared'; import { getShortSymbol } from '@sui-cli-web/shared'; +import { ConfigParser } from '../cli/ConfigParser'; +import { SuiCliExecutor } from '../cli/SuiCliExecutor'; import { getKnownToken, getTokenPriority, isVerifiedToken } from '../utils/knownTokens'; import { getAllBalancesViaGrpc, getOwnedCoinsViaGrpc } from '../utils/suiGrpcClient'; import { getCoinMetadataViaGraphQL } from './GraphQLService'; @@ -74,16 +74,7 @@ export class CoinService { } private async getActiveRpcUrl(): Promise { - try { - const config = await this.configParser.getConfig(); - if (config) { - const activeEnv = config.envs.find((e) => e.alias === config.active_env); - return activeEnv?.rpc || null; - } - } catch { - // Ignore - } - return null; + return this.configParser.getActiveRpcUrl(); } private async getActiveAddress(): Promise { diff --git a/apps/server/src/services/ParameterHelperService.ts b/apps/server/src/services/ParameterHelperService.ts index 4d960a0..dbe16f8 100644 --- a/apps/server/src/services/ParameterHelperService.ts +++ b/apps/server/src/services/ParameterHelperService.ts @@ -1,26 +1,26 @@ -import { SuiCliExecutor } from '../cli/SuiCliExecutor'; import { ConfigParser } from '../cli/ConfigParser'; -import { InspectorService, FunctionInfo, ParameterInfo } from './dev/InspectorService'; -import { getObjectFullViaGrpc, getOwnedObjectsViaGrpc } from '../utils/suiGrpcClient'; +import { SuiCliExecutor } from '../cli/SuiCliExecutor'; import { normalizeCliObjectShape } from '../utils/normalizeSuiObject'; +import { getObjectFullViaGrpc, getOwnedObjectsViaGrpc } from '../utils/suiGrpcClient'; +import { FunctionInfo, InspectorService, ParameterInfo } from './dev/InspectorService'; // Type categories for parameter classification export type ParameterCategory = - | 'reference_mut' // &mut T - mutable reference, needs owned object - | 'reference' // &T - immutable reference - | 'owned' // T (struct) - owned object - | 'primitive_u8' // u8 - | 'primitive_u16' // u16 - | 'primitive_u32' // u32 - | 'primitive_u64' // u64 - | 'primitive_u128' // u128 - | 'primitive_u256' // u256 - | 'primitive_bool' // bool + | 'reference_mut' // &mut T - mutable reference, needs owned object + | 'reference' // &T - immutable reference + | 'owned' // T (struct) - owned object + | 'primitive_u8' // u8 + | 'primitive_u16' // u16 + | 'primitive_u32' // u32 + | 'primitive_u64' // u64 + | 'primitive_u128' // u128 + | 'primitive_u256' // u256 + | 'primitive_bool' // bool | 'primitive_address' // address - | 'vector_u8' // vector - commonly used for strings - | 'vector' // vector (other types) - | 'option' // Option - | 'type_param' // Generic T + | 'vector_u8' // vector - commonly used for strings + | 'vector' // vector (other types) + | 'option' // Option + | 'type_param' // Generic T | 'unknown'; export interface ParsedType { @@ -84,7 +84,10 @@ const TYPE_BOUNDS = { u32: { min: '0', max: '4294967295' }, u64: { min: '0', max: '18446744073709551615' }, u128: { min: '0', max: '340282366920938463463374607431768211455' }, - u256: { min: '0', max: '115792089237316195423570985008687907853269984665640564039457584007913129639935' }, + u256: { + min: '0', + max: '115792089237316195423570985008687907853269984665640564039457584007913129639935', + }, }; // Common Sui types @@ -147,7 +150,7 @@ export class ParameterHelperService { } // Find the module - const module = packageResult.modules.find(m => m.name === moduleName); + const module = packageResult.modules.find((m) => m.name === moduleName); if (!module) { return { success: false, @@ -156,7 +159,7 @@ export class ParameterHelperService { } // Find the function - const func = module.functions.find(f => f.name === functionName); + const func = module.functions.find((f) => f.name === functionName); if (!func) { return { success: false, @@ -231,7 +234,15 @@ export class ParameterHelperService { const baseType = baseTypeStr.replace(/<.+>$/, '').trim(); // Determine category - const category = this.categorizeType(trimmed, baseType, isReference, isMutable, isVector, isOption, genericParams); + const category = this.categorizeType( + trimmed, + baseType, + isReference, + isMutable, + isVector, + isOption, + genericParams + ); return { category, @@ -310,14 +321,22 @@ export class ParameterHelperService { // Handle primitive types switch (baseType) { - case 'u8': return 'primitive_u8'; - case 'u16': return 'primitive_u16'; - case 'u32': return 'primitive_u32'; - case 'u64': return 'primitive_u64'; - case 'u128': return 'primitive_u128'; - case 'u256': return 'primitive_u256'; - case 'bool': return 'primitive_bool'; - case 'address': return 'primitive_address'; + case 'u8': + return 'primitive_u8'; + case 'u16': + return 'primitive_u16'; + case 'u32': + return 'primitive_u32'; + case 'u64': + return 'primitive_u64'; + case 'u128': + return 'primitive_u128'; + case 'u256': + return 'primitive_u256'; + case 'bool': + return 'primitive_bool'; + case 'address': + return 'primitive_address'; } // Check for generic type parameter (single uppercase letter) @@ -417,7 +436,8 @@ export class ParameterHelperService { // Special case for u64 often used for amounts (SUI in MIST) if (parsedType.category === 'primitive_u64') { examples.push('1000000000 (1 SUI in MIST)'); - helpText = 'Enter an unsigned 64-bit integer. For SUI amounts, use MIST (1 SUI = 1,000,000,000 MIST).'; + helpText = + 'Enter an unsigned 64-bit integer. For SUI amounts, use MIST (1 SUI = 1,000,000,000 MIST).'; } else { helpText = `Enter an unsigned ${parsedType.baseType.substring(1)}-bit integer.`; } @@ -458,7 +478,8 @@ export class ParameterHelperService { '0x68656c6c6f (as hex)', '[104, 101, 108, 108, 111] (as bytes)' ); - helpText = 'Enter a string (will be converted to bytes), hex string (0x...), or byte array.'; + helpText = + 'Enter a string (will be converted to bytes), hex string (0x...), or byte array.'; break; } @@ -508,7 +529,7 @@ export class ParameterHelperService { * Filter user's objects by type */ private filterObjectsByType(objects: any[], parsedType: ParsedType): any[] { - return objects.filter(obj => { + return objects.filter((obj) => { const objType = obj.type || obj.data?.type || ''; // For reference types, extract the referenced type @@ -641,7 +662,7 @@ export class ParameterHelperService { throw new Error(response.statusText); } - const result = await response.json() as { result?: { data?: any[] } }; + const result = (await response.json()) as { result?: { data?: any[] } }; return result.result?.data || []; } @@ -651,7 +672,7 @@ export class ParameterHelperService { public async getObjectsByType(address: string, typePattern: string): Promise { const allObjects = await this.getUserObjects(address); - return allObjects.filter(obj => { + return allObjects.filter((obj) => { const objType = obj.type || obj.data?.type || ''; // Exact match @@ -707,7 +728,7 @@ export class ParameterHelperService { }); if (response.ok) { - const result = await response.json() as { result?: { data?: any } }; + const result = (await response.json()) as { result?: { data?: any } }; return result.result?.data; } } catch { @@ -742,16 +763,7 @@ export class ParameterHelperService { * Get the active RPC URL from config */ private async getActiveRpcUrl(): Promise { - try { - const config = await this.configParser.getConfig(); - if (config) { - const activeEnv = config.envs.find(e => e.alias === config.active_env); - return activeEnv?.rpc || null; - } - } catch { - // Ignore - } - return null; + return this.configParser.getActiveRpcUrl(); } /** @@ -798,8 +810,10 @@ export class ParameterHelperService { } // Quoted string - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { return this.stringToVectorU8(value.slice(1, -1)); } diff --git a/apps/server/src/services/WalrusMemoryService.ts b/apps/server/src/services/WalrusMemoryService.ts index 8406d91..dd734ce 100644 --- a/apps/server/src/services/WalrusMemoryService.ts +++ b/apps/server/src/services/WalrusMemoryService.ts @@ -1,13 +1,12 @@ +import { EncryptedObject, SealClient, SessionKey } from '@mysten/seal'; import { bcs } from '@mysten/sui/bcs'; import { Transaction } from '@mysten/sui/transactions'; import { fromHex, normalizeSuiAddress, toHex } from '@mysten/sui/utils'; -import { SealClient, SessionKey, EncryptedObject } from '@mysten/seal'; -import { getSharedGrpcClient, inferNetwork } from '../utils/suiGrpcClient'; -import { getObjectsJsonViaGrpc } from '../utils/suiGrpcClient'; -import { loadLocalKeypairForAddress } from '../utils/localKeystore'; +import fetch from 'node-fetch'; import { ConfigParser } from '../cli/ConfigParser'; import { SuiCliExecutor } from '../cli/SuiCliExecutor'; -import fetch from 'node-fetch'; +import { loadLocalKeypairForAddress } from '../utils/localKeystore'; +import { getObjectsJsonViaGrpc, getSharedGrpcClient, inferNetwork } from '../utils/suiGrpcClient'; // Verified against the deployed testnet/mainnet packages directly (gRPC // MovePackageService.getMoveFunction) - see docs/contract/overview.md in @@ -69,8 +68,7 @@ export class WalrusMemoryService { private cliExecutor = SuiCliExecutor.getInstance(); private async getActiveRpcUrl(): Promise { - const config = await this.configParser.getConfig(); - const rpcUrl = config?.envs.find((e) => e.alias === config.active_env)?.rpc; + const rpcUrl = await this.configParser.getActiveRpcUrl(); if (!rpcUrl) throw new Error('No active RPC URL found'); return rpcUrl; } @@ -79,7 +77,9 @@ export class WalrusMemoryService { const network = inferNetwork(rpcUrl); const config = NETWORK_CONFIG[network]; if (!config) { - throw new Error(`Walrus Memory is only available on mainnet/testnet (active network: ${network})`); + throw new Error( + `Walrus Memory is only available on mainnet/testnet (active network: ${network})` + ); } return { network, ...config }; } @@ -184,7 +184,11 @@ export class WalrusMemoryService { throw new Error('The account owner cannot also be added as a delegate'); } const existingAccount = await this.getAccountDetails(accountId); - if (existingAccount?.delegateKeys.some((k) => normalizeSuiAddress(k.suiAddress) === normalizedDelegate)) { + if ( + existingAccount?.delegateKeys.some( + (k) => normalizeSuiAddress(k.suiAddress) === normalizedDelegate + ) + ) { throw new Error('This address is already a registered delegate for this account'); } @@ -259,7 +263,9 @@ export class WalrusMemoryService { * succeeds. Shared by addDelegateKey/removeDelegateKey. */ private async runPtbDryRunThenExecute(ptbArgs: string[]): Promise<{ digest: string }> { - const dryRunOutput = await this.cliExecutor.execute([...ptbArgs, '--dry-run'], { timeout: 60000 }); + const dryRunOutput = await this.cliExecutor.execute([...ptbArgs, '--dry-run'], { + timeout: 60000, + }); const dryRunResult = this.parsePtbOutput(dryRunOutput, true); if (!dryRunResult.success) { throw new Error(dryRunResult.error ?? 'Dry run failed'); @@ -273,7 +279,10 @@ export class WalrusMemoryService { // A missing digest here means the CLI reported success but our output // parsing couldn't find it - the transaction still landed on-chain, so // this must not be reported as a failure (see parsePtbOutput). - return { digest: result.digest ?? 'unknown (transaction succeeded - check your wallet or a block explorer)' }; + return { + digest: + result.digest ?? 'unknown (transaction succeeded - check your wallet or a block explorer)', + }; } private parsePtbOutput( @@ -316,7 +325,9 @@ export class WalrusMemoryService { for (const base of aggregators) { try { - const res = await fetch(`${base}/v1/blobs/${walrusBlobId}`, { signal: AbortSignal.timeout(20_000) }); + const res = await fetch(`${base}/v1/blobs/${walrusBlobId}`, { + signal: AbortSignal.timeout(20_000), + }); if (res.ok) { return new Uint8Array(await res.arrayBuffer()); } @@ -359,7 +370,10 @@ export class WalrusMemoryService { const client = getSharedGrpcClient(rpcUrl); const { sealServers } = NETWORK_CONFIG[network]; - const threshold = Math.min(2, sealServers.reduce((sum, s) => sum + s.weight, 0)); + const threshold = Math.min( + 2, + sealServers.reduce((sum, s) => sum + s.weight, 0) + ); const sealClient = new SealClient({ suiClient: client as any, @@ -409,7 +423,11 @@ export class WalrusMemoryService { const text = decoder.decode(decrypted); return { isText: true, text, byteLength: decrypted.length }; } catch { - return { isText: false, base64: Buffer.from(decrypted).toString('base64'), byteLength: decrypted.length }; + return { + isText: false, + base64: Buffer.from(decrypted).toString('base64'), + byteLength: decrypted.length, + }; } } } diff --git a/apps/server/src/services/core/OutputService.ts b/apps/server/src/services/core/OutputService.ts index 5298c24..49b288f 100644 --- a/apps/server/src/services/core/OutputService.ts +++ b/apps/server/src/services/core/OutputService.ts @@ -2,10 +2,11 @@ * OutputService - Handle large outputs with file storage and streaming */ +import * as crypto from 'crypto'; import * as fs from 'fs/promises'; -import * as path from 'path'; import * as os from 'os'; -import * as crypto from 'crypto'; +import * as path from 'path'; +import { validateOutputId } from '../../utils/validation'; export interface OutputMetadata { type: 'trace' | 'coverage' | 'build' | 'test' | 'replay' | 'other'; @@ -63,13 +64,20 @@ export class OutputService { * Get file path for an output ID */ private getFilePath(id: string): string { + // id comes straight from the URL param on the read/download/delete routes; + // without this check `../../.sui/sui_config/client` resolves outside + // OUTPUT_DIR (path.join collapses `..` segments). + validateOutputId(id); return path.join(OUTPUT_DIR, `${id}.json`); } /** * Store large output to file and return reference */ - async storeLargeOutput(data: string, metadata: Omit): Promise { + async storeLargeOutput( + data: string, + metadata: Omit + ): Promise { await this.ensureDir(); const id = this.generateId(); @@ -128,7 +136,9 @@ export class OutputService { /** * Get output for download */ - async downloadOutput(id: string): Promise<{ data: Buffer; filename: string; contentType: string }> { + async downloadOutput( + id: string + ): Promise<{ data: Buffer; filename: string; contentType: string }> { const output = await this.getOutput(id); if (!output) { throw new Error(`Output not found: ${id}`); @@ -172,8 +182,8 @@ export class OutputService { } } - return outputs.sort((a, b) => - new Date(b.metadata.createdAt).getTime() - new Date(a.metadata.createdAt).getTime() + return outputs.sort( + (a, b) => new Date(b.metadata.createdAt).getTime() - new Date(a.metadata.createdAt).getTime() ); } diff --git a/apps/server/src/utils/authToken.ts b/apps/server/src/utils/authToken.ts new file mode 100644 index 0000000..599606f --- /dev/null +++ b/apps/server/src/utils/authToken.ts @@ -0,0 +1,84 @@ +/** + * Pairing token for routes that move funds or export key material. + * + * This server has no login system - anyone who can reach it can drive it. + * CORS trusts the hosted UI's origin so that page can talk to a user's local + * install, which means an HTTP endpoint reachable by this server is reachable + * by that origin too. A pairing token only closes anything if it is NEVER + * served over HTTP: the one channel the hosted origin cannot read is this + * process's own stdout, so that is the only place the token is ever shown. + */ + +import { randomBytes, timingSafeEqual } from 'crypto'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +const TOKEN_DIR = join(homedir(), '.sui-cli-web'); +const TOKEN_PATH = join(TOKEN_DIR, 'auth-token'); + +let cachedToken: string | null = null; + +/** + * Reads the persisted token, generating and storing one on first run. + * Cached in memory so repeated calls within a process don't re-read the file. + */ +export function getOrCreateAuthToken(): string { + if (cachedToken) return cachedToken; + + if (existsSync(TOKEN_PATH)) { + const existing = readFileSync(TOKEN_PATH, 'utf-8').trim(); + if (existing) { + cachedToken = existing; + return existing; + } + } + + const token = randomBytes(32).toString('hex'); + mkdirSync(TOKEN_DIR, { recursive: true }); + writeFileSync(TOKEN_PATH, token, { mode: 0o600 }); + try { + chmodSync(TOKEN_PATH, 0o600); + } catch { + // Best-effort - chmod semantics differ on Windows. + } + cachedToken = token; + return token; +} + +function extractBearerToken(header: string | string[] | undefined): string | null { + const value = Array.isArray(header) ? header[0] : header; + if (!value) return null; + const match = value.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : null; +} + +/** Constant-time comparison so a wrong guess can't be narrowed down via response timing. */ +function tokensMatch(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + +/** + * Fastify preHandler for routes that move funds or export key material. + * Requires `Authorization: Bearer ` matching the token printed in + * this server's own startup banner - pairing a browser means copying that + * value from the terminal, not fetching it from anywhere over the network. + */ +export async function requireAuthToken(request: FastifyRequest, reply: FastifyReply) { + const provided = extractBearerToken(request.headers.authorization); + const expected = getOrCreateAuthToken(); + + if (!provided || !tokensMatch(provided, expected)) { + reply.status(401); + return reply.send({ + success: false, + error: + 'This action requires pairing. Copy the token from your terminal and enter it when prompted.', + code: 'PAIRING_REQUIRED', + }); + } +} diff --git a/apps/server/src/utils/validation.ts b/apps/server/src/utils/validation.ts index fe4565d..0f088b2 100644 --- a/apps/server/src/utils/validation.ts +++ b/apps/server/src/utils/validation.ts @@ -33,9 +33,7 @@ export function isValidObjectId(objectId: string): boolean { return typeof objectId === 'string' && OBJECT_ID_REGEX.test(objectId); } -export function isValidKeyScheme( - scheme: string -): scheme is 'ed25519' | 'secp256k1' | 'secp256r1' { +export function isValidKeyScheme(scheme: string): scheme is 'ed25519' | 'secp256k1' | 'secp256r1' { return VALID_KEY_SCHEMES.includes(scheme as any); } @@ -43,9 +41,7 @@ export function isValidAlias(alias: string): boolean { return typeof alias === 'string' && ALIAS_REGEX.test(alias); } -export function isValidNetwork( - network: string -): network is 'testnet' | 'devnet' | 'localnet' { +export function isValidNetwork(network: string): network is 'testnet' | 'devnet' | 'localnet' { return VALID_NETWORKS.includes(network as any); } @@ -127,7 +123,10 @@ export function validateKeyScheme( } if (typeof scheme !== 'string' || !isValidKeyScheme(scheme)) { throw new ValidationException([ - { field: 'keyScheme', message: 'Invalid key scheme (expected ed25519, secp256k1, or secp256r1)' }, + { + field: 'keyScheme', + message: 'Invalid key scheme (expected ed25519, secp256k1, or secp256r1)', + }, ]); } return scheme; @@ -226,7 +225,10 @@ export function isValidTypeArg(arg: string): boolean { } export function isValidTxDigest(digest: string): boolean { - return typeof digest === 'string' && (TX_DIGEST_BASE58_REGEX.test(digest) || TX_DIGEST_HEX_REGEX.test(digest)); + return ( + typeof digest === 'string' && + (TX_DIGEST_BASE58_REGEX.test(digest) || TX_DIGEST_HEX_REGEX.test(digest)) + ); } export function isSafeArg(arg: string): boolean { @@ -236,7 +238,11 @@ export function isSafeArg(arg: string): boolean { export function validateModuleName(module: unknown, fieldName = 'module'): string { if (typeof module !== 'string' || !isValidModuleName(module)) { throw new ValidationException([ - { field: fieldName, message: 'Invalid module name (alphanumeric/underscore, starts with letter/underscore, max 128 chars)' }, + { + field: fieldName, + message: + 'Invalid module name (alphanumeric/underscore, starts with letter/underscore, max 128 chars)', + }, ]); } return module; @@ -245,7 +251,11 @@ export function validateModuleName(module: unknown, fieldName = 'module'): strin export function validateFunctionName(functionName: unknown, fieldName = 'function'): string { if (typeof functionName !== 'string' || !isValidFunctionName(functionName)) { throw new ValidationException([ - { field: fieldName, message: 'Invalid function name (alphanumeric/underscore, starts with letter/underscore, max 128 chars)' }, + { + field: fieldName, + message: + 'Invalid function name (alphanumeric/underscore, starts with letter/underscore, max 128 chars)', + }, ]); } return functionName; @@ -275,14 +285,15 @@ export function validateMoveArgs(args: unknown): string[] { return []; } if (!Array.isArray(args)) { - throw new ValidationException([ - { field: 'args', message: 'Arguments must be an array' }, - ]); + throw new ValidationException([{ field: 'args', message: 'Arguments must be an array' }]); } for (let i = 0; i < args.length; i++) { if (!isSafeArg(args[i])) { throw new ValidationException([ - { field: `args[${i}]`, message: 'Invalid argument (contains forbidden characters or too long)' }, + { + field: `args[${i}]`, + message: 'Invalid argument (contains forbidden characters or too long)', + }, ]); } } @@ -292,20 +303,34 @@ export function validateMoveArgs(args: unknown): string[] { export function validateTxDigest(digest: unknown, fieldName = 'digest'): string { if (typeof digest !== 'string' || !isValidTxDigest(digest)) { throw new ValidationException([ - { field: fieldName, message: 'Invalid transaction digest format (expected base58 43-44 chars OR hex 0x + 64 chars)' }, + { + field: fieldName, + message: + 'Invalid transaction digest format (expected base58 43-44 chars OR hex 0x + 64 chars)', + }, ]); } return digest; } +// Output IDs are always generated server-side via crypto.randomUUID() - a +// caller-supplied value that isn't a UUID has no legitimate use and, if +// interpolated into a file path, is how `../../..` traversal happens. +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function validateOutputId(id: unknown, fieldName = 'id'): string { + if (typeof id !== 'string' || !UUID_REGEX.test(id)) { + throw new ValidationException([{ field: fieldName, message: 'Invalid output ID format' }]); + } + return id; +} + // Package path validation - prevent path traversal attacks -const PACKAGE_PATH_REGEX = /^[a-zA-Z0-9\/_-]+$/; +const PACKAGE_PATH_REGEX = /^[a-zA-Z0-9/_-]+$/; export function validatePackagePath(packagePath: unknown): string { if (typeof packagePath !== 'string' || !packagePath.trim()) { - throw new ValidationException([ - { field: 'packagePath', message: 'Package path is required' }, - ]); + throw new ValidationException([{ field: 'packagePath', message: 'Package path is required' }]); } const trimmed = packagePath.trim(); @@ -377,7 +402,10 @@ export function validateTransferAmount(amount: unknown, fieldName = 'amount'): s export function validatePrivateKey(privateKey: unknown, fieldName = 'privateKey'): string { if (typeof privateKey !== 'string' || !isValidPrivateKey(privateKey)) { throw new ValidationException([ - { field: fieldName, message: 'Invalid private key format (expected Bech32 starting with "suiprivkey")' }, + { + field: fieldName, + message: 'Invalid private key format (expected Bech32 starting with "suiprivkey")', + }, ]); } return privateKey; @@ -403,9 +431,7 @@ export function isValidBase64(str: string): boolean { export function validateBase64(data: unknown, fieldName = 'data'): string { if (typeof data !== 'string' || !isValidBase64(data)) { - throw new ValidationException([ - { field: fieldName, message: 'Invalid base64 string' }, - ]); + throw new ValidationException([{ field: fieldName, message: 'Invalid base64 string' }]); } return data; } @@ -421,7 +447,10 @@ export function isValidHexString(str: string): boolean { export function validateHexString(data: unknown, fieldName = 'data'): string { if (typeof data !== 'string' || !isValidHexString(data)) { throw new ValidationException([ - { field: fieldName, message: 'Invalid hex string (expected hex digits with optional 0x prefix)' }, + { + field: fieldName, + message: 'Invalid hex string (expected hex digits with optional 0x prefix)', + }, ]); } return data; @@ -430,9 +459,7 @@ export function validateHexString(data: unknown, fieldName = 'data'): string { // Public key validation (Base64 or hex) export function validatePublicKey(publicKey: unknown, fieldName = 'publicKey'): string { if (typeof publicKey !== 'string' || publicKey.length === 0) { - throw new ValidationException([ - { field: fieldName, message: 'Public key is required' }, - ]); + throw new ValidationException([{ field: fieldName, message: 'Public key is required' }]); } // Accept either base64 or hex format @@ -448,9 +475,7 @@ export function validatePublicKey(publicKey: unknown, fieldName = 'publicKey'): // Signature validation (Base64 or hex) export function validateSignature(signature: unknown, fieldName = 'signature'): string { if (typeof signature !== 'string' || signature.length === 0) { - throw new ValidationException([ - { field: fieldName, message: 'Signature is required' }, - ]); + throw new ValidationException([{ field: fieldName, message: 'Signature is required' }]); } // Accept either base64 or hex format diff --git a/apps/web/src/api/core/request.ts b/apps/web/src/api/core/request.ts index 51ba615..acf39bf 100644 --- a/apps/web/src/api/core/request.ts +++ b/apps/web/src/api/core/request.ts @@ -3,6 +3,7 @@ * @module api/core/request */ +import { pairingHeader } from '@/lib/authToken'; import type { ApiResponse } from '@/types'; import { getApiBaseUrl, setConnectionStatus } from './connection'; @@ -25,6 +26,7 @@ export async function fetchApi( const response = await fetch(`${API_BASE}${endpoint}`, { headers: { 'Content-Type': 'application/json', + ...pairingHeader(), }, ...fetchOptions, signal: controller.signal, @@ -80,7 +82,9 @@ export async function fetchApi( } if (error instanceof TypeError && error.message.includes('fetch')) { setConnectionStatus(false); - throw new Error('Cannot connect to local server. Make sure the server is running (npx sui-cli-web-server).'); + throw new Error( + 'Cannot connect to local server. Make sure the server is running (npx sui-cli-web-server).' + ); } if (error instanceof SyntaxError && error.message.includes('JSON')) { setConnectionStatus(false); @@ -101,7 +105,7 @@ async function fetchApiRaw(endpoint: string, options?: RequestInit): Promise< try { const response = await fetch(`${API_BASE}${endpoint}`, { - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...pairingHeader() }, ...options, signal: controller.signal, }); @@ -139,7 +143,10 @@ export const apiClient = { } }, - async post(endpoint: string, body?: any): Promise { + async post( + endpoint: string, + body?: any + ): Promise { try { const data = await fetchApiRaw(endpoint, { method: 'POST', @@ -159,7 +166,9 @@ export const apiClient = { async delete(endpoint: string): Promise { try { - const data = await fetchApiRaw(endpoint, { method: 'DELETE' }); + const data = await fetchApiRaw(endpoint, { + method: 'DELETE', + }); if ('success' in data) { return data as T & { success: boolean; error?: string }; } diff --git a/apps/web/src/components/AddressList/index.tsx b/apps/web/src/components/AddressList/index.tsx index 59782a1..e64b114 100644 --- a/apps/web/src/components/AddressList/index.tsx +++ b/apps/web/src/components/AddressList/index.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { Tooltip } from '@/components/ui/tooltip'; +import { useCopyToClipboard } from '@/hooks'; import { buildExplorerUrl, detectNetwork, @@ -399,13 +400,10 @@ export function AddressList() { }); }, [addresses, debouncedSearchQuery, metadata]); + const copyToClipboard = useCopyToClipboard(); + // Copy-for-AI export. Public only: address, alias, active flag, balance - // never private keys, recovery phrases, or local notes/labels. - const copyToClipboard = useCallback((text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }, []); - const aiExport = useMemo(() => { const publicAddresses = sortedAddresses.map((a) => ({ address: a.address, diff --git a/apps/web/src/components/CoinList/index.tsx b/apps/web/src/components/CoinList/index.tsx index 32354a2..6027ae1 100644 --- a/apps/web/src/components/CoinList/index.tsx +++ b/apps/web/src/components/CoinList/index.tsx @@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { ShimmerSkeleton } from '@/components/unlumen-ui/shimmer-skeleton'; +import { useCopyToClipboard } from '@/hooks'; import { useAppStore } from '@/stores/useAppStore'; // Format balance with proper decimals @@ -86,10 +87,7 @@ export function CoinList() { }); }; - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); // Filter groups by search query const filteredGroups = useMemo(() => { diff --git a/apps/web/src/components/CoinMerge/index.tsx b/apps/web/src/components/CoinMerge/index.tsx index 87f9373..29e7e11 100644 --- a/apps/web/src/components/CoinMerge/index.tsx +++ b/apps/web/src/components/CoinMerge/index.tsx @@ -17,6 +17,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import * as api from '@/api/client'; import { Button } from '@/components/ui/button'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { buildExplorerUrl, detectNetwork, getDefaultExplorer } from '@/lib/explorer'; import { showErrorToast, showSuccessToast } from '@/lib/toast'; import { useAppStore } from '@/stores/useAppStore'; @@ -39,7 +40,9 @@ function formatBalance(balance: string, decimals: number): string { export function CoinMerge() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { addresses } = useAppStore(); + const { addresses, environments } = useAppStore(); + const activeEnv = environments.find((e) => e.isActive); + const currentNetwork = detectNetwork(activeEnv?.alias, activeEnv?.rpc); const activeAddress = addresses.find((a) => a.isActive); // URL params @@ -608,7 +611,12 @@ export function CoinMerge() { {/* Explorer Link */} { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); // Copy-for-AI export. Public only: env name, rpc url, active flag - all of // which are plain network config, no secrets. diff --git a/apps/web/src/components/EventExplorer/index.tsx b/apps/web/src/components/EventExplorer/index.tsx index b6d5578..b0cdcad 100644 --- a/apps/web/src/components/EventExplorer/index.tsx +++ b/apps/web/src/components/EventExplorer/index.tsx @@ -30,6 +30,9 @@ import toast from 'react-hot-toast'; import { apiClient } from '@/api/client'; import { Button } from '@/components/ui/button'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { useCopyToClipboard } from '@/hooks'; +import { buildExplorerUrl, detectNetwork, getDefaultExplorer } from '@/lib/explorer'; +import { useAppStore } from '@/stores/useAppStore'; interface ParsedEvent { id: string; @@ -159,10 +162,10 @@ function EventCard({ const description = getEventDescription(event.eventName); const icon = getEventIcon(event.eventName); + const copyToClipboardBase = useCopyToClipboard(); const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); + copyToClipboardBase(text, label); setCopied(label); - toast.success('Copied!'); setTimeout(() => setCopied(null), 2000); }; @@ -302,6 +305,9 @@ function EventCard({ } export function EventExplorer() { + const { environments } = useAppStore(); + const activeEnv = environments.find((e) => e.isActive); + const currentNetwork = detectNetwork(activeEnv?.alias, activeEnv?.rpc); const [digest, setDigest] = useState(''); const [events, setEvents] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -499,7 +505,7 @@ export function EventExplorer() { Transaction Summary { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); const aiJson = JSON.stringify( { diff --git a/apps/web/src/components/GasAnalysis/index.tsx b/apps/web/src/components/GasAnalysis/index.tsx index c09e52d..c6a4c3c 100644 --- a/apps/web/src/components/GasAnalysis/index.tsx +++ b/apps/web/src/components/GasAnalysis/index.tsx @@ -28,10 +28,10 @@ import { Zap, } from 'lucide-react'; import React, { useCallback, useState } from 'react'; -import toast from 'react-hot-toast'; import { apiClient } from '@/api/client'; import { Button } from '@/components/ui/button'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { useCopyToClipboard } from '@/hooks'; interface GasBreakdown { computationCost: string; @@ -226,15 +226,7 @@ export function GasAnalysis() { if (e.key === 'Enter') analyzeTransaction(); }; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - toast.success('Copied!'); - }; - - const copyForAi = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); const getEfficiencyInfo = (eff: number) => { if (eff >= 70) @@ -301,7 +293,7 @@ export function GasAnalysis() { - {breakdown && } + {breakdown && }
@@ -349,7 +341,7 @@ export function GasAnalysis() {
- )} - - ); - } - - return ( -
- {/* Header */} -
-
- -
-

{config.title}

- {error.code && ( -

Error Code: {error.code}

- )} -
-
- -
- - {onDismiss && ( - - )} -
-
- - {/* Message */} -

{error.message}

- - {/* Details */} - {error.details && ( -
-
-
-              {error.details}
-            
-
-
- )} - - {/* Suggestions */} - {error.suggestions && error.suggestions.length > 0 && ( -
-

Suggestions:

-
    - {error.suggestions.map((suggestion, index) => ( -
  • - - {suggestion} -
  • - ))} -
-
- )} - - {/* Actions */} - {onRetry && error.recoverable && ( -
- {onRetry && error.recoverable && ( - - )} -
- )} -
- ); -} diff --git a/apps/web/src/components/MoveDeploy/components/Feedback/LoadingState.tsx b/apps/web/src/components/MoveDeploy/components/Feedback/LoadingState.tsx deleted file mode 100644 index 8b11cd0..0000000 --- a/apps/web/src/components/MoveDeploy/components/Feedback/LoadingState.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Loading state component with animated skeleton and messages - */ - -import { Loader2 } from 'lucide-react'; - -interface LoadingStateProps { - message?: string; - submessage?: string; - variant?: 'spinner' | 'skeleton' | 'dots'; - className?: string; -} - -export function LoadingState({ - message = 'Loading...', - submessage, - variant = 'spinner', - className = '', -}: LoadingStateProps) { - if (variant === 'spinner') { - return ( -
- -

{message}

- {submessage &&

{submessage}

} -
- ); - } - - if (variant === 'dots') { - return ( -
-
-
-
-
-
-

{message}

- {submessage &&

{submessage}

} -
- ); - } - - // Skeleton variant - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -} - -// Specific loading states for operations -export function BuildingState() { - return ( - - ); -} - -export function TestingState() { - return ( - - ); -} - -export function PublishingState() { - return ( - - ); -} - -export function UpgradingState() { - return ( - - ); -} diff --git a/apps/web/src/components/MoveDeploy/components/Operations/BuildCard.tsx b/apps/web/src/components/MoveDeploy/components/Operations/BuildCard.tsx deleted file mode 100644 index 97e3c6f..0000000 --- a/apps/web/src/components/MoveDeploy/components/Operations/BuildCard.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Build operations card component - */ - -import { Hammer, Play, Settings } from 'lucide-react'; -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { useBuildPackage } from '../../hooks/api/useBuildPackage'; -import { OperationCard } from './OperationCard'; -import { ErrorDisplay } from '../Feedback/ErrorDisplay'; -import { OutputDisplay } from '../Results/OutputDisplay'; -import { LoadingState } from '../Feedback/LoadingState'; - -export function BuildCard() { - const { build, loading, success, error, data, canBuild } = useBuildPackage(); - const [showAdvanced, setShowAdvanced] = useState(false); - const [options, setOptions] = useState({ - skipFetchLatest: false, - withUnpublished: false, - }); - - const handleBuild = async () => { - try { - await build(options); - } catch (err) { - // Error handled by state - } - }; - - const getStatus = () => { - if (loading) return 'loading'; - if (error) return 'error'; - if (success) return 'success'; - return 'idle'; - }; - - return ( - } - status={getStatus()} - defaultExpanded - > - {/* Advanced Options */} -
- - - {showAdvanced && ( -
- - -
- )} -
- - {/* Build Button */} - - - {/* Loading State */} - {loading && ( - - )} - - {/* Error Display */} - {error && ( - - )} - - {/* Success Output */} - {data && success && ( -
- {/* Summary */} -
-
-

Build Successful

-

- {data.modules?.length || 0} module(s) compiled in {data.duration}ms -

-
-
- - {/* Modules List */} - {data.modules && data.modules.length > 0 && ( -
-

Compiled Modules:

-
- {data.modules.map((module, index) => ( -
- {module} -
- ))} -
-
- )} - - {/* Output */} - -
- )} -
- ); -} diff --git a/apps/web/src/components/MoveDeploy/components/Operations/OperationCard.tsx b/apps/web/src/components/MoveDeploy/components/Operations/OperationCard.tsx deleted file mode 100644 index 34028e9..0000000 --- a/apps/web/src/components/MoveDeploy/components/Operations/OperationCard.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Reusable card component for operation sections (build, test, publish, upgrade) - */ - -import { ChevronDown, ChevronRight } from 'lucide-react'; -import { useState, type ReactNode } from 'react'; -import type { OperationStatus } from '../../types'; - -interface OperationCardProps { - title: string; - description: string; - icon: ReactNode; - status: OperationStatus; - children: ReactNode; - actions?: ReactNode; - defaultExpanded?: boolean; - collapsible?: boolean; - badge?: ReactNode; - className?: string; -} - -const STATUS_STYLES = { - idle: 'border-border', - loading: 'border-blue-500/30 bg-blue-500/5', - success: 'border-green-500/30 bg-green-500/5', - error: 'border-red-500/30 bg-red-500/5', -}; - -const STATUS_INDICATOR = { - idle: null, - loading: ( -
-
- Processing... -
- ), - success: ( -
-
- Success -
- ), - error: ( -
-
- Error -
- ), -}; - -export function OperationCard({ - title, - description, - icon, - status, - children, - actions, - defaultExpanded = false, - collapsible = true, - badge, - className = '', -}: OperationCardProps) { - const [expanded, setExpanded] = useState(defaultExpanded); - - const handleToggle = () => { - if (collapsible) { - setExpanded(!expanded); - } - }; - - return ( -
- {/* Header */} -
-
- {/* Icon */} -
{icon}
- - {/* Title & Description */} -
-
-

{title}

- {badge} -
-

{description}

-
- - {/* Status Indicator */} - {STATUS_INDICATOR[status]} - - {/* Collapse Icon */} - {collapsible && ( -
- {expanded ? ( - - ) : ( - - )} -
- )} -
-
- - {/* Content */} - {(!collapsible || expanded) && ( - <> -
-
{children}
- - {/* Actions */} - {actions && ( - <> -
-
{actions}
- - )} - - )} -
- ); -} diff --git a/apps/web/src/components/MoveDeploy/components/PackageManagement/PackageSelector.tsx b/apps/web/src/components/MoveDeploy/components/PackageManagement/PackageSelector.tsx deleted file mode 100644 index db60e89..0000000 --- a/apps/web/src/components/MoveDeploy/components/PackageManagement/PackageSelector.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Package path selector with validation and recent projects - */ - -import { Folder, Check, Clock, X } from 'lucide-react'; -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { useMoveDevStore, selectCurrentPackage, selectRecentProjects } from '../../hooks/state/useMoveDevState'; - -export function PackageSelector() { - const currentPackage = useMoveDevStore(selectCurrentPackage); - const recentProjects = useMoveDevStore(selectRecentProjects); - const setPackage = useMoveDevStore((state) => state.setPackage); - const clearPackage = useMoveDevStore((state) => state.clearPackage); - - const [inputPath, setInputPath] = useState(currentPackage?.path || ''); - const [showRecent, setShowRecent] = useState(false); - const [validating, setValidating] = useState(false); - const [validationError, setValidationError] = useState(null); - - useEffect(() => { - if (currentPackage?.path) { - setInputPath(currentPackage.path); - } - }, [currentPackage?.path]); - - const handleSelectPath = async (path: string) => { - setValidating(true); - setValidationError(null); - - try { - // Basic validation - if (!path.trim()) { - throw new Error('Package path cannot be empty'); - } - - // TODO: Add backend validation endpoint to check if path exists and has Move.toml - await setPackage(path); - setShowRecent(false); - } catch (error) { - setValidationError(error instanceof Error ? error.message : 'Invalid package path'); - } finally { - setValidating(false); - } - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - handleSelectPath(inputPath); - }; - - const handleClear = () => { - clearPackage(); - setInputPath(''); - setValidationError(null); - }; - - return ( -
- {/* Input Form */} -
-
- -
- - setInputPath(e.target.value)} - onFocus={() => setShowRecent(true)} - placeholder="/path/to/your/move/package" - className="w-full pl-10 pr-20 py-2.5 bg-card border border-border rounded-lg text-foreground placeholder-tertiary focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors" - /> - {currentPackage && ( - - )} -
- {validationError && ( -

{validationError}

- )} -
- - {/* Actions */} -
- - - {recentProjects.length > 0 && ( - - )} -
-
- - {/* Recent Projects Dropdown */} - {showRecent && recentProjects.length > 0 && ( -
-
-

Recent Projects

-
-
- {recentProjects.map((project) => ( - - ))} -
-
- )} - - {/* Current Package Info */} - {currentPackage && !validationError && ( -
- -
-

Package Selected

-

- {currentPackage.path} -

-
-
- )} -
- ); -} - -// Helper function to format timestamp -function formatTimestamp(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - - if (minutes < 1) return 'Just now'; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - return `${days}d ago`; -} diff --git a/apps/web/src/components/MoveDeploy/components/Results/OutputDisplay.tsx b/apps/web/src/components/MoveDeploy/components/Results/OutputDisplay.tsx deleted file mode 100644 index 85d996e..0000000 --- a/apps/web/src/components/MoveDeploy/components/Results/OutputDisplay.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Terminal-style output display component with syntax highlighting and copy functionality - */ - -import { Copy, Check } from 'lucide-react'; -import { useState, useRef, useEffect } from 'react'; - -interface OutputDisplayProps { - output: string; - type?: 'success' | 'error' | 'info'; - maxHeight?: number; - showLineNumbers?: boolean; - copyable?: boolean; - className?: string; -} - -const TYPE_STYLES = { - success: { - border: 'border-green-500/20', - bg: 'bg-green-500/5', - text: 'text-green-300', - }, - error: { - border: 'border-red-500/20', - bg: 'bg-red-500/5', - text: 'text-red-300', - }, - info: { - border: 'border-blue-500/20', - bg: 'bg-blue-500/5', - text: 'text-blue-300', - }, -}; - -export function OutputDisplay({ - output, - type = 'info', - maxHeight = 400, - showLineNumbers = false, - copyable = true, - className = '', -}: OutputDisplayProps) { - const [copied, setCopied] = useState(false); - const [autoScroll, setAutoScroll] = useState(true); - const outputRef = useRef(null); - const styles = TYPE_STYLES[type]; - - // Auto-scroll to bottom when output changes - useEffect(() => { - if (autoScroll && outputRef.current) { - outputRef.current.scrollTop = outputRef.current.scrollHeight; - } - }, [output, autoScroll]); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(output); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - - const handleScroll = () => { - if (outputRef.current) { - const { scrollTop, scrollHeight, clientHeight } = outputRef.current; - // Consider at bottom if within 10px of bottom - const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; - setAutoScroll(isAtBottom); - } - }; - - const lines = output.split('\n'); - - return ( -
- {/* Header */} -
- Output -
- {copyable && ( - - )} -
-
- - {/* Output Content */} -
-
- {lines.map((line, index) => ( -
- {showLineNumbers && ( - - {index + 1} - - )} -
-                {line || ' '}
-              
-
- ))} -
-
- - {/* Scroll to bottom indicator */} - {!autoScroll && ( - - )} -
- ); -} diff --git a/apps/web/src/components/MoveDeploy/hooks/api/useApiOperation.ts b/apps/web/src/components/MoveDeploy/hooks/api/useApiOperation.ts deleted file mode 100644 index a8067ca..0000000 --- a/apps/web/src/components/MoveDeploy/hooks/api/useApiOperation.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Generic hook for API operations with loading states, error handling, and retries - */ - -import { useState, useCallback, useRef } from 'react'; -import type { OperationError } from '../../types'; - -interface UseApiOperationOptions { - operation: (request: TRequest) => Promise; - onSuccess?: (result: TResult) => void; - onError?: (error: OperationError) => void; - retryCount?: number; - retryDelay?: number; -} - -interface UseApiOperationReturn { - execute: (request: TRequest) => Promise; - loading: boolean; - error: OperationError | null; - data: TResult | null; - reset: () => void; - retry: () => Promise; -} - -export function useApiOperation({ - operation, - onSuccess, - onError, - retryCount = 0, - retryDelay = 1000, -}: UseApiOperationOptions): UseApiOperationReturn { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [data, setData] = useState(null); - const lastRequestRef = useRef(null); - const abortControllerRef = useRef(null); - - const execute = useCallback( - async (request: TRequest, attempt = 0): Promise => { - // Store last request for retry - lastRequestRef.current = request; - - // Cancel previous request if still running - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - abortControllerRef.current = new AbortController(); - - setLoading(true); - setError(null); - - try { - const result = await operation(request); - setData(result); - setLoading(false); - onSuccess?.(result); - return result; - } catch (err) { - // Handle abort - if (err instanceof Error && err.name === 'AbortError') { - setLoading(false); - throw err; - } - - // Convert to OperationError - const operationError: OperationError = { - type: 'unknown', - message: err instanceof Error ? err.message : 'Operation failed', - recoverable: true, - ...(err && typeof err === 'object' && 'type' in err ? err : {}), - }; - - // Retry logic - if (operationError.recoverable && attempt < retryCount) { - await new Promise((resolve) => setTimeout(resolve, retryDelay * (attempt + 1))); - return execute(request, attempt + 1); - } - - setError(operationError); - setLoading(false); - onError?.(operationError); - throw operationError; - } - }, - [operation, onSuccess, onError, retryCount, retryDelay] - ); - - const retry = useCallback(async () => { - if (lastRequestRef.current) { - await execute(lastRequestRef.current); - } - }, [execute]); - - const reset = useCallback(() => { - setLoading(false); - setError(null); - setData(null); - lastRequestRef.current = null; - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, []); - - return { - execute, - loading, - error, - data, - reset, - retry, - }; -} diff --git a/apps/web/src/components/MoveDeploy/hooks/api/useBuildPackage.ts b/apps/web/src/components/MoveDeploy/hooks/api/useBuildPackage.ts deleted file mode 100644 index 26cbd13..0000000 --- a/apps/web/src/components/MoveDeploy/hooks/api/useBuildPackage.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Hook for building Move packages - */ - -import { useCallback } from 'react'; -import { useMoveDevStore, selectBuildState } from '../state/useMoveDevState'; -import type { BuildRequest } from '../../types'; - -export function useBuildPackage() { - const buildState = useMoveDevStore(selectBuildState); - const executeBuild = useMoveDevStore((state) => state.executeBuild); - const currentPackage = useMoveDevStore((state) => state.currentPackage); - - const build = useCallback( - async (options?: Partial) => { - if (!currentPackage?.path) { - throw new Error('No package selected'); - } - - const request: BuildRequest = { - packagePath: currentPackage.path, - network: options?.network, - skipFetchLatest: options?.skipFetchLatest ?? false, - withUnpublished: options?.withUnpublished ?? false, - }; - - return executeBuild(request); - }, - [currentPackage, executeBuild] - ); - - return { - build, - loading: buildState.status === 'loading', - success: buildState.status === 'success', - error: buildState.error, - data: buildState.data, - canBuild: !!currentPackage?.path && buildState.status !== 'loading', - }; -} diff --git a/apps/web/src/components/MoveDeploy/index.new.tsx b/apps/web/src/components/MoveDeploy/index.new.tsx deleted file mode 100644 index 3072333..0000000 --- a/apps/web/src/components/MoveDeploy/index.new.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Move Development Studio - Main Orchestrator - * Redesigned with component composition and proper state management - */ - -import { Rocket, Settings } from 'lucide-react'; -import { PackageSelector } from './components/PackageManagement/PackageSelector'; -import { BuildCard } from './components/Operations/BuildCard'; -import { useMoveDevStore, selectCurrentPackage, selectUI } from './hooks/state/useMoveDevState'; - -export default function MoveDevelopmentStudio() { - const currentPackage = useMoveDevStore(selectCurrentPackage); - const ui = useMoveDevStore(selectUI); - const setNetwork = useMoveDevStore((state) => state.setNetwork); - - return ( -
-
- {/* Header */} -
-
-

- - Move Development Studio -

-

- Build, test, and deploy Move packages with ease -

-
- - {/* Network Selector */} -
- Network: - -
-
- - {/* Package Selection */} -
-

Select Package

- -
- - {/* Operations Section */} - {currentPackage && ( -
- {/* Build Card */} - - - {/* Test Card */} - {/* */} - - {/* Publish Card */} - {/* */} - - {/* Upgrade Card */} - {/* */} - - {/* One-Click Workflow */} - {/* */} -
- )} - - {/* Empty State */} - {!currentPackage && ( -
-
- -
-

- No Package Selected -

-

- Select a Move package directory to get started with building, testing, - and deploying your smart contracts. -

-
- )} - - {/* Footer */} -
-

- Move Development Studio v2.0 -

- -
-
-
- ); -} diff --git a/apps/web/src/components/MoveDeploy/index.tsx b/apps/web/src/components/MoveDeploy/index.tsx index 9bd685c..2892cb6 100644 --- a/apps/web/src/components/MoveDeploy/index.tsx +++ b/apps/web/src/components/MoveDeploy/index.tsx @@ -36,11 +36,14 @@ import { upgradePackage, } from '@/api/client'; import { explorePackage, type MoveFunction } from '@/api/services/packages'; -import { - AnimatedToastStack, - useAnimatedToastStack, -} from '@/components/ui/animated-toast-stack'; -import { CommandPalette, type CommandItem } from '@/components/ui/command-palette'; +import { ParameterInputField } from '@/components/ParameterInputField'; +import { AnimatedToastStack, useAnimatedToastStack } from '@/components/ui/animated-toast-stack'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { type CommandItem, CommandPalette } from '@/components/ui/command-palette'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { Label } from '@/components/ui/label'; import { Loader } from '@/components/ui/loader'; import { Checkbox } from '@/components/ui/motion-checkbox'; import { @@ -50,23 +53,18 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/motion-select'; -import { ParameterInputField } from '@/components/ParameterInputField'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; -import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { WorkflowPipelineIndicator } from '@/components/ui/workflow-pipeline'; -import { useAppStore } from '@/stores/useAppStore'; +import { useCopyToClipboard } from '@/hooks'; import { buildAiContext } from '@/lib/ai-context'; import { cn } from '@/lib/utils'; +import { useAppStore } from '@/stores/useAppStore'; import { FileBrowser } from './FileBrowser'; +import { MoveStudioFlow, type StudioStage } from './MoveStudioFlow'; import { TerminalBuildOutput } from './TerminalBuildOutput'; import { splitCliOutput, TerminalErrorDisplay } from './TerminalErrorDisplay'; -import { MoveStudioFlow, type StudioStage } from './MoveStudioFlow'; import { TerminalSuccessDisplay } from './TerminalSuccessDisplay'; import { TerminalTestOutput } from './TerminalTestOutput'; @@ -568,8 +566,7 @@ export function MoveDeploy() { // functions only, so the count badge reflects what you can actually click. if (m.name.toLowerCase().includes(q)) return m; const functions = m.functions.filter( - (f) => - f.name.toLowerCase().includes(q) || (f.signature ?? '').toLowerCase().includes(q) + (f) => f.name.toLowerCase().includes(q) || (f.signature ?? '').toLowerCase().includes(q) ); return functions.length ? { ...m, functions } : null; }) @@ -702,10 +699,7 @@ export function MoveDeploy() { const isAnyLoading = building || testing || publishing || upgrading || isOneClickRunning; // Copy-for-AI: assemble the current deployment state into shareable context - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); const packageName = packagePath.trim() ? packagePath.trim().split('/').pop() || packagePath.trim() @@ -919,14 +913,9 @@ export function MoveDeploy() { })), ]; - return ( <> - + - {/* Right Column: Main Content */} @@ -1124,151 +1112,154 @@ export function MoveDeploy() { block drops away there. */} {activeTab !== 'interact' && (
- {/* Package Configuration - Compact */} - -
- - - - Package Configuration - - - - {/* Package Path - Compact */} -
- -
- setPackagePath(e.target.value)} - placeholder="/path/to/your/move/package" - className="flex-1 px-2.5 py-1.5 bg-card border border-border rounded text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring focus:border-primary transition-all text-xs font-mono" - disabled={isAnyLoading} - /> - -
-

- - Absolute path to your Move package directory (containing Move.toml) -

-
- - - - {/* Gas & Options. Single column until `sm`: side by side in a - narrow card the two checkbox labels collide. */} -
+ {/* Package Configuration - Compact */} + +
+ + + + Package Configuration + + + + {/* Package Path - Compact */}
- - - {gasPreset === 'custom' && ( + Package Path * + +
setGasBudget(e.target.value)} - placeholder="100000000" - className="w-full px-2.5 py-1.5 bg-card border border-border rounded text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring transition-all text-xs font-mono" + value={packagePath} + onChange={(e) => setPackagePath(e.target.value)} + placeholder="/path/to/your/move/package" + className="flex-1 px-2.5 py-1.5 bg-card border border-border rounded text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring focus:border-primary transition-all text-xs font-mono" disabled={isAnyLoading} /> - )} -

- {gasBudget} MIST (0.1 SUI = 100000000 MIST) + +

+

+ + Absolute path to your Move package directory (containing Move.toml)

-
- -
- - + + {/* Gas & Options. Single column until `sm`: side by side in a + narrow card the two checkbox labels collide. */} +
+
+ + + {gasPreset === 'custom' && ( + setGasBudget(e.target.value)} + placeholder="100000000" + className="w-full px-2.5 py-1.5 bg-card border border-border rounded text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring transition-all text-xs font-mono" + disabled={isAnyLoading} + /> + )} +

+ {gasBudget} MIST (0.1 SUI = 100000000 MIST) +

+
+ +
+ +
+ + +
-
- - + + - {/* One-Click Workflow - Compact */} - - - - - One-Click Workflow - - Automatically build, test, and publish - - - - - -

- - Recommended for production deployment -

-
-
+ {/* One-Click Workflow - Compact */} + + + + + One-Click Workflow + + Automatically build, test, and publish + + + + + +

+ + Recommended for production deployment +

+
+
)} @@ -1411,7 +1402,12 @@ export function MoveDeploy() { className="space-y-1.5 p-2 bg-muted/50 border border-border rounded" >
- + Compiling...
@@ -1449,7 +1445,12 @@ export function MoveDeploy() { className="space-y-1.5 p-2 bg-muted/50 border border-border rounded" >
- + Running tests... @@ -1520,7 +1521,12 @@ export function MoveDeploy() { className="p-2 bg-muted/50 border border-border rounded space-y-1" >
- + Publishing...
@@ -1529,7 +1535,12 @@ export function MoveDeploy() { Compiling...
- + Generating tx...
@@ -1674,7 +1685,12 @@ export function MoveDeploy() { className="p-2 bg-muted/50 border border-border rounded space-y-1" >
- + Upgrading...
diff --git a/apps/web/src/components/MoveMigrate/index.tsx b/apps/web/src/components/MoveMigrate/index.tsx index ac617ae..482034c 100644 --- a/apps/web/src/components/MoveMigrate/index.tsx +++ b/apps/web/src/components/MoveMigrate/index.tsx @@ -16,10 +16,10 @@ import { RefreshCw, } from 'lucide-react'; import React, { useState } from 'react'; -import toast from 'react-hot-toast'; import { apiClient } from '@/api/client'; import { Button } from '@/components/ui/button'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { useCopyToClipboard } from '@/hooks'; interface MigrationChange { file: string; @@ -108,10 +108,7 @@ export function MoveMigrate() { } }; - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); // Copy-for-AI: assemble the current migration state into shareable context const packageName = packagePath.trim() diff --git a/apps/web/src/components/MultiPay/index.tsx b/apps/web/src/components/MultiPay/index.tsx index 46158a1..ec7ef43 100644 --- a/apps/web/src/components/MultiPay/index.tsx +++ b/apps/web/src/components/MultiPay/index.tsx @@ -20,6 +20,9 @@ import toast from 'react-hot-toast'; import { apiClient } from '@/api/client'; import { Button } from '@/components/ui/button'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { useCopyToClipboard } from '@/hooks'; +import { buildExplorerUrl, detectNetwork, getDefaultExplorer } from '@/lib/explorer'; +import { useAppStore } from '@/stores/useAppStore'; interface Recipient { id: string; @@ -34,6 +37,9 @@ interface PayResult { } export function MultiPay() { + const { environments } = useAppStore(); + const activeEnv = environments.find((e) => e.isActive); + const currentNetwork = detectNetwork(activeEnv?.alias, activeEnv?.rpc); const [recipients, setRecipients] = useState([{ id: '1', address: '', amount: '' }]); const [isLoading, setIsLoading] = useState(false); const [result, setResult] = useState(null); @@ -100,10 +106,7 @@ export function MultiPay() { } }; - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); const filledRecipients = recipients.filter((r) => r.address || r.amount); const totalAmount = getTotalAmount(); @@ -282,7 +285,7 @@ export function MultiPay() { { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); const getTypeDisplay = (type: string) => { if (!type) return 'Unknown'; @@ -1379,312 +1377,309 @@ export function ObjectList() { <> - {/* Concrete-type filter. The category tabs are heuristic buckets + {/* Concrete-type filter. The category tabs are heuristic buckets ("anything with 'cap' in the type"); this narrows to one exact Move type, which is what you want once a bucket has hundreds. */} - {typeOptions.length > 1 && ( -
- - {typeOptions.slice(0, TYPE_CHIP_CAP).map((opt) => { - const on = typeFilter.has(opt.type); - return ( - - ); - })} - {typeOptions.length > TYPE_CHIP_CAP && ( - - +{typeOptions.length - TYPE_CHIP_CAP} more type - {typeOptions.length - TYPE_CHIP_CAP !== 1 ? 's' : ''} - - )} + {typeOptions.length > 1 && ( +
+ + {typeOptions.slice(0, TYPE_CHIP_CAP).map((opt) => { + const on = typeFilter.has(opt.type); + return ( + + ); + })} + {typeOptions.length > TYPE_CHIP_CAP && ( + + +{typeOptions.length - TYPE_CHIP_CAP} more type + {typeOptions.length - TYPE_CHIP_CAP !== 1 ? 's' : ''} + + )} +
+ )} + {/* Direct Object Lookup Result - when user searches for a full Object ID */} + {isFullObjectId(searchQuery) && ( +
+
+ + Direct Object Lookup
- )} - {/* Direct Object Lookup Result - when user searches for a full Object ID */} - {isFullObjectId(searchQuery) && ( -
-
- - Direct Object Lookup + {isLookingUp ? ( +
+ + Looking up object...
- {isLookingUp ? ( -
- - Looking up object... + ) : directLookupObject ? ( +
setSelectedObject(directLookupObject)} + > +
+ {getTypeIcon( + (directLookupObject as any).data?.type || + (directLookupObject as any).type || + '' + )}
- ) : directLookupObject ? ( -
setSelectedObject(directLookupObject)} - > -
- {getTypeIcon( - (directLookupObject as any).data?.type || - (directLookupObject as any).type || - '' - )} -
-
-
- Found Object - - {getTypeDisplay( - (directLookupObject as any).data?.type || - (directLookupObject as any).type || - '' - )} - -
-
- {searchQuery} -
-
+
- - v - {(directLookupObject as any).data?.version || - (directLookupObject as any).version || - '?'} + Found Object + + {getTypeDisplay( + (directLookupObject as any).data?.type || + (directLookupObject as any).type || + '' + )} - +
+
+ {searchQuery}
- ) : ( -
- - Object not found or deleted +
+ + v + {(directLookupObject as any).data?.version || + (directLookupObject as any).version || + '?'} + +
- )} -
- )} - - {visibleObjects.length === 0 && !isFullObjectId(searchQuery) ? ( -
-
{objects.length === 0 ? '📭' : '🔍'}
-
- {objects.length === 0 - ? 'This address has no objects yet' - : 'No objects match your filter'}
+ ) : ( +
+ + Object not found or deleted +
+ )} +
+ )} + + {visibleObjects.length === 0 && !isFullObjectId(searchQuery) ? ( +
+
{objects.length === 0 ? '📭' : '🔍'}
+
+ {objects.length === 0 + ? 'This address has no objects yet' + : 'No objects match your filter'} +
- {objects.length === 0 && ( -
-

- {isExternalAddress - ? 'This might be a new multi-sig address. To start using it:' - : 'To add objects to this address:'} -

-
- ) : visibleObjects.length === 0 && - isFullObjectId(searchQuery) ? /* Only direct lookup result shown */ - null : ( - /* Virtualized table - smooth at 10k+ rows, sortable/resizable/reorderable + {activeCategory !== 'all' && objects.length > 0 && ( + + )} +
+ ) : visibleObjects.length === 0 && + isFullObjectId(searchQuery) ? /* Only direct lookup result shown */ + null : ( + /* Virtualized table - smooth at 10k+ rows, sortable/resizable/reorderable headers, sticky header, row selection. */ -
- {selectedIds.size > 0 && ( -
-
- {selectedIds.size} selected -
- {commonCoinType && ( - - )} +
+ {selectedIds.size > 0 && ( +
+
+ {selectedIds.size} selected +
+ {commonCoinType && ( - - -
+ )} + + +
- {bulkTransferOpen && ( -
- setBulkTransferAddress(e.target.value)} - placeholder="0x... recipient address" - className="flex-1 text-xs font-mono px-2 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-tertiary" - disabled={isBulkTransferring} - /> - - -
- )}
- )} - {isMobile ? ( - // Narrow screens: the multi-column resizable table can't fit, so fall - // back to a compact single-line-per-object list (icon + type + id). -
- {plainObjectRows} -
- ) : ( - + setBulkTransferAddress(e.target.value)} + placeholder="0x... recipient address" + className="flex-1 text-xs font-mono px-2 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-tertiary" + disabled={isBulkTransferring} + /> + + +
+ )} +
+ )} + {isMobile ? ( + // Narrow screens: the multi-column resizable table can't fit, so fall + // back to a compact single-line-per-object list (icon + type + id). +
+ {plainObjectRows} +
+ ) : ( + { + setSelectedObject(obj); + const clickedId = + (obj.objectId as string) || (obj.data as { objectId?: string })?.objectId; + if (clickedId) { + navigate(`/app/objects/${clickedId}`); } - data={visibleObjects} - getRowId={getObjectRowId} - selectedIds={selectedIds} - onSelectionChange={setSelectedIds} - onRowClick={(obj) => { - setSelectedObject(obj); - const clickedId = - (obj.objectId as string) || (obj.data as { objectId?: string })?.objectId; - if (clickedId) { - navigate(`/app/objects/${clickedId}`); + }} + rowHeight={56} + // Click a row (or its chevron) to expand a richer attributes panel - + // owner, digest, previous tx, storage rebate, transferable, Display - + // fetched lazily per row. "View full details" inside opens the object page. + renderExpanded={(obj) => ( + ( - - )} - expandedRowHeight={300} - // Fixed 560px used to leave a lot of dead space below the scrollable box on - // tall viewports - the visible table card looked taller than the actual - // wheel-scrollable region, so scrolling only worked in a narrow strip instead - // of anywhere over the table (bad UX - had to hunt for the real scrollbar). - // Scales with the viewport instead, bounded so it never gets unreasonably short. - className="h-[min(70vh,720px)] min-h-[320px]" - /> - )} -
- )} + baseType={(obj.type as string) || (obj.data as { type?: string })?.type} + baseVersion={ + (obj.version as string) || (obj.data as { version?: string })?.version + } + baseOwner={ + (obj.owner as unknown) ?? (obj.data as { owner?: unknown })?.owner + } + /> + )} + expandedRowHeight={300} + // Fixed 560px used to leave a lot of dead space below the scrollable box on + // tall viewports - the visible table card looked taller than the actual + // wheel-scrollable region, so scrolling only worked in a narrow strip instead + // of anywhere over the table (bad UX - had to hunt for the real scrollbar). + // Scales with the viewport instead, bounded so it never gets unreasonably short. + className="h-[min(70vh,720px)] min-h-[320px]" + /> + )} +
+ )} diff --git a/apps/web/src/components/PairingControl.tsx b/apps/web/src/components/PairingControl.tsx new file mode 100644 index 0000000..e2810d6 --- /dev/null +++ b/apps/web/src/components/PairingControl.tsx @@ -0,0 +1,113 @@ +import { Check, KeyRound } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { clearPairingToken, isPaired, setPairingToken } from '@/lib/authToken'; + +/** + * Pairs this browser with the local server for the routes that move funds or + * export key material (see apps/server/src/utils/authToken.ts). The token is + * never fetched over HTTP - it only ever appears in the server's own + * terminal output, so pairing means copying it from there. + */ +export function PairingControl() { + const [isOpen, setIsOpen] = useState(false); + const [paired, setPaired] = useState(() => isPaired()); + const [input, setInput] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) inputRef.current?.focus(); + }, [isOpen]); + + const handleSave = () => { + if (!input.trim()) { + toast.error('Paste the token from your terminal first'); + return; + } + setPairingToken(input.trim()); + setInput(''); + setIsOpen(false); + toast.success('Browser paired'); + }; + + const handleClear = () => { + clearPairingToken(); + setPaired(false); + toast.success('Pairing cleared'); + }; + + return ( + <> + + + {isOpen && ( +
+ + +
+ {paired && ( + + )} +
+
+ )} + + ); +} diff --git a/apps/web/src/components/ParameterInputField/ObjectMetadataPopover.tsx b/apps/web/src/components/ParameterInputField/ObjectMetadataPopover.tsx index 176662d..e369aab 100644 --- a/apps/web/src/components/ParameterInputField/ObjectMetadataPopover.tsx +++ b/apps/web/src/components/ParameterInputField/ObjectMetadataPopover.tsx @@ -1,11 +1,16 @@ +import { Check, Copy, ExternalLink } from 'lucide-react'; import React, { useState } from 'react'; -import { Copy, Check, ExternalLink } from 'lucide-react'; +import { buildExplorerUrl, detectNetwork, getDefaultExplorer } from '@/lib/explorer'; +import { useAppStore } from '@/stores/useAppStore'; import type { ObjectMetadataPopoverProps } from './types'; export const ObjectMetadataPopover: React.FC = ({ suggestion, children, }) => { + const { environments } = useAppStore(); + const activeEnv = environments.find((e) => e.isActive); + const currentNetwork = detectNetwork(activeEnv?.alias, activeEnv?.rpc); const [isHovered, setIsHovered] = useState(false); const [copied, setCopied] = useState(false); @@ -52,7 +57,12 @@ export const ObjectMetadataPopover: React.FC = ({ {metadata.objectId && ( e.stopPropagation()} @@ -80,9 +90,7 @@ export const ObjectMetadataPopover: React.FC = ({
Type

- {metadata.type.length > 50 - ? `${metadata.type.slice(0, 50)}...` - : metadata.type} + {metadata.type.length > 50 ? `${metadata.type.slice(0, 50)}...` : metadata.type}

)} @@ -118,14 +126,16 @@ export const ObjectMetadataPopover: React.FC = ({
Fields
- {Object.entries(metadata.fields).slice(0, 5).map(([key, value]) => ( -
- {key}: - - {typeof value === 'object' ? JSON.stringify(value) : String(value)} - -
- ))} + {Object.entries(metadata.fields) + .slice(0, 5) + .map(([key, value]) => ( +
+ {key}: + + {typeof value === 'object' ? JSON.stringify(value) : String(value)} + +
+ ))} {Object.keys(metadata.fields).length > 5 && ( ...and more )} diff --git a/apps/web/src/components/SecurityTools/index.tsx b/apps/web/src/components/SecurityTools/index.tsx index 950c5ba..2a3d82c 100644 --- a/apps/web/src/components/SecurityTools/index.tsx +++ b/apps/web/src/components/SecurityTools/index.tsx @@ -28,6 +28,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useCopyToClipboard } from '@/hooks'; // Simplified warning for user-friendly display interface SimplifiedWarning { @@ -322,11 +323,7 @@ export function SecurityTools() { } }; - // Copy to clipboard - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard`); - }; + const copyToClipboard = useCopyToClipboard(); // Non-sensitive snapshot for AI export: tool config + verification/decode // results only. No transaction bytes, signatures, or other raw inputs. diff --git a/apps/web/src/components/TransactionBuilder/index.tsx b/apps/web/src/components/TransactionBuilder/index.tsx index 749eabc..9988588 100644 --- a/apps/web/src/components/TransactionBuilder/index.tsx +++ b/apps/web/src/components/TransactionBuilder/index.tsx @@ -38,6 +38,7 @@ import { Button } from '@/components/ui/button'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useCopyToClipboard } from '@/hooks'; import { analyzeTransaction } from '@/utils/transactionAnalyzer'; interface InspectResult { @@ -129,11 +130,7 @@ export function TransactionBuilder() { const [executeResult, setExecuteResult] = useState(null); const [ptbResult, setPtbResult] = useState(null); - // Copy to clipboard helper - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard!`); - }; + const copyToClipboard = useCopyToClipboard(); // Inspect transaction const handleInspect = async () => { diff --git a/apps/web/src/components/TransferSui/index.tsx b/apps/web/src/components/TransferSui/index.tsx index ffad80c..02b4067 100644 --- a/apps/web/src/components/TransferSui/index.tsx +++ b/apps/web/src/components/TransferSui/index.tsx @@ -19,15 +19,17 @@ import { Zap, } from 'lucide-react'; import { useEffect, useState } from 'react'; -import toast from 'react-hot-toast'; import { useSearchParams } from 'react-router-dom'; import { getApiBaseUrl } from '@/api/client'; import { Button } from '@/components/ui/button'; import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useCopyToClipboard } from '@/hooks'; +import { buildAiContext } from '@/lib/ai-context'; +import { pairingHeader } from '@/lib/authToken'; import { ClarityEvents, trackEvent } from '@/lib/clarity'; +import { buildExplorerUrl, detectNetwork, getDefaultExplorer } from '@/lib/explorer'; import { showErrorToast, showInfoToast, showSuccessToast } from '@/lib/toast'; -import { buildAiContext } from '@/lib/ai-context'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/stores/useAppStore'; @@ -53,7 +55,9 @@ type TransferMode = 'external' | 'internal' | 'batch'; export function TransferSui() { const [searchParams, setSearchParams] = useSearchParams(); - const { addresses, fetchAddresses } = useAppStore(); + const { addresses, fetchAddresses, environments } = useAppStore(); + const activeEnv = environments.find((e) => e.isActive); + const currentNetwork = detectNetwork(activeEnv?.alias, activeEnv?.rpc); const activeAddress = addresses.find((a) => a.isActive); const internalAddresses = addresses.filter((a) => !a.isActive); @@ -256,7 +260,7 @@ export function TransferSui() { try { const response = await fetch(`${getApiBaseUrl()}/transfers/sui`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...pairingHeader() }, body: JSON.stringify({ to: finalToAddress, amount, coinId: selectedCoin }), }); const data = await response.json(); @@ -309,10 +313,7 @@ export function TransferSui() { const isAnyLoading = isLoadingCoins || isEstimating || isTransferring; - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied`); - }; + const copyToClipboard = useCopyToClipboard(); const destination = transferMode === 'internal' ? selectedInternalAddress : toAddress; const totalAmount = getTotalAmount(); @@ -369,10 +370,7 @@ export function TransferSui() { const aiPrompt = buildAiContext({ title: 'Sui transfer', - intro: [ - 'A transfer being composed in sui-cli-web. Nothing has been signed or', - 'submitted.', - ], + intro: ['A transfer being composed in sui-cli-web. Nothing has been signed or', 'submitted.'], stateJson: aiJson, endpoints: [ { @@ -471,9 +469,7 @@ export function TransferSui() {
{/* Address Book */} @@ -615,7 +611,8 @@ export function TransferSui() {
- {activeAddress?.balance || '0'} SUI + {activeAddress?.balance || '0'}{' '} + SUI
@@ -737,9 +734,7 @@ export function TransferSui() {
Balance{' '} - - {spendableSui.toFixed(4)} - {' '} + {spendableSui.toFixed(4)}{' '} SUI
)} @@ -982,7 +975,12 @@ export function TransferSui() {
{/* Explorer Link */}
= { '/app': 'Dashboard', @@ -47,15 +45,9 @@ function ThemeToggle() { ); } @@ -77,8 +69,8 @@ export function MainLayout() { } = useAppStore(); const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false); - const [sidebarCollapsed, setSidebarCollapsed] = useState(() => - typeof window !== 'undefined' && localStorage.getItem('sui-sidebar-collapsed') === '1' + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => typeof window !== 'undefined' && localStorage.getItem('sui-sidebar-collapsed') === '1' ); const toggleSidebar = () => setSidebarCollapsed((c) => { @@ -97,11 +89,7 @@ export function MainLayout() { const connected = await checkServerConnection(); if (connected) { // Fetch all data in parallel for faster loading - await Promise.all([ - fetchStatus(), - fetchAddresses(), - fetchEnvironments(), - ]); + await Promise.all([fetchStatus(), fetchAddresses(), fetchEnvironments()]); } }; init(); @@ -142,10 +130,7 @@ export function MainLayout() { }; return ( -
+
{/* Animated dot-grid pixel background behind all in-app pages */} {/* Breadcrumb header */}
- {!isHome && ( - - )} - {title} -
- {isLoading && } - -
- - {/* Error message */} - {error && ( -
-

{error}

-
+ + + )} + {title} +
+ {isLoading && } + + +
- {/* Sui not installed warning */} - {suiInstalled === false && ( -
-

- Sui CLI is not installed. Please install it to use this app. -

-
- )} + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Sui not installed warning */} + {suiInstalled === false && ( +
+

+ Sui CLI is not installed. Please install it to use this app. +

+
+ )} - {/* Content */} - + +
- -
- {isServerConnected === null ? ( -
-
- -
- -
- - -
+ {isServerConnected === null ? ( +
+
+ +
+ +
+ +
- - - -
-
- - Connecting to local server...
+ + +
- ) : ( - - - - - - )} -
- +
+ + Connecting to local server... +
+
+ ) : ( + + + + + + )} +
+
- setIsQuickSwitcherOpen(false)} - /> + setIsQuickSwitcherOpen(false)} /> {/* Floating toolbar over selected text, full demo preset (Bold/Italic/Underline/ Strikethrough/Link/Copy). None of the app's content is contentEditable, so the diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 907753c..38fd691 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -1,91 +1,16 @@ -import { useState, useCallback } from 'react'; - -interface UseCopyToClipboardReturn { - copied: boolean; - copiedText: string | null; - copy: (text: string) => Promise; - reset: () => void; -} +import { useCallback } from 'react'; +import toast from 'react-hot-toast'; /** - * Hook for copying text to clipboard with feedback state - * @param resetDelay - Time in ms before copied state resets (default: 2000) - * @returns Object with copy function and copied state + * Returns a stable `copy(text, label)` function that writes to the + * clipboard and shows a "{label} copied" toast - the pattern every screen + * in this app re-implements inline. */ -export function useCopyToClipboard(resetDelay = 2000): UseCopyToClipboardReturn { - const [copied, setCopied] = useState(false); - const [copiedText, setCopiedText] = useState(null); - - const copy = useCallback(async (text: string): Promise => { - if (!navigator?.clipboard) { - console.warn('Clipboard API not available'); - return false; - } - - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setCopiedText(text); - - // Reset after delay - setTimeout(() => { - setCopied(false); - setCopiedText(null); - }, resetDelay); - - return true; - } catch (error) { - console.error('Failed to copy to clipboard:', error); - setCopied(false); - setCopiedText(null); - return false; - } - }, [resetDelay]); - - const reset = useCallback(() => { - setCopied(false); - setCopiedText(null); +export function useCopyToClipboard() { + return useCallback((text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); }, []); - - return { copied, copiedText, copy, reset }; -} - -/** - * Hook for copying with ID tracking (for multiple copy buttons) - * @param resetDelay - Time in ms before copied state resets (default: 2000) - * @returns Object with copy function and copiedId state - */ -export function useCopyWithId(resetDelay = 2000) { - const [copiedId, setCopiedId] = useState(null); - - const copy = useCallback(async (text: string, id: string): Promise => { - if (!navigator?.clipboard) { - console.warn('Clipboard API not available'); - return false; - } - - try { - await navigator.clipboard.writeText(text); - setCopiedId(id); - - // Reset after delay - setTimeout(() => { - setCopiedId(null); - }, resetDelay); - - return true; - } catch (error) { - console.error('Failed to copy to clipboard:', error); - setCopiedId(null); - return false; - } - }, [resetDelay]); - - const reset = useCallback(() => { - setCopiedId(null); - }, []); - - return { copiedId, copy, reset, isCopied: (id: string) => copiedId === id }; } export default useCopyToClipboard; diff --git a/apps/web/src/lib/authToken.ts b/apps/web/src/lib/authToken.ts new file mode 100644 index 0000000..82bf27c --- /dev/null +++ b/apps/web/src/lib/authToken.ts @@ -0,0 +1,32 @@ +// Pairing token for the routes that move funds or export key material (see +// apps/server/src/utils/authToken.ts). Stored per-browser in localStorage - +// there is no server endpoint that returns this value, so the only way to +// set it is to copy it from the server's own terminal output. + +const STORAGE_KEY = 'sui-cli-web-pairing-token'; + +export function getPairingToken(): string | null { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +} + +export function setPairingToken(token: string): void { + localStorage.setItem(STORAGE_KEY, token.trim()); +} + +export function clearPairingToken(): void { + localStorage.removeItem(STORAGE_KEY); +} + +export function isPaired(): boolean { + return !!getPairingToken(); +} + +/** Header object to spread into a fetch call - empty when unpaired. */ +export function pairingHeader(): Record { + const token = getPairingToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +}