diff --git a/src/components/AuthModal.vue b/src/components/AuthModal.vue index 857cdae..cedfb82 100644 --- a/src/components/AuthModal.vue +++ b/src/components/AuthModal.vue @@ -66,6 +66,18 @@ Sign in with Nostr Extension + + + + + + {{ nostrConnectLoading ? 'Waiting for Primal...' : 'Login with Nostr (Primal)' }} + + () const { loginWithNostr, isLoading: authLoading } = useAuth() const { loginWithExtension, loginWithPrivateKey } = useAccounts() +const { loginWithRemoteSigner, isLoading: nostrConnectLoading, error: nostrConnectError } = useNostrConnect() const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode) const errorMessage = ref(null) -const isLoading = computed(() => authLoading.value || sovereignGenerating.value) +const isLoading = computed( + () => authLoading.value || sovereignGenerating.value || nostrConnectLoading.value, +) // nsec login (tap to reveal field) 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() { emit('close') 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() { showNsecField.value = false nsecInput.value = '' diff --git a/src/composables/useNostrConnect.ts b/src/composables/useNostrConnect.ts new file mode 100644 index 0000000..3289ef6 --- /dev/null +++ b/src/composables/useNostrConnect.ts @@ -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(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 { + 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, + } +} diff --git a/src/router/index.ts b/src/router/index.ts index 107ea48..64cb65b 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -25,6 +25,14 @@ const router = createRouter({ 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 ──────────────────────────────────────────── { path: '/backstage', diff --git a/src/views/NostrCallback.vue b/src/views/NostrCallback.vue new file mode 100644 index 0000000..180255a --- /dev/null +++ b/src/views/NostrCallback.vue @@ -0,0 +1,56 @@ + + + + + + + + + Redirecting back to IndeeHub... + + + If you're not redirected automatically, + + click here + + to return home. + + + + + + diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 4ead9b7..dcf6298 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file
+ If you're not redirected automatically, + + click here + + to return home. +