diff --git a/src/components/auth/LoginDialog.test.tsx b/src/components/auth/LoginDialog.test.tsx index c4d6fedf2..29a5cdb75 100644 --- a/src/components/auth/LoginDialog.test.tsx +++ b/src/components/auth/LoginDialog.test.tsx @@ -395,6 +395,100 @@ describe('LoginDialog', () => { expect(onClose).not.toHaveBeenCalled(); }); + // divine-web#485: a NIP-46 signer can answer with an auth challenge instead + // of a result. We open it in a tab, but that fires after an await so popup + // blockers routinely eat it — the dialog has to offer the link. + it('shows the auth challenge link when the browser blocks the popup', async () => { + const user = userEvent.setup(); + mockLoginActions.bunker.mockImplementation( + async (_uri: string, options?: { onAuthChallenge?: (c: { url: string; opened: boolean }) => void }) => { + options?.onAuthChallenge?.({ url: 'https://signer.example/approve', opened: false }); + return new Promise(() => {}); // still waiting on the signer + } + ); + + render(); + + await user.click(await screen.findByRole('tab', { name: /^Sign in$/i })); + await user.click(screen.getByRole('button', { name: /Use Nostr instead/i })); + await user.click(await screen.findByRole('tab', { name: /Bunker/i })); + fireEvent.change(screen.getByLabelText(/Bunker URI/i), { + target: { value: 'bunker://remote-signer.example?relay=wss%3A%2F%2Frelay.example' }, + }); + await user.click(screen.getByRole('button', { name: /Login with Bunker/i })); + + const link = await screen.findByRole('link', { name: /Approve in your signer/i }); + expect(link).toHaveAttribute('href', 'https://signer.example/approve'); + // The signer picks this URL, so the destination must be visible rather + // than hidden behind our own localized label. + expect(screen.getByText('(signer.example)')).toBeInTheDocument(); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')); + }); + + it('stays quiet when the challenge tab opened successfully', async () => { + const user = userEvent.setup(); + mockLoginActions.bunker.mockImplementation( + async (_uri: string, options?: { onAuthChallenge?: (c: { url: string; opened: boolean }) => void }) => { + options?.onAuthChallenge?.({ url: 'https://signer.example/approve', opened: true }); + return new Promise(() => {}); + } + ); + + render(); + + await user.click(await screen.findByRole('tab', { name: /^Sign in$/i })); + await user.click(screen.getByRole('button', { name: /Use Nostr instead/i })); + await user.click(await screen.findByRole('tab', { name: /Bunker/i })); + fireEvent.change(screen.getByLabelText(/Bunker URI/i), { + target: { value: 'bunker://remote-signer.example?relay=wss%3A%2F%2Frelay.example' }, + }); + await user.click(screen.getByRole('button', { name: /Login with Bunker/i })); + + expect(screen.queryByRole('link', { name: /Approve in your signer/i })).toBeNull(); + }); + + // LoginArea renders this dialog unconditionally and only toggles `isOpen`, + // so dismissing it leaves the NIP-46 handshake running. A signer approval + // that lands afterwards must not log the user in behind their back. + it('refuses to commit a bunker login the user dismissed while it was pending', async () => { + const user = userEvent.setup(); + let capturedBeforeCommit: (() => boolean) | undefined; + mockLoginActions.bunker.mockImplementation( + async (_uri: string, options?: { beforeCommit?: () => boolean }) => { + capturedBeforeCommit = options?.beforeCommit; + return new Promise(() => {}); // signer still waiting on out-of-band approval + } + ); + + const { rerender } = render( + + ); + + await user.click(await screen.findByRole('tab', { name: /^Sign in$/i })); + await user.click(screen.getByRole('button', { name: /Use Nostr instead/i })); + await user.click(await screen.findByRole('tab', { name: /Bunker/i })); + fireEvent.change(screen.getByLabelText(/Bunker URI/i), { + target: { value: 'bunker://remote-signer.example?relay=wss%3A%2F%2Frelay.example' }, + }); + await user.click(screen.getByRole('button', { name: /Login with Bunker/i })); + + expect(capturedBeforeCommit?.()).toBe(true); + + // User gives up and closes the dialog; the component stays mounted. + rerender(); + + expect(capturedBeforeCommit?.()).toBe(false); + + // Reopening must not re-arm the guard. Anything that calls + // openLoginDialog() later, a like button for instance, reopens this same + // mounted instance, and the abandoned approval can still be in flight + // because nothing bounds how long the signer takes. + rerender(); + + expect(capturedBeforeCommit?.()).toBe(false); + }); + // fireEvent (not userEvent) so the local clipboard spy stays attached; // userEvent.setup() installs its own navigator.clipboard stub. it('aborts an in-flight nsec backup when the restriction engages mid-flight (real parent path)', async () => { diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 166b29852..68f00a6f9 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -35,11 +35,22 @@ type RegisterView = 'invite' | 'waitlist'; const validateNsec = (nsec: string) => /^nsec1[a-zA-Z0-9]{58}$/.test(nsec); const validateBunkerUri = (uri: string) => uri.startsWith('bunker://'); +/** Host of a NIP-46 challenge URL, so the user can see where a link they were + * handed by a remote signer actually leads. Null if it will not parse. */ +const getChallengeHost = (url: string): string | null => { + try { + return new URL(url).host; + } catch { + return null; + } +}; + const LoginDialog: React.FC = ({ isOpen, onClose, onLogin }) => { const { t } = useTranslation(); const [advancedOpen, setAdvancedOpen] = useState(false); const [bunkerError, setBunkerError] = useState(null); const [bunkerUri, setBunkerUri] = useState(''); + const [bunkerAuthUrl, setBunkerAuthUrl] = useState(null); const [generalError, setGeneralError] = useState(null); const [inviteConfigError, setInviteConfigError] = useState(null); const [inviteCode, setInviteCode] = useState(''); @@ -75,6 +86,16 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin }) = // check can resolve `protected` mid-interaction. The ref always holds the // latest verdict so each signer-swap re-checks when it actually runs. const keyHandoverRestrictedRef = useRef(false); + // LoginArea renders this dialog unconditionally and only toggles `isOpen`, so + // dismissing it never unmounts the component and never settles an in-flight + // bunker handshake. These track whether the attempt that is resolving is + // still the one the user is waiting on. + const isOpenRef = useRef(isOpen); + const bunkerAttemptRef = useRef(0); + + useEffect(() => { + isOpenRef.current = isOpen; + }, [isOpen]); keyHandoverRestrictedRef.current = keyHandoverRestricted; // The render-side gates below stay closed synchronously; this only clears the @@ -88,12 +109,20 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin }) = useEffect(() => { if (!isOpen) { + // Retire any in-flight bunker attempt on close. Checking `isOpenRef` + // alone is not enough: this effect re-arms it when the dialog is reopened + // later, so an approval the user abandoned would land against a guard + // that passes again. Bumping the counter means a superseded attempt can + // never become current, whatever happens to `isOpen` afterwards, and it + // also stops a stale challenge writing into the fresh dialog's state. + bunkerAttemptRef.current++; return; } setAdvancedOpen(false); setBunkerError(null); setBunkerUri(''); + setBunkerAuthUrl(null); setGeneralError(null); setInviteConfigError(null); setInviteCode(''); @@ -212,21 +241,38 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin }) = if (keyHandoverRestrictedRef.current) return; setIsLoginLoading(true); setBunkerError(null); + setBunkerAuthUrl(null); + + const attempt = ++bunkerAttemptRef.current; + const isCurrentAttempt = () => attempt === bunkerAttemptRef.current; try { // Same commit-boundary re-check as the extension path: the pre-click // check goes stale while the bunker connect is pending. const committed = await login.bunker(bunkerUri, { - beforeCommit: () => !keyHandoverRestrictedRef.current, + // A NIP-46 handshake can sit unresolved indefinitely while the user + // approves out of band, and the dialog stays mounted the whole time. If + // they gave up and closed it, or started a fresh attempt, this one must + // not silently log them in and set the cross-subdomain cookie. + beforeCommit: () => + !keyHandoverRestrictedRef.current && isOpenRef.current && isCurrentAttempt(), + // The signer wants the user to approve in its own UI. We open a tab + // for them; keep the URL around in case the popup was blocked. + onAuthChallenge: ({ url, opened }) => { + if (url && !opened && isCurrentAttempt()) setBunkerAuthUrl(url); + }, }); if (!committed) return; onLogin(); onClose(); setBunkerUri(''); + setBunkerAuthUrl(null); } catch { - setBunkerError(t('loginDialog.errorBunkerConnectFailed')); + if (isCurrentAttempt()) setBunkerError(t('loginDialog.errorBunkerConnectFailed')); } finally { - setIsLoginLoading(false); + // A superseded attempt settling must not clear the spinner belonging to + // the attempt the user is actually waiting on. + if (isCurrentAttempt()) setIsLoginLoading(false); } }; @@ -496,6 +542,29 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin }) = value={bunkerUri} /> {bunkerError ?

{bunkerError}

: null} + {bunkerAuthUrl ? ( +
+

{t('loginDialog.bunkerAuthPrompt')}

+ + {t('loginDialog.bunkerAuthLink')} + + {/* The remote signer chooses this URL, so show where + the link actually goes rather than hiding an + arbitrary destination behind our own wording. + A hostname is data, not copy, so no locale + needs updating. */} + {getChallengeHost(bunkerAuthUrl) ? ( + + ({getChallengeHost(bunkerAuthUrl)}) + + ) : null} +
+ ) : null}