From 891c9ce579dfaccb2fa389ad0372c4edb545a4ec Mon Sep 17 00:00:00 2001 From: Felipe Forbeck Date: Thu, 10 Jul 2025 16:14:42 -0300 Subject: [PATCH] feat: private spaces --- .env.tpl | 5 ++ src/app/page.tsx | 73 +++++++++++------- src/app/space/[did]/layout.tsx | 25 ++++++- src/components/SpaceCreator.tsx | 100 ++++++++++++++++++++++++- src/components/SpacesList.tsx | 71 ++++++++++++++++++ src/components/SpacesTabNavigation.tsx | 52 +++++++++++++ src/components/UpgradePrompt.tsx | 50 +++++++++++++ src/hooks/useFilteredSpaces.ts | 24 ++++++ src/hooks/usePrivateSpacesAccess.ts | 59 +++++++++++++++ src/lib/featureFlags.ts | 21 ++++++ src/types/spaces.ts | 35 +++++++++ 11 files changed, 481 insertions(+), 34 deletions(-) create mode 100644 src/components/SpacesList.tsx create mode 100644 src/components/SpacesTabNavigation.tsx create mode 100644 src/components/UpgradePrompt.tsx create mode 100644 src/hooks/useFilteredSpaces.ts create mode 100644 src/hooks/usePrivateSpacesAccess.ts create mode 100644 src/lib/featureFlags.ts create mode 100644 src/types/spaces.ts diff --git a/.env.tpl b/.env.tpl index dc36829..19ef5be 100644 --- a/.env.tpl +++ b/.env.tpl @@ -26,3 +26,8 @@ NEXT_PUBLIC_SENTRY_DSN=https://bf79c216fe3c72328219f04aabeebc99@o609598.ingest.u NEXT_PUBLIC_SENTRY_ORG=storacha-it NEXT_PUBLIC_SENTRY_PROJECT=console NEXT_PUBLIC_SENTRY_ENV=development + +# set this to enable the private spaces feature +NEXT_PUBLIC_PRIVATE_SPACES_ENABLED=true +# set this to restrict which users can access the private spaces feature if enabled +NEXT_PUBLIC_PRIVATE_SPACES_DOMAINS=dmail.ai,storacha.network \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 17421ef..57d30c9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,14 +1,18 @@ 'use client' -import { useW3, Space } from '@w3ui/react' -import { DidIcon } from '@/components/DidIcon' -import Link from 'next/link' +import { useState } from 'react' +import { useW3 } from '@w3ui/react' import { SpacesNav } from './space/layout' import { H1, H2 } from '@/components/Text' import SidebarLayout from '@/components/SidebarLayout' -import { ReactNode } from 'react' +import { SpacesTabNavigation } from '@/components/SpacesTabNavigation' +import { SpacesList } from '@/components/SpacesList' +import { UpgradePrompt } from '@/components/UpgradePrompt' +import { usePrivateSpacesAccess } from '@/hooks/usePrivateSpacesAccess' +import { useFeatureFlags } from '@/lib/featureFlags' +import { useFilteredSpaces } from '@/hooks/useFilteredSpaces' -export default function HomePage () { +export default function HomePage() { return ( @@ -16,37 +20,54 @@ export default function HomePage () { ) } -function SpacePage (): ReactNode { +function SpacePage() { + const [activeTab, setActiveTab] = useState<'public' | 'private'>('public') const [{ spaces }] = useW3() + const { canAccessPrivateSpaces, shouldShowUpgradePrompt, planLoading } = usePrivateSpacesAccess() + const { canSeePrivateSpacesFeature } = useFeatureFlags() + const { publicSpaces, privateSpaces, hasHiddenPrivateSpaces } = useFilteredSpaces() + + const shouldShowPrivateSpacesTab = canSeePrivateSpacesFeature if (spaces.length === 0) { return
} + if (planLoading) { + return ( +
+
Loading...
+
+ ) + } + return ( <>

Spaces

-

Pick a Space

-
- { spaces.map(s => ) } -
- - ) -} + + -function Item ({space}: {space: Space}) { - return ( - - -
- - {space.name || 'Untitled'} - - - {space.did()} - -
- + {activeTab === 'public' && ( + <> + + + )} + + {activeTab === 'private' && ( + <> + {canAccessPrivateSpaces ? ( + + ) : ( + + )} + + )} + ) } diff --git a/src/app/space/[did]/layout.tsx b/src/app/space/[did]/layout.tsx index faef53b..17da4a1 100644 --- a/src/app/space/[did]/layout.tsx +++ b/src/app/space/[did]/layout.tsx @@ -4,7 +4,7 @@ import { PropsWithChildren } from 'react' import { useW3 } from '@w3ui/react' import { DidIcon } from '@/components/DidIcon' import { Nav, NavLink } from '@/components/Nav' -import { QueueListIcon, ShareIcon, CloudArrowUpIcon } from '@heroicons/react/24/outline' +import { QueueListIcon, ShareIcon, CloudArrowUpIcon, LockClosedIcon, GlobeAltIcon } from '@heroicons/react/24/outline' interface LayoutProps extends PropsWithChildren { params: { @@ -39,9 +39,26 @@ export default function Layout ({children, params}: LayoutProps): JSX.Element {
-

- {space.name || 'Untitled'} -

+
+

+ {space.name || 'Untitled'} +

+ {(space as any).accessType === 'private' ? ( + <> + + + Private + + + ) : ( + <> + + + Public + + + )} +
diff --git a/src/components/SpaceCreator.tsx b/src/components/SpaceCreator.tsx index 05eb0d2..2a25388 100644 --- a/src/components/SpaceCreator.tsx +++ b/src/components/SpaceCreator.tsx @@ -6,7 +6,7 @@ import Loader from '../components/Loader' import { DIDKey } from '@ucanto/interface' import { DidIcon } from './DidIcon' import Link from 'next/link' -import { FolderPlusIcon, InformationCircleIcon } from '@heroicons/react/24/outline' +import { FolderPlusIcon, InformationCircleIcon, LockClosedIcon, GlobeAltIcon } from '@heroicons/react/24/outline' import Tooltip from './Tooltip' import { H3 } from './Text' import * as UcantoClient from '@ucanto/client' @@ -14,6 +14,8 @@ import { HTTP } from '@ucanto/transport' import * as CAR from '@ucanto/transport/car' import { gatewayHost } from './services' import { logAndCaptureError } from '@/sentry' +import { usePrivateSpacesAccess } from '@/hooks/usePrivateSpacesAccess' +import { useFeatureFlags } from '@/lib/featureFlags' export function SpaceCreatorCreating(): JSX.Element { return ( @@ -36,20 +38,31 @@ export function SpaceCreatorForm({ const [created, setCreated] = useState(false) const [name, setName] = useState('') const [space, setSpace] = useState() + const [accessType, setAccessType] = useState<'public' | 'private'>('public') + + const { canAccessPrivateSpaces, shouldShowUpgradePrompt, planLoading } = usePrivateSpacesAccess() + const { canSeePrivateSpacesFeature } = useFeatureFlags() function resetForm(): void { setName('') + setAccessType('public') } async function onSubmit(e: React.FormEvent): Promise { e.preventDefault() if (!client) return - // TODO: account selection + const account = accounts[0] if (!account) { throw new Error('cannot create space, no account found, have you authorized your email?') } + // Check if user has required access for private spaces + if (accessType === 'private' && !canAccessPrivateSpaces) { + alert('Upgrade to a paid plan to create private spaces') + return + } + const { ok: plan } = await account.plan.get() if (!plan) { throw new Error('a payment plan is required on account to provision a new space.') @@ -72,8 +85,9 @@ export function SpaceCreatorForm({ }) const space = await client.createSpace(name, { + // accessType, // This will be passed to the upload-service when backend is updated authorizeGatewayServices: [storachaGateway] - }) + } as any) const provider = toWebDID(process.env.NEXT_PUBLIC_W3UP_PROVIDER) || toWebDID('did:web:web3.storage') const result = await account.provision(space.did(), { provider }) @@ -105,6 +119,16 @@ export function SpaceCreatorForm({ } } + if (planLoading) { + return ( +
+
+
Loading...
+
+
+ ) + } + if (created && space) { return (
@@ -137,8 +161,76 @@ export function SpaceCreatorForm({ }} required={true} /> + + {canSeePrivateSpacesFeature && ( +
+ +
+ + + +
+
+ )} +
diff --git a/src/components/SpacesList.tsx b/src/components/SpacesList.tsx new file mode 100644 index 0000000..aa4d3ca --- /dev/null +++ b/src/components/SpacesList.tsx @@ -0,0 +1,71 @@ +import { Space } from '@w3ui/react' +import { LockClosedIcon, GlobeAltIcon } from '@heroicons/react/24/outline' +import { DidIcon } from './DidIcon' +import Link from 'next/link' + +interface SpaceListProps { + spaces: Space[] + type: 'public' | 'private' +} + +export function SpacesList({ spaces, type }: SpaceListProps) { + if (spaces.length === 0) { + return ( +
+

No {type} spaces yet.

+ + Create your first {type} space + +
+ ) + } + + return ( +
+ {spaces.map(space => ( + + ))} +
+ ) +} + +interface SpaceItemProps { + space: Space + type: 'public' | 'private' +} + +function SpaceItem({ space, type }: SpaceItemProps) { + const Icon = type === 'private' ? LockClosedIcon : GlobeAltIcon + + return ( + +
+ + +
+
+
+ + {space.name || 'Untitled'} + + + {type === 'private' ? 'Private' : 'Public'} + +
+ + {space.did()} + +
+ + ) +} \ No newline at end of file diff --git a/src/components/SpacesTabNavigation.tsx b/src/components/SpacesTabNavigation.tsx new file mode 100644 index 0000000..e985035 --- /dev/null +++ b/src/components/SpacesTabNavigation.tsx @@ -0,0 +1,52 @@ +import { LockClosedIcon, GlobeAltIcon } from '@heroicons/react/24/outline' + +interface SpacesTabNavigationProps { + activeTab: 'public' | 'private' + onTabChange: (tab: 'public' | 'private') => void + showPrivateTab: boolean + privateTabLocked: boolean +} + +export function SpacesTabNavigation({ + activeTab, + onTabChange, + showPrivateTab, + privateTabLocked +}: SpacesTabNavigationProps) { + return ( +
+ + + {showPrivateTab && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/src/components/UpgradePrompt.tsx b/src/components/UpgradePrompt.tsx new file mode 100644 index 0000000..1e3cabc --- /dev/null +++ b/src/components/UpgradePrompt.tsx @@ -0,0 +1,50 @@ +import { LockClosedIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline' +import { H2, H3 } from './Text' +import Link from 'next/link' + +interface UpgradePromptProps { + hasHiddenSpaces?: boolean +} + +export function UpgradePrompt({ hasHiddenSpaces = false }: UpgradePromptProps) { + return ( +
+
+ +

Upgrade to Access Private Spaces

+ + {hasHiddenSpaces && ( +
+

+ + You have private spaces that are currently hidden. + Upgrade to access them again. +

+
+ )} + +

+ Private spaces allow you to encrypt files locally before upload, + ensuring only you can access your data. +

+ +
+

Private Spaces Features:

+
    +
  • • Client-side encryption before upload
  • +
  • • Files are encrypted with your local keys
  • +
  • • Decrypt and download files securely
  • +
  • • Complete privacy and control over your data
  • +
+
+ + + Upgrade Now + +
+
+ ) +} \ No newline at end of file diff --git a/src/hooks/useFilteredSpaces.ts b/src/hooks/useFilteredSpaces.ts new file mode 100644 index 0000000..52f7b32 --- /dev/null +++ b/src/hooks/useFilteredSpaces.ts @@ -0,0 +1,24 @@ +import { useW3 } from '@w3ui/react' +import { usePrivateSpacesAccess } from './usePrivateSpacesAccess' + +export const useFilteredSpaces = () => { + const [{ spaces }] = useW3() + const { canAccessPrivateSpaces } = usePrivateSpacesAccess() + + // Filter spaces based on accessType + // Note: accessType might not be available yet until backend is updated + const allPublicSpaces = spaces.filter(s => (s as any).accessType !== 'private') + const allPrivateSpaces = spaces.filter(s => (s as any).accessType === 'private') + + // Hide but preserve: private spaces are hidden when user loses access + // but they're still in the backend and will reappear if user upgrades + const visiblePrivateSpaces = canAccessPrivateSpaces ? allPrivateSpaces : [] + const hiddenPrivateSpaces = canAccessPrivateSpaces ? [] : allPrivateSpaces + + return { + publicSpaces: allPublicSpaces, + privateSpaces: visiblePrivateSpaces, + hiddenPrivateSpaces, // For debugging/admin purposes + hasHiddenPrivateSpaces: hiddenPrivateSpaces.length > 0 + } +} \ No newline at end of file diff --git a/src/hooks/usePrivateSpacesAccess.ts b/src/hooks/usePrivateSpacesAccess.ts new file mode 100644 index 0000000..c579513 --- /dev/null +++ b/src/hooks/usePrivateSpacesAccess.ts @@ -0,0 +1,59 @@ +import { useW3 } from '@w3ui/react' +import { useState, useEffect } from 'react' +import { usePlan } from '@/hooks' + +export const usePrivateSpacesAccess = () => { + const [{ accounts }] = useW3() + const account = accounts[0] + + const { data: plan, error: planError } = usePlan(account) + const email = account?.toEmail() + const [isPaidUser, setIsPaidUser] = useState(false) + + // Fetch plan information + useEffect(() => { + if (plan) { + console.log('Plan', plan) + const isPaid = plan.product !== 'did:web:starter.web3.storage' && plan.product !== 'did:web:free.web3.storage' + console.log('Is paid user', isPaid) + setIsPaidUser(isPaid) + } else if (planError) { + console.log('Plan API error:', planError) + // Temporary fallback: if plan API is failing, assume eligible users with @storacha.network emails are paid users + // This is a temporary workaround for staging environment issues + const isStorachaUser = email?.endsWith('@storacha.network') + if (isStorachaUser) { + console.log('Plan API failing but user is @storacha.network, assuming paid user for testing') + setIsPaidUser(true) + } + } + }, [plan, planError, email]) + + const isDmailUser = email?.endsWith('@dmail.ai') + const isStorachaUser = email?.endsWith('@storacha.network') + const isEligibleDomain = isDmailUser || isStorachaUser + + // Debug logging + console.log('=== Private Spaces Access Debug ===') + console.log('Email:', email) + console.log('Plan:', plan) + console.log('Plan Error:', planError) + console.log('isDmailUser:', isDmailUser) + console.log('isStorachaUser:', isStorachaUser) + console.log('isEligibleDomain:', isEligibleDomain) + console.log('isPaidUser:', isPaidUser) + console.log('canAccessPrivateSpaces:', isEligibleDomain && isPaidUser) + console.log('shouldShowUpgradePrompt:', isEligibleDomain && !isPaidUser) + console.log('===================================') + + return { + canAccessPrivateSpaces: isEligibleDomain && isPaidUser, + shouldShowUpgradePrompt: isEligibleDomain && !isPaidUser, + shouldShowPrivateSpacesTab: isEligibleDomain, + isEligibleDomain, + isPaidUser, + email, + plan, + planLoading: !plan && !planError // Loading if we don't have plan data and no error + } +} \ No newline at end of file diff --git a/src/lib/featureFlags.ts b/src/lib/featureFlags.ts new file mode 100644 index 0000000..27795a6 --- /dev/null +++ b/src/lib/featureFlags.ts @@ -0,0 +1,21 @@ +export const FEATURE_FLAGS = { + PRIVATE_SPACES_ENABLED: process.env.NEXT_PUBLIC_PRIVATE_SPACES_ENABLED === 'true', + PRIVATE_SPACES_ALLOWED_DOMAINS: process.env.NEXT_PUBLIC_PRIVATE_SPACES_DOMAINS?.split(',') || ['dmail.ai', 'storacha.network'] +} as const + +export const useFeatureFlags = () => { + // Import here to avoid circular dependency + const { usePrivateSpacesAccess } = require('../hooks/usePrivateSpacesAccess') + const { email } = usePrivateSpacesAccess() + + const isPrivateSpacesEnabled = FEATURE_FLAGS.PRIVATE_SPACES_ENABLED + const isUserInAllowedDomains = FEATURE_FLAGS.PRIVATE_SPACES_ALLOWED_DOMAINS.some(domain => + email?.endsWith(`@${domain}`) + ) + + return { + isPrivateSpacesEnabled, + isUserInAllowedDomains, + canSeePrivateSpacesFeature: isPrivateSpacesEnabled && isUserInAllowedDomains + } +} \ No newline at end of file diff --git a/src/types/spaces.ts b/src/types/spaces.ts new file mode 100644 index 0000000..6b8a34e --- /dev/null +++ b/src/types/spaces.ts @@ -0,0 +1,35 @@ +export interface SpaceWithAccessType { + accessType?: 'public' | 'private' + // ... other space properties from @w3ui/react Space interface +} + +export interface PrivateSpacesFeatureFlags { + canAccessPrivateSpaces: boolean + shouldShowUpgradePrompt: boolean + shouldShowPrivateSpacesTab: boolean + isEligibleDomain: boolean + isPaidUser: boolean + email?: string + plan?: string + planLoading: boolean +} + +export interface SpaceCreationOptions { + accessType?: 'public' | 'private' + name: string +} + +export interface FeatureFlags { + isPrivateSpacesEnabled: boolean + isUserInAllowedDomains: boolean + canSeePrivateSpacesFeature: boolean +} + +export type SpaceType = 'public' | 'private' + +export interface FilteredSpaces { + publicSpaces: any[] // Will be Space[] from @w3ui/react when available + privateSpaces: any[] // Will be Space[] from @w3ui/react when available + hiddenPrivateSpaces: any[] // For debugging/admin purposes + hasHiddenPrivateSpaces: boolean +} \ No newline at end of file