716 lines
36 KiB
TypeScript
716 lines
36 KiB
TypeScript
// 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.
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
// Extend window with a cleanup ref for Amber visibilitychange listener
|
|
declare global {
|
|
interface Window { __amberUnsubRef?: () => void; }
|
|
}
|
|
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound, QrCode, Copy, Unlock } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { cn } from '@/lib/utils';
|
|
import { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber';
|
|
import { getAmberPublicKeyViaPlugin, isNativeAmberAvailable } from '@/lib/capacitorAmberSigner';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
|
import { startNostrConnect, nostrConnectBunkerUri, type NostrConnectHandle } from '@/lib/nostrConnect';
|
|
import { decryptNcryptsec } from '@/lib/ncryptsec';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
|
|
interface LoginDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onLogin: () => void;
|
|
onSignup?: () => void;
|
|
}
|
|
|
|
export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
|
|
const { t } = useTranslation();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [bunkerUri, setBunkerUri] = useState('');
|
|
const [bunkerError, setBunkerError] = useState('');
|
|
const [nsecInput, setNsecInput] = useState('');
|
|
const [nsecError, setNsecError] = useState('');
|
|
|
|
// Amber NIP-55 state
|
|
const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'paste' | 'done' | 'error'>('idle');
|
|
const [amberError, setAmberError] = useState('');
|
|
// Whether the user explicitly opened the manual-paste escape hatch. The proper
|
|
// NIP-55 redirect flow never needs this; it's only a "having trouble?" fallback.
|
|
const [showManualPaste, setShowManualPaste] = useState(false);
|
|
|
|
// NIP-46 nostrconnect — pair with a phone signer via QR (desktop → phone).
|
|
const [connectSession, setConnectSession] = useState<NostrConnectHandle | null>(null);
|
|
const [connectState, setConnectState] = useState<'idle' | 'waiting' | 'error'>('idle');
|
|
const [connectError, setConnectError] = useState('');
|
|
const [copiedUri, setCopiedUri] = useState(false);
|
|
const connectRef = useRef<NostrConnectHandle | null>(null);
|
|
|
|
// NIP-49 — open a password-protected key (ncryptsec).
|
|
const [nsecMode, setNsecMode] = useState<'raw' | 'encrypted'>('raw');
|
|
const [ncryptsecInput, setNcryptsecInput] = useState('');
|
|
const [ncryptsecPassword, setNcryptsecPassword] = useState('');
|
|
|
|
const { nostr } = useNostr();
|
|
|
|
const login = useLoginActions();
|
|
const { toast } = useToast();
|
|
|
|
const isAndroid = /Android/i.test(navigator.userAgent);
|
|
// Which login method panel is showing. The three method cards below switch it.
|
|
const [loginTab, setLoginTab] = useState<'amber' | 'bunker' | 'nsec' | 'extension' | 'connect'>(
|
|
isAndroid ? 'amber' : 'bunker',
|
|
);
|
|
|
|
// Step 1: open Amber
|
|
// - On Capacitor (ZapStore APK): use the native plugin — direct Promise, no redirects needed
|
|
// - On web/PWA: use intent:// URL with callbackUrl so Amber redirects back
|
|
const handleAmberLogin = async () => {
|
|
setAmberStep('loading');
|
|
setAmberError('');
|
|
|
|
// Decide native-vs-web at CLICK TIME using the reliable Capacitor check from
|
|
// the signer module (imported @capacitor/core), NOT window.Capacitor which
|
|
// can be false at mount inside the WebView — that mismatch was causing the
|
|
// APK to fall into the web redirect flow and never actually open Amber.
|
|
if (isNativeAmberAvailable()) {
|
|
// ── Native path (ZapStore/Capacitor APK) ────────────────────────────────
|
|
// Attempt login directly — skip isExternalSignerInstalled() check because
|
|
// Android 11+ package visibility rules can make it return false even when
|
|
// Amber IS installed (requires <queries> in AndroidManifest.xml).
|
|
try {
|
|
// This opens Amber natively, user approves, pubkey comes back as a Promise
|
|
const pubkey = await getAmberPublicKeyViaPlugin();
|
|
completeAmberLogin(pubkey);
|
|
} catch (e: unknown) {
|
|
const msg = (e as Error)?.message ?? t('settingsUi.loginDialog.amberLoginFailed');
|
|
// Distinguish: user cancelled vs Amber not installed vs other error
|
|
if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('denied')) {
|
|
setAmberError(t('settingsUi.loginDialog.amberCancelled'));
|
|
} else if (msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('no activity') || msg.toLowerCase().includes('resolve')) {
|
|
setAmberError(t('settingsUi.loginDialog.amberNotFound'));
|
|
} else {
|
|
setAmberError(msg);
|
|
}
|
|
setAmberStep('error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Web/PWA path ─────────────────────────────────────────────────────────
|
|
// This is the proper NIP-55 web flow — NO copy/paste required:
|
|
// 1. We open Amber via an intent:// URL carrying a callbackUrl.
|
|
// 2. Amber signs the get_public_key request and redirects the browser to
|
|
// /amber-callback?event=<pubkey>.
|
|
// 3. AmberCallbackPage stores the pubkey; the poll below (or the
|
|
// visibilitychange handler) picks it up and completes login.amber().
|
|
// The manual-paste step exists ONLY as a last-resort "having trouble?"
|
|
// escape hatch the user must explicitly open — it is never forced.
|
|
const currentPath = window.location.hash.replace(/^#/, '') || '/';
|
|
localStorage.setItem('amber_return_path', currentPath);
|
|
const unsub = subscribeToAmberReturn(() => {
|
|
checkAmberReturnSilently();
|
|
});
|
|
window.__amberUnsubRef = unsub;
|
|
openAmberForLogin();
|
|
};
|
|
|
|
// Silent variant used when the app regains focus: complete login if Amber's
|
|
// callback delivered the pubkey, otherwise stay on the loading/waiting screen
|
|
// (do NOT force the paste step — the redirect may still be in flight).
|
|
const checkAmberReturnSilently = async () => {
|
|
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
|
|
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
|
|
localStorage.removeItem('amber_pending_pubkey');
|
|
completeAmberLogin(storedPubkey.toLowerCase());
|
|
return;
|
|
}
|
|
// Best-effort clipboard read (some Amber builds copy the key). Never blocks.
|
|
try {
|
|
const text = (await navigator.clipboard.readText()).trim();
|
|
if (/^[0-9a-f]{64}$/i.test(text)) {
|
|
completeAmberLogin(text.toLowerCase());
|
|
return;
|
|
}
|
|
if (text.startsWith('npub1')) {
|
|
const { nip19 } = await import('nostr-tools');
|
|
const decoded = nip19.decode(text);
|
|
if (decoded.type === 'npub') completeAmberLogin(decoded.data as string);
|
|
}
|
|
} catch { /* clipboard denied — remain on waiting screen */ }
|
|
};
|
|
|
|
// Poll for amber_pending_pubkey (written by AmberCallbackPage). This is what
|
|
// makes the proper NIP-55 redirect flow complete automatically — no paste.
|
|
// We keep polling on BOTH 'loading' and 'paste' so a late-arriving callback
|
|
// still auto-completes even if the user opened the manual escape hatch.
|
|
useEffect(() => {
|
|
if (amberStep !== 'loading' && amberStep !== 'paste') return;
|
|
const interval = setInterval(() => {
|
|
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
|
|
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
|
|
localStorage.removeItem('amber_pending_pubkey');
|
|
clearInterval(interval);
|
|
completeAmberLogin(storedPubkey.toLowerCase());
|
|
}
|
|
}, 500);
|
|
// Stop polling after 3 minutes
|
|
const timeout = setTimeout(() => {
|
|
clearInterval(interval);
|
|
}, 180_000);
|
|
return () => { clearInterval(interval); clearTimeout(timeout); };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [amberStep]);
|
|
|
|
const completeAmberLogin = (pubkey: string) => {
|
|
try {
|
|
login.amber(pubkey);
|
|
setAmberStep('done');
|
|
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.connectedAmber') });
|
|
setTimeout(() => { onLogin(); onClose(); }, 800);
|
|
} catch (e: unknown) {
|
|
setAmberError((e as Error)?.message ?? t('settingsUi.loginDialog.loginFailed'));
|
|
setAmberStep('error');
|
|
}
|
|
};
|
|
|
|
const [pubkeyInput, setPubkeyInput] = useState('');
|
|
const [pubkeyError, setPubkeyError] = useState('');
|
|
|
|
const handlePubkeySubmit = async () => {
|
|
const val = pubkeyInput.trim();
|
|
// Accept hex pubkey or npub
|
|
if (/^[0-9a-f]{64}$/i.test(val)) {
|
|
completeAmberLogin(val.toLowerCase());
|
|
return;
|
|
}
|
|
if (val.startsWith('npub1')) {
|
|
try {
|
|
const { nip19 } = await import('nostr-tools');
|
|
const decoded = nip19.decode(val);
|
|
if (decoded.type === 'npub') {
|
|
completeAmberLogin(decoded.data as string);
|
|
return;
|
|
}
|
|
} catch { /* fall through */ }
|
|
}
|
|
setPubkeyError(t('settingsUi.loginDialog.pubkeyError'));
|
|
};
|
|
|
|
/**
|
|
* NIP-46 nostrconnect: show a QR, wait for a phone signer to pair, then hand
|
|
* the derived bunker URI to the existing (well-tested) bunker login path.
|
|
*/
|
|
const handleNostrConnect = () => {
|
|
setConnectError('');
|
|
setConnectState('waiting');
|
|
const session = startNostrConnect(nostr, {
|
|
appImage: 'https://skate-spots.org/images/icon-1024.png',
|
|
timeoutMs: 180_000,
|
|
});
|
|
connectRef.current = session;
|
|
setConnectSession(session);
|
|
|
|
session.connected
|
|
.then(async ({ signerPubkey }) => {
|
|
const uri = nostrConnectBunkerUri(signerPubkey, session.relays, session.secret);
|
|
await login.bunker(uri);
|
|
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.pairedSigner') });
|
|
onLogin();
|
|
onClose();
|
|
})
|
|
.catch((e: unknown) => {
|
|
setConnectState('error');
|
|
setConnectError((e as Error)?.message ?? t('settingsUi.loginDialog.connectFailed'));
|
|
});
|
|
};
|
|
|
|
const cancelNostrConnect = () => {
|
|
connectRef.current?.cancel();
|
|
connectRef.current = null;
|
|
setConnectSession(null);
|
|
setConnectState('idle');
|
|
setConnectError('');
|
|
};
|
|
|
|
/** NIP-49: decrypt a password-protected key, then log in with it. */
|
|
const handleNcryptsecLogin = () => {
|
|
setNsecError('');
|
|
try {
|
|
const nsec = decryptNcryptsec(ncryptsecInput, ncryptsecPassword);
|
|
login.nsec(nsec);
|
|
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.openedEncrypted') });
|
|
onLogin();
|
|
onClose();
|
|
} catch (e: unknown) {
|
|
setNsecError((e as Error)?.message ?? t('settingsUi.loginDialog.openKeyFailed'));
|
|
}
|
|
};
|
|
|
|
|
|
|
|
// Reset when dialog closes
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
// Clean up any pending Amber visibilitychange listener
|
|
window.__amberUnsubRef?.();
|
|
window.__amberUnsubRef = undefined;
|
|
// Cancel any in-flight signer pairing (it holds a relay subscription).
|
|
connectRef.current?.cancel();
|
|
connectRef.current = null;
|
|
setConnectSession(null);
|
|
setConnectState('idle');
|
|
setConnectError('');
|
|
setCopiedUri(false);
|
|
setAmberStep('idle');
|
|
setAmberError('');
|
|
setShowManualPaste(false);
|
|
setPubkeyInput('');
|
|
setPubkeyError('');
|
|
setBunkerUri('');
|
|
setBunkerError('');
|
|
setNsecInput('');
|
|
setNsecError('');
|
|
setNcryptsecInput('');
|
|
setNcryptsecPassword('');
|
|
setNsecMode('raw');
|
|
setIsLoading(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Cancel a pairing if the component unmounts mid-wait.
|
|
useEffect(() => () => { connectRef.current?.cancel(); }, []);
|
|
|
|
const handleBunkerLogin = async () => {
|
|
const uri = bunkerUri.trim();
|
|
if (!uri.startsWith('bunker://')) { setBunkerError(t('settingsUi.loginDialog.bunkerPrefix')); return; }
|
|
setIsLoading(true);
|
|
setBunkerError('');
|
|
try {
|
|
await login.bunker(uri);
|
|
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.connectedBunker') });
|
|
onLogin(); onClose();
|
|
} catch (e: unknown) {
|
|
setBunkerError(t('settingsUi.loginDialog.bunkerFailed', { error: (e as Error)?.message ?? t('settingsUi.loginDialog.checkBunkerUri') }));
|
|
} finally { setIsLoading(false); }
|
|
};
|
|
|
|
const handleNsecLogin = () => {
|
|
const val = nsecInput.trim();
|
|
if (!val.startsWith('nsec1')) { setNsecError(t('settingsUi.loginDialog.nsecPrefix')); return; }
|
|
try {
|
|
login.nsec(val);
|
|
toast({ title: t('settingsUi.loginDialog.loggedInTitle') });
|
|
onLogin(); onClose();
|
|
} catch (e: unknown) {
|
|
setNsecError(t('settingsUi.loginDialog.invalidNsec', { error: (e as Error)?.message ?? t('settingsUi.loginDialog.checkKey') }));
|
|
}
|
|
};
|
|
|
|
const handleExtensionLogin = async () => {
|
|
try { await login.extension(); onLogin(); onClose(); }
|
|
catch { toast({ title: t('settingsUi.loginDialog.noExtensionTitle'), description: t('settingsUi.loginDialog.noExtensionDesc'), variant: 'destructive' }); }
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className={cn("z-[2200] flex w-[calc(100%-1rem)] flex-col sm:max-w-md max-h-[calc(100dvh-2rem)] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
|
|
<DialogHeader className={cn('px-5 pt-5 pb-3')}>
|
|
<DialogTitle className="text-center font-black uppercase tracking-wide text-lg">
|
|
{t('settingsUi.loginDialog.welcomeTitle')}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-center text-xs text-gray-400">
|
|
{t('settingsUi.loginDialog.welcomeSubtitleBrowse')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className='min-h-0 flex-1 overflow-y-auto px-5 pb-5 pt-1 space-y-4'>
|
|
{/* Sign up — a compact row, not a second hero: the dialog should have
|
|
ONE dominant action per state, and logging in is the main path. */}
|
|
<div className='flex items-center gap-3 rounded-xl border border-[#ccff00]/40 bg-[#ccff00]/5 p-3'>
|
|
<div className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#ccff00]/15'>
|
|
<Sparkles className='h-4 w-4 text-[#ccff00]' />
|
|
</div>
|
|
<div className='min-w-0 flex-1'>
|
|
<p className='text-sm font-bold text-[#ccff00]'>{t('settingsUi.loginDialog.newHere')}</p>
|
|
<p className='text-[11px] leading-snug text-gray-400'>{t('settingsUi.loginDialog.createFree')}</p>
|
|
</div>
|
|
<Button
|
|
size='sm'
|
|
onClick={() => { onClose(); onSignup?.(); }}
|
|
className='h-9 shrink-0 gap-1.5 bg-[#ccff00] px-3 text-xs font-bold text-black hover:bg-[#ccff00]/90'
|
|
>
|
|
<UserPlus className='h-3.5 w-3.5' />{t('settingsUi.loginDialog.createAccount')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className='relative flex items-center'>
|
|
<div className='flex-1 border-t border-gray-800' />
|
|
<span className='px-3 text-[11px] text-gray-500'>{t('settingsUi.loginDialog.orLogIn')}</span>
|
|
<div className='flex-1 border-t border-gray-800' />
|
|
</div>
|
|
|
|
<Tabs value={loginTab} onValueChange={(v) => setLoginTab(v as typeof loginTab)} className="w-full">
|
|
{/* Method cards — plain-language labels; protocol names move to a
|
|
tiny hint so newcomers aren't met with "NIP-46" as the main word. */}
|
|
<div className="mb-3 grid grid-cols-3 gap-2">
|
|
{([
|
|
{ id: 'extension', Icon: Shield, label: t('settingsUi.loginDialog.extensionCard'), hint: 'NIP-07' },
|
|
{ id: isAndroid ? 'amber' : 'bunker', Icon: isAndroid ? Smartphone : Cloud, label: t('settingsUi.loginDialog.signerCard'), hint: isAndroid ? 'NIP-55' : 'NIP-46' },
|
|
{ id: 'connect', Icon: QrCode, label: t('settingsUi.loginDialog.signInWithPhone'), hint: 'NIP-46' },
|
|
] as const).map(({ id, Icon, label, hint }) => {
|
|
const active = loginTab === id;
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
onClick={() => setLoginTab(id)}
|
|
className={cn(
|
|
'flex min-h-0 flex-col items-center gap-1 rounded-xl border p-2.5 text-center transition-colors',
|
|
active
|
|
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-[#ccff00]'
|
|
: 'border-white/10 bg-black/40 text-gray-300 hover:border-white/25 hover:text-white',
|
|
)}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
<span className="text-[11px] font-bold leading-tight">{label}</span>
|
|
<span className="text-[9px] uppercase tracking-widest text-gray-500">{hint}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* ── AMBER TAB ── */}
|
|
<TabsContent value='amber' className='space-y-3'>
|
|
|
|
{/* Success */}
|
|
{amberStep === 'done' && (
|
|
<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'>{t('settingsUi.loginDialog.loggedIn')}</p>
|
|
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Idle — the primary, one-tap NIP-55 flow */}
|
|
{amberStep === 'idle' && (
|
|
<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">
|
|
<Smartphone className='w-7 h-7 text-[#ccff00]' />
|
|
</div>
|
|
<div>
|
|
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.loginWithAmber')}</p>
|
|
<p className='text-xs text-gray-400 mt-1'>
|
|
{t('settingsUi.loginDialog.amberIdleDesc')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
|
|
onClick={handleAmberLogin}
|
|
>
|
|
<Smartphone className="w-4 h-4 mr-2" />{t('settingsUi.loginDialog.continueWithAmber')}
|
|
</Button>
|
|
<a
|
|
href="https://github.com/greenart7c3/Amber"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block text-[11px] text-gray-600 hover:text-[#ccff00]"
|
|
>
|
|
{t('settingsUi.loginDialog.noAmberGetIt')}
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* Waiting — the redirect flow is in progress. Auto-completes via
|
|
the poll. Only reveals the manual escape hatch on explicit tap. */}
|
|
{amberStep === 'loading' && !showManualPaste && (
|
|
<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">
|
|
<Loader2 className='w-7 h-7 text-[#ccff00] animate-spin' />
|
|
</div>
|
|
<div>
|
|
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.waitingAmberTitle')}</p>
|
|
<p className='text-xs text-gray-400 mt-1'>
|
|
{t('settingsUi.loginDialog.waitingAmberDesc')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant='outline'
|
|
className='w-full rounded-full py-2.5 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 font-bold'
|
|
onClick={handleAmberLogin}
|
|
>
|
|
<Smartphone className="w-4 h-4 mr-2" />{t('settingsUi.loginDialog.openAmberAgain')}
|
|
</Button>
|
|
<button
|
|
className='w-full text-[11px] text-gray-600 hover:text-gray-400 py-1'
|
|
onClick={() => setShowManualPaste(true)}
|
|
>
|
|
{t('settingsUi.loginDialog.havingTrouble')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Manual paste — LAST-RESORT escape hatch only. Reached when the
|
|
user taps "Having trouble?" or an explicit error occurs. */}
|
|
{(amberStep === 'paste' || amberStep === 'error' || (amberStep === 'loading' && showManualPaste)) && (
|
|
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
|
|
<div className='text-center space-y-1'>
|
|
<Smartphone className='w-8 h-8 mx-auto text-[#ccff00]' />
|
|
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.enterPublicKey')}</p>
|
|
<p className='text-xs text-gray-400'>
|
|
{t('settingsUi.loginDialog.enterPublicKeyDescBefore')}{' '}
|
|
<strong className='text-white'>{t('settingsUi.loginDialog.copyPublicKey')}</strong>, {t('settingsUi.loginDialog.enterPublicKeyDescAfter')}
|
|
</p>
|
|
</div>
|
|
{amberStep === 'error' && amberError && (
|
|
<p className='text-xs text-red-400 bg-red-500/10 rounded-lg p-2 text-center'>{amberError}</p>
|
|
)}
|
|
<div className='space-y-2'>
|
|
<Input
|
|
placeholder='npub1... or hex pubkey'
|
|
value={pubkeyInput}
|
|
onChange={e => { setPubkeyInput(e.target.value); setPubkeyError(''); }}
|
|
className='bg-black/60 border-[#ccff00]/30 text-white placeholder:text-gray-600 text-xs font-mono'
|
|
autoComplete='off' autoCorrect='off' spellCheck={false}
|
|
/>
|
|
{pubkeyError && <p className="text-xs text-red-400">{pubkeyError}</p>}
|
|
</div>
|
|
<Button
|
|
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
|
|
disabled={!pubkeyInput.trim()}
|
|
onClick={handlePubkeySubmit}
|
|
>
|
|
<Check className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.connect')}
|
|
</Button>
|
|
<Button variant='ghost' className='w-full text-xs text-gray-600 h-auto py-1'
|
|
onClick={() => { setAmberStep('idle'); setShowManualPaste(false); setPubkeyInput(''); setPubkeyError(''); setAmberError(''); }}>
|
|
{t('settingsUi.loginDialog.back')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
</TabsContent>
|
|
|
|
{/* ── NOSTR CONNECT TAB ── */}
|
|
<TabsContent value='connect' className='space-y-3'>
|
|
{/* Phone pairing — the canonical "log in on desktop with the key
|
|
already on your phone" flow. Client-generated URI; the signer
|
|
scans and pairs over a relay. */}
|
|
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
|
|
<div className='text-center'>
|
|
<QrCode className='w-10 h-10 mx-auto mb-2 text-blue-400' />
|
|
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.signInWithPhone')}</p>
|
|
<p className='text-xs text-gray-400 mt-1'>
|
|
{t('settingsUi.loginDialog.signInWithPhoneDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{connectState === 'idle' && (
|
|
<Button
|
|
className='w-full rounded-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
|
|
onClick={handleNostrConnect}
|
|
>
|
|
<QrCode className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.showPairingCode')}
|
|
</Button>
|
|
)}
|
|
|
|
{connectState === 'waiting' && connectSession && (
|
|
<div className='space-y-3'>
|
|
<div className='mx-auto w-fit rounded-xl bg-white p-3'>
|
|
<QRCodeCanvas value={connectSession.uri} size={196} level='M' />
|
|
</div>
|
|
<p className='flex items-center justify-center gap-2 text-center text-xs text-gray-400'>
|
|
<Loader2 className='w-3.5 h-3.5 animate-spin' /> {t('settingsUi.loginDialog.waitingSigner')}
|
|
</p>
|
|
<div className='flex gap-2'>
|
|
<Button
|
|
variant='outline'
|
|
className='flex-1 rounded-full text-xs border-blue-500/40 text-blue-300 hover:bg-blue-500/10'
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(connectSession.uri).then(() => {
|
|
setCopiedUri(true);
|
|
setTimeout(() => setCopiedUri(false), 1500);
|
|
}).catch(() => {});
|
|
}}
|
|
>
|
|
{copiedUri
|
|
? <><Check className='w-3.5 h-3.5 mr-1' />{t('common.copied')}</>
|
|
: <><Copy className='w-3.5 h-3.5 mr-1' />{t('settingsUi.loginDialog.copyLink')}</>}
|
|
</Button>
|
|
<Button
|
|
variant='outline'
|
|
className='flex-1 rounded-full text-xs border-white/15 text-gray-400 hover:bg-white/5'
|
|
onClick={cancelNostrConnect}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{connectState === 'error' && (
|
|
<div className='space-y-2'>
|
|
<p className='text-xs text-red-400 bg-red-500/10 rounded-lg p-2 text-center'>{connectError}</p>
|
|
<Button
|
|
className='w-full rounded-full py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
|
|
onClick={handleNostrConnect}
|
|
>
|
|
{t('common.tryAgain')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ── SIGNER (BUNKER) TAB ── */}
|
|
<TabsContent value='bunker' className='space-y-3'>
|
|
<div className='space-y-3 rounded-xl border border-blue-500/30 bg-black/50 p-4'>
|
|
<div className='flex items-center gap-2'>
|
|
<Cloud className='h-4 w-4 text-blue-400' />
|
|
<p className='text-sm font-bold text-white'>{t('settingsUi.loginDialog.signerCard')}</p>
|
|
</div>
|
|
<Input placeholder='bunker://...' value={bunkerUri} onChange={e => { setBunkerUri(e.target.value); setBunkerError(''); }} className='bg-black/60 border-blue-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} />
|
|
{bunkerError && <p className="text-xs text-red-400">{bunkerError}</p>}
|
|
<Button className='w-full rounded-full py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase' disabled={isLoading || !bunkerUri.trim()} onClick={handleBunkerLogin}>
|
|
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : <Cloud className='w-4 h-4 mr-2' />}{t('settingsUi.loginDialog.connect')}
|
|
</Button>
|
|
{/* Instructions collapse: the default state is just input + Connect. */}
|
|
<details>
|
|
<summary className='cursor-pointer text-[11px] text-gray-500 hover:text-gray-300'>
|
|
{t('settingsUi.loginDialog.whereBunker')}
|
|
</summary>
|
|
<div className='mt-2 space-y-1 rounded-lg border border-gray-800 bg-black/40 p-3 text-[11px] text-gray-400'>
|
|
<p><strong className='text-blue-300'>Amber:</strong> {t('settingsUi.loginDialog.amberInstructions')}</p>
|
|
<p><strong className='text-blue-300'>nsec.app:</strong> {t('settingsUi.loginDialog.nsecAppInstructions')}</p>
|
|
<div className='flex gap-2 pt-1'>
|
|
<a href="https://nsec.app" target="_blank" rel="noopener noreferrer" className="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-blue-400 hover:bg-blue-500/30">nsec.app →</a>
|
|
<a href="https://nostrum.social" target="_blank" rel="noopener noreferrer" className="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-blue-400 hover:bg-blue-500/30">Nostrum →</a>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ── NSEC TAB ── */}
|
|
<TabsContent value='nsec' className='space-y-3'>
|
|
<div className='p-5 rounded-xl bg-black/50 border-2 border-yellow-500/30 space-y-4'>
|
|
<div className='text-center'>
|
|
<KeyRound className='w-10 h-10 mx-auto mb-2 text-yellow-400' />
|
|
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.secretKeyTitle')}</p>
|
|
<p className='text-xs text-gray-400 mt-1'>
|
|
{nsecMode === 'raw'
|
|
? t('settingsUi.loginDialog.secretKeyRawDesc')
|
|
: t('settingsUi.loginDialog.secretKeyEncDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Raw nsec vs NIP-49 encrypted key */}
|
|
<div className='flex gap-1 rounded-full bg-black/50 border border-white/10 p-1'>
|
|
{(['raw', 'encrypted'] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
type='button'
|
|
onClick={() => { setNsecMode(m); setNsecError(''); }}
|
|
className={cn(
|
|
'min-h-0 flex-1 rounded-full px-2 py-1 text-[11px] font-bold uppercase tracking-wide transition-colors',
|
|
nsecMode === m ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white',
|
|
)}
|
|
>
|
|
{m === 'raw' ? t('settingsUi.loginDialog.rawNsec') : t('settingsUi.loginDialog.encryptedNip49')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{nsecMode === 'raw' ? (
|
|
<>
|
|
<div className='p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30'>
|
|
<p className='text-xs text-yellow-300'>{t('settingsUi.loginDialog.nsecWarning')}</p>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Input type="password" placeholder='nsec1...' value={nsecInput} onChange={e => { setNsecInput(e.target.value); setNsecError(''); }} className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} />
|
|
{nsecError && <p className="text-xs text-red-400">{nsecError}</p>}
|
|
</div>
|
|
<Button className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase' disabled={!nsecInput.trim()} onClick={handleNsecLogin}>
|
|
<KeyRound className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.login')}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className='p-3 rounded-lg bg-green-500/10 border border-green-500/30'>
|
|
<p className='text-xs text-green-300'>
|
|
{t('settingsUi.loginDialog.encSaferDesc')}
|
|
</p>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Input
|
|
placeholder='ncryptsec1...'
|
|
value={ncryptsecInput}
|
|
onChange={e => { setNcryptsecInput(e.target.value); setNsecError(''); }}
|
|
className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600 text-xs font-mono'
|
|
autoComplete='off' autoCorrect='off' spellCheck={false}
|
|
/>
|
|
<Input
|
|
type='password'
|
|
placeholder={t('settingsUi.loginDialog.password')}
|
|
value={ncryptsecPassword}
|
|
onChange={e => { setNcryptsecPassword(e.target.value); setNsecError(''); }}
|
|
onKeyDown={e => { if (e.key === 'Enter' && ncryptsecInput.trim() && ncryptsecPassword) handleNcryptsecLogin(); }}
|
|
className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600'
|
|
autoComplete='off' autoCorrect='off' spellCheck={false}
|
|
/>
|
|
{nsecError && <p className="text-xs text-red-400">{nsecError}</p>}
|
|
</div>
|
|
<Button
|
|
className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase'
|
|
disabled={!ncryptsecInput.trim() || !ncryptsecPassword}
|
|
onClick={handleNcryptsecLogin}
|
|
>
|
|
<Unlock className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.openEncryptedKey')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ── EXTENSION TAB ── */}
|
|
<TabsContent value='extension' className='space-y-3'>
|
|
<div className='space-y-3 rounded-xl border border-[#ff0040]/30 bg-black/50 p-4'>
|
|
<div className='flex items-center gap-2'>
|
|
<Shield className='h-4 w-4 text-[#ff0040]' />
|
|
<p className='text-sm font-bold text-white'>{t('settingsUi.loginDialog.browserExtensionTitle')}</p>
|
|
</div>
|
|
<p className='text-xs text-gray-400'>{t('settingsUi.loginDialog.browserExtensionDesc')}</p>
|
|
<Button className='w-full rounded-full py-2.5 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold uppercase' onClick={handleExtensionLogin}>
|
|
{t('settingsUi.loginDialog.loginWithExtension')}
|
|
</Button>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* Quiet fallback for advanced users; not a third hero action. */}
|
|
<div className='pt-1 text-center'>
|
|
<button
|
|
type="button"
|
|
onClick={() => setLoginTab('nsec')}
|
|
className={cn(
|
|
'text-[11px] underline underline-offset-2',
|
|
loginTab === 'nsec' ? 'text-yellow-300' : 'text-gray-500 hover:text-gray-300',
|
|
)}
|
|
>
|
|
{t('settingsUi.loginDialog.secretKeyLink')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default LoginDialog;
|