mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): redesign Nostr bind verification flow (#1850)
Co-authored-by: tulsi <tulsi@block.xyz>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export type NostrBindDeepLinkPayload = {
|
||||
origin: string;
|
||||
expiresAt: string;
|
||||
returnMode: "clipboard";
|
||||
callbackUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user