+
+
+
+
Waiting for Amber...
+
+ Approve the request in Amber. You'll be logged in automatically when you return.
+
+
+ {/* Animated dots */}
+
+ {[0, 1, 2].map(i => (
+
+ ))}
+
+
)}
-
- {/* Success state */}
+
+ {/* Success */}
{amberStep === 'success' && (
-
-
-
- Successfully logged in!
-
-
+
)}
diff --git a/src/lib/amber.ts b/src/lib/amber.ts
index 3b03b59..10b0fe6 100644
--- a/src/lib/amber.ts
+++ b/src/lib/amber.ts
@@ -239,43 +239,56 @@ function generateOperationId(): string {
}
/**
- * Build callback URL for Amber operations.
+ * Build the dedicated Amber callback URL.
+ * Using a fixed /amber-callback path so Amber always returns to the same place.
*/
function buildCallbackUrl(): string {
- const baseUrl = window.location.origin + window.location.pathname;
- return `${baseUrl}?event=`;
+ return `${window.location.origin}/amber-callback?event=`;
}
/**
* Open Amber for get_public_key operation.
+ * Uses a dedicated /amber-callback route so the app state is preserved
+ * and the callback is processed reliably on return.
*/
export function openAmberForLogin(): void {
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
-
+
const pendingOp = {
type: 'get_public_key' as const,
id: operationId,
timestamp: Date.now(),
};
-
- console.log('[Amber] Opening Amber for login...');
- console.log('[Amber] Callback URL:', callbackUrl);
- console.log('[Amber] Pending operation:', pendingOp);
-
+
setAmberPendingOperation(pendingOp);
-
- // Verify it was stored
- const stored = localStorage.getItem('amber_pending_operation');
- console.log('[Amber] Stored in localStorage:', stored);
-
- // NIP-55 format for web applications
+
+ // NIP-55 nostrsigner: URI — Amber opens, user approves, Amber calls callbackUrl
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`;
-
- console.log('[Amber] Opening URL:', amberUrl);
+
+ console.log('[Amber] Opening Amber for login, callback:', callbackUrl);
window.location.href = amberUrl;
}
+/**
+ * Subscribe to app becoming visible again after user switches back from Amber.
+ * Calls `onReturn` once when the page regains visibility.
+ * Returns an unsubscribe function.
+ *
+ * Usage:
+ * const unsub = subscribeToAmberReturn(() => checkClipboardForPubkey());
+ */
+export function subscribeToAmberReturn(onReturn: () => void): () => void {
+ const handler = () => {
+ if (document.visibilityState === 'visible') {
+ document.removeEventListener('visibilitychange', handler);
+ onReturn();
+ }
+ };
+ document.addEventListener('visibilitychange', handler);
+ return () => document.removeEventListener('visibilitychange', handler);
+}
+
/**
* Open Amber for sign_event operation.
* Returns a promise that resolves when user returns to the page.
diff --git a/src/pages/AmberCallbackPage.tsx b/src/pages/AmberCallbackPage.tsx
new file mode 100644
index 0000000..d8ce71e
--- /dev/null
+++ b/src/pages/AmberCallbackPage.tsx
@@ -0,0 +1,169 @@
+/**
+ * AmberCallbackPage
+ *
+ * Amber (NIP-55) redirects back to /amber-callback?event=
+ * after the user approves a request. This page processes the result immediately
+ * and redirects the user back to where they came from (or home).
+ *
+ * Having a dedicated route means:
+ * - The main app state is never destroyed on outgoing redirect
+ * - Amber always knows where to return regardless of which page opened it
+ * - Processing is fast and automatic — no manual pasting needed
+ */
+
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useLoginActions } from '@/hooks/useLoginActions';
+import { useToast } from '@/hooks/useToast';
+import {
+ getAmberCallbackResult,
+ getAmberPendingOperationAsync,
+ clearAmberPendingOperation,
+ cleanAmberCallbackUrl,
+} from '@/lib/amber';
+import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
+
+type Status = 'processing' | 'success' | 'error';
+
+export default function AmberCallbackPage() {
+ const navigate = useNavigate();
+ const login = useLoginActions();
+ const { toast } = useToast();
+ const hasProcessed = useRef(false);
+ const [status, setStatus] = useState('processing');
+ const [message, setMessage] = useState('Completing login with Amber...');
+
+ useEffect(() => {
+ if (hasProcessed.current) return;
+ hasProcessed.current = true;
+
+ processCallback();
+
+ async function processCallback() {
+ const result = getAmberCallbackResult();
+ cleanAmberCallbackUrl();
+
+ if (!result) {
+ setStatus('error');
+ setMessage('No result received from Amber. The request may have been cancelled.');
+ clearAmberPendingOperation();
+ redirectBack(false);
+ return;
+ }
+
+ const pendingOp = await getAmberPendingOperationAsync();
+ const pubkeyRegex = /^[0-9a-fA-F]{64}$/;
+
+ // ── get_public_key (login) ──────────────────────────────────────────────
+ const isLoginOp = !pendingOp || pendingOp.type === 'get_public_key';
+ if (isLoginOp) {
+ if (pubkeyRegex.test(result)) {
+ try {
+ login.amber(result.toLowerCase());
+ clearAmberPendingOperation();
+ setStatus('success');
+ setMessage('Logged in! Returning to SkateSpots...');
+ toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
+ redirectBack(true);
+ } catch (err) {
+ console.error('[AmberCallback] login.amber failed:', err);
+ setStatus('error');
+ setMessage('Login failed. Please try again.');
+ clearAmberPendingOperation();
+ redirectBack(false);
+ }
+ } else {
+ setStatus('error');
+ setMessage('Invalid pubkey received from Amber.');
+ clearAmberPendingOperation();
+ redirectBack(false);
+ }
+ return;
+ }
+
+ // ── sign_event ──────────────────────────────────────────────────────────
+ if (pendingOp.type === 'sign_event') {
+ try {
+ const signedEvent = JSON.parse(result);
+ sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
+ clearAmberPendingOperation();
+ setStatus('success');
+ setMessage('Event signed! Returning...');
+ toast({ title: 'Event signed', description: 'Your event was signed by Amber.' });
+ redirectBack(true);
+ } catch {
+ setStatus('error');
+ setMessage('Failed to process signed event from Amber.');
+ clearAmberPendingOperation();
+ redirectBack(false);
+ }
+ return;
+ }
+
+ // ── encrypt / decrypt ───────────────────────────────────────────────────
+ if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) {
+ sessionStorage.setItem('amber_crypto_result', result);
+ clearAmberPendingOperation();
+ setStatus('success');
+ setMessage('Operation complete! Returning...');
+ redirectBack(true);
+ return;
+ }
+
+ // Unknown
+ clearAmberPendingOperation();
+ redirectBack(false);
+ }
+
+ function redirectBack(success: boolean) {
+ // Try to go back to where we came from, fall back to home
+ const returnTo = sessionStorage.getItem('amber_return_path') || '/';
+ sessionStorage.removeItem('amber_return_path');
+ setTimeout(() => navigate(returnTo, { replace: true }), success ? 800 : 1500);
+ }
+ }, [login, toast, navigate]);
+
+ return (
+
+
+ {/* Icon */}
+
+ {status === 'processing' && (
+
+
+
+ )}
+ {status === 'success' && (
+
+
+
+ )}
+ {status === 'error' && (
+
+
+
+ )}
+
+
+ {/* Amber logo text */}
+
+
Amber · NIP-55
+
{message}
+
+
+ {/* Dots loader */}
+ {status === 'processing' && (
+
+ {[0, 1, 2].map(i => (
+
+ ))}
+
+ )}
+
+
+ );
+}