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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user