feat(ui): Add profile now offers 'Sign in with a signer (Amber)' first

The Amber pairing QR existed but only behind the sidebar's Signer Mode,
so clicking 'Add profile' expectedly led to a local-key form and users
never found the signer flow. The modal now opens as a choice:

- 'Sign in with a signer app (Amber)' — mints the nostrconnect:// QR
  inline, polls signer status every 2s (same channel as Signer Mode),
  and flips to an 'Amber is now your signer!' confirmation once the
  handshake lands; cancelling mid-pairing aborts it cleanly.
- 'Create a new key on this computer' — the previous local-key form,
  unchanged, with a Back step.

frontend: 125 tests passed (CreateProfileModal suite rewritten to cover
choice, QR start, connected poll, cancel-abort; App.test updated for the
new dialog title), typecheck/lint/format:check/electron:build/build green.
This commit is contained in:
Avi 2026-09-25 15:34:28 -05:00
commit 5b13162a36
3 changed files with 317 additions and 20 deletions

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import QRCode from 'qrcode';
import { useApp } from '../state/AppProvider';
import { shortenNpub } from '../lib/format';
import { Button } from '../components/Button';
@ -11,14 +12,15 @@ interface CreateProfileModalProps {
onClose: () => void;
}
type Phase = 'form' | 'creating' | 'success';
type Phase = 'choice' | 'local' | 'creating' | 'success' | 'pairing' | 'paired';
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const { state, createProfile } = useApp();
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
const [label, setLabel] = useState('');
const [phase, setPhase] = useState<Phase>('form');
const [phase, setPhase] = useState<Phase>('choice');
const [error, setError] = useState<string | null>(null);
const [createdNpub, setCreatedNpub] = useState<string | null>(null);
const [pairingQr, setPairingQr] = useState<string | null>(null);
const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`);
const inputRef = useRef<HTMLInputElement>(null);
const shorten = state?.settings.shorten_npub ?? true;
@ -26,15 +28,88 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
useEffect(() => {
if (open) {
setLabel('');
setPhase('form');
setPhase('choice');
setError(null);
setCreatedNpub(null);
// Let the modal mount before focusing.
setPairingQr(null);
return undefined;
}
return undefined;
}, [open]);
// Let the local-form input take focus once that step mounts.
useEffect(() => {
if (phase === 'local') {
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [open]);
}, [phase]);
// Pairing is a server-side handshake with no push channel: poll the
// signer status while this modal sits on the QR, exactly like the Signer
// Mode screen does. Connected → show success; an error → show it (the
// backend also clears pairing_uri, so the QR view exits on failure).
useEffect(() => {
if (phase !== 'pairing') return undefined;
let cancelled = false;
const tick = async () => {
try {
const status = await nip46Status();
if (cancelled) return;
if (status.connected) {
await refresh().catch(() => {});
if (!cancelled) setPhase('paired');
} else if (status.error) {
if (!cancelled) setError(status.error);
}
} catch {
// Transient IPC errors are fine; the next poll retries.
}
};
void tick();
const timer = setInterval(() => void tick(), 2000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [phase, nip46Status, refresh]);
const [livePairingUri, setLivePairingUri] = useState<string | null>(null);
useEffect(() => {
if (phase !== 'pairing' || livePairingUri) return undefined;
let cancelled = false;
void nip46Status()
.then((status) => {
if (!cancelled && status.pairing_uri) setLivePairingUri(status.pairing_uri);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [phase, livePairingUri, nip46Status]);
useEffect(() => {
if (!livePairingUri) {
setPairingQr(null);
return;
}
let cancelled = false;
QRCode.toDataURL(livePairingUri, {
width: 480,
margin: 2,
errorCorrectionLevel: 'M',
})
.then((url) => {
if (!cancelled) setPairingQr(url);
})
.catch(() => {
if (!cancelled) setError('Could not render the pairing QR code.');
});
return () => {
cancelled = true;
};
}, [livePairingUri]);
const canSubmit = label.trim().length > 0 && phase !== 'creating';
@ -50,13 +125,151 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
setCreatedNpub(summary.npub);
setPhase('success');
} catch (err) {
setPhase('form');
setPhase('local');
setError(err instanceof Error ? err.message : String(err));
}
};
const startPairing = async () => {
setError(null);
try {
const status = await nip46PairStart(label.trim() || 'Remote Signer');
if (status.pairing_uri) setLivePairingUri(status.pairing_uri);
setPhase('pairing');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
// Leaving the QR view mid-pairing aborts the in-flight pairing; nothing
// was persisted yet, so teardown is safe at any point (same as Signer
// Mode's "Cancel pairing").
const cancelPairing = async () => {
setLivePairingUri(null);
setPairingQr(null);
setPhase('choice');
try {
await nip46Disconnect();
} catch {
// Best-effort abort; a dead pairing attempt expires on its own.
}
};
const handleClose = () => {
if (phase === 'pairing') {
void cancelPairing();
}
onClose();
};
if (phase === 'choice') {
return (
<Modal open={open} title="Add a profile" onClose={handleClose}>
<div className="create-explainer">
<p>How do you want this profile to sign?</p>
</div>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
<div style={{ display: 'grid', gap: 12 }}>
<Button variant="primary" onClick={() => void startPairing()}>
<Icon name="key" size={16} />
Sign in with a signer app (Amber)
</Button>
<p className="hint" style={{ marginTop: -4 }}>
Show a QR code to Amber on your phone — your keys stay on the phone, and every signature
is approved there.
</p>
<Button variant="secondary" onClick={() => setPhase('local')}>
<Icon name="shield" size={16} />
Create a new key on this computer
</Button>
<p className="hint" style={{ marginTop: -4 }}>
A brand-new local identity whose private key lives in this app&apos;s vault.
</p>
</div>
</Modal>
);
}
if (phase === 'pairing') {
return (
<Modal open={open} title="Sign in with Amber" onClose={handleClose}>
<div className="signer-pairing">
<p>
Scan this code with <strong>Amber</strong> (or any NIP-46 signer) and approve the
connection.
</p>
{pairingQr ? (
<img
src={pairingQr}
alt="Pairing QR code"
style={{
width: 260,
height: 260,
imageRendering: 'pixelated',
borderRadius: 8,
display: 'block',
margin: '12px auto',
background: '#fff',
padding: 8,
}}
/>
) : (
<p className="hint" style={{ textAlign: 'center' }}>
Preparing the pairing code…
</p>
)}
<p className="hint" style={{ textAlign: 'center' }}>
Waiting for the signer to scan… the profile appears automatically once approved. The
code expires after a few minutes.
</p>
{error && <ErrorText>{error}</ErrorText>}
<div className="modal-actions">
<Button variant="ghost" onClick={() => void cancelPairing()}>
Cancel pairing
</Button>
</div>
{livePairingUri && (
<details style={{ marginTop: 12 }}>
<summary className="hint">Or copy the pairing link</summary>
<code className="mono" style={{ wordBreak: 'break-all', fontSize: 11 }}>
{livePairingUri}
</code>
</details>
)}
</div>
</Modal>
);
}
if (phase === 'paired') {
return (
<Modal open={open} title="Signer connected" onClose={onClose}>
<div className="create-success">
<div className="create-success-icon" aria-hidden="true">
<Icon name="check" size={26} />
</div>
<h3>Amber is now your signer!</h3>
<p>
The connected account was added as a profile and selected. Every signature will ask for
approval in Amber — nothing to install here, and the connection comes back automatically
after restarts.
</p>
<div className="modal-actions">
<Button variant="primary" onClick={onClose}>
Done
</Button>
</div>
</div>
</Modal>
);
}
return (
<Modal open={open} title="Create a Nostr profile" onClose={onClose}>
<Modal
open={open}
title={phase === 'local' || phase === 'creating' ? 'Create a Nostr profile' : 'Add a profile'}
onClose={handleClose}
>
{phase === 'success' && createdNpub ? (
<div className="create-success">
<div className="create-success-icon" aria-hidden="true">
@ -115,8 +328,12 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={phase === 'creating'}>
Cancel
<Button
variant="ghost"
onClick={() => setPhase('choice')}
disabled={phase === 'creating'}
>
Back
</Button>
<Button
variant="primary"

View file

@ -22,9 +22,7 @@ describe('App', () => {
expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Create a new profile/i }));
expect(
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
).toBeInTheDocument();
expect(await screen.findByRole('dialog', { name: 'Add a profile' })).toBeInTheDocument();
});
it('renders the main screen after loading with an existing profile', async () => {

View file

@ -5,14 +5,35 @@ import { renderWithApp } from './render';
import { makeEmptyState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') },
}));
describe('CreateProfileModal', () => {
it('creates a profile through the backend and shows a success confirmation', async () => {
it('offers the signer (Amber) and local-key choices first', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
await screen.findByRole('dialog', { name: 'Add a profile' });
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /Create a new key on this computer/ }),
).toBeInTheDocument();
});
it('creates a local-key profile through the backend and shows a success confirmation', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
const onClose = vi.fn();
renderWithApp(<CreateProfileModal open onClose={onClose} />);
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
await screen.findByRole('dialog', { name: 'Add a profile' });
await userEvent
.setup()
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
const input = screen.getByLabelText('Profile name');
await userEvent.setup().type(input, 'Sam');
@ -36,20 +57,81 @@ describe('CreateProfileModal', () => {
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
await screen.findByRole('dialog', { name: 'Add a profile' });
await userEvent
.setup()
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
expect(screen.getByRole('button', { name: 'Create profile' })).toBeDisabled();
});
it('closes without creating when Cancel is clicked', async () => {
it('closes without creating when Back then close is used', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
const onClose = vi.fn();
renderWithApp(<CreateProfileModal open onClose={onClose} />);
await userEvent
.setup()
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
await userEvent.setup().type(screen.getByLabelText('Profile name'), 'Sam');
await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalled();
await userEvent.setup().click(screen.getByRole('button', { name: 'Back' }));
// Back returns to the choice step; nothing was created yet.
expect(backend.state.profiles).toHaveLength(0);
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();
});
it('starts Amber pairing from the choice step and shows the QR', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument();
expect(backend.requests.some((r) => r.method === 'nip46_pair_start')).toBe(true);
expect(await screen.findByAltText('Pairing QR code')).toBeInTheDocument();
});
it('shows the connected confirmation once the poll reports the signer online', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
await screen.findByText(/Waiting for the signer to scan/i);
// Amber approves: the fake backend now reports the handshake done. The
// modal polls the signer status every 2s, so wait past one interval.
backend.setNip46({
type: 'nip46',
connected: true,
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
});
expect(
await screen.findByText('Amber is now your signer!', {}, { timeout: 5000 }),
).toBeInTheDocument();
});
it('aborts an in-flight pairing when Cancel pairing is clicked', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
await screen.findByText(/Waiting for the signer to scan/i);
await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true);
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();
});
});