From 0372eb64fee95a71281789036b0fda51d8caa8de Mon Sep 17 00:00:00 2001 From: hugotiburtino <45924645+hugotiburtino@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:17:33 -0300 Subject: [PATCH 1/4] feat(nip-46): make login with bunker work --- docker-relay-config.toml | 3 +- src/lib/components/auth/LoginSheet.svelte | 39 +++-- src/lib/stores/authStore.svelte.ts | 63 +++++++- src/routes/cardsboard/LoginDialog.svelte | 178 +++++++++++----------- 4 files changed, 181 insertions(+), 102 deletions(-) diff --git a/docker-relay-config.toml b/docker-relay-config.toml index c00927f0..1aacaa31 100644 --- a/docker-relay-config.toml +++ b/docker-relay-config.toml @@ -27,5 +27,6 @@ level = "info" # 30023: Long Text # 30142: Nostr Learning Resource # 20000: Ephemeral Events, um online-Users zu zeigen -event_kind_allowlist = [0, 1, 5, 8571, 30000, 30301, 30302, 30303, 30023, 20001, 30142, 20000] +# 24133: Für nip-46 +event_kind_allowlist = [0, 1, 5, 8571, 30000, 30301, 30302, 30303, 30023, 20001, 30142, 20000, 24133] diff --git a/src/lib/components/auth/LoginSheet.svelte b/src/lib/components/auth/LoginSheet.svelte index 500a2845..2ca26840 100644 --- a/src/lib/components/auth/LoginSheet.svelte +++ b/src/lib/components/auth/LoginSheet.svelte @@ -210,32 +210,47 @@
- Coming Soon

- Connect to remote wallets via NIP-46 + Verbinde dich mit einem Remote Signer via NIP-46 (Nostr Connect). Unterstützt nsec.app, Amber und andere Bunker.

- Connection String + Bunker-Verbindungszeichenfolge - +
+ +
+
+

So funktioniert's:

+
    +
  1. Öffne dein Nostr-Wallet
  2. +
  3. Erstelle eine neue "Bunker"-Verbindung
  4. +
  5. Kopiere die Bunker-URL (beginnt mit bunker://)
  6. +
  7. Füge sie hier ein und klicke auf "Mit NIP-46 verbinden"
  8. +
+
diff --git a/src/lib/stores/authStore.svelte.ts b/src/lib/stores/authStore.svelte.ts index 66f7364d..1745fa1b 100644 --- a/src/lib/stores/authStore.svelte.ts +++ b/src/lib/stores/authStore.svelte.ts @@ -1,7 +1,7 @@ import { persisted } from "svelte-persisted-store"; import { NDKNip07Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk"; -import type { NDKUser } from "@nostr-dev-kit/ndk"; +import { NDKNip46Signer, type NDKUser } from "@nostr-dev-kit/ndk"; import { get } from 'svelte/store' import { settingsStore } from "./settingsStore.svelte.js"; @@ -207,8 +207,65 @@ export class AuthStore { * NIP-46 Remote Signing - FUTURE */ public async loginWithNip46(connectionString: string): Promise { - // TODO: Implement NIP-46 - throw new Error("NIP-46 not yet implemented"); + try { + this.isLoading = true; + this.errorMessage = null; + + if (!connectionString || connectionString.trim() === '') { + const message = 'Bitte gib eine gültige Bunker-Verbindungszeichenfolge ein.'; + toast.error(message); + return Promise.reject(message); + } + const signer = NDKNip46Signer.bunker(this.ndk, connectionString); + + signer.on("authUrl", (url) => { window.open(url, "auth", "width=600,height=600") }) + + this.ndk.signer = signer; + + await this.ndk.connect(10000) + + const user = await signer.blockUntilReady() + + await user.fetchProfile(); + + this.currentUser = user; + await this.saveSession(user, "nip46"); + + try { + getSyncManager().updateSigner(signer); + console.log('✅ SyncManager signer updated after NIP-46 login'); + + // 🔄 Reconnect AUTH_REQUIRED relays now that signer is available + const { reconnectAuthRelays } = await import('./syncManager.svelte.js'); + await reconnectAuthRelays(); + } catch (error) { + console.warn('⚠️ SyncManager signer update warning:', error); + } + + try { + const { boardStore } = await import('./kanbanStore.svelte.js'); + boardStore.updateBoardAuthor?.(); + + // 🆕 DEMO-BOARD MIGRATION: Vollständige Board-Migration nach Login + await boardStore.onAuthChanged?.(); + + await boardStore.loadBoardsFromNostrForCurrentUser?.(); + boardStore.subscribeToBoardUpdatesForCurrentUser?.(); + console.log('[AuthStore] ✅ Boards synced from Nostr after NIP-46 login'); + } catch (error) { + console.warn('[AuthStore] ⚠️ Failed to sync boards from Nostr after NIP-46 login:', error); + } + + return user + } catch (error) { + const { message = 'NIP-46 Login fehlgeschlagen' } = error as Error; + this.errorMessage = message; + toast.error(message) + return Promise.reject(error); + } finally { + this.isLoading = false; + } + } /** diff --git a/src/routes/cardsboard/LoginDialog.svelte b/src/routes/cardsboard/LoginDialog.svelte index 0d972046..3ae30918 100644 --- a/src/routes/cardsboard/LoginDialog.svelte +++ b/src/routes/cardsboard/LoginDialog.svelte @@ -17,6 +17,7 @@ let { open = $bindable(false) }: { open: boolean } = $props(); let nsecInput = $state(''); + let nip46ConnectionString = $state(''); let isLoading = $derived(authStore.isLoading); let errorMessage = $derived(authStore.errorMessage); let isAuthenticated = $derived(authStore.isAuthenticated); @@ -98,6 +99,17 @@ open = false; } } + async function handleNip46Login() { + if (!nip46ConnectionString.trim()) return; + + try { + await authStore.loginWithNip46(nip46ConnectionString); + open = false; + nip46ConnectionString = ''; + } catch (error) { + console.error('NIP-46 login failed:', error); + } + } @@ -113,19 +125,16 @@ - + Browser-Extension nsec - + @@ -211,91 +220,88 @@ ⚠️ Niemals den privaten Schlüssel öffentlich teilen oder in Production nutzen!

- {#if errorMessage} -
- {errorMessage} -
- {/if} - - - - - - -
-

- Remote Signer (NIP-46) verwenden — URL des Signer-Services und optionaler Pubkey des Signers. -

- -
-
- - -
- -
- - -
-
+ {#if errorMessage} +
+ {errorMessage}
+ {/if} - {#if errorMessage} -
- {errorMessage} -
+ + -

- ⚠️ Remote Signing: Vertraue nur Signer, denen du vertraust. Private Keys verbleiben beim Signer. -

+ + +
+ +
+ +
+
+

So funktioniert's:

+
    +
  1. Öffne dein Nostr-Wallet
  2. +
  3. Erstelle eine neue "Bunker"-Verbindung
  4. +
  5. Kopiere die Bunker-URL (beginnt mit bunker://)
  6. +
  7. Füge sie hier ein und klicke auf "Mit NIP-46 verbinden"
  8. +
+
+
- -
+ {#if errorMessage} +
+ {errorMessage} +
+ {/if} + + + - + - - 🔒 Deine Authentifizierungsdaten werden lokal gespeichert - - + + 🔒 Deine Authentifizierungsdaten werden lokal gespeichert + + From 8cf21273a031d13c4eb95096cb3537a7507d2150 Mon Sep 17 00:00:00 2001 From: hugotiburtino <45924645+hugotiburtino@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:17:51 -0300 Subject: [PATCH 2/4] remove dead code --- src/lib/stores/syncManager.svelte.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/stores/syncManager.svelte.ts b/src/lib/stores/syncManager.svelte.ts index d58d2b43..5f5511b4 100644 --- a/src/lib/stores/syncManager.svelte.ts +++ b/src/lib/stores/syncManager.svelte.ts @@ -119,10 +119,7 @@ export class SyncManager { } public updateSigner(signer: NDKSigner | undefined): void { - const wasSigner = this.signer ? 'yes' : 'no'; - const isSigner = signer ? 'yes' : 'no'; this.signer = signer; - // console.log(`[SyncManager] Signer updated: ${wasSigner} ${isSigner}`); if (signer && this.isOnline && this.eventQueue.length > 0) { console.log(`[SyncManager] New signer available! Syncing ${this.eventQueue.length} queued event(s)...`); this.syncQueue(); From 546131663db284693640b431fc3a3c993cbd31b2 Mon Sep 17 00:00:00 2001 From: hugotiburtino <45924645+hugotiburtino@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:58:09 -0300 Subject: [PATCH 3/4] fix e2e test --- src/routes/cardsboard/LoginDialog.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/cardsboard/LoginDialog.svelte b/src/routes/cardsboard/LoginDialog.svelte index 3ae30918..dc88d8ad 100644 --- a/src/routes/cardsboard/LoginDialog.svelte +++ b/src/routes/cardsboard/LoginDialog.svelte @@ -239,7 +239,7 @@ isLoading = false; } }} - disabled={isLoading} + disabled={isLoading || !nsecInput.trim()} variant="outline" class="w-full" > @@ -288,7 +288,7 @@ disabled={isLoading || !nip46ConnectionString.trim()} variant="outline" class="w-full" - > + > {#if isLoading} Verbinde mit Remote Signer... {:else} From fa7b4bfc9838913a38915ca614742c7ea021090e Mon Sep 17 00:00:00 2001 From: hugotiburtino <45924645+hugotiburtino@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:12:00 -0300 Subject: [PATCH 4/4] feat(bunker): add qr code connection, it can only be tested in deployment --- src/lib/components/auth/LoginSheet.svelte | 274 ++++++++++++++++++---- src/lib/stores/authStore.svelte.ts | 128 ++++++++++ src/routes/cardsboard/LoginDialog.svelte | 269 +++++++++++++++++---- 3 files changed, 588 insertions(+), 83 deletions(-) diff --git a/src/lib/components/auth/LoginSheet.svelte b/src/lib/components/auth/LoginSheet.svelte index 2ca26840..17ad2ace 100644 --- a/src/lib/components/auth/LoginSheet.svelte +++ b/src/lib/components/auth/LoginSheet.svelte @@ -1,5 +1,6 @@ !newOpen && onClose()}> - - + +
@@ -97,7 +201,7 @@ -
+
@@ -209,49 +313,135 @@
-
-

- Verbinde dich mit einem Remote Signer via NIP-46 (Nostr Connect). Unterstützt nsec.app, Amber und andere Bunker. -

-
- - Bunker-Verbindungszeichenfolge - -
- + {#if !qrCodeDataUrl} + +
+
+ +

Scan with Mobile Bunker App

+
+

+ Use Amber or another mobile bunker app to sign in securely +

+ +
+ +
+
+ +
+
+ Or +
+
+ + +
+ + Paste Bunker Connection String + +
+ +
+
+
+ + +
+ + {:else} + +
+
+
+ {#if qrCodeDataUrl} + NIP-46 Connection QR Code + {/if} + + {#if isWaitingForApproval} +
+
+ Waiting for approval on your mobile device... +
+ {/if} +
-
-

So funktioniert's:

-
    -
  1. Öffne dein Nostr-Wallet
  2. -
  3. Erstelle eine neue "Bunker"-Verbindung
  4. -
  5. Kopiere die Bunker-URL (beginnt mit bunker://)
  6. -
  7. Füge sie hier ein und klicke auf "Mit NIP-46 verbinden"
  8. + +
    +

    Instructions:

    +
      +
    1. Open Amber or your bunker app on mobile
    2. +
    3. Scan this QR code
    4. +
    5. Approve the connection request
    6. +
    7. You'll be automatically signed in
    - - - - + + {#if connectionUrl} +
    +

    Or copy the connection URL:

    +
    + + +
    +
    + {/if} + + +
+ {/if}
diff --git a/src/lib/stores/authStore.svelte.ts b/src/lib/stores/authStore.svelte.ts index 1745fa1b..5efcb9d6 100644 --- a/src/lib/stores/authStore.svelte.ts +++ b/src/lib/stores/authStore.svelte.ts @@ -265,7 +265,132 @@ export class AuthStore { } finally { this.isLoading = false; } + } + + /** + * Generate NIP-46 QR Code for Mobile App Login + * Creates a nostrconnect URL that can be scanned by mobile apps (Amber, etc.) + * Returns the URL and waits for approval + */ + public async generateNip46QRCode(): Promise<{ + url: string; + waitForApproval: () => Promise; + }> { + try { + this.isLoading = true; + this.errorMessage = null; + + console.log('[AuthStore] Starting NIP-46 QR generation...'); + + // Get user's configured relays from NDK pool + const userRelays = Array.from(this.ndk.pool?.relays.values() || []) + .map(relay => relay.url) + .filter(url => url && url.startsWith('wss://')); + + console.log('[AuthStore] Using relays for NIP-46:', userRelays); + + // Create local signer (ephemeral keypair for this session) + console.log('[AuthStore] Creating local signer...'); + const localSigner = NDKPrivateKeySigner.generate(); + + // Create NIP-46 signer that will wait for remote connection + console.log('[AuthStore] Creating NIP-46 signer...'); + const remoteSigner = new NDKNip46Signer(this.ndk, undefined, localSigner); + + // Try createAccount with a 5-second timeout + const createAccountWithTimeout = () => { + return Promise.race([ + remoteSigner.createAccount(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('createAccount timeout - constructing URL manually')), 5000) + ) + ]); + }; + + let nostrconnectUrl: string; + try { + nostrconnectUrl = await createAccountWithTimeout(); + console.log('[AuthStore] createAccount() succeeded'); + } catch (timeoutError) { + // Fallback: Construct the URL manually using user's configured relays + console.warn('[AuthStore] createAccount() timed out, using manual URL construction'); + const localPubkey = await localSigner.user(); + const pubkeyHex = localPubkey.pubkey; + + // Build relay parameters - include all user relays for better connectivity + const relayParams = userRelays.map(url => `relay=${encodeURIComponent(url)}`).join('&'); + const metadata = encodeURIComponent(JSON.stringify({name: 'Kanban Board', url: window.location.origin})); + + nostrconnectUrl = `nostrconnect://${pubkeyHex}?${relayParams}&metadata=${metadata}`; + console.log('[AuthStore] Manual URL construction complete with', userRelays.length, 'relays'); + } + + console.log('🔗 Generated nostrconnect URL:', nostrconnectUrl.substring(0, 80) + '...'); + + // ✅ IMPORTANT: Set loading to false after URL generation + // The waitForApproval will handle its own loading state + this.isLoading = false; + // Return URL and a promise that resolves when connection is approved + return { + url: nostrconnectUrl, + waitForApproval: async () => { + console.log('[AuthStore] Waiting for mobile approval...'); + this.isLoading = true; + + // Set the signer and wait for it to be ready + this.ndk.signer = remoteSigner; + + // Wait for remote app to approve (this will block until approved) + const user = await remoteSigner.blockUntilReady(); + + console.log('[AuthStore] Mobile approval received!'); + + // Fetch user profile + await user.fetchProfile(); + + this.currentUser = user; + await this.saveSession(user, "nip46"); + + // Update SyncManager + try { + getSyncManager().updateSigner(remoteSigner); + console.log('✅ SyncManager signer updated after NIP-46 QR login'); + + const { reconnectAuthRelays } = await import('./syncManager.svelte.js'); + await reconnectAuthRelays(); + } catch (error) { + console.warn('⚠️ SyncManager signer update warning:', error); + } + + // Load boards from Nostr + try { + const { boardStore } = await import('./kanbanStore.svelte.js'); + boardStore.updateBoardAuthor?.(); + await boardStore.onAuthChanged?.(); + await boardStore.loadBoardsFromNostrForCurrentUser?.(); + boardStore.subscribeToBoardUpdatesForCurrentUser?.(); + console.log('[AuthStore] ✅ Boards synced from Nostr after NIP-46 QR login'); + } catch (error) { + console.warn('[AuthStore] ⚠️ Failed to sync boards from Nostr after NIP-46 QR login:', error); + } + + this.isLoading = false; + return user; + } + }; + } catch (error) { + this.isLoading = false; + const { message = 'NIP-46 QR generation failed' } = error as Error; + this.errorMessage = message; + console.error('[AuthStore] QR generation error:', error); + console.error('[AuthStore] Error details:', { + message: (error as Error).message, + stack: (error as Error).stack + }); + toast.error(message); + return Promise.reject(error); + } } /** @@ -1016,6 +1141,9 @@ class AuthStoreProxy { loginWithNip46(relayUrl: string) { return AuthStoreWrapper.getInstance().loginWithNip46(relayUrl); } + generateNip46QRCode() { + return AuthStoreWrapper.getInstance().generateNip46QRCode(); + } logout() { return AuthStoreWrapper.getInstance().logout(); } diff --git a/src/routes/cardsboard/LoginDialog.svelte b/src/routes/cardsboard/LoginDialog.svelte index dc88d8ad..5056c369 100644 --- a/src/routes/cardsboard/LoginDialog.svelte +++ b/src/routes/cardsboard/LoginDialog.svelte @@ -10,9 +10,14 @@ import { Label } from "$lib/components/ui/label/index.js"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs/index.js"; import { authStore } from "$lib/stores/authStore.svelte.js"; + import QRCode from 'qrcode'; import LogInIcon from "@lucide/svelte/icons/log-in"; import UserIcon from "@lucide/svelte/icons/user"; import KeyRoundIcon from "@lucide/svelte/icons/key-round"; + import QrCodeIcon from "@lucide/svelte/icons/qr-code"; + import SmartphoneIcon from "@lucide/svelte/icons/smartphone"; + import CopyIcon from "@lucide/svelte/icons/copy"; + import CheckIcon from "@lucide/svelte/icons/check"; let { open = $bindable(false) }: { open: boolean } = $props(); @@ -21,6 +26,13 @@ let isLoading = $derived(authStore.isLoading); let errorMessage = $derived(authStore.errorMessage); let isAuthenticated = $derived(authStore.isAuthenticated); + + // QR Code state + let qrCodeDataUrl = $state(null); + let connectionUrl = $state(null); + let isGeneratingQr = $state(false); + let isWaitingForApproval = $state(false); + let copied = $state(false); // Track if dialog was open when auth was NOT active, then close after fresh auth // This prevents premature closing if user was already authenticated let wasUnauthenticatedWhenOpened = $state(false); @@ -46,6 +58,15 @@ }, 500); } }); + + // Reset QR code when dialog closes + $effect(() => { + if (!open) { + resetQrCode(); + nsecInput = ''; + nip46ConnectionString = ''; + } + }); // Browser detection let browserType = $state<'chrome' | 'firefox' | 'safari' | 'edge' | 'opera' | 'brave' | 'unknown'>('unknown'); @@ -110,11 +131,77 @@ console.error('NIP-46 login failed:', error); } } + + async function generateQrCode() { + try { + isGeneratingQr = true; + + console.log('[LoginDialog] Starting QR code generation...'); + + // Generate the nostrconnect URL using authStore + const result = await authStore.generateNip46QRCode(); + console.log('[LoginDialog] Got nostrconnect URL:', result.url.substring(0, 50) + '...'); + + connectionUrl = result.url; + + // Generate QR code as data URL + qrCodeDataUrl = await QRCode.toDataURL(result.url, { + width: 300, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }); + + console.log('[LoginDialog] QR code generated successfully'); + + // Finish generating state + isGeneratingQr = false; + + // Start waiting for approval + isWaitingForApproval = true; + console.log('[LoginDialog] Waiting for mobile approval...'); + + // Wait for the mobile app to approve (don't await - let it run in background) + result.waitForApproval() + .then((user) => { + if (user) { + console.log('[LoginDialog] Mobile approval received!'); + isWaitingForApproval = false; + open = false; + } + }) + .catch((approvalError: any) => { + console.error('[LoginDialog] Approval failed:', approvalError); + isWaitingForApproval = false; + }); + + } catch (err: any) { + console.error('[LoginDialog] QR generation error:', err); + isGeneratingQr = false; + isWaitingForApproval = false; + } + } + + function copyToClipboard() { + if (connectionUrl) { + navigator.clipboard.writeText(connectionUrl); + copied = true; + setTimeout(() => copied = false, 2000); + } + } + + function resetQrCode() { + qrCodeDataUrl = null; + connectionUrl = null; + isWaitingForApproval = false; + } - - + + Login @@ -124,7 +211,8 @@ - +
+ Browser-Extension @@ -254,53 +342,152 @@ -
- -
- + {#if !qrCodeDataUrl} + +
+
+ +

Scan with Mobile Bunker App

+
+

+ Use Amber or another mobile bunker app to sign in securely +

+
-
-

So funktioniert's:

-
    -
  1. Öffne dein Nostr-Wallet
  2. -
  3. Erstelle eine neue "Bunker"-Verbindung
  4. -
  5. Kopiere die Bunker-URL (beginnt mit bunker://)
  6. -
  7. Füge sie hier ein und klicke auf "Mit NIP-46 verbinden"
  8. -
+ +
+
+ +
+
+ Or +
-
- - {#if errorMessage} -
- {errorMessage} + + +
+ +
+ +
+
+

How it works:

+
    +
  1. Open your Nostr wallet
  2. +
  3. Create a new "Bunker" connection
  4. +
  5. Copy the bunker URL (starts with bunker://)
  6. +
  7. Paste it here and click "Connect with NIP-46"
  8. +
+
- {/if} - {:else} - - Mit NIP-46 verbinden + +
+
+
+ {#if qrCodeDataUrl} + NIP-46 Connection QR Code + {/if} + + {#if isWaitingForApproval} +
+
+ Waiting for approval on your mobile device... +
+ {/if} +
+
+ +
+

Instructions:

+
    +
  1. Open Amber or your bunker app on mobile
  2. +
  3. Scan this QR code
  4. +
  5. Approve the connection request
  6. +
  7. You'll be automatically signed in
  8. +
+
+ + {#if connectionUrl} +
+

Or copy the connection URL:

+
+ + +
+
+ {/if} + + +
{/if} - - + +
- + 🔒 Deine Authentifizierungsdaten werden lokal gespeichert