diff --git a/src/servers/sidecar/wallet/routes.ts b/src/servers/sidecar/wallet/routes.ts index dc947dfd..259f13de 100644 --- a/src/servers/sidecar/wallet/routes.ts +++ b/src/servers/sidecar/wallet/routes.ts @@ -160,6 +160,14 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise }>(req); + // A rename goes through the same rule as a create: trimmed, and never blank. Without this a stray + // empty string would leave a wallet with no name anywhere in the UI and no way to type one back. + if (patch.name !== undefined) { + const name = patch.name.trim(); + if (!name) return badRequest('name cannot be empty'); + if (name.length > 64) return badRequest('name is too long (64 characters max)'); + patch.name = name; + } const updated = await updateWallet(userId, walletId, patch); invalidate(walletId); return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404); @@ -377,6 +385,8 @@ async function createWalletRoute(ctx: OfficerContext): Promise { } const b = await body(ctx.req); if (!b.name?.trim()) return badRequest('name is required'); + // Same cap as the rename route, so a name you can create is always a name you can type back. + if (b.name.trim().length > 64) return badRequest('name is too long (64 characters max)'); if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) { return badRequest(`kind must be one of ${KINDS.join(', ')}`); } diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx index 095d288b..629c5a6d 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx @@ -1,7 +1,9 @@ -import { useState } from 'react'; +import type { WalletSummary } from './shared'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; -import { Eye, KeyRound, Star, Trash2 } from 'lucide-react'; +import { Check, Eye, KeyRound, Pencil, Star, Trash2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { KIND_LABELS, walletSectionPath } from './shared'; import { truncateMiddle } from './format'; import { CopyField } from './CopyField'; @@ -24,7 +26,7 @@ export const WalletSettingsView = () => { const { config } = useWalletConfig(); const { capabilities, kind } = useCapabilities(walletId); const { hasSeed } = useLockCountdown(walletId); - const { activate } = useWalletLifecycle(); + const { activate, rename } = useWalletLifecycle(); const [passphraseOpen, setPassphraseOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); @@ -39,7 +41,11 @@ export const WalletSettingsView = () => {
-

{wallet.name}

+ rename.mutateAsync({ walletId, name })} + isPending={rename.isPending} + />

{KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)}

@@ -171,6 +177,96 @@ export const WalletSettingsView = () => { ); }; +/** The wallet's name is the one field here that is purely a label — editing it in place, not in a dialog. */ +const MAX_NAME = 64; + +type WalletNameFieldProps = { + wallet: WalletSummary; + onSave: (name: string) => Promise; + isPending: boolean; +}; + +const WalletNameField = ({ wallet, onSave, isPending }: WalletNameFieldProps) => { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(wallet.name); + const inputRef = useRef(null); + + // Switching wallets while the editor is open would otherwise rename the NEW wallet to the old one's draft. + useEffect(() => { + setEditing(false); + setDraft(wallet.name); + }, [wallet.id, wallet.name]); + + useEffect(() => { + if (editing) inputRef.current?.select(); + }, [editing]); + + const trimmed = draft.trim(); + const dirty = trimmed !== wallet.name; + + const commit = async () => { + if (!trimmed || !dirty) { + setDraft(wallet.name); + setEditing(false); + return; + } + try { + await onSave(trimmed); + setEditing(false); + } catch { + // The mutation already toasted; keep the editor open so the typed name isn't lost. + } + }; + + const cancel = () => { + setDraft(wallet.name); + setEditing(false); + }; + + if (!editing) { + return ( +
+

{wallet.name}

+ +
+ ); + } + + return ( +
+ setDraft(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') void commit(); + if (ev.key === 'Escape') cancel(); + }} + className="h-7 max-w-56 text-sm font-semibold" + aria-label="Wallet name" + autoComplete="off" + spellCheck={false} + /> + + +
+ ); +}; + const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`); const Row = ({ label, children }: { label: string; children: React.ReactNode }) => ( diff --git a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts index 42cf3b26..ea401fd0 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts +++ b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts @@ -315,7 +315,7 @@ export type CreateWalletInput = { export type CreateWalletResult = { wallet: WalletSummary; mnemonic?: string }; export function useWalletLifecycle() { - const { post, delete: del } = useClient(); + const { post, patch, delete: del } = useClient(); const qc = useQueryClient(); const invalidateList = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'wallets'] }); @@ -336,6 +336,18 @@ export function useWalletLifecycle() { onError: (err) => toast.error(errorMessage(err, 'Could not activate the wallet')), }); + const rename = useMutation({ + // Only the label. The name is the one wallet field with no bearing on the key material, which is why + // this needs neither the passphrase nor an unlocked wallet. + mutationFn: ({ walletId, name }: { walletId: number; name: string }) => + patch<{ wallet: WalletSummary }>(`/wallet/_officer/wallets/${walletId}`, { name }), + onSuccess: () => { + invalidateList(); + toast.success('Wallet renamed'); + }, + onError: (err) => toast.error(errorMessage(err, 'Could not rename the wallet')), + }); + const remove = useMutation({ // The passphrase is required for a seeded wallet even when it is already unlocked — an open session // must not be enough to destroy the only copy of the key material. @@ -348,7 +360,7 @@ export function useWalletLifecycle() { onError: (err) => toast.error(errorMessage(err, 'Could not delete the wallet')), }); - return { create, activate, remove }; + return { create, activate, rename, remove }; } // ── lock lifecycle ────────────────────────────────────────────────────────────────────────────────