feat(desktop): streamline nostr identity pairing (#1974)

Signed-off-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Kalvin C
2026-07-16 12:44:14 -07:00
committed by GitHub
co-authored by npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54
parent 74f3f29758
commit 1eae8575ed
4 changed files with 325 additions and 105 deletions
@@ -1,5 +1,6 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { toast } from "sonner";
@@ -88,6 +89,11 @@ function normalizeVerificationCode(value: string): string[] {
.map((character) => character.trim());
}
function isNostrBindRequestExpired(payload: NostrBindDeepLinkPayload): boolean {
const expiry = new Date(payload.expiresAt).getTime();
return Number.isNaN(expiry) || expiry <= Date.now();
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
@@ -135,6 +141,74 @@ async function returnSignedResponseToBrowser(
}
}
function SignedResponseControls({
copyFailed,
copyLabel,
onCopy,
signedResponse,
}: {
copyFailed: boolean;
copyLabel: string;
onCopy: () => void;
signedResponse: string;
}) {
return (
<div className="space-y-4" data-testid="nostr-bind-manual-fallback-content">
<pre
className="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="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}
<Button
aria-label={copyLabel}
className="h-10 w-full"
data-testid="nostr-bind-copy-response"
onClick={onCopy}
type="button"
>
<span aria-live="polite" className="sr-only">
{copyLabel}
</span>
<span
aria-hidden="true"
className="inline-grid h-5 place-items-center overflow-hidden"
>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
copyLabel === "Copy response"
? "translate-y-0 opacity-100"
: "-translate-y-0.5 opacity-0",
)}
>
Copy response
</span>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
copyLabel === "Copied"
? "translate-y-0 opacity-100"
: "translate-y-0.5 opacity-0",
)}
>
Copied
</span>
</span>
</Button>
</div>
);
}
export function NostrBindConsentDialog() {
const isPreview = isNostrBindPreviewEnabled();
const [payload, setPayload] = React.useState<NostrBindDeepLinkPayload | null>(
@@ -154,12 +228,15 @@ export function NostrBindConsentDialog() {
const [hasCodeMismatch, setHasCodeMismatch] = React.useState(false);
const [copyFailed, setCopyFailed] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [isManualFallbackOpen, setIsManualFallbackOpen] = React.useState(false);
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 autoSignAttemptRef = React.useRef<string | null>(null);
const activeSignAttemptRef = React.useRef<symbol | null>(null);
const systemColorScheme = useSystemColorScheme();
const shouldReduceMotion = useReducedMotion();
const enteredVerificationCode = verificationCode.join("");
@@ -204,13 +281,17 @@ export function NostrBindConsentDialog() {
const unlistenPromise = listenForNostrBindDeepLinks((nextPayload) => {
clearCopiedState();
autoSignAttemptRef.current = null;
activeSignAttemptRef.current = null;
setPayload(nextPayload);
setIdentity(null);
setIsSigning(false);
setSignedResponse(null);
setVerificationCode(createEmptyVerificationCode());
setHasCodeMismatch(false);
setCopyFailed(false);
setError(null);
setIsManualFallbackOpen(false);
getIdentity()
.then(setIdentity)
.catch((error) => {
@@ -225,22 +306,19 @@ export function NostrBindConsentDialog() {
};
}, [clearCopiedState, isPreview]);
const isExpired = React.useMemo(() => {
if (!payload) {
return false;
}
const expiry = new Date(payload.expiresAt).getTime();
return Number.isNaN(expiry) || expiry <= Date.now();
}, [payload]);
const isExpired = payload !== null && isNostrBindRequestExpired(payload);
const resetDialog = React.useCallback(() => {
clearCopiedState();
autoSignAttemptRef.current = null;
activeSignAttemptRef.current = null;
setPayload(null);
setSignedResponse(null);
setVerificationCode(createEmptyVerificationCode());
setHasCodeMismatch(false);
setCopyFailed(false);
setError(null);
setIsManualFallbackOpen(false);
setIdentity(null);
setIsSigning(false);
}, [clearCopiedState]);
@@ -297,6 +375,7 @@ export function NostrBindConsentDialog() {
(index: number, value: string) => {
const nextDigits = value.replace(/\D/g, "");
const next = [...verificationCode];
autoSignAttemptRef.current = null;
if (!nextDigits) {
next[index] = "";
@@ -363,6 +442,7 @@ export function NostrBindConsentDialog() {
}
event.preventDefault();
autoSignAttemptRef.current = null;
const next = normalizeVerificationCode(pastedCode);
setVerificationCode(next);
if (
@@ -402,6 +482,7 @@ export function NostrBindConsentDialog() {
if (event.key === "Backspace") {
event.preventDefault();
autoSignAttemptRef.current = null;
const targetIndex = verificationCode[index]
? index
: Math.max(index - 1, 0);
@@ -429,10 +510,10 @@ export function NostrBindConsentDialog() {
);
const handleSign = React.useCallback(async () => {
if (!payload) {
if (activeSignAttemptRef.current || !payload) {
return;
}
if (isExpired) {
if (isNostrBindRequestExpired(payload)) {
setError(EXPIRED_LINK_MESSAGE);
return;
}
@@ -447,6 +528,8 @@ export function NostrBindConsentDialog() {
return;
}
const attempt = Symbol("nostr-bind-sign");
activeSignAttemptRef.current = attempt;
setIsSigning(true);
clearCopiedState();
setError(null);
@@ -461,21 +544,35 @@ export function NostrBindConsentDialog() {
origin: payload.origin,
expiresAt: payload.expiresAt,
});
if (activeSignAttemptRef.current !== attempt) {
return;
}
setSignedResponse(signed);
if (payload.returnMode === "browser_fragment_v1" && payload.callbackUrl) {
setError(
await returnSignedResponseToBrowser(payload.callbackUrl, signed),
const callbackError = await returnSignedResponseToBrowser(
payload.callbackUrl,
signed,
);
if (activeSignAttemptRef.current !== attempt) {
return;
}
setError(callbackError);
setIsManualFallbackOpen(callbackError !== null);
}
} catch (error) {
setError(formatError(error) || "Failed to sign binding response.");
if (activeSignAttemptRef.current === attempt) {
setError(formatError(error) || "Failed to sign binding response.");
}
} finally {
setIsSigning(false);
if (activeSignAttemptRef.current === attempt) {
activeSignAttemptRef.current = null;
setIsSigning(false);
}
}
}, [
clearCopiedState,
enteredVerificationCode,
isExpired,
isPreview,
isVerificationCodeComplete,
isVerificationCodeValid,
@@ -484,6 +581,35 @@ export function NostrBindConsentDialog() {
verificationCode,
]);
React.useEffect(() => {
if (
!payload ||
identity === null ||
isExpired ||
isSigning ||
signedResponse !== null ||
!isVerificationCodeValid
) {
return;
}
const attemptKey = `${payload.challengeId}:${enteredVerificationCode}`;
if (autoSignAttemptRef.current === attemptKey) {
return;
}
autoSignAttemptRef.current = attemptKey;
void handleSign();
}, [
enteredVerificationCode,
handleSign,
identity,
isExpired,
isSigning,
isVerificationCodeValid,
payload,
signedResponse,
]);
const handleCopyAgain = React.useCallback(async () => {
if (!signedResponse) {
return;
@@ -548,7 +674,7 @@ export function NostrBindConsentDialog() {
id="nostr-bind-description"
>
{payload.returnMode === "browser_fragment_v1"
? "Buzz opened your browser to finish verification. If it did not open, copy the response below."
? "Buzz opened your browser to finish verification."
: "Copy the response below, then paste it into the Buzz website to finish verification."}
</DialogPrimitive.Description>
@@ -558,65 +684,50 @@ export function NostrBindConsentDialog() {
</p>
) : null}
<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}
{payload.returnMode === "browser_fragment_v1" ? (
<details
className="group mt-10 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/30 text-left shadow-xs"
data-testid="nostr-bind-manual-fallback"
onToggle={(event) =>
setIsManualFallbackOpen(event.currentTarget.open)
}
open={isManualFallbackOpen}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 text-sm font-medium transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<span>Pairing didn’t finish automatically?</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open:rotate-180" />
</summary>
<div className="space-y-4 border-t border-border/55 p-4">
<p className="text-sm leading-6 text-muted-foreground">
Copy this response and paste it into the pairing page.
</p>
<SignedResponseControls
copyFailed={copyFailed}
copyLabel={finishCopyButtonLabel}
onCopy={handleCopyAgain}
signedResponse={signedResponse}
/>
</div>
</details>
) : (
<div className="mt-10 w-full">
<SignedResponseControls
copyFailed={copyFailed}
copyLabel={finishCopyButtonLabel}
onCopy={handleCopyAgain}
signedResponse={signedResponse}
/>
</div>
)}
<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
Continue
</Button>
</div>
</OnboardingSlideTransition>
+11
View File
@@ -189,6 +189,10 @@ type E2eConfig = {
* Linux .deb install where Tauri's updater cannot swap the binary).
* Defaults to true for all existing tests. */
autoUpdateSupported?: boolean;
/** Reject `plugin:opener|open_url` to exercise browser-return fallback UI. */
openerError?: string;
/** Delay binding signatures so specs can exercise request supersession. */
nostrBindSignDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
@@ -8641,6 +8645,10 @@ export function maybeInstallE2eTauriMocks() {
origin: string;
verificationCode: string;
};
const signDelayMs = activeConfig?.mock?.nostrBindSignDelayMs ?? 0;
if (signDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, signDelayMs));
}
const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY;
return JSON.stringify({
id: "e2e-signed-nostr-binding",
@@ -9654,6 +9662,9 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof sendToMockSocket>[0],
);
case "plugin:opener|open_url":
if (activeConfig?.mock?.openerError) {
throw new Error(activeConfig.mock.openerError);
}
openedExternalUrls.push(String((payload as { url: string | URL }).url));
return null;
case "get_e2e_opened_external_urls":