From bf1f5a2c4d018fb6bbd10c74cbf1dc1f7e908572 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 14 Jul 2026 09:43:18 +0100 Subject: [PATCH] feat(desktop): redesign Nostr bind verification flow --- .../profile/ui/NostrBindConsentDialog.tsx | 784 ++++++++++++++---- 1 file changed, 644 insertions(+), 140 deletions(-) diff --git a/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx index d4ef09862..5bfc7c15c 100644 --- a/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx +++ b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx @@ -1,3 +1,5 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { toast } from "sonner"; @@ -5,30 +7,83 @@ import { getIdentity } from "@/shared/api/tauriIdentity"; import type { Identity } from "@/shared/api/types"; import type { NostrBindDeepLinkPayload } from "@/shared/deep-link"; import { listenForNostrBindDeepLinks } from "@/shared/deep-link"; +import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition"; import { signNostrIdentityBinding } from "@/features/profile/lib/nostrIdentityBinding"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { cn } from "@/shared/lib/cn"; +import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { Textarea } from "@/shared/ui/textarea"; +import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; const COPY_SUCCESS_MESSAGE = - "Signed response copied. Paste it back into the requesting app."; + "Signed response copied. Paste it into the Buzz admin console."; +const PREVIEW_COPY_SUCCESS_MESSAGE = "Preview response copied."; +const COPY_FAILURE_MESSAGE = "Buzz couldn't access the clipboard. Try again."; const EXPIRED_LINK_MESSAGE = "This binding link has expired. Request a new one from the requesting app."; +const VERIFICATION_CODE_LENGTH = 6; +const VERIFICATION_CODE_DIGIT_KEYS = ["1", "2", "3", "4", "5", "6"] as const; +const VERIFICATION_CODE_MISMATCH_MESSAGE = + "That code doesn't match. Check the code and try again."; +const COPY_BUTTON_LABEL_CLASS = + "col-start-1 row-start-1 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-y-0 motion-reduce:duration-0"; +const NOSTR_BIND_PREVIEW_PAYLOAD: NostrBindDeepLinkPayload = { + challengeId: "550e8400-e29b-41d4-a716-446655440000", + nonce: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + verificationCode: "123456", + audience: "buzz:nostr-identity", + action: "bind_nostr_identity", + protocol: "buzz-nostr-identity", + version: "1", + origin: "https://example.com", + expiresAt: "2099-01-01T00:00:00Z", + returnMode: "clipboard", +}; +const NOSTR_BIND_PREVIEW_IDENTITY: Identity = { + pubkey: "deadbeef".repeat(8), + displayName: "Preview identity", +}; +const NOSTR_BIND_PREVIEW_SIGNED_RESPONSE = JSON.stringify({ + id: "preview-only-not-a-real-signature", + pubkey: NOSTR_BIND_PREVIEW_IDENTITY.pubkey, + created_at: 0, + kind: 24243, + tags: [ + ["challenge_id", NOSTR_BIND_PREVIEW_PAYLOAD.challengeId], + ["nonce", NOSTR_BIND_PREVIEW_PAYLOAD.nonce], + ["verification_code", NOSTR_BIND_PREVIEW_PAYLOAD.verificationCode], + ["audience", NOSTR_BIND_PREVIEW_PAYLOAD.audience], + ["action", NOSTR_BIND_PREVIEW_PAYLOAD.action], + ["protocol", NOSTR_BIND_PREVIEW_PAYLOAD.protocol], + ["version", NOSTR_BIND_PREVIEW_PAYLOAD.version], + ["origin", NOSTR_BIND_PREVIEW_PAYLOAD.origin], + ["expires_at", NOSTR_BIND_PREVIEW_PAYLOAD.expiresAt], + ], + content: "", + sig: "preview-only-not-a-real-signature", +}); -function formatExpiry(expiresAt: string): string { - const date = new Date(expiresAt); - if (Number.isNaN(date.getTime())) { - return expiresAt; +function isNostrBindPreviewEnabled(): boolean { + if (!import.meta.env.DEV) { + return false; } - return date.toLocaleString(); + + return ( + import.meta.env.VITE_NOSTR_BIND_PREVIEW === "1" || + new URLSearchParams(window.location.search).get("preview") === "nostr-bind" + ); +} + +function createEmptyVerificationCode(): string[] { + return Array.from({ length: VERIFICATION_CODE_LENGTH }, () => ""); +} + +function normalizeVerificationCode(value: string): string[] { + return value + .replace(/\D/g, "") + .slice(0, VERIFICATION_CODE_LENGTH) + .padEnd(VERIFICATION_CODE_LENGTH, " ") + .split("") + .map((character) => character.trim()); } function formatError(error: unknown): string { @@ -49,22 +104,79 @@ async function copyToClipboard(text: string): Promise { } export function NostrBindConsentDialog() { + const isPreview = isNostrBindPreviewEnabled(); const [payload, setPayload] = React.useState( - null, + isPreview ? NOSTR_BIND_PREVIEW_PAYLOAD : null, + ); + const [identity, setIdentity] = React.useState( + isPreview ? NOSTR_BIND_PREVIEW_IDENTITY : null, ); - const [identity, setIdentity] = React.useState(null); const [isSigning, setIsSigning] = React.useState(false); const [signedResponse, setSignedResponse] = React.useState( null, ); + const [isCopied, setIsCopied] = React.useState(false); + const [verificationCode, setVerificationCode] = React.useState( + createEmptyVerificationCode, + ); + const [hasCodeMismatch, setHasCodeMismatch] = React.useState(false); const [copyFailed, setCopyFailed] = React.useState(false); const [error, setError] = React.useState(null); + const codeInputRefs = React.useRef>([]); + const codeShakeRef = React.useRef(null); + const codeShakeAnimationRef = React.useRef(null); + const copiedTimerRef = React.useRef | null>( + null, + ); + const systemColorScheme = useSystemColorScheme(); + const shouldReduceMotion = useReducedMotion(); + const enteredVerificationCode = verificationCode.join(""); + const isVerificationCodeComplete = + enteredVerificationCode.length === VERIFICATION_CODE_LENGTH; + const isVerificationCodeValid = + payload !== null && enteredVerificationCode === payload.verificationCode; + const copyButtonLabel = isSigning ? "Signing…" : "Continue"; + const finishCopyButtonLabel = isCopied ? "Copied" : "Copy response"; + + const clearCopiedState = React.useCallback(() => { + if (copiedTimerRef.current) { + clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = null; + } + setIsCopied(false); + }, []); + + const showCopiedState = React.useCallback(() => { + clearCopiedState(); + setIsCopied(true); + copiedTimerRef.current = setTimeout(() => { + setIsCopied(false); + copiedTimerRef.current = null; + }, 2_000); + }, [clearCopiedState]); + + React.useEffect( + () => () => { + codeShakeAnimationRef.current?.cancel(); + if (copiedTimerRef.current) { + clearTimeout(copiedTimerRef.current); + } + }, + [], + ); React.useEffect(() => { + if (isPreview) { + return; + } + const unlistenPromise = listenForNostrBindDeepLinks((nextPayload) => { + clearCopiedState(); setPayload(nextPayload); setIdentity(null); setSignedResponse(null); + setVerificationCode(createEmptyVerificationCode()); + setHasCodeMismatch(false); setCopyFailed(false); setError(null); getIdentity() @@ -79,7 +191,7 @@ export function NostrBindConsentDialog() { return () => { void unlistenPromise.then((unlisten) => unlisten()); }; - }, []); + }, [clearCopiedState, isPreview]); const isExpired = React.useMemo(() => { if (!payload) { @@ -90,21 +202,198 @@ export function NostrBindConsentDialog() { }, [payload]); const resetDialog = React.useCallback(() => { + clearCopiedState(); setPayload(null); setSignedResponse(null); + setVerificationCode(createEmptyVerificationCode()); + setHasCodeMismatch(false); setCopyFailed(false); setError(null); setIdentity(null); setIsSigning(false); - }, []); + }, [clearCopiedState]); const handleOpenChange = React.useCallback( (open: boolean) => { - if (!open) { + if (!open && !isPreview) { resetDialog(); } }, - [resetDialog], + [isPreview, resetDialog], + ); + + const shakeVerificationCode = React.useCallback(() => { + if (shouldReduceMotion || !codeShakeRef.current) { + return; + } + + codeShakeAnimationRef.current?.cancel(); + codeShakeAnimationRef.current = codeShakeRef.current.animate( + [ + { + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + offset: 0, + transform: "translateX(0px)", + }, + { + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + offset: 0.2857, + transform: "translateX(6px)", + }, + { + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + offset: 0.5714, + transform: "translateX(-6px)", + }, + { + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + offset: 0.7857, + transform: "translateX(4px)", + }, + { offset: 1, transform: "translateX(0px)" }, + ], + { duration: 280, easing: "linear" }, + ); + }, [shouldReduceMotion]); + + const showVerificationCodeMismatch = React.useCallback(() => { + setHasCodeMismatch(true); + shakeVerificationCode(); + }, [shakeVerificationCode]); + + const handleVerificationCodeChange = React.useCallback( + (index: number, value: string) => { + const nextDigits = value.replace(/\D/g, ""); + const next = [...verificationCode]; + + if (!nextDigits) { + next[index] = ""; + setVerificationCode(next); + setHasCodeMismatch(false); + return; + } + + if (index === VERIFICATION_CODE_LENGTH - 1 && verificationCode[index]) { + shakeVerificationCode(); + return; + } + + for ( + let offset = 0; + offset < nextDigits.length && index + offset < next.length; + offset += 1 + ) { + next[index + offset] = nextDigits[offset] ?? ""; + } + setVerificationCode(next); + + const completedCode = next.join(""); + if ( + completedCode.length === VERIFICATION_CODE_LENGTH && + completedCode !== payload?.verificationCode + ) { + showVerificationCodeMismatch(); + } else { + setHasCodeMismatch(false); + } + + const nextIndex = Math.min( + index + nextDigits.length, + VERIFICATION_CODE_LENGTH - 1, + ); + codeInputRefs.current[nextIndex]?.focus(); + codeInputRefs.current[nextIndex]?.select(); + }, + [ + payload?.verificationCode, + shakeVerificationCode, + showVerificationCodeMismatch, + verificationCode, + ], + ); + + const handleVerificationCodePaste = React.useCallback( + (index: number, event: React.ClipboardEvent) => { + if (index === VERIFICATION_CODE_LENGTH - 1 && verificationCode[index]) { + event.preventDefault(); + if (/\d/.test(event.clipboardData.getData("text"))) { + shakeVerificationCode(); + } + return; + } + + const pastedCode = event.clipboardData + .getData("text") + .replace(/\D/g, "") + .slice(0, VERIFICATION_CODE_LENGTH); + if (!pastedCode) { + return; + } + + event.preventDefault(); + const next = normalizeVerificationCode(pastedCode); + setVerificationCode(next); + if ( + pastedCode.length === VERIFICATION_CODE_LENGTH && + pastedCode !== payload?.verificationCode + ) { + showVerificationCodeMismatch(); + } else { + setHasCodeMismatch(false); + } + const nextIndex = Math.min( + pastedCode.length, + VERIFICATION_CODE_LENGTH - 1, + ); + codeInputRefs.current[nextIndex]?.focus(); + codeInputRefs.current[nextIndex]?.select(); + }, + [ + payload?.verificationCode, + shakeVerificationCode, + showVerificationCodeMismatch, + verificationCode, + ], + ); + + const handleVerificationCodeKeyDown = React.useCallback( + (index: number, event: React.KeyboardEvent) => { + if ( + index === VERIFICATION_CODE_LENGTH - 1 && + verificationCode[index] && + /^\d$/.test(event.key) + ) { + event.preventDefault(); + shakeVerificationCode(); + return; + } + + if (event.key === "Backspace") { + event.preventDefault(); + const targetIndex = verificationCode[index] + ? index + : Math.max(index - 1, 0); + setVerificationCode((current) => { + const next = [...current]; + next[targetIndex] = ""; + return next; + }); + setHasCodeMismatch(false); + codeInputRefs.current[targetIndex]?.focus(); + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + codeInputRefs.current[Math.max(index - 1, 0)]?.focus(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + codeInputRefs.current[ + Math.min(index + 1, VERIFICATION_CODE_LENGTH - 1) + ]?.focus(); + } + }, + [shakeVerificationCode, verificationCode], ); const handleSign = React.useCallback(async () => { @@ -115,32 +404,48 @@ export function NostrBindConsentDialog() { setError(EXPIRED_LINK_MESSAGE); return; } + if (!isVerificationCodeValid) { + if (isVerificationCodeComplete) { + showVerificationCodeMismatch(); + } + const firstEmptyIndex = verificationCode.findIndex((digit) => !digit); + codeInputRefs.current[ + firstEmptyIndex === -1 ? 0 : firstEmptyIndex + ]?.focus(); + return; + } setIsSigning(true); + clearCopiedState(); setError(null); setCopyFailed(false); try { - const signed = await signNostrIdentityBinding({ - challengeId: payload.challengeId, - nonce: payload.nonce, - verificationCode: payload.verificationCode, - origin: payload.origin, - expiresAt: payload.expiresAt, - }); + const signed = isPreview + ? NOSTR_BIND_PREVIEW_SIGNED_RESPONSE + : await signNostrIdentityBinding({ + challengeId: payload.challengeId, + nonce: payload.nonce, + verificationCode: enteredVerificationCode, + origin: payload.origin, + expiresAt: payload.expiresAt, + }); setSignedResponse(signed); - const copied = await copyToClipboard(signed); - setCopyFailed(!copied); - if (copied) { - toast.success(COPY_SUCCESS_MESSAGE); - } else { - toast.warning("Signed response ready. Copy it manually below."); - } } catch (error) { setError(formatError(error) || "Failed to sign binding response."); } finally { setIsSigning(false); } - }, [isExpired, payload]); + }, [ + clearCopiedState, + enteredVerificationCode, + isExpired, + isPreview, + isVerificationCodeComplete, + isVerificationCodeValid, + payload, + showVerificationCodeMismatch, + verificationCode, + ]); const handleCopyAgain = React.useCallback(async () => { if (!signedResponse) { @@ -149,114 +454,313 @@ export function NostrBindConsentDialog() { const copied = await copyToClipboard(signedResponse); setCopyFailed(!copied); if (copied) { - toast.success(COPY_SUCCESS_MESSAGE); + showCopiedState(); + toast.success( + isPreview ? PREVIEW_COPY_SUCCESS_MESSAGE : COPY_SUCCESS_MESSAGE, + ); + } else { + toast.warning(COPY_FAILURE_MESSAGE); } - }, [signedResponse]); + }, [isPreview, showCopiedState, signedResponse]); return ( - - - - Bind Buzz identity? - - Buzz will sign a one-time proof. Your private key is not shared. - - - + + {payload ? ( -
-
-

- Verification code -

-

- {payload.verificationCode} -

-

- Only sign if this code matches the code shown by the requesting - website. -

-
+ + +
+ Buzz -
-
-
Requesting origin
-
- {payload.origin} -
-
-
-
Buzz identity
-
- {identity - ? `${identity.displayName} (${truncatePubkey(identity.pubkey)})` - : "Loading…"} -
-
-
-
Expires
-
- {formatExpiry(payload.expiresAt)} -
-
-
- - {isExpired ? ( -

- {EXPIRED_LINK_MESSAGE} -

- ) : null} - - {error ? ( -

- {error} -

- ) : null} - - {signedResponse ? ( -
-

- Signed response {copyFailed ? "ready" : "copied"}. Paste it - back into the requesting app. -

-