Fix Amber login: replace nostrconnect/relay flow with proper NIP-55 intent flow
The entire nostrconnect relay approach was wrong for a native Android/Capacitor app.
Amber on Android uses NIP-55 intents (nostrsigner: URI scheme), not NIP-46 relays.
New flow (same as Amethyst):
1. openAmberForLogin() fires nostrsigner:?type=get_public_key&callbackUrl=...
2. Amber opens, user taps approve
3. Amber calls back to org.skatespots.app://amber-callback?event=<pubkey>
4. AmberCallbackPage writes pubkey to localStorage('amber_pending_pubkey')
5. LoginDialog polls localStorage every 500ms and calls login.amber(pubkey)
6. Done — no relays, no waiting, no spinning
Also added login.amber(pubkey) method to useLoginActions.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
e3dd66d078
commit
4f7852fec7
2 changed files with 112 additions and 391 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
// NOTE: This file is stable and usually should not be modified.
|
// NOTE: This file is stable and usually should not be modified.
|
||||||
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, Copy, RefreshCw, KeyRound } from 'lucide-react';
|
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
|
@ -10,10 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { NLogin } from '@nostrify/react/login';
|
import { openAmberForLogin } from '@/lib/amber';
|
||||||
import { useNostrLogin } from '@nostrify/react/login';
|
|
||||||
|
|
||||||
import { SimplePool, generateSecretKey, getPublicKey, nip19, nip04 } from 'nostr-tools';
|
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -22,68 +19,6 @@ interface LoginDialogProps {
|
||||||
onSignup?: () => void;
|
onSignup?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOSTRCONNECT_RELAYS = [
|
|
||||||
'wss://relay.nsec.app',
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Key for storing pending nostrconnect session across app restarts
|
|
||||||
const PENDING_NC_KEY = 'skatespots_pending_nc';
|
|
||||||
|
|
||||||
interface PendingNostrConnect {
|
|
||||||
clientNsec: string;
|
|
||||||
clientPubkey: string;
|
|
||||||
secret: string;
|
|
||||||
relays: string[];
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNostrConnectURI(opts: {
|
|
||||||
clientPubkey: string;
|
|
||||||
relays: string[];
|
|
||||||
secret: string;
|
|
||||||
name?: string;
|
|
||||||
url?: string;
|
|
||||||
perms?: string;
|
|
||||||
}): string {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
for (const r of opts.relays) params.append('relay', r);
|
|
||||||
params.set('secret', opts.secret);
|
|
||||||
if (opts.name) params.set('name', opts.name);
|
|
||||||
if (opts.url) params.set('url', opts.url);
|
|
||||||
if (opts.perms) params.set('perms', opts.perms);
|
|
||||||
return `nostrconnect://${opts.clientPubkey}?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomSecret(): string {
|
|
||||||
const arr = new Uint8Array(12);
|
|
||||||
crypto.getRandomValues(arr);
|
|
||||||
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePendingNC(data: PendingNostrConnect) {
|
|
||||||
try { localStorage.setItem(PENDING_NC_KEY, JSON.stringify(data)); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPendingNC(): PendingNostrConnect | null {
|
|
||||||
try {
|
|
||||||
const s = localStorage.getItem(PENDING_NC_KEY);
|
|
||||||
if (!s) return null;
|
|
||||||
const d = JSON.parse(s) as PendingNostrConnect;
|
|
||||||
// Expire after 10 minutes
|
|
||||||
if (Date.now() - d.timestamp > 10 * 60 * 1000) {
|
|
||||||
localStorage.removeItem(PENDING_NC_KEY);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPendingNC() {
|
|
||||||
try { localStorage.removeItem(PENDING_NC_KEY); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
|
export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [bunkerUri, setBunkerUri] = useState('');
|
const [bunkerUri, setBunkerUri] = useState('');
|
||||||
|
|
@ -91,265 +26,90 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
const [nsecInput, setNsecInput] = useState('');
|
const [nsecInput, setNsecInput] = useState('');
|
||||||
const [nsecError, setNsecError] = useState('');
|
const [nsecError, setNsecError] = useState('');
|
||||||
|
|
||||||
const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle');
|
// Amber NIP-55 state — just waiting for callback, no relay nonsense
|
||||||
const [amberStatus, setAmberStatus] = useState('');
|
const [amberWaiting, setAmberWaiting] = useState(false);
|
||||||
const [amberElapsed, setAmberElapsed] = useState(0);
|
const [amberDone, setAmberDone] = useState(false);
|
||||||
const [nostrConnectUri, setNostrConnectUri] = useState('');
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
const poolRef = useRef<SimplePool | null>(null);
|
|
||||||
const subRef = useRef<{ close: () => void } | null>(null);
|
|
||||||
const pendingRef = useRef<PendingNostrConnect | null>(null);
|
|
||||||
const amberStepRef = useRef<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle');
|
|
||||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const login = useLoginActions();
|
const login = useLoginActions();
|
||||||
const { addLogin, setLogin } = useNostrLogin();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||||
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
|
|
||||||
|
|
||||||
const cleanup = useCallback(() => {
|
// Poll localStorage for the pubkey that AmberCallbackPage writes when Amber returns
|
||||||
try { subRef.current?.close(); } catch { /* ignore */ }
|
const startPolling = () => {
|
||||||
try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ }
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
subRef.current = null;
|
pollRef.current = setInterval(() => {
|
||||||
poolRef.current = null;
|
const pubkey = localStorage.getItem('amber_pending_pubkey');
|
||||||
if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; }
|
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) {
|
||||||
if (elapsedIntervalRef.current) { clearInterval(elapsedIntervalRef.current); elapsedIntervalRef.current = null; }
|
localStorage.removeItem('amber_pending_pubkey');
|
||||||
}, []);
|
clearInterval(pollRef.current!);
|
||||||
|
pollRef.current = null;
|
||||||
// Complete login — store as 'amber' type (NIP-55), not bunker.
|
console.log('[Amber] Got pubkey from callback:', pubkey);
|
||||||
// Amber signs as the user's own key, so signerPubkey IS the user's pubkey.
|
try {
|
||||||
const completeLogin = useCallback(async (signerPubkey: string) => {
|
login.amber(pubkey);
|
||||||
setAmberStatus('Logging in...');
|
setAmberDone(true);
|
||||||
console.log('[Amber] Completing login for pubkey:', signerPubkey);
|
setAmberWaiting(false);
|
||||||
|
|
||||||
// Store as amber login type — picked up by useCurrentUser → AmberSigner
|
|
||||||
const nlogin = new NLogin('amber' as Parameters<typeof NLogin>[0], signerPubkey);
|
|
||||||
addLogin(nlogin);
|
|
||||||
setLogin(nlogin.id);
|
|
||||||
clearPendingNC();
|
|
||||||
cleanup();
|
|
||||||
amberStepRef.current = 'success';
|
|
||||||
setAmberStep('success');
|
|
||||||
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
|
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
|
||||||
setTimeout(() => { onLogin(); onClose(); }, 800);
|
setTimeout(() => { onLogin(); onClose(); }, 800);
|
||||||
}, [addLogin, setLogin, cleanup, onLogin, onClose, toast]);
|
} catch (e) {
|
||||||
|
console.error('[Amber] login.amber() failed:', e);
|
||||||
// Poll relay once — returns true if Amber's response was found and login completed
|
setAmberWaiting(false);
|
||||||
const pollRelay = useCallback(async (): Promise<boolean> => {
|
toast({ title: 'Login failed', description: 'Could not log in with Amber. Try again.', variant: 'destructive' });
|
||||||
const pending = pendingRef.current || loadPendingNC();
|
|
||||||
if (!pending || amberStepRef.current !== 'waiting') return false;
|
|
||||||
|
|
||||||
console.log('[Amber] Polling relay for response...');
|
|
||||||
setAmberStatus('Checking relay for Amber response...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = nip19.decode(pending.clientNsec);
|
|
||||||
if (decoded.type !== 'nsec') return false;
|
|
||||||
const clientSk = decoded.data;
|
|
||||||
const since = Math.floor(pending.timestamp / 1000) - 30;
|
|
||||||
const freshPool = new SimplePool();
|
|
||||||
let found = false;
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const timeout = setTimeout(() => { freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 6000);
|
|
||||||
|
|
||||||
const sub = freshPool.subscribeMany(
|
|
||||||
NOSTRCONNECT_RELAYS,
|
|
||||||
[{ kinds: [24133], '#p': [pending.clientPubkey], since }],
|
|
||||||
{
|
|
||||||
onevent: async (event) => {
|
|
||||||
if (found) return;
|
|
||||||
console.log('[Amber] Poll received event from:', event.pubkey);
|
|
||||||
try {
|
|
||||||
const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content);
|
|
||||||
const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string };
|
|
||||||
if (parsed.error && parsed.error !== '') return;
|
|
||||||
if (parsed.result !== pending.secret && parsed.result !== 'ack') return;
|
|
||||||
found = true;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
sub.close();
|
|
||||||
freshPool.close(NOSTRCONNECT_RELAYS);
|
|
||||||
pendingRef.current = null;
|
|
||||||
abortRef.current?.abort();
|
|
||||||
await completeLogin(event.pubkey);
|
|
||||||
resolve();
|
|
||||||
} catch { /* wrong encryption key or malformed event */ }
|
|
||||||
},
|
|
||||||
oneose: () => {
|
|
||||||
setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 1500);
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!found && amberStepRef.current === 'waiting') {
|
|
||||||
setAmberStatus('Nothing yet — still waiting for Amber...');
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
} catch { return false; }
|
|
||||||
}, [completeLogin]);
|
|
||||||
|
|
||||||
// When app returns from Amber: immediately poll, then keep polling every 3s
|
|
||||||
useEffect(() => {
|
|
||||||
const handleVisibility = async () => {
|
|
||||||
if (document.visibilityState !== 'visible') return;
|
|
||||||
if (amberStepRef.current !== 'waiting') return;
|
|
||||||
|
|
||||||
console.log('[Amber] App foregrounded — starting poll loop');
|
|
||||||
|
|
||||||
// Poll immediately on return
|
|
||||||
const found = await pollRelay();
|
|
||||||
if (found) return;
|
|
||||||
|
|
||||||
// Then poll every 3 seconds until success or timeout/cancel clears the interval
|
|
||||||
if (!pollIntervalRef.current) {
|
|
||||||
pollIntervalRef.current = setInterval(async () => {
|
|
||||||
if (amberStepRef.current !== 'waiting') {
|
|
||||||
clearInterval(pollIntervalRef.current!);
|
|
||||||
pollIntervalRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const done = await pollRelay();
|
|
||||||
if (done) {
|
|
||||||
clearInterval(pollIntervalRef.current!);
|
|
||||||
pollIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibility);
|
const handleAmberLogin = () => {
|
||||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
console.log('[Amber] Opening Amber for NIP-55 get_public_key...');
|
||||||
}, [pollRelay]);
|
localStorage.removeItem('amber_pending_pubkey'); // clear any stale value
|
||||||
|
openAmberForLogin();
|
||||||
|
setAmberWaiting(true);
|
||||||
|
startPolling();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also detect when user comes back to the app (visibilitychange)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!amberWaiting) return;
|
||||||
|
const handleVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
console.log('[Amber] App foregrounded — checking for pubkey');
|
||||||
|
const pubkey = localStorage.getItem('amber_pending_pubkey');
|
||||||
|
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) {
|
||||||
|
localStorage.removeItem('amber_pending_pubkey');
|
||||||
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||||
|
try {
|
||||||
|
login.amber(pubkey);
|
||||||
|
setAmberDone(true);
|
||||||
|
setAmberWaiting(false);
|
||||||
|
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
|
||||||
|
setTimeout(() => { onLogin(); onClose(); }, 800);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Amber] login.amber() failed:', e);
|
||||||
|
setAmberWaiting(false);
|
||||||
|
toast({ title: 'Login failed', description: 'Could not log in with Amber. Try again.', variant: 'destructive' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisible);
|
||||||
|
return () => document.removeEventListener('visibilitychange', handleVisible);
|
||||||
|
}, [amberWaiting, login, onLogin, onClose, toast]);
|
||||||
|
|
||||||
// Reset when dialog closes
|
// Reset when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
abortRef.current?.abort();
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||||
abortRef.current = null;
|
setAmberWaiting(false);
|
||||||
cleanup();
|
setAmberDone(false);
|
||||||
amberStepRef.current = 'idle';
|
|
||||||
setAmberStep('idle');
|
|
||||||
setAmberStatus('');
|
|
||||||
setAmberElapsed(0);
|
|
||||||
setNostrConnectUri('');
|
|
||||||
setBunkerUri('');
|
setBunkerUri('');
|
||||||
setBunkerError('');
|
setBunkerError('');
|
||||||
setNsecInput('');
|
setNsecInput('');
|
||||||
setNsecError('');
|
setNsecError('');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, cleanup]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const startAmberConnect = useCallback(async () => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
abortRef.current = controller;
|
|
||||||
|
|
||||||
const clientSk = generateSecretKey();
|
|
||||||
const clientPubkey = getPublicKey(clientSk);
|
|
||||||
const clientNsec = nip19.nsecEncode(clientSk);
|
|
||||||
const secret = randomSecret();
|
|
||||||
|
|
||||||
const uri = buildNostrConnectURI({
|
|
||||||
clientPubkey,
|
|
||||||
relays: NOSTRCONNECT_RELAYS,
|
|
||||||
secret,
|
|
||||||
name: 'SkateSpots',
|
|
||||||
url: 'https://skate-spots.org',
|
|
||||||
perms: 'get_public_key,sign_event,nip44_encrypt,nip44_decrypt',
|
|
||||||
});
|
|
||||||
|
|
||||||
const pending: PendingNostrConnect = {
|
|
||||||
clientNsec,
|
|
||||||
clientPubkey,
|
|
||||||
secret,
|
|
||||||
relays: NOSTRCONNECT_RELAYS,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
pendingRef.current = pending;
|
|
||||||
savePendingNC(pending);
|
|
||||||
|
|
||||||
setNostrConnectUri(uri);
|
|
||||||
amberStepRef.current = 'connecting';
|
|
||||||
setAmberStep('connecting');
|
|
||||||
setAmberStatus('Opening Amber...');
|
|
||||||
|
|
||||||
console.log('[Amber] Generated nostrconnect URI:', uri);
|
|
||||||
console.log('[Amber] Relays:', NOSTRCONNECT_RELAYS);
|
|
||||||
console.log('[Amber] Client pubkey:', clientPubkey);
|
|
||||||
|
|
||||||
try { window.open(uri, '_blank'); } catch { /* user copies manually */ }
|
|
||||||
|
|
||||||
amberStepRef.current = 'waiting';
|
|
||||||
setAmberStep('waiting');
|
|
||||||
setAmberStatus('Approve in Amber, then come back here.');
|
|
||||||
setAmberElapsed(0);
|
|
||||||
if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current);
|
|
||||||
elapsedIntervalRef.current = setInterval(() => {
|
|
||||||
setAmberElapsed(s => s + 1);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// Live relay subscription (works on web/PWA; may be backgrounded in Capacitor)
|
|
||||||
const pool = new SimplePool();
|
|
||||||
poolRef.current = pool;
|
|
||||||
const since = Math.floor(Date.now() / 1000) - 10;
|
|
||||||
|
|
||||||
console.log('[Amber] Subscribing to relays since:', since);
|
|
||||||
|
|
||||||
const sub = pool.subscribeMany(
|
|
||||||
NOSTRCONNECT_RELAYS,
|
|
||||||
[{ kinds: [24133], '#p': [clientPubkey], since }],
|
|
||||||
{
|
|
||||||
onevent: async (event) => {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
console.log('[Amber] Received event from:', event.pubkey);
|
|
||||||
try {
|
|
||||||
const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content);
|
|
||||||
const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string };
|
|
||||||
console.log('[Amber] Decrypted response:', parsed);
|
|
||||||
if (parsed.error && parsed.error !== '') {
|
|
||||||
console.warn('[Amber] Error in response:', parsed.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (parsed.result !== secret && parsed.result !== 'ack') {
|
|
||||||
console.warn('[Amber] Secret mismatch, ignoring');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('[Amber] Valid response — completing login');
|
|
||||||
controller.abort();
|
|
||||||
pendingRef.current = null;
|
|
||||||
await completeLogin(event.pubkey);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Amber] Failed to decrypt event:', e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
subRef.current = sub;
|
|
||||||
|
|
||||||
// On Capacitor (native app) give 3 minutes — the visibility change handler does the real work
|
|
||||||
// after the user returns from Amber, so the live subscription just needs to stay alive.
|
|
||||||
// On web/PWA the live subscription fires instantly, so 3 min is still fine as a safety net.
|
|
||||||
const TIMEOUT_MS = isCapacitor ? 3 * 60 * 1000 : 3 * 60 * 1000;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
console.warn('[Amber] Timed out — no response received');
|
|
||||||
controller.abort();
|
|
||||||
cleanup();
|
|
||||||
if (amberStepRef.current === 'waiting') {
|
|
||||||
amberStepRef.current = 'error';
|
|
||||||
setAmberStep('error');
|
|
||||||
setAmberStatus('Amber did not respond in time. Did you approve the connection?');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, TIMEOUT_MS);
|
|
||||||
}, [cleanup, completeLogin, toast]);
|
|
||||||
|
|
||||||
const handleBunkerLogin = async () => {
|
const handleBunkerLogin = async () => {
|
||||||
const uri = bunkerUri.trim();
|
const uri = bunkerUri.trim();
|
||||||
|
|
@ -382,15 +142,6 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
catch { toast({ title: 'No extension found', description: 'Install Alby or another NIP-07 extension.', variant: 'destructive' }); }
|
catch { toast({ title: 'No extension found', description: 'Install Alby or another NIP-07 extension.', variant: 'destructive' }); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyUri = () => {
|
|
||||||
navigator.clipboard.writeText(nostrConnectUri).catch(() => {});
|
|
||||||
toast({ title: 'Copied!', description: 'Open Amber → Scan / Paste link → paste it' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const openAmberWithUri = () => {
|
|
||||||
if (nostrConnectUri) { try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); } }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
|
<DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
|
||||||
|
|
@ -440,96 +191,54 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
|
|
||||||
{/* ── AMBER TAB ── */}
|
{/* ── AMBER TAB ── */}
|
||||||
<TabsContent value='amber' className='space-y-3'>
|
<TabsContent value='amber' className='space-y-3'>
|
||||||
{(amberStep === 'idle' || amberStep === 'connecting') && (
|
{/* Success */}
|
||||||
|
{amberDone && (
|
||||||
|
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-green-500/30 space-y-3'>
|
||||||
|
<Check className='w-10 h-10 mx-auto text-green-500' />
|
||||||
|
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
|
||||||
|
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waiting for Amber to return */}
|
||||||
|
{!amberDone && amberWaiting && (
|
||||||
|
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
|
||||||
|
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin mx-auto' />
|
||||||
|
<div>
|
||||||
|
<p className='text-sm text-white font-bold'>Waiting for Amber...</p>
|
||||||
|
<p className='text-xs text-gray-400 mt-1'>Approve in Amber, then come back here.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className='text-xs text-gray-500 h-auto py-2'
|
||||||
|
onClick={() => {
|
||||||
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||||
|
setAmberWaiting(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Idle — show login button */}
|
||||||
|
{!amberDone && !amberWaiting && (
|
||||||
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
|
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
|
||||||
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
|
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
|
||||||
<Smartphone className='w-7 h-7 text-[#ccff00]' />
|
<Smartphone className='w-7 h-7 text-[#ccff00]' />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-sm text-white font-bold'>Login with Amber</p>
|
<p className='text-sm text-white font-bold'>Login with Amber</p>
|
||||||
<p className='text-xs text-gray-400 mt-1'>NIP-46 remote signing. Tap, approve in Amber, done.</p>
|
<p className='text-xs text-gray-400 mt-1'>NIP-55 Android signer. Tap, approve in Amber, done.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
|
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
|
||||||
onClick={startAmberConnect}
|
onClick={handleAmberLogin}
|
||||||
disabled={amberStep === 'connecting'}
|
|
||||||
>
|
>
|
||||||
{amberStep === 'connecting'
|
<Smartphone className="w-4 h-4 mr-2" />Login with Amber
|
||||||
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Opening...</>
|
|
||||||
: <><Smartphone className="w-4 h-4 mr-2" />Connect with Amber</>
|
|
||||||
}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(amberStep === 'waiting' || amberStep === 'error') && (
|
|
||||||
<div className={`p-5 rounded-xl bg-black/50 border-2 space-y-4 ${amberStep === 'error' ? 'border-red-500/40' : 'border-[#ccff00]/40'}`}>
|
|
||||||
<div className='text-center space-y-2'>
|
|
||||||
{amberStep === 'error'
|
|
||||||
? <p className='text-sm text-red-400 font-bold'>⏱ Timed out</p>
|
|
||||||
: <Loader2 className='w-8 h-8 text-[#ccff00] animate-spin mx-auto' />
|
|
||||||
}
|
|
||||||
<p className='text-sm text-white font-bold'>
|
|
||||||
{amberStep === 'error' ? 'No response from Amber' : 'Waiting for Amber...'}
|
|
||||||
</p>
|
|
||||||
<p className='text-xs text-gray-400'>{amberStatus}</p>
|
|
||||||
{amberStep === 'waiting' && (
|
|
||||||
<p className='text-xs text-gray-600 tabular-nums'>
|
|
||||||
{Math.floor(amberElapsed / 60)}:{String(amberElapsed % 60).padStart(2, '0')} elapsed
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-xs text-gray-300 bg-black/40 rounded-lg p-3 space-y-1 border border-gray-800'>
|
|
||||||
<p className='font-bold text-white mb-2'>In Amber:</p>
|
|
||||||
<p>1. Tap <strong className='text-[#ccff00]'>Scan / Paste link</strong></p>
|
|
||||||
<p>2. Paste the connection string below</p>
|
|
||||||
<p>3. Tap <strong className='text-[#ccff00]'>Approve</strong></p>
|
|
||||||
<p>4. Come back here</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<Button variant="outline" className='flex-1 rounded-full border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2' onClick={openAmberWithUri}>
|
|
||||||
<Smartphone className='w-3 h-3 mr-1' />Open Amber
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className='flex-1 rounded-full border-gray-700 text-gray-300 hover:bg-gray-800 text-xs h-auto py-2' onClick={copyUri}>
|
|
||||||
<Copy className='w-3 h-3 mr-1' />Copy URI
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{amberStep === 'waiting' && (
|
|
||||||
<Button variant="outline" className='w-full rounded-full border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2' onClick={pollRelay}>
|
|
||||||
<RefreshCw className='w-3 h-3 mr-1' />Check Now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCapacitor && (
|
|
||||||
<p className='text-xs text-center text-gray-500'>
|
|
||||||
After approving in Amber, come back to this app. It will detect the connection automatically.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<Button variant="ghost" className='flex-1 text-xs text-gray-500 h-auto py-2' onClick={() => { abortRef.current?.abort(); clearPendingNC(); cleanup(); amberStepRef.current = 'idle'; setAmberStep('idle'); }}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={`flex-1 text-xs h-auto py-2 ${amberStep === 'error' ? 'text-red-400 font-bold' : 'text-[#ccff00]'}`}
|
|
||||||
onClick={startAmberConnect}
|
|
||||||
>
|
|
||||||
<RefreshCw className='w-3 h-3 mr-1' />{amberStep === 'error' ? 'Try Again' : 'Retry'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{amberStep === 'success' && (
|
|
||||||
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-green-500/30 space-y-3'>
|
|
||||||
<Check className='w-10 h-10 mx-auto text-green-500' />
|
|
||||||
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
|
|
||||||
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── BUNKER TAB ── */}
|
{/* ── BUNKER TAB ── */}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import {
|
||||||
|
|
||||||
// NOTE: This file should not be edited except for adding new login methods.
|
// NOTE: This file should not be edited except for adding new login methods.
|
||||||
|
|
||||||
|
export type AmberLoginType = { id: string; type: 'amber'; pubkey: string };
|
||||||
|
|
||||||
export function useLoginActions() {
|
export function useLoginActions() {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
const { logins, addLogin, setLogin, removeLogin } = useNostrLogin();
|
const { logins, addLogin, setLogin, removeLogin } = useNostrLogin();
|
||||||
|
|
@ -32,6 +34,16 @@ export function useLoginActions() {
|
||||||
const login = await NLogin.fromExtension();
|
const login = await NLogin.fromExtension();
|
||||||
addAndActivate(login);
|
addAndActivate(login);
|
||||||
},
|
},
|
||||||
|
// Login with Amber (NIP-55) — pubkey comes from the AmberCallbackPage
|
||||||
|
amber(pubkey: string): void {
|
||||||
|
const amberLogin: AmberLoginType = {
|
||||||
|
id: `amber:${pubkey}`,
|
||||||
|
type: 'amber',
|
||||||
|
pubkey,
|
||||||
|
};
|
||||||
|
addLogin(amberLogin as unknown as NLoginType);
|
||||||
|
setLogin(amberLogin.id);
|
||||||
|
},
|
||||||
// Log out the current user
|
// Log out the current user
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
const login = logins[0];
|
const login = logins[0];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue