305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { Show, createSignal, createEffect, onCleanup } from "solid-js"
|
|
import { useNavigate, type RouteSectionProps } from "@solidjs/router"
|
|
import { ExtensionAccount, NostrConnectAccount, PasswordAccount, PrivateKeyAccount } from "applesauce-accounts/accounts"
|
|
import { PasswordSigner } from "applesauce-signers"
|
|
import QrScanner from "qr-scanner"
|
|
import QRCode from "qrcode"
|
|
import { accountManager, ensureSessionTenant, identity, PLATFORM_NAME } from "@/lib/state"
|
|
import { copyToClipboard } from "@/lib/clipboard"
|
|
import { validateBunkerUri } from "@/lib/validation"
|
|
import { decideKeyLogin, initialLoginTab, normalizeBunkerUrl, type Tab } from "@/lib/loginInput"
|
|
import useMinLoading from "@/lib/useMinLoading"
|
|
import LoginTabsScreen from "@/components/login/LoginTabsScreen"
|
|
import LoginSignerScreen from "@/components/login/LoginSignerScreen"
|
|
import LoginKeyScreen from "@/components/login/LoginKeyScreen"
|
|
import QrScannerOverlay from "@/components/login/QrScannerOverlay"
|
|
|
|
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
|
|
|
|
type Screen = "tabs" | "nip46" | "key"
|
|
type SignerTab = "qr" | "paste"
|
|
type KeyTab = "plaintext" | "encrypted"
|
|
|
|
type LoginProps = {
|
|
inModal?: boolean
|
|
onClose?: () => void
|
|
onAuthenticated?: () => void | Promise<void>
|
|
}
|
|
|
|
async function loadNostrConnectSigner() {
|
|
return import("applesauce-signers").then((m) => m.NostrConnectSigner)
|
|
}
|
|
|
|
type LoginPageProps = LoginProps & Partial<RouteSectionProps<unknown>>
|
|
|
|
export default function Login(props: LoginPageProps = {}) {
|
|
const navigate = useNavigate()
|
|
const [tab, setTab] = createSignal<Tab>(initialLoginTab(Boolean(window.nostr)))
|
|
const [rawLoading, setRawLoading] = createSignal(false)
|
|
const loading = useMinLoading(() => rawLoading())
|
|
const [error, setError] = createSignal("")
|
|
const [screen, setScreen] = createSignal<Screen>("tabs")
|
|
|
|
const [signerTab, setSignerTab] = createSignal<SignerTab>("qr")
|
|
const [keyTab, setKeyTab] = createSignal<KeyTab>("plaintext")
|
|
const [nostrConnectUri, setNostrConnectUri] = createSignal("")
|
|
const [qrDataUrl, setQrDataUrl] = createSignal("")
|
|
const [showScanner, setShowScanner] = createSignal(false)
|
|
const [bunkerUrl, setBunkerUrl] = createSignal("")
|
|
const [nsecValue, setNsecValue] = createSignal("")
|
|
const [ncryptsecValue, setNcryptsecValue] = createSignal("")
|
|
const [password, setPassword] = createSignal("")
|
|
|
|
let scannerVideo: HTMLVideoElement | undefined
|
|
let scanner: QrScanner | undefined
|
|
let abortController: AbortController | undefined
|
|
|
|
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
|
|
accountManager.addAccount(account)
|
|
accountManager.setActive(account)
|
|
try {
|
|
await ensureSessionTenant()
|
|
} catch (e) {
|
|
accountManager.removeAccount(account)
|
|
throw e
|
|
}
|
|
await props.onAuthenticated?.()
|
|
}
|
|
|
|
// Shared effect wrapper for the four login handlers: clear the error, hold the
|
|
// loading flag for the duration, and surface any thrown error with a per-handler
|
|
// fallback message. The signer construction / completeLogin lives in `fn`.
|
|
async function runLogin(fn: () => Promise<void>, fallbackMessage: string) {
|
|
setError("")
|
|
setRawLoading(true)
|
|
try {
|
|
await fn()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : fallbackMessage)
|
|
} finally {
|
|
setRawLoading(false)
|
|
}
|
|
}
|
|
|
|
function loginWithNip07() {
|
|
return runLogin(async () => {
|
|
await completeLogin(await ExtensionAccount.fromExtension())
|
|
}, "Failed to login with extension")
|
|
}
|
|
|
|
function startNostrConnect() {
|
|
return runLogin(async () => {
|
|
const NostrConnectSigner = await loadNostrConnectSigner()
|
|
const signer = new NostrConnectSigner({ relays: NIP46_RELAYS })
|
|
const uri = signer.getNostrConnectURI({
|
|
name: PLATFORM_NAME,
|
|
url: window.location.origin,
|
|
})
|
|
setNostrConnectUri(uri)
|
|
setQrDataUrl(await QRCode.toDataURL(uri, { width: 256, margin: 2 }))
|
|
|
|
abortController = new AbortController()
|
|
const timeout = window.setTimeout(() => abortController?.abort(), 60_000)
|
|
|
|
try {
|
|
await signer.waitForSigner(abortController.signal)
|
|
const pubkey = await signer.getPublicKey()
|
|
const account = new NostrConnectAccount(pubkey, signer)
|
|
await completeLogin(account)
|
|
} finally {
|
|
window.clearTimeout(timeout)
|
|
}
|
|
}, "Failed to connect signer")
|
|
}
|
|
|
|
function loginWithBunker() {
|
|
setError("")
|
|
const validationError = validateBunkerUri(bunkerUrl())
|
|
if (validationError) {
|
|
setError(validationError)
|
|
return
|
|
}
|
|
return runLogin(async () => {
|
|
const uri = normalizeBunkerUrl(bunkerUrl())
|
|
const NostrConnectSigner = await loadNostrConnectSigner()
|
|
const signer = await NostrConnectSigner.fromBunkerURI(uri)
|
|
const pubkey = await signer.getPublicKey()
|
|
const account = new NostrConnectAccount(pubkey, signer)
|
|
await completeLogin(account)
|
|
}, "Invalid bunker URL")
|
|
}
|
|
|
|
function loginWithKeyMaterial() {
|
|
return runLogin(async () => {
|
|
const plan = decideKeyLogin({ ncryptsec: ncryptsecValue(), nsec: nsecValue(), password: password() })
|
|
switch (plan.kind) {
|
|
case "error":
|
|
throw new Error(plan.message)
|
|
case "ncryptsec": {
|
|
const signer = await PasswordSigner.fromNcryptsec(plan.ncryptsec, plan.password)
|
|
const pubkey = await signer.getPublicKey()
|
|
await completeLogin(new PasswordAccount(pubkey, signer))
|
|
break
|
|
}
|
|
case "nsec":
|
|
await completeLogin(PrivateKeyAccount.fromKey(plan.key))
|
|
break
|
|
}
|
|
}, "Invalid key")
|
|
}
|
|
|
|
function closeScanner() {
|
|
setShowScanner(false)
|
|
scanner?.destroy()
|
|
scanner = undefined
|
|
}
|
|
|
|
async function openScanner() {
|
|
setError("")
|
|
setShowScanner(true)
|
|
|
|
// Wait a tick for the modal video element to mount
|
|
await new Promise(r => setTimeout(r, 0))
|
|
if (!scannerVideo) return
|
|
|
|
scanner = new QrScanner(
|
|
scannerVideo,
|
|
(result) => {
|
|
setBunkerUrl(result.data)
|
|
closeScanner()
|
|
},
|
|
{ returnDetailedScanResult: true },
|
|
)
|
|
try {
|
|
await scanner.start()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Unable to access camera")
|
|
closeScanner()
|
|
}
|
|
}
|
|
|
|
function enterSignerScreen() {
|
|
setError("")
|
|
setScreen("nip46")
|
|
setSignerTab("qr")
|
|
if (!nostrConnectUri()) void startNostrConnect()
|
|
}
|
|
|
|
function copyUri() {
|
|
void copyToClipboard(nostrConnectUri(), { successMessage: "Link copied" })
|
|
}
|
|
|
|
createEffect(() => {
|
|
if (!props.inModal && identity()) {
|
|
navigate("/relays")
|
|
}
|
|
})
|
|
|
|
onCleanup(() => {
|
|
abortController?.abort()
|
|
scanner?.destroy()
|
|
})
|
|
|
|
return (
|
|
<div class={props.inModal ? "w-full" : "min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 flex items-center justify-center p-6"}>
|
|
<div class="w-full max-w-3xl">
|
|
<div class="relative overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl">
|
|
<Show when={props.inModal && props.onClose}>
|
|
<button
|
|
type="button"
|
|
class="absolute right-4 top-4 z-10 rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
|
onClick={() => props.onClose?.()}
|
|
aria-label="Close"
|
|
>
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
|
</button>
|
|
</Show>
|
|
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(99,102,241,0.08),_transparent_45%)]" />
|
|
<div class="relative grid gap-8 p-8 md:grid-cols-[1.2fr_1fr] md:p-10">
|
|
<div class="space-y-6">
|
|
<div class="inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700">
|
|
Secure Nostr Login
|
|
</div>
|
|
<div>
|
|
<h1 class="text-2xl font-semibold text-gray-900 py-2">Welcome!</h1>
|
|
<p class="mt-3 text-sm leading-6 text-gray-600">
|
|
Connect your Nostr account to manage relay hosting, billing, and access in one place.
|
|
</p>
|
|
</div>
|
|
<div class="space-y-3 text-sm text-gray-600">
|
|
<div class="flex items-start gap-3">
|
|
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
|
|
<p>Own your identity with cryptographic sign-in.</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
|
|
<p>No passwords or email required.</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
|
|
<p>Get fast access to your relays.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-6">
|
|
<Show when={screen() === "tabs"}>
|
|
<LoginTabsScreen
|
|
tab={tab}
|
|
setTab={setTab}
|
|
loading={loading}
|
|
hasExtension={Boolean(window.nostr)}
|
|
onContinueExtension={loginWithNip07}
|
|
onContinueSigner={enterSignerScreen}
|
|
onContinueKey={() => { setError(""); setScreen("key") }}
|
|
/>
|
|
</Show>
|
|
|
|
<Show when={screen() === "nip46"}>
|
|
<LoginSignerScreen
|
|
signerTab={signerTab}
|
|
setSignerTab={setSignerTab}
|
|
qrDataUrl={qrDataUrl}
|
|
nostrConnectUri={nostrConnectUri}
|
|
bunkerUrl={bunkerUrl}
|
|
setBunkerUrl={setBunkerUrl}
|
|
loading={loading}
|
|
onBack={() => { setError(""); setScreen("tabs") }}
|
|
onCopyUri={copyUri}
|
|
onScan={openScanner}
|
|
onConnectBunker={loginWithBunker}
|
|
/>
|
|
</Show>
|
|
|
|
<Show when={screen() === "key"}>
|
|
<LoginKeyScreen
|
|
keyTab={keyTab}
|
|
setKeyTab={setKeyTab}
|
|
nsecValue={nsecValue}
|
|
setNsecValue={setNsecValue}
|
|
ncryptsecValue={ncryptsecValue}
|
|
setNcryptsecValue={setNcryptsecValue}
|
|
password={password}
|
|
setPassword={setPassword}
|
|
loading={loading}
|
|
onBack={() => { setError(""); setScreen("tabs") }}
|
|
onSubmit={() => void loginWithKeyMaterial()}
|
|
/>
|
|
</Show>
|
|
|
|
<Show when={error()}>
|
|
<p class="mt-4 text-xs text-red-600">{error()}</p>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="mt-6 text-center text-xs text-gray-500">
|
|
Having trouble? Make sure your signer is unlocked and connected.
|
|
</p>
|
|
</div>
|
|
|
|
<QrScannerOverlay open={showScanner()} onClose={closeScanner} videoRef={(el) => { scannerVideo = el }} />
|
|
</div>
|
|
)
|
|
}
|