let the wallet be renamed from settings

the PATCH route already accepted a name; nothing in the UI ever sent one. adds an
inline editor on the settings header (pencil → input, enter saves, escape cancels)
and a rename mutation. renaming touches only the label, so it needs neither the
passphrase nor an unlocked wallet.

the route took the name unvalidated — it now trims and refuses a blank one, with a
64-char cap matched on create so a name you can create is one you can type back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 22:44:16 +00:00
co-authored by Claude Opus 5
parent d7b775113b
commit f45bc9292b
3 changed files with 124 additions and 6 deletions
+10
View File
@@ -160,6 +160,14 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
}
if (req.method === 'PATCH') {
const patch = await body<{ name?: string; defaultBip?: number; config?: Record<string, unknown> }>(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<Response> {
}
const b = await body<CreateBody>(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(', ')}`);
}
@@ -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 = () => {
<section className="max-w-2xl rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold">{wallet.name}</h3>
<WalletNameField
wallet={wallet}
onSave={(name) => rename.mutateAsync({ walletId, name })}
isPending={rename.isPending}
/>
<p className="mt-0.5 text-xs text-muted-foreground">
{KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)}
</p>
@@ -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<unknown>;
isPending: boolean;
};
const WalletNameField = ({ wallet, onSave, isPending }: WalletNameFieldProps) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(wallet.name);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="truncate text-sm font-semibold">{wallet.name}</h3>
<button
type="button"
onClick={() => setEditing(true)}
aria-label="Rename wallet"
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</button>
</div>
);
}
return (
<div className="flex min-w-0 items-center gap-1.5">
<Input
ref={inputRef}
autoFocus
value={draft}
maxLength={MAX_NAME}
disabled={isPending}
onChange={(ev) => 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}
/>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={commit} disabled={isPending || !trimmed}>
<Check className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={cancel} disabled={isPending}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
};
const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`);
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
@@ -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 ────────────────────────────────────────────────────────────────────────────────