feat(ui): name the signer connection before the QR; prune stale vault rows

The pairing path minted its profile under the backend's 'Remote Signer'
default, so several test pairings left indistinguishable rows. The
'Sign in with a signer app (Amber)' button now shows a prefilled
('Amber') Connection name step first; the QR step starts only from
there and the typed name reaches nip46_pair_start as the profile label.
Enter submits directly (input focused, prefilled).

Also cleaned the live vault (not in git): dropped 2 stale 'Remote
Signer' connections and the secret-less 'Dev' remote stub tied to them,
plus 13 orphaned connection_secrets entries; kept the live Amber
pairing (npub1qn0w4a…, its connection + client key) and Testing123.

frontend: 125 tests passed (label step covered: prefill, typed label
reaches nip46_pair_start), typecheck/lint/format/electron:build/build
green. Rust untouched.
This commit is contained in:
Avi 2026-09-25 16:01:57 -05:00
commit a809a67023
2 changed files with 70 additions and 7 deletions

View file

@ -12,7 +12,7 @@ interface CreateProfileModalProps {
onClose: () => void; onClose: () => void;
} }
type Phase = 'choice' | 'local' | 'creating' | 'success' | 'pairing' | 'paired'; type Phase = 'choice' | 'pair-label' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) { export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp(); const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
@ -39,7 +39,7 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
// Let the local-form input take focus once that step mounts. // Let the local-form input take focus once that step mounts.
useEffect(() => { useEffect(() => {
if (phase === 'local') { if (phase === 'local' || phase === 'pair-label') {
const frame = requestAnimationFrame(() => inputRef.current?.focus()); const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
} }
@ -133,7 +133,7 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const startPairing = async () => { const startPairing = async () => {
setError(null); setError(null);
try { try {
const status = await nip46PairStart(label.trim() || 'Remote Signer'); const status = await nip46PairStart(label.trim() || 'Amber');
if (status.pairing_uri) setLivePairingUri(status.pairing_uri); if (status.pairing_uri) setLivePairingUri(status.pairing_uri);
setPhase('pairing'); setPhase('pairing');
} catch (err) { } catch (err) {
@ -170,7 +170,13 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
</div> </div>
{error && <ErrorText id={errorId}>{error}</ErrorText>} {error && <ErrorText id={errorId}>{error}</ErrorText>}
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: 'grid', gap: 12 }}>
<Button variant="primary" onClick={() => void startPairing()}> <Button
variant="primary"
onClick={() => {
if (!label.trim()) setLabel('Amber');
setPhase('pair-label');
}}
>
<Icon name="key" size={16} /> <Icon name="key" size={16} />
Sign in with a signer app (Amber) Sign in with a signer app (Amber)
</Button> </Button>
@ -190,6 +196,51 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
); );
} }
if (phase === 'pair-label') {
return (
<Modal open={open} title="Sign in with a signer" onClose={handleClose}>
<form
onSubmit={(event) => {
event.preventDefault();
void startPairing();
}}
noValidate
>
<div className="create-explainer">
<p>
Name this connection so you can tell it apart later — the profile it creates gets this
name, and Amber will show it when asking you to approve signatures.
</p>
</div>
<div className="field">
<label htmlFor="pair-label-input">Connection name</label>
<input
ref={inputRef}
id="pair-label-input"
type="text"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="e.g. Amber"
maxLength={60}
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? true : undefined}
autoComplete="off"
/>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={() => setPhase('choice')}>
Back
</Button>
<Button variant="primary" type="submit" disabled={!canSubmit}>
Show QR code
</Button>
</div>
</form>
</Modal>
);
}
if (phase === 'pairing') { if (phase === 'pairing') {
return ( return (
<Modal open={open} title="Sign in with Amber" onClose={handleClose}> <Modal open={open} title="Sign in with Amber" onClose={handleClose}>

View file

@ -9,6 +9,11 @@ vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') }, default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') },
})); }));
async function startPairingFlow(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
await user.click(screen.getByRole('button', { name: 'Show QR code' }));
}
describe('CreateProfileModal', () => { describe('CreateProfileModal', () => {
it('offers the signer (Amber) and local-key choices first', async () => { it('offers the signer (Amber) and local-key choices first', async () => {
const backend = createFakeBackend(makeEmptyState()); const backend = createFakeBackend(makeEmptyState());
@ -89,9 +94,16 @@ describe('CreateProfileModal', () => {
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ })); await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
// The label step comes first and is prefilled so Enter just works.
const nameInput = await screen.findByLabelText('Connection name');
expect(nameInput).toHaveValue('Amber');
await user.clear(nameInput);
await user.type(nameInput, 'My Phone');
await user.click(screen.getByRole('button', { name: 'Show QR code' }));
expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument(); expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument();
expect(backend.requests.some((r) => r.method === 'nip46_pair_start')).toBe(true); const start = backend.requests.find((r) => r.method === 'nip46_pair_start');
expect(start?.params.label).toBe('My Phone');
expect(await screen.findByAltText('Pairing QR code')).toBeInTheDocument(); expect(await screen.findByAltText('Pairing QR code')).toBeInTheDocument();
}); });
@ -101,7 +113,7 @@ describe('CreateProfileModal', () => {
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />); renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ })); await startPairingFlow(user);
await screen.findByText(/Waiting for the signer to scan/i); await screen.findByText(/Waiting for the signer to scan/i);
// Amber approves: the fake backend now reports the handshake done. The // Amber approves: the fake backend now reports the handshake done. The
@ -125,7 +137,7 @@ describe('CreateProfileModal', () => {
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />); renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ })); await startPairingFlow(user);
await screen.findByText(/Waiting for the signer to scan/i); await screen.findByText(/Waiting for the signer to scan/i);
await user.click(screen.getByRole('button', { name: 'Cancel pairing' })); await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));