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 ═══ -->
|
<!-- ═══ NORMAL AUTH VIEW ═══ -->
|
||||||
<template v-if="sovereignPhase === 'normal'">
|
<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 -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h2 class="text-3xl font-bold text-white">
|
<h2 class="text-3xl font-bold text-white">
|
||||||
@@ -54,8 +105,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Login with Nostr (Primal) — default / first option -->
|
<!-- Remote Signer — desktop: QR phase; mobile: direct link -->
|
||||||
<div class="mt-4 space-y-2">
|
<div class="mt-4 space-y-2">
|
||||||
|
<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>
|
||||||
<button
|
<button
|
||||||
v-if="!popupBlockedUri"
|
v-if="!popupBlockedUri"
|
||||||
@click="handleNostrConnectLogin"
|
@click="handleNostrConnectLogin"
|
||||||
@@ -67,11 +133,11 @@
|
|||||||
alt="Primal"
|
alt="Primal"
|
||||||
class="w-5 h-5 shrink-0"
|
class="w-5 h-5 shrink-0"
|
||||||
/>
|
/>
|
||||||
{{ nostrConnectLoading ? 'Waiting for Primal...' : 'Login with Nostr (Primal)' }}
|
{{ nostrConnectLoading ? 'Waiting...' : 'Remote Signer' }}
|
||||||
</button>
|
</button>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-sm text-white/70 text-center">
|
<p class="text-sm text-white/70 text-center">
|
||||||
Tap below to open Primal and sign in:
|
Tap below to open your signer app:
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
:href="popupBlockedUri"
|
:href="popupBlockedUri"
|
||||||
@@ -81,12 +147,13 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="@/assets/images/primal-icon.svg"
|
src="@/assets/images/primal-icon.svg"
|
||||||
alt="Primal"
|
alt="Signer"
|
||||||
class="w-5 h-5 shrink-0"
|
class="w-5 h-5 shrink-0"
|
||||||
/>
|
/>
|
||||||
Open Primal
|
Open Signer App
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sign in with Nostr Extension (NIP-07) -->
|
<!-- Sign in with Nostr Extension (NIP-07) -->
|
||||||
@@ -258,6 +325,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ═══ SOVEREIGN PHASE: NAH! ═══ -->
|
<!-- ═══ SOVEREIGN PHASE: NAH! ═══ -->
|
||||||
<div v-else-if="sovereignPhase === 'nah'" class="sovereign-nah" key="nah">
|
<div v-else-if="sovereignPhase === 'nah'" class="sovereign-nah" key="nah">
|
||||||
@@ -325,6 +393,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
import { useAuth } from '../composables/useAuth'
|
import { useAuth } from '../composables/useAuth'
|
||||||
import { useAccounts } from '../composables/useAccounts'
|
import { useAccounts } from '../composables/useAccounts'
|
||||||
import { useNostrConnect } from '../composables/useNostrConnect'
|
import { useNostrConnect } from '../composables/useNostrConnect'
|
||||||
@@ -349,7 +418,20 @@ const emit = defineEmits<Emits>()
|
|||||||
|
|
||||||
const { loginWithNostr, isLoading: authLoading } = useAuth()
|
const { loginWithNostr, isLoading: authLoading } = useAuth()
|
||||||
const { loginWithExtension, loginWithPrivateKey } = useAccounts()
|
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 mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
||||||
const errorMessage = ref<string | null>(null)
|
const errorMessage = ref<string | null>(null)
|
||||||
@@ -388,6 +470,11 @@ watch(() => props.isOpen, (open) => {
|
|||||||
errorMessage.value = null
|
errorMessage.value = null
|
||||||
showNsecField.value = false
|
showNsecField.value = false
|
||||||
nsecInput.value = ''
|
nsecInput.value = ''
|
||||||
|
remoteSignerPhase.value = 'idle'
|
||||||
|
remoteSignerQrDataUrl.value = ''
|
||||||
|
remoteSignerUri.value = ''
|
||||||
|
remoteSignerCancel?.()
|
||||||
|
remoteSignerCancel = null
|
||||||
cancelAmber()
|
cancelAmber()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -398,6 +485,8 @@ watch(nostrConnectError, (err) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
remoteSignerCancel?.()
|
||||||
|
remoteSignerCancel = null
|
||||||
emit('close')
|
emit('close')
|
||||||
errorMessage.value = null
|
errorMessage.value = null
|
||||||
cancelAmber()
|
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.
|
* 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() {
|
async function handleNostrConnectLogin() {
|
||||||
errorMessage.value = null
|
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 {
|
return {
|
||||||
isConnecting,
|
isConnecting,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
popupBlockedUri,
|
popupBlockedUri,
|
||||||
loginWithRemoteSigner,
|
loginWithRemoteSigner,
|
||||||
|
startRemoteSignerQrFlow,
|
||||||
getCallbackUrl,
|
getCallbackUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user