feat(desktop): redesign Nostr bind verification flow (#1850)

Co-authored-by: tulsi <tulsi@block.xyz>
This commit is contained in:
klopez4212
2026-07-15 16:30:28 +01:00
committed by GitHub
co-authored by tulsi
parent 3e8dae7ff3
commit abfe78aafb
6 changed files with 1071 additions and 142 deletions
+1
View File
@@ -96,6 +96,7 @@ export default defineConfig({
"**/onboarding-avatar-skip.spec.ts",
"**/onboarding-backup.spec.ts",
"**/onboarding-agent-defaults.spec.ts",
"**/nostr-bind.spec.ts",
"**/profile-nsec-reveal.spec.ts",
"**/agent-provider-dropdowns.spec.ts",
"**/agent-lifecycle-feedback.spec.ts",
+58
View File
@@ -94,6 +94,7 @@ struct NostrBindDeepLinkPayload {
origin: String,
expires_at: String,
return_mode: String,
callback_url: Option<String>,
}
fn non_empty_param(url: &Url, name: &str) -> Result<String, String> {
@@ -104,6 +105,35 @@ fn non_empty_param(url: &Url, name: &str) -> Result<String, String> {
.ok_or_else(|| format!("missing {name}"))
}
fn optional_non_empty_param(url: &Url, name: &str) -> Option<String> {
url.query_pairs()
.find(|(key, _)| key == name)
.map(|(_, value)| value.into_owned())
.filter(|value| !value.is_empty())
}
fn validate_nostr_bind_callback_url(callback_url: &str, origin: &str) -> Result<(), String> {
let callback =
Url::parse(callback_url).map_err(|error| format!("invalid callback_url: {error}"))?;
let origin = Url::parse(origin).map_err(|error| format!("invalid origin: {error}"))?;
if callback.scheme() != "https" {
return Err("callback_url must use https".into());
}
if callback.host_str().is_none() {
return Err("callback_url missing host".into());
}
if !callback.username().is_empty() || callback.password().is_some() {
return Err("callback_url must not include credentials".into());
}
if callback.scheme() != origin.scheme()
|| callback.host_str() != origin.host_str()
|| callback.port_or_known_default() != origin.port_or_known_default()
{
return Err("callback_url must match origin".into());
}
Ok(())
}
fn parse_nostr_bind_deep_link(url: &Url) -> Result<NostrBindDeepLinkPayload, String> {
let challenge_id = non_empty_param(url, "challenge_id")?;
let nonce = non_empty_param(url, "nonce")?;
@@ -115,6 +145,7 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result<NostrBindDeepLinkPayload, Str
let origin = non_empty_param(url, "origin")?;
let expires_at = non_empty_param(url, "expires_at")?;
let return_mode = non_empty_param(url, "return")?;
let callback_url = optional_non_empty_param(url, "callback_url");
nostr_bind::validate_challenge_id(&challenge_id)?;
nostr_bind::validate_nonce(&nonce)?;
@@ -127,6 +158,9 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result<NostrBindDeepLinkPayload, Str
if return_mode != nostr_bind::RETURN_MODE {
return Err("unsupported return mode".into());
}
if let Some(callback_url) = callback_url.as_deref() {
validate_nostr_bind_callback_url(callback_url, &origin)?;
}
Ok(NostrBindDeepLinkPayload {
challenge_id,
@@ -139,6 +173,7 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result<NostrBindDeepLinkPayload, Str
origin,
expires_at,
return_mode,
callback_url,
})
}
@@ -341,6 +376,29 @@ mod tests {
assert_eq!(payload.origin, "https://example.com");
assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z");
assert_eq!(payload.return_mode, "clipboard");
assert_eq!(payload.callback_url, None);
}
#[test]
fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() {
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap();
let payload = parse_nostr_bind_deep_link(&url).unwrap();
assert_eq!(
payload.callback_url.as_deref(),
Some("https://example.com/buzz?mockSession=1")
);
}
#[test]
fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() {
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap();
assert!(parse_nostr_bind_deep_link(&url).is_err());
}
#[test]
fn parse_nostr_bind_deep_link_rejects_http_callback_url() {
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap();
assert!(parse_nostr_bind_deep_link(&url).is_err());
}
#[test]
@@ -1,3 +1,6 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { toast } from "sonner";
@@ -5,30 +8,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 {
@@ -48,23 +104,97 @@ async function copyToClipboard(text: string): Promise<boolean> {
}
}
function appendCallbackStatus(callbackUrl: string): string {
const url = new URL(callbackUrl);
url.searchParams.set("buzz_bind", "signed");
return url.toString();
}
async function notifySignedResponseReady(callbackUrl: string | undefined) {
if (!callbackUrl) {
return;
}
try {
await openUrl(appendCallbackStatus(callbackUrl));
} catch (error) {
console.warn("open nostr bind callback failed:", error);
}
}
export function NostrBindConsentDialog() {
const isPreview = isNostrBindPreviewEnabled();
const [payload, setPayload] = React.useState<NostrBindDeepLinkPayload | null>(
null,
isPreview ? NOSTR_BIND_PREVIEW_PAYLOAD : null,
);
const [identity, setIdentity] = React.useState<Identity | null>(
isPreview ? NOSTR_BIND_PREVIEW_IDENTITY : null,
);
const [identity, setIdentity] = React.useState<Identity | null>(null);
const [isSigning, setIsSigning] = React.useState(false);
const [signedResponse, setSignedResponse] = React.useState<string | null>(
null,
);
const [isCopied, setIsCopied] = React.useState(false);
const [verificationCode, setVerificationCode] = React.useState<string[]>(
createEmptyVerificationCode,
);
const [hasCodeMismatch, setHasCodeMismatch] = React.useState(false);
const [copyFailed, setCopyFailed] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const codeInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
const codeShakeRef = React.useRef<HTMLDivElement | null>(null);
const codeShakeAnimationRef = React.useRef<Animation | null>(null);
const copiedTimerRef = React.useRef<ReturnType<typeof setTimeout> | 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 +209,7 @@ export function NostrBindConsentDialog() {
return () => {
void unlistenPromise.then((unlisten) => unlisten());
};
}, []);
}, [clearCopiedState, isPreview]);
const isExpired = React.useMemo(() => {
if (!payload) {
@@ -90,21 +220,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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 +422,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 +472,314 @@ export function NostrBindConsentDialog() {
const copied = await copyToClipboard(signedResponse);
setCopyFailed(!copied);
if (copied) {
toast.success(COPY_SUCCESS_MESSAGE);
showCopiedState();
await notifySignedResponseReady(payload?.callbackUrl);
toast.success(
isPreview ? PREVIEW_COPY_SUCCESS_MESSAGE : COPY_SUCCESS_MESSAGE,
);
} else {
toast.warning(COPY_FAILURE_MESSAGE);
}
}, [signedResponse]);
}, [isPreview, payload?.callbackUrl, showCopiedState, signedResponse]);
return (
<Dialog onOpenChange={handleOpenChange} open={payload !== null}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Bind Buzz identity?</DialogTitle>
<DialogDescription>
Buzz will sign a one-time proof. Your private key is not shared.
</DialogDescription>
</DialogHeader>
<DialogPrimitive.Root
onOpenChange={handleOpenChange}
open={payload !== null}
>
<DialogPrimitive.Portal>
{payload ? (
<div className="space-y-4 text-sm">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4 text-center">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Verification code
</p>
<p className="mt-2 font-mono text-4xl font-semibold tracking-[0.35em] text-foreground">
{payload.verificationCode}
</p>
<p className="mt-3 text-muted-foreground">
Only sign if this code matches the code shown by the requesting
website.
</p>
</div>
<dl className="space-y-2 rounded-lg border border-border/60 bg-muted/25 p-3">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Requesting origin</dt>
<dd className="break-all text-right font-medium">
{payload.origin}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Buzz identity</dt>
<dd className="break-all text-right font-medium">
{identity
? `${identity.displayName} (${truncatePubkey(identity.pubkey)})`
: "Loading…"}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Expires</dt>
<dd className="text-right font-medium">
{formatExpiry(payload.expiresAt)}
</dd>
</div>
</dl>
{isExpired ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
{EXPIRED_LINK_MESSAGE}
</p>
) : null}
{error ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
{error}
</p>
) : null}
{signedResponse ? (
<div className="space-y-2">
<p className="font-medium text-foreground">
Signed response {copyFailed ? "ready" : "copied"}. Paste it
back into the requesting app.
</p>
<Textarea
className="max-h-48 min-h-32 font-mono text-xs"
readOnly
value={signedResponse}
/>
</div>
) : null}
</div>
) : null}
<DialogFooter>
<Button
disabled={isSigning}
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
<DialogPrimitive.Content
aria-describedby="nostr-bind-description"
className="buzz-onboarding-neutral-theme buzz-startup-shell fixed inset-0 z-50 flex overflow-y-auto bg-background px-4 py-12 text-foreground outline-hidden"
data-system-color-scheme={systemColorScheme}
data-testid="nostr-bind-page"
>
Cancel
</Button>
{signedResponse ? (
<Button
disabled={isSigning}
onClick={handleCopyAgain}
type="button"
>
Copy response
</Button>
) : (
<Button
disabled={isSigning || isExpired || identity === null}
onClick={handleSign}
type="button"
>
{isSigning ? "Signing…" : "Sign and copy response"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<StartupWindowDragRegion />
<div className="m-auto flex w-full max-w-[500px] flex-col items-center text-center">
<img
alt="Buzz"
className="h-14 w-14 rounded-xl shadow-xs"
src="/app-icon@2x.png"
srcSet="/app-icon@2x.png 1x, /app-icon@3x.png 2x"
/>
{signedResponse ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
data-testid="nostr-bind-finish-step"
direction="forward"
transitionKey="nostr-bind-finish"
>
<DialogPrimitive.Title className="mt-6 text-3xl font-semibold tracking-tight">
Finish on the Buzz website
</DialogPrimitive.Title>
<DialogPrimitive.Description
className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground"
id="nostr-bind-description"
>
Copy the response below, then paste it into the Buzz website
to finish verification.
</DialogPrimitive.Description>
<pre
className="mt-10 max-h-56 w-full overflow-auto rounded-2xl border border-border/70 bg-muted/60 p-4 text-left shadow-xs"
data-testid="nostr-bind-signed-response"
>
<code className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
{signedResponse}
</code>
</pre>
{copyFailed ? (
<p className="mt-4 w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-sm text-destructive">
{COPY_FAILURE_MESSAGE}
</p>
) : null}
<div className="mt-8 flex w-full flex-col gap-3">
<Button
aria-label={finishCopyButtonLabel}
className="h-10 w-full"
data-testid="nostr-bind-copy-response"
onClick={handleCopyAgain}
type="button"
>
<span aria-live="polite" className="sr-only">
{finishCopyButtonLabel}
</span>
<span
aria-hidden="true"
className="inline-grid h-5 place-items-center overflow-hidden"
>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
finishCopyButtonLabel === "Copy response"
? "translate-y-0 opacity-100"
: "-translate-y-0.5 opacity-0",
)}
>
Copy response
</span>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
finishCopyButtonLabel === "Copied"
? "translate-y-0 opacity-100"
: "translate-y-0.5 opacity-0",
)}
>
Copied
</span>
</span>
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
onClick={() => handleOpenChange(false)}
type="button"
variant="ghost"
>
Close
</Button>
</div>
</OnboardingSlideTransition>
) : (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
data-testid="nostr-bind-code-step"
direction="forward"
transitionKey="nostr-bind-code"
>
<DialogPrimitive.Title className="mt-6 text-3xl font-semibold tracking-tight">
Enter verification code
</DialogPrimitive.Title>
<DialogPrimitive.Description
className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground"
id="nostr-bind-description"
>
Enter the six-digit code shown in your browser
</DialogPrimitive.Description>
<div className="mt-10 w-full space-y-4 text-sm">
<fieldset
aria-describedby={
hasCodeMismatch ? "nostr-bind-code-error" : undefined
}
aria-invalid={hasCodeMismatch}
className="w-full min-w-0 text-center"
>
<legend className="sr-only">Verification code</legend>
<div
className="flex justify-center gap-2"
data-testid="nostr-bind-verification-code"
ref={codeShakeRef}
>
{verificationCode.map((digit, index) => (
<div
className="relative h-16 w-14 shrink-0 overflow-hidden rounded-xl"
key={VERIFICATION_CODE_DIGIT_KEYS[index]}
>
<input
aria-label={`Verification code digit ${index + 1} of ${VERIFICATION_CODE_LENGTH}`}
autoComplete={
index === 0 ? "one-time-code" : "off"
}
className={cn(
"absolute inset-0 h-full w-full rounded-xl border text-center text-transparent shadow-xs caret-transparent selection:bg-transparent selection:text-transparent transition-[border-color,box-shadow] focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
systemColorScheme === "light"
? "bg-[#fafafa]"
: "bg-muted",
hasCodeMismatch
? "border-destructive focus-visible:border-destructive focus-visible:ring-2 focus-visible:ring-destructive/25"
: "border-input/60 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30",
)}
data-testid={`nostr-bind-code-digit-${index + 1}`}
disabled={isSigning}
inputMode="numeric"
maxLength={1}
onChange={(event) =>
handleVerificationCodeChange(
index,
event.target.value,
)
}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event) =>
handleVerificationCodeKeyDown(index, event)
}
onPaste={(event) =>
handleVerificationCodePaste(index, event)
}
pattern="[0-9]*"
ref={(element) => {
codeInputRefs.current[index] = element;
}}
type="text"
value={digit}
/>
<AnimatePresence initial={false} mode="wait">
{digit ? (
<motion.span
animate={{
opacity: 1,
transform: "translateY(0px)",
}}
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-2xl font-semibold text-foreground"
data-testid={`nostr-bind-code-digit-value-${index + 1}`}
exit={{
opacity: 0,
transform: shouldReduceMotion
? "translateY(0px)"
: "translateY(8px)",
}}
initial={{
opacity: 0,
transform: shouldReduceMotion
? "translateY(0px)"
: "translateY(8px)",
}}
key={digit}
transition={{
duration: shouldReduceMotion ? 0 : 0.15,
ease: "easeOut",
}}
>
{digit}
</motion.span>
) : null}
</AnimatePresence>
</div>
))}
</div>
<p
aria-live="polite"
className={cn(
"mt-2 min-h-5 text-destructive transition-opacity duration-150 ease-out",
hasCodeMismatch ? "opacity-100" : "opacity-0",
)}
id="nostr-bind-code-error"
role={hasCodeMismatch ? "alert" : undefined}
>
{hasCodeMismatch
? VERIFICATION_CODE_MISMATCH_MESSAGE
: "\u00a0"}
</p>
</fieldset>
{isExpired ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-destructive">
{EXPIRED_LINK_MESSAGE}
</p>
) : null}
{error ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-destructive">
{error}
</p>
) : null}
</div>
<div className="mt-8 flex w-full flex-col gap-3">
<Button
aria-label={copyButtonLabel}
className="h-10 w-full"
data-testid="nostr-bind-sign-and-copy"
disabled={
isSigning ||
isExpired ||
identity === null ||
!isVerificationCodeValid
}
onClick={handleSign}
type="button"
>
<span aria-live="polite" className="sr-only">
{copyButtonLabel}
</span>
<span
aria-hidden="true"
className="inline-grid h-5 place-items-center overflow-hidden"
>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
copyButtonLabel === "Continue"
? "translate-y-0 opacity-100"
: "-translate-y-0.5 opacity-0",
)}
>
Continue
</span>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
copyButtonLabel === "Signing…"
? "translate-y-0 opacity-100"
: "translate-y-0.5 opacity-0",
)}
>
Signing
</span>
</span>
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
disabled={isSigning}
onClick={() => handleOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
</OnboardingSlideTransition>
)}
</div>
</DialogPrimitive.Content>
) : null}
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
+1
View File
@@ -39,6 +39,7 @@ export type NostrBindDeepLinkPayload = {
origin: string;
expiresAt: string;
returnMode: "clipboard";
callbackUrl?: string;
};
/**
+26 -1
View File
@@ -8508,6 +8508,31 @@ export function maybeInstallE2eTauriMocks() {
return { ...DEFAULT_MOCK_IDENTITY, lost: isLost, locked: isLocked };
}
case "sign_nostr_identity_binding": {
const request = payload as {
challengeId: string;
expiresAt: string;
nonce: string;
origin: string;
verificationCode: string;
};
const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY;
return JSON.stringify({
id: "e2e-signed-nostr-binding",
pubkey: activeIdentity.pubkey,
created_at: 0,
kind: 24243,
tags: [
["challenge_id", request.challengeId],
["nonce", request.nonce],
["verification_code", request.verificationCode],
["origin", request.origin],
["expires_at", request.expiresAt],
],
content: "",
sig: "e2e-signed-nostr-binding",
});
}
case "get_nsec": {
const nsecSequence = activeConfig?.mock?.nsecErrors;
if (nsecSequence && nsecSequence.length > 0) {
@@ -9591,7 +9616,7 @@ export function maybeInstallE2eTauriMocks() {
};
window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ = (command, payload) =>
handleMockCommand(command, payload ?? null);
mockIPC(handleMockCommand);
mockIPC(handleMockCommand, { shouldMockEvents: true });
// Wire up __TAURI_INTERNALS__.listen so tests can subscribe to backend-emitted
// events (e.g. "agents-data-changed"). mockIPC already ensures __TAURI_INTERNALS__
+321
View File
@@ -0,0 +1,321 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
type NostrBindPayload = {
action: string;
audience: string;
challengeId: string;
expiresAt: string;
nonce: string;
origin: string;
protocol: string;
returnMode: string;
verificationCode: string;
version: string;
};
const VALID_REQUEST: NostrBindPayload = {
action: "bind_nostr_identity",
audience: "buzz:nostr-identity",
challengeId: "550e8400-e29b-41d4-a716-446655440000",
expiresAt: "2099-01-01T00:00:00Z",
nonce: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
origin: "https://admin.example.com",
protocol: "buzz-nostr-identity",
returnMode: "clipboard",
verificationCode: "123456",
version: "1",
};
async function openNostrBind(
page: Page,
payload: NostrBindPayload = VALID_REQUEST,
) {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
);
await page.evaluate(async (nextPayload) => {
const internals = (
window as Window & {
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
args: Record<string, unknown>,
) => Promise<unknown>;
};
}
).__TAURI_INTERNALS__;
if (!internals?.invoke) {
throw new Error("Tauri E2E event bridge is unavailable");
}
await internals.invoke("plugin:event|emit", {
event: "deep-link-nostr-bind",
payload: nextPayload,
});
}, payload);
await expect(page.getByTestId("nostr-bind-page")).toBeVisible();
}
async function pasteCode(input: Locator, code: string) {
await input.evaluate((element, pastedCode) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text", pastedCode);
element.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}, code);
}
async function signCommandPayloads(page: Page): Promise<unknown[]> {
return page.evaluate(() =>
(
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
)
.filter(({ command }) => command === "sign_nostr_identity_binding")
.map(({ payload }) => payload),
);
}
async function installClipboardStub(page: Page, shouldFail: boolean) {
await page.addInitScript(
({ fail }) => {
const testWindow = window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (text: string) => {
if (fail) {
throw new Error("clipboard unavailable");
}
testWindow.__BUZZ_E2E_CLIPBOARD_TEXT__ = text;
},
},
});
},
{ fail: shouldFail },
);
}
async function installShakeCounter(page: Page) {
await page.addInitScript(() => {
const testWindow = window as Window & {
__BUZZ_E2E_CODE_SHAKE_CALLS__?: number;
};
testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ = 0;
const originalAnimate = Element.prototype.animate;
Element.prototype.animate = function animate(keyframes, options) {
if (
this instanceof HTMLElement &&
this.dataset.testid === "nostr-bind-verification-code"
) {
testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ =
(testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ ?? 0) + 1;
}
return originalAnimate.call(this, keyframes, options);
};
});
}
async function shakeCount(page: Page): Promise<number> {
return page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CODE_SHAKE_CALLS__?: number;
}
).__BUZZ_E2E_CODE_SHAKE_CALLS__ ?? 0,
);
}
test("supports OTP entry, navigation, and paste without signing incomplete input", async ({
page,
}) => {
await openNostrBind(page);
await expect(page.getByText("Requesting origin")).toHaveCount(0);
await expect(page.getByText(VALID_REQUEST.origin)).toHaveCount(0);
const first = page.getByTestId("nostr-bind-code-digit-1");
const second = page.getByTestId("nostr-bind-code-digit-2");
const third = page.getByTestId("nostr-bind-code-digit-3");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
await first.click();
await first.press("1");
await expect(second).toBeFocused();
await second.press("2");
await expect(third).toBeFocused();
await third.press("ArrowLeft");
await expect(second).toBeFocused();
await second.press("Backspace");
await expect(second).toHaveValue("");
await expect(continueButton).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
await pasteCode(first, VALID_REQUEST.verificationCode);
for (const [index, digit] of [...VALID_REQUEST.verificationCode].entries()) {
await expect(
page.getByTestId(`nostr-bind-code-digit-${index + 1}`),
).toHaveValue(digit);
}
await expect(page.getByTestId("nostr-bind-code-digit-6")).toBeFocused();
await expect(continueButton).toBeEnabled();
});
test("locks a filled sixth slot and repeats mismatch feedback without signing", async ({
page,
}) => {
await installShakeCounter(page);
await openNostrBind(page);
const first = page.getByTestId("nostr-bind-code-digit-1");
const last = page.getByTestId("nostr-bind-code-digit-6");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
await pasteCode(first, VALID_REQUEST.verificationCode);
await last.press("9");
await expect(last).toHaveValue("6");
await expect(continueButton).toBeEnabled();
await expect.poll(() => shakeCount(page)).toBe(1);
await pasteCode(first, "654321");
await expect(page.getByRole("alert")).toHaveText(
"That code doesn't match. Check the code and try again.",
);
await expect(continueButton).toBeDisabled();
await expect.poll(() => shakeCount(page)).toBe(2);
await pasteCode(first, "654321");
await expect.poll(() => shakeCount(page)).toBe(3);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("honors reduced motion while rejecting a mismatched code", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installShakeCounter(page);
await openNostrBind(page);
await pasteCode(page.getByTestId("nostr-bind-code-digit-1"), "654321");
await expect(page.getByRole("alert")).toBeVisible();
await expect.poll(() => shakeCount(page)).toBe(0);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("rejects an expired request without signing", async ({ page }) => {
await openNostrBind(page, {
...VALID_REQUEST,
expiresAt: "2000-01-01T00:00:00Z",
});
await expect(
page.getByText(
"This binding link has expired. Request a new one from the requesting app.",
),
).toBeVisible();
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-sign-and-copy")).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("cancels a request without signing", async ({ page }) => {
await openNostrBind(page);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("signs a valid request, shows the response, and copies it", async ({
page,
}) => {
await installClipboardStub(page, false);
await openNostrBind(page);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await page.getByTestId("nostr-bind-sign-and-copy").click();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
const response = page.getByTestId("nostr-bind-signed-response");
await expect(response).toContainText("e2e-signed-nostr-binding");
await expect
.poll(() => signCommandPayloads(page))
.toEqual([
{
challengeId: VALID_REQUEST.challengeId,
expiresAt: VALID_REQUEST.expiresAt,
nonce: VALID_REQUEST.nonce,
origin: VALID_REQUEST.origin,
verificationCode: VALID_REQUEST.verificationCode,
},
]);
const signedResponse = await response.textContent();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
page.getByTestId("nostr-bind-copy-response"),
).toHaveAccessibleName("Copied");
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
}
).__BUZZ_E2E_CLIPBOARD_TEXT__,
),
)
.toBe(signedResponse);
});
test("keeps the signed response available when clipboard access fails", async ({
page,
}) => {
await installClipboardStub(page, true);
await openNostrBind(page);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await page.getByTestId("nostr-bind-sign-and-copy").click();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
page.getByText("Buzz couldn't access the clipboard. Try again."),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-signed-response")).toContainText(
"e2e-signed-nostr-binding",
);
await expect(
page.getByTestId("nostr-bind-copy-response"),
).toHaveAccessibleName("Copy response");
});