From 85dc2a28e76f302b84006068e05bff36b2cc5fba Mon Sep 17 00:00:00 2001 From: Felipe Forbeck Date: Fri, 27 Jun 2025 10:01:48 -0300 Subject: [PATCH] feat: iframe support --- next.config.js | 35 +++- src/app/iframe/layout.tsx | 46 ++++++ src/app/iframe/page.tsx | 14 ++ src/app/iframe/space/[did]/layout.tsx | 21 +++ src/app/iframe/space/[did]/page.tsx | 96 +++++++++++ src/components/IframeAuthenticator.tsx | 219 +++++++++++++++++++++++++ src/components/IframeDashboard.tsx | 69 ++++++++ src/contexts/IframeContext.tsx | 101 ++++++++++++ 8 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 src/app/iframe/layout.tsx create mode 100644 src/app/iframe/page.tsx create mode 100644 src/app/iframe/space/[did]/layout.tsx create mode 100644 src/app/iframe/space/[did]/page.tsx create mode 100644 src/components/IframeAuthenticator.tsx create mode 100644 src/components/IframeDashboard.tsx create mode 100644 src/contexts/IframeContext.tsx diff --git a/next.config.js b/next.config.js index fd90d7c..a77079a 100644 --- a/next.config.js +++ b/next.config.js @@ -2,7 +2,40 @@ const nextConfig = { typescript: { ignoreBuildErrors: true, - } + }, + // Configure headers for iframe support + async headers() { + return [ + // Default security headers for main app + { + source: '/((?!iframe).*)', + headers: [ + { + key: 'X-Frame-Options', + value: 'DENY', + }, + { + key: 'Content-Security-Policy', + value: "frame-ancestors 'none';", + }, + ], + }, + // Iframe-friendly headers only for /iframe routes + { + source: '/iframe/:path*', + headers: [ + { + key: 'X-Frame-Options', + value: 'ALLOWALL', + }, + { + key: 'Content-Security-Policy', + value: "frame-ancestors *; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';", + }, + ], + }, + ] + }, } module.exports = nextConfig diff --git a/src/app/iframe/layout.tsx b/src/app/iframe/layout.tsx new file mode 100644 index 0000000..e73586d --- /dev/null +++ b/src/app/iframe/layout.tsx @@ -0,0 +1,46 @@ +import '../globals.css' +import type { Metadata } from 'next' +import Provider from '@/components/W3UIProvider' +import Toaster from '@/components/Toaster' +import { Provider as MigrationsProvider } from '@/components/MigrationsProvider' +import { IframeProvider } from '@/contexts/IframeContext' +import IframeHeader from '@/components/IframeHeader' +import IframeAuthenticator from '@/components/IframeAuthenticator' + +export const metadata: Metadata = { + title: 'Storacha Workspaces', + description: 'Manage your Storacha storage spaces', +} + +export default function IframeLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + + + + + + + + + +
+
+ {children} +
+
+
+
+
+ +
+ + + ) +} \ No newline at end of file diff --git a/src/app/iframe/page.tsx b/src/app/iframe/page.tsx new file mode 100644 index 0000000..58565dc --- /dev/null +++ b/src/app/iframe/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' +import IframeDashboard from '@/components/IframeDashboard' + +export default function IframePage() { + return ( +
+
+ Loading...
}> + + +
+ + ) +} \ No newline at end of file diff --git a/src/app/iframe/space/[did]/layout.tsx b/src/app/iframe/space/[did]/layout.tsx new file mode 100644 index 0000000..7845d8d --- /dev/null +++ b/src/app/iframe/space/[did]/layout.tsx @@ -0,0 +1,21 @@ +import { PropsWithChildren, ReactNode } from 'react' +import IframeCompactSidebar from '@/components/IframeCompactSidebar' + +export const runtime = 'edge' + +interface LayoutProps extends PropsWithChildren { + params: { + did: string + } +} + +export default function IframeSpaceLayout({ children }: LayoutProps): ReactNode { + return ( +
+ +
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/src/app/iframe/space/[did]/page.tsx b/src/app/iframe/space/[did]/page.tsx new file mode 100644 index 0000000..8da4d89 --- /dev/null +++ b/src/app/iframe/space/[did]/page.tsx @@ -0,0 +1,96 @@ +'use client' + +import { UploadsList } from '@/components/UploadsList' +import { useW3, UnknownLink, UploadListSuccess, Authenticator } from '@w3ui/react' +import useSWR from 'swr' +import { useRouter, usePathname } from 'next/navigation' +import { createUploadsListKey } from '@/cache' +import { Breadcrumbs } from '@/components/Breadcrumbs' +import { logAndCaptureError } from '@/sentry' +import { AuthenticationEnsurer } from '@/components/Authenticator' +import { SpaceEnsurer } from '@/components/SpaceEnsurer' +import { MaybePlanGate } from '@/components/PlanGate' +import IframeAuthenticator from '@/components/IframeAuthenticator' + +const pageSize = 15 + +interface PageProps { + params: { + did: string + }, + searchParams: { + cursor: string + pre: string + } +} + +export default function IframeSpacePage({ params, searchParams }: PageProps): JSX.Element { + return ( + + + + + + + + + + ) +} + +function SpacePageContent({ params, searchParams }: PageProps): JSX.Element { + const [{ client, spaces }] = useW3() + const spaceDID = decodeURIComponent(params.did) + const space = spaces.find(s => s.did() === spaceDID) + + const key = space ? createUploadsListKey(space.did(), searchParams.cursor, searchParams.pre === 'true') : '' + const { data: uploads, isLoading, isValidating, mutate } = useSWR(key, { + fetcher: async () => { + if (!client || !space) return + + if (client.currentSpace()?.did() !== space.did()) { + await client.setCurrentSpace(space.did()) + } + + return await client.capability.upload.list({ + cursor: searchParams.cursor, + pre: searchParams.pre === 'true', + size: pageSize + }) + }, + onError: logAndCaptureError, + keepPreviousData: true + }) + + const router = useRouter() + const pathname = usePathname() + + if (!space) return
Space not found
+ + const handleSelect = (root: UnknownLink) => router.push(`${pathname}/root/${root}`) + const handleNext = uploads?.after && (uploads.results.length === pageSize) + ? () => router.push(`${pathname}?cursor=${uploads.after}`) + : undefined + const handlePrev = searchParams.cursor && uploads?.before + ? () => router.push(`${pathname}?cursor=${uploads.before ?? ''}&pre=true`) + : undefined + const handleRefresh = () => mutate() + + return ( +
+
+ +
+ +
+ ) +} \ No newline at end of file diff --git a/src/components/IframeAuthenticator.tsx b/src/components/IframeAuthenticator.tsx new file mode 100644 index 0000000..858d11f --- /dev/null +++ b/src/components/IframeAuthenticator.tsx @@ -0,0 +1,219 @@ +'use client' + +import { useIframe } from '@/contexts/IframeContext' +import { useW3, Authenticator } from '@w3ui/react' +import { useEffect, useState, ReactNode } from 'react' +import { Logo } from '@/brand' + +interface IframeAuthenticatorProps { + children: ReactNode +} + +export default function IframeAuthenticator({ children }: IframeAuthenticatorProps) { + const { isIframe, isClient, parentUser, sendMessageToParent } = useIframe() + const [{ accounts }] = useW3() + const [authState, setAuthState] = useState<'pending' | 'authenticating' | 'authenticated' | 'failed'>('pending') + const [error, setError] = useState(null) + + const isAuthenticated = accounts.length > 0 + + // Programmatic DMAIL authentication + useEffect(() => { + const authenticateWithDmail = async () => { + if (!parentUser?.email || authState !== 'pending' || isAuthenticated) return + + try { + setAuthState('authenticating') + sendMessageToParent({ + type: 'AUTH_STATUS', + status: 'authenticating', + email: parentUser.email + }) + + // Step 1: Validate user with DMAIL API (simulated) + console.log('🔐 Starting DMAIL OAuth authentication for:', parentUser.email) + const dmailValidation = await validateDmailUser(parentUser) + if (!dmailValidation.valid) { + throw new Error(`User ${parentUser.email} not found in DMAIL system`) + } + + // Step 2: Simulate programmatic OAuth that bypasses email verification + await simulateProgrammaticAuth(parentUser.email) + + setAuthState('authenticated') + sendMessageToParent({ + type: 'AUTH_STATUS', + status: 'authenticated', + email: parentUser.email + }) + + } catch (err) { + console.error('DMAIL authentication failed:', err) + setError(err instanceof Error ? err.message : 'Authentication failed') + setAuthState('failed') + sendMessageToParent({ + type: 'AUTH_STATUS', + status: 'failed', + email: parentUser.email, + error: err instanceof Error ? err.message : 'Authentication failed' + }) + } + } + + if (parentUser?.email && !isAuthenticated) { + authenticateWithDmail() + } + }, [parentUser, authState, isAuthenticated, sendMessageToParent]) + + // Monitor authentication state and notify parent + useEffect(() => { + if (isAuthenticated && parentUser) { + console.log('✅ User authenticated successfully!') + sendMessageToParent({ + type: 'AUTH_STATUS', + status: 'authenticated', + email: parentUser.email + }) + } + }, [isAuthenticated, parentUser, sendMessageToParent]) + + // Notify parent when user credentials are received + useEffect(() => { + if (parentUser && !isAuthenticated) { + sendMessageToParent({ + type: 'AUTH_RECEIVED', + user: parentUser + }) + } + }, [parentUser, isAuthenticated, sendMessageToParent]) + + // Don't render until client-side + if (!isClient) { + return ( +
+
+
+ ) + } + + // If not in iframe, use regular authenticator + if (!isIframe) { + return {children} + } + + // If no parent user provided, show waiting state + if (!parentUser) { + return ( +
+
+
+
+
+

+ Waiting for Authentication +

+

+ Waiting for user credentials from email provider... +

+
+
+ ) + } + + // Show authentication progress + if (authState === 'authenticating') { + return ( +
+
+

+ Authenticating with DMAIL +

+

+ Validating {parentUser.email}... +

+

+ This should take just a moment +

+
+ ) + } + + // Show authentication failure + if (authState === 'failed') { + return ( +
+
+
+
+ +
+

Authentication Failed

+
+

{error}

+ +
+
+ ) + } + + // If user is authenticated OR we completed our simulation, show the console + if (isAuthenticated || authState === 'authenticated') { + return {children} + } + + // Default: show preparation state + return ( +
+
+ +

+ Preparing Workspace +

+

+ Setting up your Storacha workspace... +

+
+
+ ) +} + +/** + * Validate user exists in DMAIL system + */ +async function validateDmailUser(user: { email: string, id: string, name?: string }): Promise<{ valid: boolean, plan?: string }> { + // Simulate API call to DMAIL + await new Promise(resolve => setTimeout(resolve, 1500)) + + // Simulate validation logic + if (user.email.endsWith('@dmail.ai')) { + console.log('✅ User validated with DMAIL API:', user.email) + return { valid: true, plan: 'pro' } + } + + throw new Error('User not found in DMAIL system') +} + +/** + * Simulate programmatic authentication that would happen after DMAIL OAuth validation + */ +async function simulateProgrammaticAuth(email: string) { + console.log('🔐 Simulating DMAIL OAuth backend flow for:', email) + + // Simulate the OAuth flow timing + await new Promise(resolve => setTimeout(resolve, 2000)) + + console.log('✅ Programmatic authentication completed for:', email) + console.log('📝 In production, this would:') + console.log(' 1. Validate user with DMAIL API') + console.log(' 2. Issue Access.confirm delegation on backend') + console.log(' 3. Use w3up client to claim delegated access') + console.log(' 4. User would be authenticated with Storacha') +} \ No newline at end of file diff --git a/src/components/IframeDashboard.tsx b/src/components/IframeDashboard.tsx new file mode 100644 index 0000000..966e00c --- /dev/null +++ b/src/components/IframeDashboard.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useW3, Space } from '@w3ui/react' +import { DidIcon } from '@/components/DidIcon' +import Link from 'next/link' +import { H1, H2 } from '@/components/Text' +import { ReactNode } from 'react' +import { AuthenticationEnsurer } from '@/components/Authenticator' +import { SpaceEnsurer } from '@/components/SpaceEnsurer' +import { MaybePlanGate } from '@/components/PlanGate' +import IframeAuthenticator from '@/components/IframeAuthenticator' + +export default function IframeDashboard(): ReactNode { + return ( + + + + + + + + + + ) +} + +function SpacePage(): ReactNode { + const [{ spaces }] = useW3() + + if (spaces.length === 0) { + return ( +
+

Welcome to Storacha Workspaces

+

+ No spaces found. Create your first space to get started. +

+
+ ) + } + + return ( +
+

Your Workspaces

+

Select a Space to Manage

+
+ {spaces.map(s => )} +
+
+ ) +} + +function Item({ space }: { space: Space }) { + return ( + + +
+ + {space.name || 'Untitled'} + + + {space.did()} + +
+ + ) +} \ No newline at end of file diff --git a/src/contexts/IframeContext.tsx b/src/contexts/IframeContext.tsx new file mode 100644 index 0000000..2ef13ad --- /dev/null +++ b/src/contexts/IframeContext.tsx @@ -0,0 +1,101 @@ +'use client' + +import { createContext, useContext, useEffect, useState, ReactNode } from 'react' + +interface IframeContextType { + isIframe: boolean + isClient: boolean + parentOrigin: string | null + parentUser: { email: string; id: string } | null + sendMessageToParent: (data: any) => void + requestParentNavigation: (url: string) => void +} + +const IframeContext = createContext(undefined) + +export function IframeProvider({ children }: { children: ReactNode }) { + const [isIframe, setIsIframe] = useState(false) + const [parentOrigin, setParentOrigin] = useState(null) + const [isClient, setIsClient] = useState(false) + const [parentUser, setParentUser] = useState<{ email: string; id: string } | null>(null) + + useEffect(() => { + // Mark as client-side rendered + setIsClient(true) + + // Detect if running in iframe + const inIframe = window.self !== window.top + setIsIframe(inIframe) + + if (inIframe) { + // Listen for messages from parent + const handleMessage = (event: MessageEvent) => { + // Validate origin - in production, whitelist specific domains + if (event.origin !== parentOrigin && parentOrigin === null) { + setParentOrigin(event.origin) + } + + // Handle parent messages + if (event.data.type === 'IFRAME_INIT') { + sendMessageToParent({ + type: 'CONSOLE_READY', + url: window.location.href + }) + } + + if (event.data.type === 'USER_AUTH') { + console.log('🔐 Received user authentication from parent:', event.data.user) + setParentUser(event.data.user) + sendMessageToParent({ + type: 'AUTH_RECEIVED', + user: event.data.user + }) + } + } + + window.addEventListener('message', handleMessage) + + // Notify parent that iframe is loaded + window.parent?.postMessage({ + type: 'CONSOLE_LOADED', + url: window.location.href + }, '*') + + return () => window.removeEventListener('message', handleMessage) + } + }, [parentOrigin]) + + const sendMessageToParent = (data: any) => { + if (isIframe && window.parent) { + window.parent.postMessage(data, parentOrigin || '*') + } + } + + const requestParentNavigation = (url: string) => { + sendMessageToParent({ + type: 'REQUEST_NAVIGATION', + url + }) + } + + return ( + + {children} + + ) +} + +export function useIframe() { + const context = useContext(IframeContext) + if (context === undefined) { + throw new Error('useIframe must be used within an IframeProvider') + } + return context +} \ No newline at end of file