diff --git a/.env.example b/.env.example index c926e43..8b528bd 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,7 @@ SMTP_FROM="noreply@openframe.dev" # ============================================================================ # APPLICATION # ============================================================================ +NEXT_PUBLIC_APP_URL="http://localhost:3000" # development | production | test NODE_ENV="development" # Disable all app-level rate limiting for local testing. Leave unset to keep rate limiting enabled. @@ -86,3 +87,11 @@ NEXT_PUBLIC_BUNNY_CDN_URL="your-url-to-bunny-cdn" # Bunny orphan cleanup configuration (script + external cron; app runtime does not schedule this) # Grace period is fixed at 24 hours in the script. # */15 * * * * cd /home/yusuf/Programming/OpenFrame && bun run bunny:cleanup-orphans + +# ============================================================================ +# BILLING +# ============================================================================ +# Stripe recurring price used for paid accounts +STRIPE_SECRET_KEY="sk_test_..." +STRIPE_PRICE_ID="prod_UBWFFKZC3d80z4" +STRIPE_WEBHOOK_SECRET="whsec_..." diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index df2476b..76adeee 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -2,7 +2,9 @@ import { auth } from '@/lib/auth'; import { redirect } from 'next/navigation'; import { db } from '@/lib/db'; import { Prisma } from '@prisma/client'; +import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access'; import { DashboardClient } from './dashboard-client'; +import { buildBillingAccessWhereInput } from '@/lib/billing'; export default async function DashboardPage({ searchParams, @@ -14,6 +16,11 @@ export default async function DashboardPage({ redirect('/login'); } + const hasCollaboratorAccess = await hasCollaboratorBillingBackedAccess(session.user.id); + if (!hasCollaboratorAccess) { + await requireBillingAccessOrRedirect({ userId: session.user.id }); + } + const userOnboarding = await db.user.findUnique({ where: { id: session.user.id }, select: { onboardingCompletedAt: true }, @@ -37,6 +44,7 @@ export default async function DashboardPage({ { members: { some: { userId: session.user.id } } }, { workspace: { + owner: buildBillingAccessWhereInput(), OR: [ { ownerId: session.user.id }, { members: { some: { userId: session.user.id } } }, @@ -44,6 +52,9 @@ export default async function DashboardPage({ }, }, ], + workspace: { + owner: buildBillingAccessWhereInput(), + }, }; // Build unique workspace list for filter (Needs an unbounded list of accessible workspaces) diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index dfc19e9..fcf000c 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,5 +1,6 @@ import { Header } from '@/components/layout'; import { auth } from '@/lib/auth'; +import { hasAppNavigationAccess } from '@/lib/route-access'; export default async function DashboardLayout({ children, @@ -7,10 +8,13 @@ export default async function DashboardLayout({ children: React.ReactNode; }) { const session = await auth(); + const showAppNavigation = session?.user?.id + ? await hasAppNavigationAccess(session.user.id) + : false; return (
-
+
{children}
); diff --git a/app/(dashboard)/projects/[projectId]/page.tsx b/app/(dashboard)/projects/[projectId]/page.tsx index ffd0c93..f359c55 100644 --- a/app/(dashboard)/projects/[projectId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/page.tsx @@ -4,7 +4,7 @@ import { ArrowLeft, } from 'lucide-react'; import { GuestGate } from '@/components/guest-gate'; -import { auth } from '@/lib/auth'; +import { auth, checkProjectAccess } from '@/lib/auth'; import { db } from '@/lib/db'; import { ProjectContentClient } from './project-content-client'; @@ -65,6 +65,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP notFound(); } + const access = await checkProjectAccess(project, session?.user?.id); + // Check access const isOwner = session?.user?.id === project.ownerId; const isMember = project.members.length > 0; @@ -92,7 +94,7 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP } } - if (!isOwner && !isMember && !isPublic && !isWorkspaceMember) { + if (!access.hasAccess || (!isOwner && !isMember && !isPublic && !isWorkspaceMember)) { if (!session?.user?.id) { redirect('/login'); } @@ -139,7 +141,7 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP }; }); - const canEdit = isOwner || project.members[0]?.role === 'ADMIN' || workspaceRole === 'OWNER' || workspaceRole === 'ADMIN'; + const canEdit = access.canEdit && (isOwner || project.members[0]?.role === 'ADMIN' || workspaceRole === 'OWNER' || workspaceRole === 'ADMIN'); const isAuthenticated = !!session?.user?.id; const projectData = { diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index ed66eb0..e9d93d3 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -1,7 +1,21 @@ -import { requireAuthOrRedirect } from '@/lib/route-access'; +import { hasCollaboratorBillingBackedAccess, requireAuthOrRedirect } from '@/lib/route-access'; +import { getBillingOverview } from '@/lib/billing'; import SettingsPageClient from './settings-page-client'; export default async function SettingsPage() { - await requireAuthOrRedirect(); - return ; + const session = await requireAuthOrRedirect(); + if (!session?.user?.id) { + return null; + } + + const [billing, hasCollaboratorAccess] = await Promise.all([ + getBillingOverview(session.user.id), + hasCollaboratorBillingBackedAccess(session.user.id), + ]); + + return ( + + ); } diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 6c64c76..5c6e64b 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe } from 'lucide-react'; +import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe, CreditCard } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -32,6 +32,32 @@ interface NotificationSettings { timezone: string; } +interface BillingOverview { + isConfigured: boolean; + checkoutAvailable: boolean; + portalAvailable: boolean; + subscription: { + status: string; + label: string; + hasActiveSubscription: boolean; + hasActiveTrial: boolean; + hasBillingAccess: boolean; + priceId: string | null; + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; + cancelAt: string | null; + trialEndsAt: string | null; + billingAccessEndedAt: string | null; + storageCleanupEligibleAt: string | null; + }; + workspaceCreation: { + canCreateWorkspace: boolean; + reason: string | null; + ownedWorkspaceCount: number; + invitedWorkspaceCount: number; + }; +} + function ToggleButton({ enabled, onToggle, @@ -77,7 +103,7 @@ function ToggleButton({ ); } -export default function SettingsPage() { +export default function SettingsPage({ billingOnly = false }: { billingOnly?: boolean }) { const [settings, setSettings] = useState({ telegramChatId: null, telegramEnabled: false, @@ -92,24 +118,41 @@ export default function SettingsPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(null); + const [billing, setBilling] = useState(null); + const [billingLoading, setBillingLoading] = useState(true); + const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // Form state for Telegram chat ID (separate from saved settings for editing) const [telegramChatId, setTelegramChatId] = useState(''); + const hasScheduledCancellation = Boolean( + billing?.subscription.cancelAtPeriodEnd || billing?.subscription.cancelAt + ); + useEffect(() => { async function fetchSettings() { try { - const res = await fetch('/api/settings/notifications'); - if (res.ok) { - const data = await res.json(); + const [settingsRes, billingRes] = await Promise.all([ + fetch('/api/settings/notifications'), + fetch('/api/billing'), + ]); + + if (settingsRes.ok) { + const data = await settingsRes.json(); setSettings(data.data); setTelegramChatId(data.data.telegramChatId || ''); } + + if (billingRes.ok) { + const data = await billingRes.json(); + setBilling(data.data); + } } catch { - console.error('Failed to fetch notification settings'); + console.error('Failed to fetch settings'); } finally { setLoading(false); + setBillingLoading(false); } } fetchSettings(); @@ -174,6 +217,31 @@ export default function SettingsPage() { [telegramChatId, showMessage] ); + const handleBillingRedirect = useCallback( + async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => { + setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal'); + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + + if (!res.ok) { + showMessage('error', data.error || 'Failed to open billing flow'); + return; + } + + window.location.href = data.data.url; + } catch { + showMessage('error', 'Failed to open billing flow'); + } finally { + setBillingAction(null); + } + }, + [showMessage] + ); + if (loading) { return (
@@ -212,7 +280,7 @@ export default function SettingsPage() {

Settings

- Manage your notification preferences + {billingOnly ? 'Manage your billing access' : 'Manage your notification preferences'}

@@ -235,6 +303,130 @@ export default function SettingsPage() {
)} + + + + + Billing + + + Manage your paid plan and workspace creation access + + + + {billingLoading || !billing ? ( +
+ + + +
+ ) : !billing.isConfigured ? ( +
+ Stripe is not configured yet. Add your Stripe environment variables before using billing. +
+ ) : ( + <> +
+
+

Current plan

+

+ {billing.subscription.hasActiveSubscription + ? hasScheduledCancellation + ? billing.subscription.hasActiveTrial + ? 'Trial canceled. Access remains active until the trial ends.' + : 'Subscription canceled. Access remains active until the end of the current billing period.' + : 'Paid account with workspace creation unlocked.' + : billing.subscription.hasActiveTrial + ? 'Trial access is active.' + : 'Billing access has ended.'} +

+
+ + {billing.subscription.label} + +
+ + {billing.subscription.hasActiveTrial + && billing.subscription.trialEndsAt + && hasScheduledCancellation ? ( +

+ Access ends on {' '} + {new Date(billing.subscription.trialEndsAt).toLocaleDateString()}. +

+ ) : null} + + {billing.subscription.currentPeriodEnd ? ( +

+ {hasScheduledCancellation ? 'Your subscription ends on ' : 'Current billing period ends on '} + {new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}. +

+ ) : null} + + {hasScheduledCancellation && billing.subscription.cancelAt ? ( +

+ Cancellation was scheduled on {new Date(billing.subscription.cancelAt).toLocaleDateString()}. +

+ ) : null} + + {!billing.subscription.hasBillingAccess + && billing.subscription.billingAccessEndedAt + && billing.subscription.storageCleanupEligibleAt ? ( +

+ Stored media cleanup is scheduled after {new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()} unless billing is restored first. +

+ ) : null} + +
+

Workspace creation

+

+ {billing.workspaceCreation.canCreateWorkspace + ? 'This account can create workspaces.' + : billing.workspaceCreation.reason || 'Upgrade to create another workspace.'} +

+
+ +
+ {billing.subscription.hasActiveSubscription && billing.portalAvailable ? ( + + ) : ( + + )} +
+ + )} +
+
+ + {!billingOnly && ( + <> {/* Event Subscriptions */} @@ -246,7 +438,7 @@ export default function SettingsPage() { Choose which events trigger notifications - + @@ -518,6 +710,8 @@ export default function SettingsPage() { )} + + )} ); } diff --git a/app/(dashboard)/workspaces/[workspaceId]/page.tsx b/app/(dashboard)/workspaces/[workspaceId]/page.tsx index ba91b4f..4f10c92 100644 --- a/app/(dashboard)/workspaces/[workspaceId]/page.tsx +++ b/app/(dashboard)/workspaces/[workspaceId]/page.tsx @@ -14,7 +14,7 @@ import { import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { auth } from '@/lib/auth'; +import { auth, checkWorkspaceAccess } from '@/lib/auth'; import { db } from '@/lib/db'; import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader'; @@ -94,8 +94,9 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP const membership = workspace.members[0]; const isMember = !!membership; const isAdmin = isOwner || membership?.role === 'ADMIN'; + const access = await checkWorkspaceAccess({ id: workspace.id, ownerId: workspace.ownerId }, session.user.id); - if (!isOwner && !isMember) { + if (!access.hasAccess || (!isOwner && !isMember)) { redirect('/dashboard'); } diff --git a/app/(dashboard)/workspaces/[workspaceId]/settings/page.tsx b/app/(dashboard)/workspaces/[workspaceId]/settings/page.tsx index 49db740..1ba9547 100644 --- a/app/(dashboard)/workspaces/[workspaceId]/settings/page.tsx +++ b/app/(dashboard)/workspaces/[workspaceId]/settings/page.tsx @@ -8,10 +8,10 @@ interface WorkspaceSettingsPageProps { export default async function WorkspaceSettingsPage({ params }: WorkspaceSettingsPageProps) { const { workspaceId } = await params; - await requireWorkspaceAccessOrRedirect({ + const { access } = await requireWorkspaceAccessOrRedirect({ workspaceId, intent: 'manage', }); - return ; + return ; } diff --git a/app/(dashboard)/workspaces/[workspaceId]/settings/workspace-settings-page-client.tsx b/app/(dashboard)/workspaces/[workspaceId]/settings/workspace-settings-page-client.tsx index cd6a337..50150eb 100644 --- a/app/(dashboard)/workspaces/[workspaceId]/settings/workspace-settings-page-client.tsx +++ b/app/(dashboard)/workspaces/[workspaceId]/settings/workspace-settings-page-client.tsx @@ -30,7 +30,13 @@ interface WorkspaceData { ownerId: string; } -export default function WorkspaceSettingsPageClient({ workspaceId }: { workspaceId: string }) { +export default function WorkspaceSettingsPageClient({ + workspaceId, + canDelete, +}: { + workspaceId: string; + canDelete: boolean; +}) { const router = useRouter(); const [workspace, setWorkspace] = useState(null); @@ -202,65 +208,68 @@ export default function WorkspaceSettingsPageClient({ workspaceId }: { workspace - + {canDelete ? ( + <> + - {/* Danger Zone */} - - - Danger Zone - - Irreversible actions. Proceed with caution. - - - - - - - - - - Delete "{workspace.name}"? - -
-

- This will permanently delete this workspace and everything inside it - (projects, videos, comments, images, and voice notes). This action cannot be undone. -

-
- - setDeleteConfirmation(e.target.value)} - placeholder="Workspace name" - className="h-11" - /> -
-
-
-
- - setDeleteConfirmation('')}> - Cancel - - - {isDeleting && } - Delete Workspace - - -
-
-
-
+ + + Danger Zone + + Irreversible actions. Proceed with caution. + + + + + + + + + + Delete "{workspace.name}"? + +
+

+ This will permanently delete this workspace and everything inside it + (projects, videos, comments, images, and voice notes). This action cannot be undone. +

+
+ + setDeleteConfirmation(e.target.value)} + placeholder="Workspace name" + className="h-11" + /> +
+
+
+
+ + setDeleteConfirmation('')}> + Cancel + + + {isDeleting && } + Delete Workspace + + +
+
+
+
+ + ) : null} ); } diff --git a/app/(dashboard)/workspaces/new/new-workspace-page-client.tsx b/app/(dashboard)/workspaces/new/new-workspace-page-client.tsx index e0c43c9..70a388c 100644 --- a/app/(dashboard)/workspaces/new/new-workspace-page-client.tsx +++ b/app/(dashboard)/workspaces/new/new-workspace-page-client.tsx @@ -3,14 +3,21 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { ArrowLeft, Loader2, Building2 } from 'lucide-react'; +import { ArrowLeft, Loader2, Building2, Lock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -export default function NewWorkspacePage() { +export default function NewWorkspacePage({ + workspaceCreation, +}: { + workspaceCreation: { + canCreateWorkspace: boolean; + reason: string | null; + }; +}) { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); @@ -62,65 +69,84 @@ export default function NewWorkspacePage() {
- + {workspaceCreation.canCreateWorkspace ? ( + + ) : ( + + )}
- Create New Workspace + + {workspaceCreation.canCreateWorkspace ? 'Create New Workspace' : 'Upgrade Required'} + - Set up a workspace to organize projects and invite your team + {workspaceCreation.canCreateWorkspace + ? 'Set up a workspace to organize projects and invite your team' + : workspaceCreation.reason || 'Upgrade your account to create another workspace.'}
-
-
- - - setFormData({ ...formData, name: e.target.value }) - } - required - disabled={isLoading} - /> -
- -
- -