feat: add Nostr Connect login functionality and update routing
- Introduced a new login option in AuthModal for Nostr Connect using remote signer (Primal), enhancing authentication methods. - Updated the router to include a new path for Nostr Connect callback, allowing for seamless integration after remote signer approval. - Enhanced error handling in AuthModal to surface Nostr Connect errors, improving user feedback during the login process. These changes improve the authentication experience by providing additional login options and ensuring robust error management.
This commit is contained in:
@@ -66,6 +66,18 @@
|
|||||||
Sign in with Nostr Extension
|
Sign in with Nostr Extension
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Login with Nostr (Remote Signer / Primal) -->
|
||||||
|
<button
|
||||||
|
@click="handleNostrConnectLogin"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="nostr-login-button w-full flex items-center justify-center gap-2 mt-3"
|
||||||
|
>
|
||||||
|
<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="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{{ nostrConnectLoading ? 'Waiting for Primal...' : 'Login with Nostr (Primal)' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- nsec login (hidden by default; tap to reveal field) -->
|
<!-- nsec login (hidden by default; tap to reveal field) -->
|
||||||
<template v-if="!showNsecField">
|
<template v-if="!showNsecField">
|
||||||
<button
|
<button
|
||||||
@@ -292,6 +304,7 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
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 { accountManager } from '../lib/accounts'
|
import { accountManager } from '../lib/accounts'
|
||||||
import { authService } from '../services/auth.service'
|
import { authService } from '../services/auth.service'
|
||||||
|
|
||||||
@@ -313,10 +326,13 @@ 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 } = useNostrConnect()
|
||||||
|
|
||||||
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)
|
||||||
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
|
const isLoading = computed(
|
||||||
|
() => authLoading.value || sovereignGenerating.value || nostrConnectLoading.value,
|
||||||
|
)
|
||||||
|
|
||||||
// nsec login (tap to reveal field)
|
// nsec login (tap to reveal field)
|
||||||
const showNsecField = ref(false)
|
const showNsecField = ref(false)
|
||||||
@@ -353,6 +369,11 @@ watch(() => props.isOpen, (open) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Surface Nostr Connect errors in the modal
|
||||||
|
watch(nostrConnectError, (err) => {
|
||||||
|
if (err) errorMessage.value = err
|
||||||
|
})
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
emit('close')
|
emit('close')
|
||||||
errorMessage.value = null
|
errorMessage.value = null
|
||||||
@@ -498,6 +519,23 @@ async function handleNostrLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async function handleNostrConnectLogin() {
|
||||||
|
errorMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginWithRemoteSigner()
|
||||||
|
emit('success')
|
||||||
|
closeModal()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Nostr Connect login failed:', error)
|
||||||
|
errorMessage.value = error?.message || 'Remote signer login failed. Please try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cancelNsecField() {
|
function cancelNsecField() {
|
||||||
showNsecField.value = false
|
showNsecField.value = false
|
||||||
nsecInput.value = ''
|
nsecInput.value = ''
|
||||||
|
|||||||
121
src/composables/useNostrConnect.ts
Normal file
121
src/composables/useNostrConnect.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { NostrConnectSigner, PrivateKeySigner } from 'applesauce-signers'
|
||||||
|
import { NostrConnectAccount } from 'applesauce-accounts/accounts'
|
||||||
|
import { accountManager } from '../lib/accounts'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
// Primal relay for Nostr Connect login (Primal must reach this relay)
|
||||||
|
const NOSTR_CONNECT_RELAYS = ['wss://relay.primal.net']
|
||||||
|
const WAIT_FOR_SIGNER_TIMEOUT_MS = 120_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for NIP-46 Nostr Connect (remote signer / bunker) login.
|
||||||
|
* Uses the nostrconnect:// URI scheme to trigger signers like Primal.
|
||||||
|
* Primal supports a callback parameter for redirecting back after connection.
|
||||||
|
*
|
||||||
|
* @see https://nostr.com/nprofile1qqsdv8emcke7k3qqaldwv956tstu40ejg663gdsaayuuujs6pknw7jspzfmhxue69uhhqatjwpkx2urpvuhx2ucpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgwmqjug
|
||||||
|
*/
|
||||||
|
export function useNostrConnect() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isConnecting = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const isLoading = computed(() => isConnecting.value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the callback URL for Primal to redirect to after establishing connection.
|
||||||
|
* Uses the current origin so it works in dev and production.
|
||||||
|
*/
|
||||||
|
function getCallbackUrl(): string {
|
||||||
|
return `${window.location.origin}/auth/nostr-callback`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append Primal's callback parameter to a nostrconnect URI.
|
||||||
|
* Primal redirects the user back to this URL after they approve the connection.
|
||||||
|
*/
|
||||||
|
function appendCallbackToUri(uri: string): string {
|
||||||
|
const separator = uri.includes('?') ? '&' : '?'
|
||||||
|
return `${uri}${separator}callback=${encodeURIComponent(getCallbackUrl())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with Nostr using a remote signer (Primal, etc.) via nostrconnect:// URI.
|
||||||
|
* Opens the signer app, waits for connection, then creates a backend session.
|
||||||
|
*/
|
||||||
|
async function loginWithRemoteSigner(): Promise<void> {
|
||||||
|
isConnecting.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
let signer: NostrConnectSigner | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// NostrConnectSigner is wired to our relay pool in accounts.ts
|
||||||
|
const clientSigner = new PrivateKeySigner()
|
||||||
|
signer = new NostrConnectSigner({
|
||||||
|
relays: NOSTR_CONNECT_RELAYS,
|
||||||
|
signer: clientSigner,
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseUri = signer.getNostrConnectURI({
|
||||||
|
name: 'IndeeHub',
|
||||||
|
url: window.location.origin,
|
||||||
|
})
|
||||||
|
|
||||||
|
const uri = appendCallbackToUri(baseUri)
|
||||||
|
|
||||||
|
// Open the signer (Primal, etc.) - use new tab so our page stays and can receive the connection
|
||||||
|
const signerWindow = window.open(uri, '_blank', 'noopener,noreferrer')
|
||||||
|
|
||||||
|
if (!signerWindow) {
|
||||||
|
throw new Error(
|
||||||
|
'Pop-up was blocked. Please allow pop-ups for this site and try again, or use another sign-in method.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for remote signer to connect (with timeout)
|
||||||
|
const abort = new AbortController()
|
||||||
|
const timeout = setTimeout(() => abort.abort(), WAIT_FOR_SIGNER_TIMEOUT_MS)
|
||||||
|
|
||||||
|
await signer.waitForSigner(abort.signal)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
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.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register NostrConnectAccount for future signing (comments, etc.)
|
||||||
|
const account = new NostrConnectAccount(pubkey, signer)
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
|
||||||
|
// Create backend session (NIP-98 is signed by the remote signer)
|
||||||
|
await authStore.loginWithNostr(pubkey, 'nostr-connect', {})
|
||||||
|
} catch (err: any) {
|
||||||
|
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(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnecting,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loginWithRemoteSigner,
|
||||||
|
getCallbackUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,14 @@ const router = createRouter({
|
|||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Nostr Connect callback (Primal redirects here after remote signer approval)
|
||||||
|
{
|
||||||
|
path: '/auth/nostr-callback',
|
||||||
|
name: 'nostr-callback',
|
||||||
|
component: () => import('../views/NostrCallback.vue'),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Creator / Filmmaker Routes ────────────────────────────────────────────
|
// ── Creator / Filmmaker Routes ────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
path: '/backstage',
|
path: '/backstage',
|
||||||
|
|||||||
56
src/views/NostrCallback.vue
Normal file
56
src/views/NostrCallback.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col items-center justify-center bg-[#0a0a0a] px-4">
|
||||||
|
<div class="text-center space-y-6">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-[#FAFAFA]">
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 animate-spin text-[#F7931A]"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-lg font-medium">Redirecting back to IndeeHub...</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-white/50">
|
||||||
|
If you're not redirected automatically,
|
||||||
|
<router-link to="/" class="text-[#F7931A] hover:text-[#F7931A]/80 underline">
|
||||||
|
click here
|
||||||
|
</router-link>
|
||||||
|
to return home.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
let redirectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Primal redirects here after user approves the connection.
|
||||||
|
// The original tab (if still open) has already completed the login.
|
||||||
|
// Redirect to home so the user lands on the app; auth state is in sessionStorage.
|
||||||
|
redirectTimer = setTimeout(() => {
|
||||||
|
router.replace('/')
|
||||||
|
}, 1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (redirectTimer) clearTimeout(redirectTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useaccess.ts","./src/composables/useaccounts.ts","./src/composables/useauth.ts","./src/composables/usecontentdiscovery.ts","./src/composables/usefilmmaker.ts","./src/composables/usemobile.ts","./src/composables/usenostr.ts","./src/composables/useobservable.ts","./src/composables/usetoast.ts","./src/composables/useupload.ts","./src/config/api.config.ts","./src/data/indeehubfilms.ts","./src/data/testpersonas.ts","./src/data/topdocfilms.ts","./src/lib/accounts.ts","./src/lib/nostr.ts","./src/lib/relay.ts","./src/router/guards.ts","./src/router/index.ts","./src/services/api.service.ts","./src/services/auth.service.ts","./src/services/content.service.ts","./src/services/filmmaker.service.ts","./src/services/indeehub-api.service.ts","./src/services/library.service.ts","./src/services/nip98.service.ts","./src/services/subscription.service.ts","./src/stores/auth.ts","./src/stores/content.ts","./src/stores/contentsource.ts","./src/stores/searchselection.ts","./src/types/api.ts","./src/types/content.ts","./src/utils/indeehubapi.ts","./src/utils/mappers.ts","./src/utils/mock.ts","./src/utils/nostr.ts","./src/app.vue","./src/components/appheader.vue","./src/components/authmodal.vue","./src/components/backstageheader.vue","./src/components/backstagemobilenav.vue","./src/components/commentnode.vue","./src/components/contentdetailmodal.vue","./src/components/contentrow.vue","./src/components/keysmodal.vue","./src/components/mobilenav.vue","./src/components/mobilesearch.vue","./src/components/rentalmodal.vue","./src/components/splashintro.vue","./src/components/splashintroicon.vue","./src/components/subscriptionmodal.vue","./src/components/toastcontainer.vue","./src/components/videoplayer.vue","./src/components/zapmodal.vue","./src/components/backstage/assetstab.vue","./src/components/backstage/castcrewtab.vue","./src/components/backstage/contenttab.vue","./src/components/backstage/couponstab.vue","./src/components/backstage/detailstab.vue","./src/components/backstage/documentationtab.vue","./src/components/backstage/permissionstab.vue","./src/components/backstage/revenuetab.vue","./src/components/backstage/uploadzone.vue","./src/views/browse.vue","./src/views/profile.vue","./src/views/backstage/analytics.vue","./src/views/backstage/backstage.vue","./src/views/backstage/projecteditor.vue","./src/views/backstage/settings.vue"],"version":"5.9.3"}
|
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useaccess.ts","./src/composables/useaccounts.ts","./src/composables/useauth.ts","./src/composables/usecontentdiscovery.ts","./src/composables/usefilmmaker.ts","./src/composables/usemobile.ts","./src/composables/usenostr.ts","./src/composables/usenostrconnect.ts","./src/composables/useobservable.ts","./src/composables/usetoast.ts","./src/composables/useupload.ts","./src/config/api.config.ts","./src/data/indeehubfilms.ts","./src/data/testpersonas.ts","./src/data/topdocfilms.ts","./src/lib/accounts.ts","./src/lib/nostr.ts","./src/lib/relay.ts","./src/router/guards.ts","./src/router/index.ts","./src/services/api.service.ts","./src/services/auth.service.ts","./src/services/content.service.ts","./src/services/filmmaker.service.ts","./src/services/indeehub-api.service.ts","./src/services/library.service.ts","./src/services/nip98.service.ts","./src/services/subscription.service.ts","./src/stores/auth.ts","./src/stores/content.ts","./src/stores/contentsource.ts","./src/stores/searchselection.ts","./src/types/api.ts","./src/types/content.ts","./src/utils/indeehubapi.ts","./src/utils/mappers.ts","./src/utils/mock.ts","./src/utils/nostr.ts","./src/app.vue","./src/components/appheader.vue","./src/components/authmodal.vue","./src/components/backstageheader.vue","./src/components/backstagemobilenav.vue","./src/components/commentnode.vue","./src/components/contentdetailmodal.vue","./src/components/contentrow.vue","./src/components/keysmodal.vue","./src/components/mobilenav.vue","./src/components/mobilesearch.vue","./src/components/rentalmodal.vue","./src/components/splashintro.vue","./src/components/splashintroicon.vue","./src/components/subscriptionmodal.vue","./src/components/toastcontainer.vue","./src/components/videoplayer.vue","./src/components/zapmodal.vue","./src/components/backstage/assetstab.vue","./src/components/backstage/castcrewtab.vue","./src/components/backstage/contenttab.vue","./src/components/backstage/couponstab.vue","./src/components/backstage/detailstab.vue","./src/components/backstage/documentationtab.vue","./src/components/backstage/permissionstab.vue","./src/components/backstage/revenuetab.vue","./src/components/backstage/uploadzone.vue","./src/views/browse.vue","./src/views/nostrcallback.vue","./src/views/profile.vue","./src/views/backstage/analytics.vue","./src/views/backstage/backstage.vue","./src/views/backstage/projecteditor.vue","./src/views/backstage/settings.vue"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user