diff --git a/nuwa-kit/typescript/packages/identity-kit-web/README.md b/nuwa-kit/typescript/packages/identity-kit-web/README.md index c1c79804..4cb7a66d 100644 --- a/nuwa-kit/typescript/packages/identity-kit-web/README.md +++ b/nuwa-kit/typescript/packages/identity-kit-web/README.md @@ -4,12 +4,14 @@ Web extensions for Nuwa Identity Kit, providing browser-friendly implementations ## Features -- Multiple KeyStore implementations: +- **Popup Blocker Handling** - Automatically detects and handles popup blockers with multiple fallback strategies +- **Multiple KeyStore implementations**: - `LocalStorageKeyStore` - Uses browser's localStorage for key storage - `IndexedDBKeyStore` - Uses IndexedDB for key storage, supports CryptoKey objects -- `DeepLinkManager` - Manages deep link authentication flow -- `IdentityKitWeb` - High-level API for web applications -- React hooks (optional) - `useIdentityKit` hook for React applications +- **DeepLinkManager** - Manages deep link authentication flow with CSRF protection +- **IdentityKitWeb** - High-level API for web applications +- **React hooks** - `useIdentityKit` and `useIdentityKitWithFallbacks` hooks for React applications +- **Session Key Scopes** - Support for custom permission scopes in authentication flow ## Installation @@ -27,9 +29,33 @@ import { IdentityKitWeb } from '@nuwa-ai/identity-kit-web'; // Initialize the SDK const nuwa = await IdentityKitWeb.init(); -// Connect to Cadop +// Basic connect (backward compatible - returns void) await nuwa.connect(); +// OR: Connect with popup blocker handling (returns detailed result) +const result = await nuwa.connect({ + scopes: ['0xdefi::swap::*'], + fallbackMethod: 'copy', // Options: 'redirect' | 'copy' | 'manual' + returnResult: true // Enable detailed result +}); + +if (result) { // Only when returnResult=true + switch (result.action) { + case 'popup': + console.log('Popup opened successfully'); + break; + case 'copy': + console.log('URL copied to clipboard'); + break; + case 'redirect': + console.log('Page will redirect'); + break; + case 'manual': + console.log('Manual handling required:', result.url); + break; + } +} + // Handle callback (in your callback page) await nuwa.handleCallback(location.search); @@ -51,12 +77,28 @@ import { useIdentityKit } from '@nuwa-ai/identity-kit-web'; function MyComponent() { const { state, connect, sign, verify, logout } = useIdentityKit(); + const handleConnect = async () => { + // Option 1: Backward compatible (no detailed result) + await connect(); + + // Option 2: With detailed result for popup handling + const result = await connectWithResult({ + fallbackMethod: 'copy' + }); + + if (result.action === 'copy' && result.success) { + alert('Authorization link copied to clipboard!'); + } else if (result.action === 'manual') { + alert(`Please open this link: ${result.url}`); + } + }; + if (state.isConnecting) { return
Connecting...
; } if (!state.isConnected) { - return ; + return ; } return ( @@ -68,6 +110,40 @@ function MyComponent() { } ``` +### Enhanced React Hook (Recommended) + +```tsx +import { useIdentityKitWithFallbacks } from '@nuwa-ai/identity-kit-web'; + +function MyComponent() { + const { state, connectWithFallbacks, logout } = useIdentityKitWithFallbacks(); + + const handleConnect = async () => { + await connectWithFallbacks({ + scopes: ['0xdefi::swap::*'], + onPopupBlocked: () => alert('Popup blocked by browser'), + onUrlCopied: () => alert('Link copied! Paste in new tab'), + onManualUrl: (url) => alert(`Please click: ${url}`) + }); + }; + + if (state.isConnected) { + return ( +
+

Connected: {state.agentDid}

+ +
+ ); + } + + return ( + + ); +} +``` + ### Advanced Usage ```typescript diff --git a/nuwa-kit/typescript/packages/identity-kit-web/src/IdentityKitWeb.ts b/nuwa-kit/typescript/packages/identity-kit-web/src/IdentityKitWeb.ts index e9f7fc78..57d97168 100644 --- a/nuwa-kit/typescript/packages/identity-kit-web/src/IdentityKitWeb.ts +++ b/nuwa-kit/typescript/packages/identity-kit-web/src/IdentityKitWeb.ts @@ -116,8 +116,20 @@ export class IdentityKitWeb { /** * Connect to Cadop * This will open a new window with the Cadop add-key page + * For backward compatibility, returns void by default. Set returnResult=true to get detailed result. */ - async connect(options?: { scopes?: string[] }): Promise { + async connect(options?: { + scopes?: string[]; + /** Fallback method when popup is blocked */ + fallbackMethod?: 'redirect' | 'copy' | 'manual'; + /** Return detailed result instead of void (for popup blocker handling) */ + returnResult?: boolean; + }): Promise { const idFragment = this.generateIdFragment(); const { url } = await this.deepLinkManager.buildAddKeyUrl({ @@ -126,8 +138,91 @@ export class IdentityKitWeb { scopes: options?.scopes, }); - // Open the URL in a new window/tab - window.open(url, '_blank'); + try { + // Try to open popup first + const popup = window.open(url, '_blank', 'noopener,noreferrer'); + + // Add a small timeout to allow popup.closed to update + const isPopupBlocked = await new Promise((resolve) => { + setTimeout(() => { + resolve(!popup || popup.closed || typeof popup.closed === 'undefined'); + }, 100); // 100ms timeout + }); + + if (isPopupBlocked) { + // Popup was blocked, try fallback method + const result = await this.handlePopupBlocked(url, options?.fallbackMethod); + return options?.returnResult ? result : undefined; + } + + // Popup opened successfully + const result = { + action: 'popup' as const, + url, + success: true + }; + return options?.returnResult ? result : undefined; + } catch (error) { + // Error opening popup, try fallback method + const result = await this.handlePopupBlocked(url, options?.fallbackMethod, error instanceof Error ? error.message : String(error)); + return options?.returnResult ? result : undefined; + } + } + + /** + * Handle popup blocker scenarios with fallback methods + */ + private async handlePopupBlocked( + url: string, + fallbackMethod?: 'redirect' | 'copy' | 'manual', + originalError?: string + ): Promise<{ + action: 'redirect' | 'copy' | 'manual'; + url: string; + success: boolean; + error?: string; + }> { + const method = fallbackMethod || 'redirect'; // Default to redirect + + switch (method) { + case 'redirect': + // Redirect current page to CADOP + window.location.href = url; + return { + action: 'redirect', + url, + success: true + }; + + case 'copy': + // Copy URL to clipboard and show user notification + try { + await navigator.clipboard.writeText(url); + return { + action: 'copy', + url, + success: true + }; + } catch (clipboardError) { + // Fallback to manual if clipboard fails + return { + action: 'manual', + url, + success: false, + error: `Clipboard access failed: ${clipboardError instanceof Error ? clipboardError.message : String(clipboardError)}` + }; + } + + case 'manual': + default: + // Return URL for manual handling by the application + return { + action: 'manual', + url, + success: false, + error: originalError ? `Popup blocked: ${originalError}` : 'Popup blocked by browser' + }; + } } /** diff --git a/nuwa-kit/typescript/packages/identity-kit-web/src/react/useIdentityKit.ts b/nuwa-kit/typescript/packages/identity-kit-web/src/react/useIdentityKit.ts index 1c95d0fb..c5d8f42e 100644 --- a/nuwa-kit/typescript/packages/identity-kit-web/src/react/useIdentityKit.ts +++ b/nuwa-kit/typescript/packages/identity-kit-web/src/react/useIdentityKit.ts @@ -10,15 +10,49 @@ export interface IdentityKitState { error: string | null; } +export interface ConnectResult { + action: 'popup' | 'redirect' | 'copy' | 'manual'; + url?: string; + success: boolean; + error?: string; +} + export interface IdentityKitHook { state: IdentityKitState; - connect: (options?: { scopes?: string[] }) => Promise; + connect: (options?: { + scopes?: string[]; + fallbackMethod?: 'redirect' | 'copy' | 'manual'; + }) => Promise; + /** Enhanced connect method that returns detailed result for popup blocker handling */ + connectWithResult: (options?: { + scopes?: string[]; + fallbackMethod?: 'redirect' | 'copy' | 'manual'; + }) => Promise; sign: (payload: any) => Promise; verify: (sig: NIP1SignedObject) => Promise; logout: () => Promise; sdk: IdentityKitWeb | null; } +/** + * Enhanced hook that provides user-friendly popup blocker handling + */ +export interface IdentityKitHookWithFallbacks extends IdentityKitHook { + /** + * Connect with automatic fallback handling and user notifications + * Returns a promise that resolves with instructions for the user + */ + connectWithFallbacks: (options?: { + scopes?: string[]; + /** Callback to show user notifications */ + onPopupBlocked?: (result: ConnectResult) => void; + /** Callback to show clipboard success notification */ + onUrlCopied?: (url: string) => void; + /** Callback to show manual URL handling */ + onManualUrl?: (url: string) => void; + }) => Promise; +} + export interface UseIdentityKitOptions { appName?: string; cadopDomain?: string; @@ -102,8 +136,11 @@ export function useIdentityKit(options: UseIdentityKitOptions = {}): IdentityKit return () => window.removeEventListener('message', handleMessage); }, [sdk]); - // Connect action - const connect = useCallback(async (options?: { scopes?: string[] }) => { + // Connect action (backward compatible) + const connect = useCallback(async (options?: { + scopes?: string[]; + fallbackMethod?: 'redirect' | 'copy' | 'manual'; + }) => { if (!sdk) { setState(prev => ({ ...prev, error: 'SDK not initialized' })); return; @@ -112,9 +149,64 @@ export function useIdentityKit(options: UseIdentityKitOptions = {}): IdentityKit setState(prev => ({ ...prev, isConnecting: true, error: null })); try { - await sdk.connect(options); - // Actual connection result will be handled via postMessage in callback - setState(prev => ({ ...prev, isConnecting: false })); + const result = await sdk.connect({ ...options, returnResult: true }); + if (result.success) { + setState(prev => ({ ...prev, isConnecting: false })); + } else if (options?.fallbackMethod) { + // Handle fallback method if connection fails + setState(prev => ({ ...prev, isConnecting: false, error: `Fallback method triggered: ${options.fallbackMethod}` })); + } else { + setState(prev => ({ ...prev, isConnecting: false, error: 'Connection failed without fallback' })); + } + } catch (error) { + setState({ + isConnected: false, + isConnecting: false, + agentDid: null, + keyId: null, + error: `Connection failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, [sdk]); + + // Enhanced connect action with result + const connectWithResult = useCallback(async (options?: { + scopes?: string[]; + fallbackMethod?: 'redirect' | 'copy' | 'manual'; + }): Promise => { + if (!sdk) { + setState(prev => ({ ...prev, error: 'SDK not initialized' })); + return { + action: 'manual', + success: false, + error: 'SDK not initialized' + }; + } + + setState(prev => ({ ...prev, isConnecting: true, error: null })); + + try { + const result = await sdk.connect({ + ...options, + returnResult: true // Request detailed result + }); + + if (result && typeof result === 'object' && 'action' in result) { + // Only set connecting to false if action is not redirect + // (redirect will navigate away from current page) + if (result.action !== 'redirect') { + setState(prev => ({ ...prev, isConnecting: false })); + } + + return result as ConnectResult; + } else { + setState(prev => ({ ...prev, isConnecting: false, error: 'Unexpected response from SDK' })); + return { + action: 'manual', + success: false, + error: 'Unexpected response from SDK' + }; + } } catch (error) { setState({ isConnected: false, @@ -123,6 +215,12 @@ export function useIdentityKit(options: UseIdentityKitOptions = {}): IdentityKit keyId: null, error: `Connection failed: ${error instanceof Error ? error.message : String(error)}`, }); + + return { + action: 'manual', + success: false, + error: `Connection failed: ${error instanceof Error ? error.message : String(error)}` + }; } }, [sdk]); @@ -159,5 +257,59 @@ export function useIdentityKit(options: UseIdentityKitOptions = {}): IdentityKit }); }, [sdk]); - return { state, connect, sign, verify, logout, sdk }; + return { state, connect, connectWithResult, sign, verify, logout, sdk }; +} + +/** + * Enhanced version of useIdentityKit with automatic popup blocker fallbacks + */ +export function useIdentityKitWithFallbacks(options: UseIdentityKitOptions = {}): IdentityKitHookWithFallbacks { + const baseHook = useIdentityKit(options); + + const connectWithFallbacks = useCallback(async (connectOptions?: { + scopes?: string[]; + onPopupBlocked?: (result: ConnectResult) => void; + onUrlCopied?: (url: string) => void; + onManualUrl?: (url: string) => void; + }): Promise => { + // Try popup with detailed result + const result = await baseHook.connectWithResult({ + scopes: connectOptions?.scopes, + fallbackMethod: 'copy' // Default to copy fallback + }); + + // Handle different scenarios + switch (result.action) { + case 'popup': + // Success, no additional handling needed + break; + + case 'redirect': + // Page will redirect, no notification needed + break; + + case 'copy': + if (result.success && result.url) { + connectOptions?.onUrlCopied?.(result.url); + } else { + // Copy failed, show manual URL + connectOptions?.onManualUrl?.(result.url || ''); + } + break; + + case 'manual': + connectOptions?.onPopupBlocked?.(result); + if (result.url) { + connectOptions?.onManualUrl?.(result.url); + } + break; + } + + return result; + }, [baseHook.connectWithResult]); + + return { + ...baseHook, + connectWithFallbacks + }; } \ No newline at end of file