feat: add remote signer QR flow to AuthModal

- Introduced a new phase for remote signer login using QR codes, enhancing the authentication experience for desktop users.
- Implemented UI elements for displaying the QR code and handling user interactions, including error messages and loading states.
- Updated the Nostr Connect composable to support the new QR flow, including cancellation handling and improved error management.

These changes provide users with a seamless and modern way to authenticate using remote signers, improving overall usability.
This commit is contained in:
Dorian
2026-02-17 04:18:55 +00:00
parent bd1f370760
commit d8f54a5032
2 changed files with 253 additions and 26 deletions

View File

@@ -12,6 +12,57 @@
<!-- NORMAL AUTH VIEW -->
<template v-if="sovereignPhase === 'normal'">
<!-- REMOTE SIGNER QR PHASE (desktop) -->
<template v-if="remoteSignerPhase === 'qr'">
<div class="text-center">
<button
@click="handleRemoteSignerBack"
class="flex items-center gap-2 text-white/60 hover:text-white transition-colors mb-6 w-full justify-start"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
<div v-if="errorMessage" class="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 text-sm text-left">
{{ errorMessage }}
</div>
<h2 class="text-2xl font-bold text-white mb-2">Remote Signer</h2>
<p class="text-white/50 text-sm mb-6">Scan the QR code with your phone to sign in</p>
<!-- QR Code -->
<div class="bg-white rounded-2xl p-4 inline-block mb-5">
<img v-if="remoteSignerQrDataUrl" :src="remoteSignerQrDataUrl" alt="Nostr Connect QR" class="w-56 h-56" />
<div v-else class="w-56 h-56 flex items-center justify-center text-gray-400">
Generating QR...
</div>
</div>
<p v-if="nostrConnectLoading" class="text-sm text-white/70 animate-pulse mb-4">
Waiting for signer...
</p>
<!-- Copy URI -->
<button
@click="copyRemoteSignerUri"
class="w-full bg-white/10 hover:bg-white/15 text-white rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center justify-center gap-2 mb-4"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
{{ remoteSignerCopyText }}
</button>
<p class="text-xs text-white/40">
Use Primal, Amber, or any Nostr Connect signer app
</p>
</div>
</template>
<!-- AUTH OPTIONS (idle) -->
<template v-else>
<!-- Header -->
<div class="text-center mb-8">
<h2 class="text-3xl font-bold text-white">
@@ -54,38 +105,54 @@
</button>
</form>
<!-- Login with Nostr (Primal) default / first option -->
<!-- Remote Signer desktop: QR phase; mobile: direct link -->
<div class="mt-4 space-y-2">
<button
v-if="!popupBlockedUri"
@click="handleNostrConnectLogin"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2"
>
<img
src="@/assets/images/primal-icon.svg"
alt="Primal"
class="w-5 h-5 shrink-0"
/>
{{ nostrConnectLoading ? 'Waiting for Primal...' : 'Login with Nostr (Primal)' }}
</button>
<template v-if="isDesktop">
<button
@click="handleRemoteSignerClick"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2"
>
<img
src="@/assets/images/primal-icon.svg"
alt="Remote Signer"
class="w-5 h-5 shrink-0"
/>
Remote Signer
</button>
</template>
<template v-else>
<p class="text-sm text-white/70 text-center">
Tap below to open Primal and sign in:
</p>
<a
:href="popupBlockedUri"
target="_blank"
rel="noopener noreferrer"
class="nostr-login-button w-full flex items-center justify-center gap-2 no-underline"
<button
v-if="!popupBlockedUri"
@click="handleNostrConnectLogin"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2"
>
<img
src="@/assets/images/primal-icon.svg"
alt="Primal"
class="w-5 h-5 shrink-0"
/>
Open Primal
</a>
{{ nostrConnectLoading ? 'Waiting...' : 'Remote Signer' }}
</button>
<template v-else>
<p class="text-sm text-white/70 text-center">
Tap below to open your signer app:
</p>
<a
:href="popupBlockedUri"
target="_blank"
rel="noopener noreferrer"
class="nostr-login-button w-full flex items-center justify-center gap-2 no-underline"
>
<img
src="@/assets/images/primal-icon.svg"
alt="Signer"
class="w-5 h-5 shrink-0"
/>
Open Signer App
</a>
</template>
</template>
</div>
@@ -257,6 +324,7 @@
{{ mode === 'register' ? 'Sign in' : 'Sign up' }}
</button>
</div>
</template>
</template>
<!-- SOVEREIGN PHASE: NAH! -->
@@ -325,6 +393,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import QRCode from 'qrcode'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useNostrConnect } from '../composables/useNostrConnect'
@@ -349,7 +418,20 @@ const emit = defineEmits<Emits>()
const { loginWithNostr, isLoading: authLoading } = useAuth()
const { loginWithExtension, loginWithPrivateKey } = useAccounts()
const { loginWithRemoteSigner, isLoading: nostrConnectLoading, error: nostrConnectError, popupBlockedUri } = useNostrConnect()
const {
loginWithRemoteSigner,
startRemoteSignerQrFlow,
isLoading: nostrConnectLoading,
error: nostrConnectError,
popupBlockedUri,
} = useNostrConnect()
const isDesktop = !/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
const remoteSignerPhase = ref<'idle' | 'qr'>('idle')
const remoteSignerQrDataUrl = ref('')
const remoteSignerCopyText = ref('Copy URI')
const remoteSignerUri = ref('')
let remoteSignerCancel: (() => void) | null = null
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
const errorMessage = ref<string | null>(null)
@@ -388,6 +470,11 @@ watch(() => props.isOpen, (open) => {
errorMessage.value = null
showNsecField.value = false
nsecInput.value = ''
remoteSignerPhase.value = 'idle'
remoteSignerQrDataUrl.value = ''
remoteSignerUri.value = ''
remoteSignerCancel?.()
remoteSignerCancel = null
cancelAmber()
}
})
@@ -398,6 +485,8 @@ watch(nostrConnectError, (err) => {
})
function closeModal() {
remoteSignerCancel?.()
remoteSignerCancel = null
emit('close')
errorMessage.value = null
cancelAmber()
@@ -542,9 +631,67 @@ async function handleNostrLogin() {
}
}
/**
* Desktop: Open Remote Signer QR phase.
*/
async function handleRemoteSignerClick() {
errorMessage.value = null
remoteSignerPhase.value = 'qr'
remoteSignerQrDataUrl.value = ''
remoteSignerUri.value = ''
remoteSignerCopyText.value = 'Copy URI'
try {
const { uri, complete, cancel } = startRemoteSignerQrFlow()
remoteSignerCancel = cancel
remoteSignerUri.value = uri
const dataUrl = await QRCode.toDataURL(uri, {
width: 224,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
errorCorrectionLevel: 'M',
})
remoteSignerQrDataUrl.value = dataUrl
await complete
emit('success')
closeModal()
} catch (error: any) {
if (remoteSignerPhase.value === 'qr') {
errorMessage.value = error?.message || 'Remote signer login failed. Please try again.'
}
} finally {
remoteSignerCancel = null
}
}
/**
* Back from Remote Signer QR phase.
*/
function handleRemoteSignerBack() {
remoteSignerCancel?.()
remoteSignerCancel = null
remoteSignerPhase.value = 'idle'
remoteSignerQrDataUrl.value = ''
remoteSignerUri.value = ''
errorMessage.value = null
}
async function copyRemoteSignerUri() {
if (!remoteSignerUri.value) return
try {
await navigator.clipboard.writeText(remoteSignerUri.value)
remoteSignerCopyText.value = 'Copied!'
setTimeout(() => { remoteSignerCopyText.value = 'Copy URI' }, 2000)
} catch {
remoteSignerCopyText.value = 'Copy failed'
}
}
/**
* Login with Nostr via remote signer (Primal, etc.) using nostrconnect:// URI.
* Opens the signer in a new tab, waits for connection, then creates backend session.
* Mobile: Opens the signer via link, waits for connection, then creates backend session.
*/
async function handleNostrConnectLogin() {
errorMessage.value = null

View File

@@ -116,12 +116,92 @@ export function useNostrConnect() {
}
}
let qrFlowAbort: AbortController | null = null
let qrFlowUserCancelled = false
/**
* Start remote signer flow in QR mode (desktop).
* Returns the URI for the QR code and a promise that resolves when login completes.
* Does not open a window — user scans QR with their phone.
*/
function startRemoteSignerQrFlow(): { uri: string; complete: Promise<void>; cancel: () => void } {
const clientSigner = new PrivateKeySigner()
const signer = new NostrConnectSigner({
relays: NOSTR_CONNECT_RELAYS,
signer: clientSigner,
})
const baseUri = signer.getNostrConnectURI({
name: 'IndeeHub',
url: window.location.origin,
})
const uri = appendCallbackToUri(baseUri)
isConnecting.value = true
error.value = null
qrFlowUserCancelled = false
qrFlowAbort = new AbortController()
const timeout = setTimeout(() => qrFlowAbort!.abort(), WAIT_FOR_SIGNER_TIMEOUT_MS)
const complete = (async () => {
try {
await signer.waitForSigner(qrFlowAbort!.signal)
clearTimeout(timeout)
qrFlowAbort = null
if (!signer.remote) {
throw new Error('Connection was closed before the signer responded.')
}
const pubkey = await signer.getPublicKey()
if (!pubkey) {
throw new Error('Could not get public key from signer.')
}
const account = new NostrConnectAccount(pubkey, signer)
accountManager.addAccount(account)
accountManager.setActive(account)
await authStore.loginWithNostr(pubkey, 'nostr-connect', {})
} catch (err: any) {
clearTimeout(timeout)
qrFlowAbort = null
if (!qrFlowUserCancelled) {
if (err?.name === 'AbortError' || err?.message?.includes('Aborted')) {
error.value = 'Connection timed out. Please try again.'
} else {
error.value = err?.message || 'Remote signer login failed. Please try again.'
}
}
throw err
} finally {
isConnecting.value = false
if (signer && !signer.isConnected) {
signer.close().catch(() => {})
}
}
})()
const cancel = () => {
qrFlowUserCancelled = true
clearTimeout(timeout)
qrFlowAbort?.abort()
qrFlowAbort = null
isConnecting.value = false
if (signer && !signer.isConnected) {
signer.close().catch(() => {})
}
}
return { uri, complete, cancel }
}
return {
isConnecting,
isLoading,
error,
popupBlockedUri,
loginWithRemoteSigner,
startRemoteSignerQrFlow,
getCallbackUrl,
}
}