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:
parent
224df38598
commit
5b13162a36
3 changed files with 317 additions and 20 deletions
|
|
@ -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="Create a Nostr profile" onClose={onClose}>
|
||||
<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'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={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"
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue