feat(billing): integrate Stripe for subscription management and billing access

- Added billing-related fields to the User model in the database.
- Implemented functions for managing billing access, including trial periods and subscription statuses.
- Created new billing utility functions for Stripe integration.
- Updated onboarding page to include billing overview and workspace creation eligibility.
- Enhanced route access checks to require billing access for certain actions.
- Implemented cleanup scripts for expired billing workspaces and associated media.
- Updated header component to conditionally show app navigation based on billing access.
- Added new migrations for billing-related database changes.
This commit is contained in:
Yusuf İpek
2026-04-08 17:50:40 +03:00
parent 8db7a031e6
commit 6f22b0bf8b
45 changed files with 1859 additions and 218 deletions
+9
View File
@@ -57,6 +57,7 @@ SMTP_FROM="[email protected]"
# ============================================================================ # ============================================================================
# APPLICATION # APPLICATION
# ============================================================================ # ============================================================================
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# development | production | test # development | production | test
NODE_ENV="development" NODE_ENV="development"
# Disable all app-level rate limiting for local testing. Leave unset to keep rate limiting enabled. # 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) # Bunny orphan cleanup configuration (script + external cron; app runtime does not schedule this)
# Grace period is fixed at 24 hours in the script. # Grace period is fixed at 24 hours in the script.
# */15 * * * * cd /home/yusuf/Programming/OpenFrame && bun run bunny:cleanup-orphans # */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_..."
+11
View File
@@ -2,7 +2,9 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access';
import { DashboardClient } from './dashboard-client'; import { DashboardClient } from './dashboard-client';
import { buildBillingAccessWhereInput } from '@/lib/billing';
export default async function DashboardPage({ export default async function DashboardPage({
searchParams, searchParams,
@@ -14,6 +16,11 @@ export default async function DashboardPage({
redirect('/login'); redirect('/login');
} }
const hasCollaboratorAccess = await hasCollaboratorBillingBackedAccess(session.user.id);
if (!hasCollaboratorAccess) {
await requireBillingAccessOrRedirect({ userId: session.user.id });
}
const userOnboarding = await db.user.findUnique({ const userOnboarding = await db.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },
@@ -37,6 +44,7 @@ export default async function DashboardPage({
{ members: { some: { userId: session.user.id } } }, { members: { some: { userId: session.user.id } } },
{ {
workspace: { workspace: {
owner: buildBillingAccessWhereInput(),
OR: [ OR: [
{ ownerId: session.user.id }, { ownerId: session.user.id },
{ members: { some: { userId: 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) // Build unique workspace list for filter (Needs an unbounded list of accessible workspaces)
+5 -1
View File
@@ -1,5 +1,6 @@
import { Header } from '@/components/layout'; import { Header } from '@/components/layout';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { hasAppNavigationAccess } from '@/lib/route-access';
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@@ -7,10 +8,13 @@ export default async function DashboardLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const session = await auth(); const session = await auth();
const showAppNavigation = session?.user?.id
? await hasAppNavigationAccess(session.user.id)
: false;
return ( return (
<div className="relative flex min-h-screen flex-col"> <div className="relative flex min-h-screen flex-col">
<Header user={session?.user ?? null} /> <Header user={session?.user ?? null} showAppNavigation={showAppNavigation} />
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
</div> </div>
); );
@@ -4,7 +4,7 @@ import {
ArrowLeft, ArrowLeft,
} from 'lucide-react'; } from 'lucide-react';
import { GuestGate } from '@/components/guest-gate'; import { GuestGate } from '@/components/guest-gate';
import { auth } from '@/lib/auth'; import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ProjectContentClient } from './project-content-client'; import { ProjectContentClient } from './project-content-client';
@@ -65,6 +65,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
notFound(); notFound();
} }
const access = await checkProjectAccess(project, session?.user?.id);
// Check access // Check access
const isOwner = session?.user?.id === project.ownerId; const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0; 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) { if (!session?.user?.id) {
redirect('/login'); 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 isAuthenticated = !!session?.user?.id;
const projectData = { const projectData = {
+17 -3
View File
@@ -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'; import SettingsPageClient from './settings-page-client';
export default async function SettingsPage() { export default async function SettingsPage() {
await requireAuthOrRedirect(); const session = await requireAuthOrRedirect();
return <SettingsPageClient />; if (!session?.user?.id) {
return null;
}
const [billing, hasCollaboratorAccess] = await Promise.all([
getBillingOverview(session.user.id),
hasCollaboratorBillingBackedAccess(session.user.id),
]);
return (
<SettingsPageClient
billingOnly={!billing.subscription.hasBillingAccess && !hasCollaboratorAccess}
/>
);
} }
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -32,6 +32,32 @@ interface NotificationSettings {
timezone: string; 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({ function ToggleButton({
enabled, enabled,
onToggle, onToggle,
@@ -77,7 +103,7 @@ function ToggleButton({
); );
} }
export default function SettingsPage() { export default function SettingsPage({ billingOnly = false }: { billingOnly?: boolean }) {
const [settings, setSettings] = useState<NotificationSettings>({ const [settings, setSettings] = useState<NotificationSettings>({
telegramChatId: null, telegramChatId: null,
telegramEnabled: false, telegramEnabled: false,
@@ -92,24 +118,41 @@ export default function SettingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null); const [testing, setTesting] = useState<string | null>(null);
const [billing, setBilling] = useState<BillingOverview | null>(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); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Form state for Telegram chat ID (separate from saved settings for editing) // Form state for Telegram chat ID (separate from saved settings for editing)
const [telegramChatId, setTelegramChatId] = useState(''); const [telegramChatId, setTelegramChatId] = useState('');
const hasScheduledCancellation = Boolean(
billing?.subscription.cancelAtPeriodEnd || billing?.subscription.cancelAt
);
useEffect(() => { useEffect(() => {
async function fetchSettings() { async function fetchSettings() {
try { try {
const res = await fetch('/api/settings/notifications'); const [settingsRes, billingRes] = await Promise.all([
if (res.ok) { fetch('/api/settings/notifications'),
const data = await res.json(); fetch('/api/billing'),
]);
if (settingsRes.ok) {
const data = await settingsRes.json();
setSettings(data.data); setSettings(data.data);
setTelegramChatId(data.data.telegramChatId || ''); setTelegramChatId(data.data.telegramChatId || '');
} }
if (billingRes.ok) {
const data = await billingRes.json();
setBilling(data.data);
}
} catch { } catch {
console.error('Failed to fetch notification settings'); console.error('Failed to fetch settings');
} finally { } finally {
setLoading(false); setLoading(false);
setBillingLoading(false);
} }
} }
fetchSettings(); fetchSettings();
@@ -174,6 +217,31 @@ export default function SettingsPage() {
[telegramChatId, showMessage] [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) { if (loading) {
return ( return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6"> <div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
@@ -212,7 +280,7 @@ export default function SettingsPage() {
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1> <h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
Manage your notification preferences {billingOnly ? 'Manage your billing access' : 'Manage your notification preferences'}
</p> </p>
</div> </div>
@@ -235,6 +303,130 @@ export default function SettingsPage() {
</div> </div>
)} )}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Billing
</CardTitle>
<CardDescription>
Manage your paid plan and workspace creation access
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{billingLoading || !billing ? (
<div className="space-y-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-10 w-44 rounded-md" />
</div>
) : !billing.isConfigured ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400">
Stripe is not configured yet. Add your Stripe environment variables before using billing.
</div>
) : (
<>
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Current plan</p>
<p className="text-sm text-muted-foreground mt-1">
{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.'}
</p>
</div>
<Badge
variant={billing.subscription.hasActiveSubscription ? 'default' : 'secondary'}
>
{billing.subscription.label}
</Badge>
</div>
{billing.subscription.hasActiveTrial
&& billing.subscription.trialEndsAt
&& hasScheduledCancellation ? (
<p
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive"
>
Access ends on {' '}
{new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.
</p>
) : null}
{billing.subscription.currentPeriodEnd ? (
<p className="text-sm text-muted-foreground">
{hasScheduledCancellation ? 'Your subscription ends on ' : 'Current billing period ends on '}
{new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}.
</p>
) : null}
{hasScheduledCancellation && billing.subscription.cancelAt ? (
<p className="text-sm text-muted-foreground">
Cancellation was scheduled on {new Date(billing.subscription.cancelAt).toLocaleDateString()}.
</p>
) : null}
{!billing.subscription.hasBillingAccess
&& billing.subscription.billingAccessEndedAt
&& billing.subscription.storageCleanupEligibleAt ? (
<p className="text-sm text-amber-700 dark:text-amber-400">
Stored media cleanup is scheduled after {new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()} unless billing is restored first.
</p>
) : null}
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<p className="text-sm font-medium">Workspace creation</p>
<p className="text-sm text-muted-foreground">
{billing.workspaceCreation.canCreateWorkspace
? 'This account can create workspaces.'
: billing.workspaceCreation.reason || 'Upgrade to create another workspace.'}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
{billing.subscription.hasActiveSubscription && billing.portalAvailable ? (
<Button
onClick={() => handleBillingRedirect('/api/billing/portal')}
disabled={billingAction !== null}
>
{billingAction === 'portal' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Opening Portal...
</>
) : (
'Manage Subscription'
)}
</Button>
) : (
<Button
onClick={() => handleBillingRedirect('/api/billing/checkout')}
disabled={!billing.checkoutAvailable || billingAction !== null}
>
{billingAction === 'checkout' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Redirecting...
</>
) : (
'Upgrade with Stripe'
)}
</Button>
)}
</div>
</>
)}
</CardContent>
</Card>
{!billingOnly && (
<>
{/* Event Subscriptions */} {/* Event Subscriptions */}
<Card className="mb-6"> <Card className="mb-6">
<CardHeader> <CardHeader>
@@ -246,7 +438,7 @@ export default function SettingsPage() {
Choose which events trigger notifications Choose which events trigger notifications
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-4">
<ToggleButton <ToggleButton
enabled={settings.onNewVideo} enabled={settings.onNewVideo}
onToggle={() => onToggle={() =>
@@ -518,6 +710,8 @@ export default function SettingsPage() {
)} )}
</Button> </Button>
</div> </div>
</>
)}
</div> </div>
); );
} }
@@ -14,7 +14,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { auth } from '@/lib/auth'; import { auth, checkWorkspaceAccess } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader'; 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 membership = workspace.members[0];
const isMember = !!membership; const isMember = !!membership;
const isAdmin = isOwner || membership?.role === 'ADMIN'; 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'); redirect('/dashboard');
} }
@@ -8,10 +8,10 @@ interface WorkspaceSettingsPageProps {
export default async function WorkspaceSettingsPage({ params }: WorkspaceSettingsPageProps) { export default async function WorkspaceSettingsPage({ params }: WorkspaceSettingsPageProps) {
const { workspaceId } = await params; const { workspaceId } = await params;
await requireWorkspaceAccessOrRedirect({ const { access } = await requireWorkspaceAccessOrRedirect({
workspaceId, workspaceId,
intent: 'manage', intent: 'manage',
}); });
return <WorkspaceSettingsPageClient workspaceId={workspaceId} />; return <WorkspaceSettingsPageClient workspaceId={workspaceId} canDelete={access.canDelete} />;
} }
@@ -30,7 +30,13 @@ interface WorkspaceData {
ownerId: string; ownerId: string;
} }
export default function WorkspaceSettingsPageClient({ workspaceId }: { workspaceId: string }) { export default function WorkspaceSettingsPageClient({
workspaceId,
canDelete,
}: {
workspaceId: string;
canDelete: boolean;
}) {
const router = useRouter(); const router = useRouter();
const [workspace, setWorkspace] = useState<WorkspaceData | null>(null); const [workspace, setWorkspace] = useState<WorkspaceData | null>(null);
@@ -202,65 +208,68 @@ export default function WorkspaceSettingsPageClient({ workspaceId }: { workspace
</CardContent> </CardContent>
</Card> </Card>
<Separator className="my-8" /> {canDelete ? (
<>
<Separator className="my-8" />
{/* Danger Zone */} <Card className="border-destructive/50">
<Card className="border-destructive/50"> <CardHeader>
<CardHeader> <CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardTitle className="text-destructive">Danger Zone</CardTitle> <CardDescription>
<CardDescription> Irreversible actions. Proceed with caution.
Irreversible actions. Proceed with caution. </CardDescription>
</CardDescription> </CardHeader>
</CardHeader> <CardContent>
<CardContent> <AlertDialog>
<AlertDialog> <AlertDialogTrigger asChild>
<AlertDialogTrigger asChild> <Button variant="destructive">
<Button variant="destructive"> <Trash2 className="h-4 w-4 mr-2" />
<Trash2 className="h-4 w-4 mr-2" /> Delete Workspace
Delete Workspace </Button>
</Button> </AlertDialogTrigger>
</AlertDialogTrigger> <AlertDialogContent>
<AlertDialogContent> <AlertDialogHeader>
<AlertDialogHeader> <AlertDialogTitle>Delete &quot;{workspace.name}&quot;?</AlertDialogTitle>
<AlertDialogTitle>Delete &quot;{workspace.name}&quot;?</AlertDialogTitle> <AlertDialogDescription asChild>
<AlertDialogDescription asChild> <div className="space-y-4">
<div className="space-y-4"> <p>
<p> This will permanently delete this workspace and everything inside it
This will permanently delete this workspace and everything inside it (projects, videos, comments, images, and voice notes). This action cannot be undone.
(projects, videos, comments, images, and voice notes). This action cannot be undone. </p>
</p> <div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="delete-workspace-confirm">
<Label htmlFor="delete-workspace-confirm"> Type <strong className="text-foreground">{workspace.name}</strong> to confirm
Type <strong className="text-foreground">{workspace.name}</strong> to confirm </Label>
</Label> <Input
<Input id="delete-workspace-confirm"
id="delete-workspace-confirm" value={deleteConfirmation}
value={deleteConfirmation} onChange={(e) => setDeleteConfirmation(e.target.value)}
onChange={(e) => setDeleteConfirmation(e.target.value)} placeholder="Workspace name"
placeholder="Workspace name" className="h-11"
className="h-11" />
/> </div>
</div> </div>
</div> </AlertDialogDescription>
</AlertDialogDescription> </AlertDialogHeader>
</AlertDialogHeader> <AlertDialogFooter>
<AlertDialogFooter> <AlertDialogCancel onClick={() => setDeleteConfirmation('')}>
<AlertDialogCancel onClick={() => setDeleteConfirmation('')}> Cancel
Cancel </AlertDialogCancel>
</AlertDialogCancel> <AlertDialogAction
<AlertDialogAction onClick={handleDelete}
onClick={handleDelete} disabled={deleteConfirmation !== workspace.name || isDeleting}
disabled={deleteConfirmation !== workspace.name || isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" >
> {isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} Delete Workspace
Delete Workspace </AlertDialogAction>
</AlertDialogAction> </AlertDialogFooter>
</AlertDialogFooter> </AlertDialogContent>
</AlertDialogContent> </AlertDialog>
</AlertDialog> </CardContent>
</CardContent> </Card>
</Card> </>
) : null}
</div> </div>
); );
} }
@@ -3,14 +3,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; 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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; 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 router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -62,65 +69,84 @@ export default function NewWorkspacePage() {
<Card className="border-border/50 shadow-lg"> <Card className="border-border/50 shadow-lg">
<CardHeader className="text-center pb-2"> <CardHeader className="text-center pb-2">
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-4"> <div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Building2 className="h-7 w-7 text-primary" /> {workspaceCreation.canCreateWorkspace ? (
<Building2 className="h-7 w-7 text-primary" />
) : (
<Lock className="h-7 w-7 text-primary" />
)}
</div> </div>
<CardTitle className="text-2xl">Create New Workspace</CardTitle> <CardTitle className="text-2xl">
{workspaceCreation.canCreateWorkspace ? 'Create New Workspace' : 'Upgrade Required'}
</CardTitle>
<CardDescription className="text-base"> <CardDescription className="text-base">
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.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="pt-6">
<form onSubmit={handleSubmit} className="space-y-6"> {workspaceCreation.canCreateWorkspace ? (
<div className="space-y-2"> <form onSubmit={handleSubmit} className="space-y-6">
<Label htmlFor="name" className="text-sm font-medium"> <div className="space-y-2">
Workspace Name <Label htmlFor="name" className="text-sm font-medium">
</Label> Workspace Name
<Input </Label>
id="name" <Input
placeholder="e.g., My Studio" id="name"
value={formData.name} placeholder="e.g., My Studio"
onChange={(e) => value={formData.name}
setFormData({ ...formData, name: e.target.value }) onChange={(e) =>
} setFormData({ ...formData, name: e.target.value })
required }
disabled={isLoading} required
/> disabled={isLoading}
</div> />
<div className="space-y-2">
<Label htmlFor="description" className="text-sm font-medium">
Description{' '}
<span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Textarea
id="description"
placeholder="What is this workspace for?"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
rows={3}
disabled={isLoading}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div> </div>
)}
<Button type="submit" className="w-full" disabled={isLoading}> <div className="space-y-2">
{isLoading ? ( <Label htmlFor="description" className="text-sm font-medium">
<> Description{' '}
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <span className="text-muted-foreground font-normal">(optional)</span>
Creating... </Label>
</> <Textarea
) : ( id="description"
'Create Workspace' placeholder="What is this workspace for?"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
rows={3}
disabled={isLoading}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)} )}
</Button>
</form> <Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Workspace'
)}
</Button>
</form>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
You can still create and manage projects inside workspaces where you are already a member.
</p>
<Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link>
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+9 -2
View File
@@ -1,7 +1,14 @@
import { requireAuthOrRedirect } from '@/lib/route-access'; import { requireAuthOrRedirect } from '@/lib/route-access';
import { getBillingOverview } from '@/lib/billing';
import NewWorkspacePageClient from './new-workspace-page-client'; import NewWorkspacePageClient from './new-workspace-page-client';
export default async function NewWorkspacePage() { export default async function NewWorkspacePage() {
await requireAuthOrRedirect(); const session = await requireAuthOrRedirect();
return <NewWorkspacePageClient />; if (!session?.user?.id) {
return null;
}
const billing = await getBillingOverview(session.user.id);
return <NewWorkspacePageClient workspaceCreation={billing.workspaceCreation} />;
} }
+20 -7
View File
@@ -1,6 +1,8 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access';
import { WorkspacesClient } from './workspaces-client'; import { WorkspacesClient } from './workspaces-client';
export default async function WorkspacesPage({ export default async function WorkspacesPage({
@@ -13,19 +15,24 @@ export default async function WorkspacesPage({
redirect('/login'); redirect('/login');
} }
const hasCollaboratorAccess = await hasCollaboratorBillingBackedAccess(session.user.id);
if (!hasCollaboratorAccess) {
await requireBillingAccessOrRedirect({ userId: session.user.id });
}
const resolvedSearchParams = await searchParams; const resolvedSearchParams = await searchParams;
const page = Number(resolvedSearchParams?.page) || 1; const page = Number(resolvedSearchParams?.page) || 1;
const pageSize = 20; const pageSize = 20;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const [workspaces, totalWorkspaces] = await Promise.all([ const [workspaces, totalWorkspaces, billing] = await Promise.all([
db.workspace.findMany({ db.workspace.findMany({
skip, skip,
take: pageSize, take: pageSize,
where: { where: {
OR: [ OR: [
{ ownerId: session.user.id }, { ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
{ members: { some: { userId: session.user.id } } }, { members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
], ],
}, },
include: { include: {
@@ -42,11 +49,12 @@ export default async function WorkspacesPage({
db.workspace.count({ db.workspace.count({
where: { where: {
OR: [ OR: [
{ ownerId: session.user.id }, { ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
{ members: { some: { userId: session.user.id } } }, { members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
], ],
} }
}) }),
getBillingOverview(session.user.id),
]); ]);
const totalPages = Math.ceil(totalWorkspaces / pageSize); const totalPages = Math.ceil(totalWorkspaces / pageSize);
@@ -60,6 +68,11 @@ export default async function WorkspacesPage({
})); }));
return ( return (
<WorkspacesClient workspaces={serializedWorkspaces} totalPages={totalPages} currentPage={page} /> <WorkspacesClient
workspaces={serializedWorkspaces}
totalPages={totalPages}
currentPage={page}
workspaceCreation={billing.workspaceCreation}
/>
); );
} }
@@ -35,9 +35,18 @@ interface WorkspacesClientProps {
workspaces: SerializedWorkspace[]; workspaces: SerializedWorkspace[];
totalPages: number; totalPages: number;
currentPage: number; currentPage: number;
workspaceCreation: {
canCreateWorkspace: boolean;
reason: string | null;
};
} }
export function WorkspacesClient({ workspaces, totalPages, currentPage }: WorkspacesClientProps) { export function WorkspacesClient({
workspaces,
totalPages,
currentPage,
workspaceCreation,
}: WorkspacesClientProps) {
const router = useRouter(); const router = useRouter();
return ( return (
@@ -49,13 +58,26 @@ export function WorkspacesClient({ workspaces, totalPages, currentPage }: Worksp
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
Manage your workspaces and their projects Manage your workspaces and their projects
</p> </p>
{!workspaceCreation.canCreateWorkspace && workspaceCreation.reason ? (
<p className="text-sm text-amber-700 dark:text-amber-400 mt-2">
{workspaceCreation.reason}
</p>
) : null}
</div> </div>
<Button asChild className="w-full sm:w-auto"> {workspaceCreation.canCreateWorkspace ? (
<Link href="/workspaces/new"> <Button asChild className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-2" /> <Link href="/workspaces/new">
New Workspace <Plus className="h-4 w-4 mr-2" />
</Link> New Workspace
</Button> </Link>
</Button>
) : (
<Button asChild className="w-full sm:w-auto">
<Link href="/settings">
Upgrade to Create Workspace
</Link>
</Button>
)}
</div> </div>
{/* Workspaces Grid */} {/* Workspaces Grid */}
@@ -101,12 +123,18 @@ export function WorkspacesClient({ workspaces, totalPages, currentPage }: Worksp
<p className="text-muted-foreground text-center mb-4"> <p className="text-muted-foreground text-center mb-4">
Create a workspace to organize your projects and invite team members Create a workspace to organize your projects and invite team members
</p> </p>
<Button asChild> {workspaceCreation.canCreateWorkspace ? (
<Link href="/workspaces/new"> <Button asChild>
<Plus className="h-4 w-4 mr-2" /> <Link href="/workspaces/new">
Create Workspace <Plus className="h-4 w-4 mr-2" />
</Link> Create Workspace
</Button> </Link>
</Button>
) : (
<Button asChild>
<Link href="/settings">Upgrade to Create Workspace</Link>
</Button>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function AdminLayout({
return ( return (
<div className="relative flex min-h-screen flex-col"> <div className="relative flex min-h-screen flex-col">
<Header user={session.user} /> <Header user={session.user} showAppNavigation />
<div className="w-full px-4 md:px-8 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10"> <div className="w-full px-4 md:px-8 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
{/* Mobile Nav */} {/* Mobile Nav */}
<div className="md:hidden py-4 border-b mb-4"> <div className="md:hidden py-4 border-b mb-4">
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import {
DEFAULT_TRIAL_PERIOD_DAYS,
getOrCreateStripeCustomerId,
getStripeCheckoutState,
} from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
function getAppOrigin(request: NextRequest) {
if (isTrustedSameOriginRequest(request)) {
const origin = request.headers.get('origin');
if (origin) {
return new URL(origin).origin;
}
}
return request.nextUrl.origin;
}
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
if (!isTrustedSameOriginRequest(request)) {
return apiErrors.forbidden('Invalid request origin');
}
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!isStripeConfigured()) {
return apiErrors.internalError('Stripe billing is not configured');
}
const checkoutState = await getStripeCheckoutState(session.user.id);
if (checkoutState.hasActiveSubscription) {
return apiErrors.badRequest('An active subscription already exists for this account');
}
const stripe = getStripe();
const priceId = getStripePriceId();
const customerId = await getOrCreateStripeCustomerId(session.user.id);
const appOrigin = getAppOrigin(request);
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
allow_promotion_codes: true,
success_url: `${appOrigin}/settings?billing=success`,
cancel_url: `${appOrigin}/settings?billing=canceled`,
metadata: {
userId: session.user.id,
},
subscription_data: {
metadata: {
userId: session.user.id,
},
...(checkoutState.isTrialEligible ? { trial_period_days: DEFAULT_TRIAL_PERIOD_DAYS } : {}),
},
});
if (!checkoutSession.url) {
throw new Error('Stripe did not return a checkout URL');
}
const response = successResponse({ url: checkoutSession.url });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error creating Stripe checkout session:', error);
return apiErrors.internalError('Failed to start checkout');
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { getStripe, isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
function getAppOrigin(request: NextRequest) {
if (isTrustedSameOriginRequest(request)) {
const origin = request.headers.get('origin');
if (origin) {
return new URL(origin).origin;
}
}
return request.nextUrl.origin;
}
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
if (!isTrustedSameOriginRequest(request)) {
return apiErrors.forbidden('Invalid request origin');
}
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!isStripeConfigured()) {
return apiErrors.internalError('Stripe billing is not configured');
}
const billing = await getBillingOverview(session.user.id);
if (!billing.subscription.stripeCustomerId) {
return apiErrors.badRequest('No Stripe customer exists for this account');
}
const stripe = getStripe();
const portalSession = await stripe.billingPortal.sessions.create({
customer: billing.subscription.stripeCustomerId,
return_url: `${getAppOrigin(request)}/settings`,
});
const response = successResponse({ url: portalSession.url });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error creating Stripe portal session:', error);
return apiErrors.internalError('Failed to open billing portal');
}
}
+40
View File
@@ -0,0 +1,40 @@
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
import { isStripeConfigured } from '@/lib/stripe';
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const billing = await getBillingOverview(session.user.id);
const response = successResponse({
isConfigured: isStripeConfigured(),
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasActiveSubscription,
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
subscription: {
status: billing.subscription.status,
label: billing.subscription.label,
hasActiveSubscription: billing.subscription.hasActiveSubscription,
hasActiveTrial: billing.subscription.hasActiveTrial,
hasBillingAccess: billing.subscription.hasBillingAccess,
priceId: billing.subscription.stripePriceId,
currentPeriodEnd: billing.subscription.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: billing.subscription.cancelAtPeriodEnd ?? false,
cancelAt: billing.subscription.cancelAt?.toISOString() ?? null,
trialEndsAt: billing.subscription.trialEndsAt?.toISOString() ?? null,
billingAccessEndedAt: billing.subscription.billingAccessEndedAt?.toISOString() ?? null,
storageCleanupEligibleAt: billing.subscription.storageCleanupEligibleAt?.toISOString() ?? null,
},
workspaceCreation: billing.workspaceCreation,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error fetching billing overview:', error);
return apiErrors.internalError('Failed to fetch billing overview');
}
}
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth, checkProjectAccess } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client'; import { ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
@@ -29,10 +29,11 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project'); return apiErrors.notFound('Project');
} }
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id; const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!isOwner && !isAdmin) { if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
@@ -44,15 +45,24 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.'); return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.');
} }
const member = await db.projectMember.update({ const member = await db.projectMember.findFirst({
where: { id: memberId }, where: { id: memberId, projectId },
select: { id: true },
});
if (!member) {
return apiErrors.notFound('Member');
}
const updatedMember = await db.projectMember.update({
where: { id: member.id },
data: { role: role as ProjectMemberRole }, data: { role: role as ProjectMemberRole },
include: { include: {
user: { select: { id: true, name: true, image: true } }, user: { select: { id: true, name: true, image: true } },
}, },
}); });
const response = successResponse(member); const response = successResponse(updatedMember);
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
console.error('Error updating member role:', error); console.error('Error updating member role:', error);
@@ -82,11 +92,13 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project'); return apiErrors.notFound('Project');
} }
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id; const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
const memberToRemove = await db.projectMember.findUnique({ const memberToRemove = await db.projectMember.findFirst({
where: { id: memberId }, where: { id: memberId, projectId },
select: { id: true, userId: true },
}); });
if (!memberToRemove) { if (!memberToRemove) {
@@ -95,11 +107,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const isSelf = memberToRemove.userId === session.user.id; const isSelf = memberToRemove.userId === session.user.id;
if (!isOwner && !isAdmin && !isSelf) { if ((!access.canEdit || (!isOwner && !isAdmin)) && !isSelf) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
await db.projectMember.delete({ where: { id: memberId } }); await db.projectMember.delete({ where: { id: memberToRemove.id } });
const response = successResponse({ message: 'Member removed' }); const response = successResponse({ message: 'Member removed' });
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { InvitationStatus, ProjectMemberRole } from '@prisma/client'; import { InvitationStatus, ProjectMemberRole } from '@prisma/client';
import { auth } from '@/lib/auth'; import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
@@ -29,10 +29,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project'); return apiErrors.notFound('Project');
} }
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id; const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!isOwner && !isAdmin) { if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Only project owners and admins can cancel invitations'); return apiErrors.forbidden('Only project owners and admins can cancel invitations');
} }
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth, checkProjectAccess } from '@/lib/auth';
import { InvitationRole, ProjectMemberRole } from '@prisma/client'; import { InvitationRole, ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations'; import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations';
@@ -29,11 +29,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project'); return apiErrors.notFound('Project');
} }
const access = await checkProjectAccess(project, session.user.id);
const isOwner = project.ownerId === session.user.id; const isOwner = project.ownerId === session.user.id;
const isMember = project.members.length > 0; const isMember = project.members.length > 0;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!isOwner && !isMember) { if (!access.hasAccess || (!isOwner && !isMember)) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
@@ -105,10 +106,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project'); return apiErrors.notFound('Project');
} }
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
const isOwner = project.ownerId === session.user.id; const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
if (!isOwner && !isAdmin) { if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Only project owners and admins can invite members'); return apiErrors.forbidden('Only project owners and admins can invite members');
} }
+12 -5
View File
@@ -1,8 +1,9 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth, checkWorkspaceAccess } from '@/lib/auth';
import { ProjectVisibility } from '@prisma/client'; import { ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { buildBillingAccessWhereInput } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags'; import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
@@ -48,10 +49,14 @@ export async function GET(request: NextRequest) {
// Also include projects in workspaces where the user is a workspace member // Also include projects in workspaces where the user is a workspace member
...(workspaceId ? [] : [{ ...(workspaceId ? [] : [{
workspace: { workspace: {
owner: buildBillingAccessWhereInput(),
members: { some: { userId: session.user.id } }, members: { some: { userId: session.user.id } },
}, },
}]), }]),
], ],
workspace: {
owner: buildBillingAccessWhereInput(),
},
}; };
// Filter by workspace if provided // Filter by workspace if provided
@@ -150,10 +155,12 @@ export async function POST(request: NextRequest) {
return apiErrors.notFound('Workspace'); return apiErrors.notFound('Workspace');
} }
const isWsOwner = workspace.ownerId === session.user.id; const access = await checkWorkspaceAccess(
const isWsAdmin = workspace.members[0]?.role === 'ADMIN'; { id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
if (!isWsOwner && !isWsAdmin) { if (!access.canEdit) {
return apiErrors.forbidden('Only workspace owners and admins can create projects'); return apiErrors.forbidden('Only workspace owners and admins can create projects');
} }
@@ -164,7 +171,7 @@ export async function POST(request: NextRequest) {
description: description?.trim() || null, description: description?.trim() || null,
slug, slug,
visibility: visibility || ProjectVisibility.PRIVATE, visibility: visibility || ProjectVisibility.PRIVATE,
ownerId: session.user.id, ownerId: workspace.ownerId,
workspaceId, workspaceId,
}, },
include: { include: {
+88
View File
@@ -0,0 +1,88 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
import {
markSubscriptionCanceledByCustomerId,
syncStripeSubscriptionToUser,
} from '@/lib/billing';
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
export const runtime = 'nodejs';
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
const currentPeriodEnd =
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
? new Date(subscription.current_period_end * 1000)
: null;
const endedAt =
'ended_at' in subscription && typeof subscription.ended_at === 'number'
? new Date(subscription.ended_at * 1000)
: currentPeriodEnd;
await markSubscriptionCanceledByCustomerId(customerId, {
currentPeriodEnd,
endedAt,
});
}
export async function POST(request: NextRequest) {
const signature = request.headers.get('stripe-signature');
if (!signature) {
return new Response('Missing Stripe signature', { status: 400 });
}
let event: Stripe.Event;
try {
const stripe = getStripe();
const body = await request.text();
event = stripe.webhooks.constructEvent(body, signature, getStripeWebhookSecret());
} catch (error) {
console.error('Failed to verify Stripe webhook:', error);
return new Response('Invalid webhook signature', { status: 400 });
}
try {
const stripe = getStripe();
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === 'subscription' && session.subscription) {
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await syncStripeSubscriptionToUser(subscription);
}
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await syncStripeSubscriptionToUser(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
default:
break;
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Failed to process Stripe webhook:', error);
return new Response('Webhook processing failed', { status: 500 });
}
}
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth, checkWorkspaceAccess } from '@/lib/auth';
import { WorkspaceMemberRole } from '@prisma/client'; import { WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
@@ -30,10 +30,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Workspace'); return apiErrors.notFound('Workspace');
} }
const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
const isOwner = workspace.ownerId === session.user.id; const isOwner = workspace.ownerId === session.user.id;
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
if (!isOwner && !isAdmin) { if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
@@ -45,15 +49,24 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.'); return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.');
} }
const member = await db.workspaceMember.update({ const member = await db.workspaceMember.findFirst({
where: { id: memberId }, where: { id: memberId, workspaceId },
select: { id: true },
});
if (!member) {
return apiErrors.notFound('Member');
}
const updatedMember = await db.workspaceMember.update({
where: { id: member.id },
data: { role: role as WorkspaceMemberRole }, data: { role: role as WorkspaceMemberRole },
include: { include: {
user: { select: { id: true, name: true, image: true } }, user: { select: { id: true, name: true, image: true } },
}, },
}); });
const response = successResponse(member); const response = successResponse(updatedMember);
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
console.error('Error updating member role:', error); console.error('Error updating member role:', error);
@@ -83,12 +96,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Workspace'); return apiErrors.notFound('Workspace');
} }
const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
const isOwner = workspace.ownerId === session.user.id; const isOwner = workspace.ownerId === session.user.id;
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
// Users can remove themselves, admins/owners can remove anyone // Users can remove themselves, admins/owners can remove anyone
const memberToRemove = await db.workspaceMember.findUnique({ const memberToRemove = await db.workspaceMember.findFirst({
where: { id: memberId }, where: { id: memberId, workspaceId },
select: { id: true, userId: true },
}); });
if (!memberToRemove) { if (!memberToRemove) {
@@ -97,11 +115,32 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const isSelf = memberToRemove.userId === session.user.id; const isSelf = memberToRemove.userId === session.user.id;
if (!isOwner && !isAdmin && !isSelf) { if ((!access.canEdit || (!isOwner && !isAdmin)) && !isSelf) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
await db.workspaceMember.delete({ where: { id: memberId } }); await db.$transaction(async (tx) => {
await tx.projectMember.deleteMany({
where: {
userId: memberToRemove.userId,
project: {
workspaceId,
},
},
});
await tx.project.updateMany({
where: {
workspaceId,
ownerId: memberToRemove.userId,
},
data: {
ownerId: workspace.ownerId,
},
});
await tx.workspaceMember.delete({ where: { id: memberToRemove.id } });
});
const response = successResponse({ message: 'Member removed' }); const response = successResponse({ message: 'Member removed' });
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { InvitationStatus, WorkspaceMemberRole } from '@prisma/client'; import { InvitationStatus, WorkspaceMemberRole } from '@prisma/client';
import { auth } from '@/lib/auth'; import { auth, checkWorkspaceAccess } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
@@ -29,10 +29,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Workspace'); return apiErrors.notFound('Workspace');
} }
const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
const isOwner = workspace.ownerId === session.user.id; const isOwner = workspace.ownerId === session.user.id;
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
if (!isOwner && !isAdmin) { if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Only workspace owners and admins can cancel invitations'); return apiErrors.forbidden('Only workspace owners and admins can cancel invitations');
} }
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth, checkWorkspaceAccess } from '@/lib/auth';
import { InvitationRole, WorkspaceMemberRole } from '@prisma/client'; import { InvitationRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations'; import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations';
@@ -53,11 +53,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Workspace'); return apiErrors.notFound('Workspace');
} }
const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
const isOwner = workspace.ownerId === session.user.id; const isOwner = workspace.ownerId === session.user.id;
const isMember = workspace.members.length > 0; const isMember = workspace.members.length > 0;
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
if (!isOwner && !isMember) { if (!access.hasAccess || (!isOwner && !isMember)) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
@@ -145,10 +149,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Workspace'); return apiErrors.notFound('Workspace');
} }
const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
const isOwner = workspace.ownerId === session.user.id; const isOwner = workspace.ownerId === session.user.id;
const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN;
if (!isOwner && !isAdmin) { if (!access.canEdit || (!isOwner && !isAdmin)) {
return apiErrors.forbidden('Only workspace owners and admins can invite members'); return apiErrors.forbidden('Only workspace owners and admins can invite members');
} }
+10 -2
View File
@@ -2,6 +2,7 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
// GET /api/workspaces - List all workspaces for the authenticated user // GET /api/workspaces - List all workspaces for the authenticated user
@@ -39,8 +40,8 @@ export async function GET(request: NextRequest) {
const where = { const where = {
OR: [ OR: [
{ ownerId: session.user.id }, { ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
{ members: { some: { userId: session.user.id } } }, { members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
], ],
}; };
@@ -88,6 +89,13 @@ export async function POST(request: NextRequest) {
return apiErrors.unauthorized(); return apiErrors.unauthorized();
} }
const billing = await getWorkspaceCreationEligibility(session.user.id);
if (!billing.canCreateWorkspace) {
return apiErrors.forbidden(
billing.reason || 'Upgrade your account to create another workspace'
);
}
const body = await request.json(); const body = await request.json();
const { name, description } = body; const { name, description } = body;
+28
View File
@@ -1,5 +1,6 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { acceptInvitationTokenForUser } from '@/lib/invitations'; import { acceptInvitationTokenForUser } from '@/lib/invitations';
interface InvitationAcceptPageProps { interface InvitationAcceptPageProps {
@@ -23,6 +24,26 @@ export default async function InvitationAcceptPage({ searchParams }: InvitationA
redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`); redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
} }
const invitation = await db.invitation.findUnique({
where: { token },
select: {
id: true,
status: true,
scope: true,
workspaceId: true,
projectId: true,
},
});
function redirectToInvitationTarget(inviteStatus: string) {
if (invitation?.scope === 'WORKSPACE' && invitation.workspaceId) {
redirect(`/workspaces/${invitation.workspaceId}?invite=${inviteStatus}`);
}
if (invitation?.scope === 'PROJECT' && invitation.projectId) {
redirect(`/projects/${invitation.projectId}?invite=${inviteStatus}`);
}
}
const userEmail = session.user.email?.toLowerCase().trim(); const userEmail = session.user.email?.toLowerCase().trim();
if (!userEmail) { if (!userEmail) {
redirect('/dashboard?invite=invalid_email'); redirect('/dashboard?invite=invalid_email');
@@ -35,13 +56,20 @@ export default async function InvitationAcceptPage({ searchParams }: InvitationA
}); });
if (result === 'accepted') { if (result === 'accepted') {
redirectToInvitationTarget('accepted');
redirect('/dashboard?invite=accepted'); redirect('/dashboard?invite=accepted');
} }
if (result === 'expired') { if (result === 'expired') {
redirectToInvitationTarget('expired');
redirect('/dashboard?invite=expired'); redirect('/dashboard?invite=expired');
} }
if (result === 'forbidden') { if (result === 'forbidden') {
redirect('/dashboard?invite=wrong_account'); redirect('/dashboard?invite=wrong_account');
} }
if (result === 'not_found' && invitation?.status === 'ACCEPTED') {
redirectToInvitationTarget('already_accepted');
}
redirect('/dashboard?invite=not_found'); redirect('/dashboard?invite=not_found');
} }
+115 -5
View File
@@ -25,6 +25,13 @@ import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC'; type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
@@ -110,9 +117,17 @@ function StepWelcome({ userName, onNext }: { userName: string; onNext: () => voi
// ─── Step 2: Create Workspace ────────────────────────────────────────────────── // ─── Step 2: Create Workspace ──────────────────────────────────────────────────
function StepWorkspace({ function StepWorkspace({
canCreateWorkspace,
availableWorkspaces,
selectedWorkspaceId,
onWorkspaceSelected,
onNext, onNext,
onWorkspaceCreated, onWorkspaceCreated,
}: { }: {
canCreateWorkspace: boolean;
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
selectedWorkspaceId: string | null;
onWorkspaceSelected: (workspaceId: string) => void;
onNext: () => void; onNext: () => void;
onWorkspaceCreated: (id: string) => void; onWorkspaceCreated: (id: string) => void;
}) { }) {
@@ -144,6 +159,70 @@ function StepWorkspace({
} }
}; };
if (!canCreateWorkspace) {
return (
<div className="space-y-7">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<Building2 className="h-8 w-8 text-primary" />
</div>
<h2 className="text-2xl font-bold tracking-tight">Workspace access</h2>
<p className="text-base text-muted-foreground">
Your account can&apos;t create a new workspace right now.
</p>
</div>
{availableWorkspaces.length > 0 ? (
<div className="space-y-5">
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>
You can still create projects inside workspaces where you already have admin access.
</span>
</div>
<div className="space-y-2">
<Label htmlFor="onboarding-workspace">Choose a workspace</Label>
<Select
value={selectedWorkspaceId ?? undefined}
onValueChange={onWorkspaceSelected}
>
<SelectTrigger id="onboarding-workspace" className="w-full">
<SelectValue placeholder="Select a workspace" />
</SelectTrigger>
<SelectContent>
{availableWorkspaces.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}{workspace.isOwner ? ' (Owner)' : ' (Admin)'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={onNext} className="w-full h-11" disabled={!selectedWorkspaceId}>
Continue
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>
You don&apos;t currently have a workspace where you can create projects. Ask a workspace owner to invite you as an admin, or upgrade later to create your own workspace.
</span>
</div>
<Button onClick={onNext} className="w-full h-11">
Continue
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
)}
</div>
);
}
return ( return (
<div className="space-y-7"> <div className="space-y-7">
<div className="text-center space-y-3"> <div className="text-center space-y-3">
@@ -217,10 +296,14 @@ function StepWorkspace({
function StepProject({ function StepProject({
workspaceId, workspaceId,
availableWorkspaces,
canCreateWorkspace,
onNext, onNext,
onProjectCreated, onProjectCreated,
}: { }: {
workspaceId: string | null; workspaceId: string | null;
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
canCreateWorkspace: boolean;
onNext: () => void; onNext: () => void;
onProjectCreated: (id: string) => void; onProjectCreated: (id: string) => void;
}) { }) {
@@ -273,7 +356,13 @@ function StepProject({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground"> <div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 shrink-0 mt-0.5" /> <Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>You skipped workspace creation. Projects require a workspace you can create both from the dashboard later.</span> <span>
{canCreateWorkspace
? 'You skipped workspace creation. Projects require a workspace — you can create both from the dashboard later.'
: availableWorkspaces.length === 0
? 'You do not currently have permission to create projects in any workspace.'
: 'Pick a workspace in the previous step to create a project here.'}
</span>
</div> </div>
<Button onClick={onNext} className="w-full h-11"> <Button onClick={onNext} className="w-full h-11">
Continue Continue
@@ -547,10 +636,18 @@ function StepNotifications({ onFinish }: { onFinish: () => Promise<void> }) {
// ─── Wizard Shell ────────────────────────────────────────────────────────────── // ─── Wizard Shell ──────────────────────────────────────────────────────────────
export function OnboardingWizard({ userName }: { userName: string }) { export function OnboardingWizard({
userName,
canCreateWorkspace,
availableWorkspaces,
}: {
userName: string;
canCreateWorkspace: boolean;
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
}) {
const router = useRouter(); const router = useRouter();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [createdWorkspaceId, setCreatedWorkspaceId] = useState<string | null>(null); const [createdWorkspaceId, setCreatedWorkspaceId] = useState<string | null>(availableWorkspaces[0]?.id ?? null);
const [isCompleting, setIsCompleting] = useState(false); const [isCompleting, setIsCompleting] = useState(false);
const goNext = () => setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS)); const goNext = () => setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS));
@@ -615,10 +712,23 @@ export function OnboardingWizard({ userName }: { userName: string }) {
<StepWelcome userName={userName} onNext={goNext} /> <StepWelcome userName={userName} onNext={goNext} />
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<StepWorkspace onNext={goNext} onWorkspaceCreated={setCreatedWorkspaceId} /> <StepWorkspace
canCreateWorkspace={canCreateWorkspace}
availableWorkspaces={availableWorkspaces}
selectedWorkspaceId={createdWorkspaceId}
onWorkspaceSelected={setCreatedWorkspaceId}
onNext={goNext}
onWorkspaceCreated={setCreatedWorkspaceId}
/>
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<StepProject workspaceId={createdWorkspaceId} onNext={goNext} onProjectCreated={() => {}} /> <StepProject
workspaceId={createdWorkspaceId}
availableWorkspaces={availableWorkspaces}
canCreateWorkspace={canCreateWorkspace}
onNext={goNext}
onProjectCreated={() => {}}
/>
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<StepVideo onNext={goNext} /> <StepVideo onNext={goNext} />
+34 -5
View File
@@ -1,4 +1,5 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { OnboardingWizard } from './onboarding-wizard'; import { OnboardingWizard } from './onboarding-wizard';
@@ -9,10 +10,28 @@ export default async function OnboardingPage() {
redirect('/login'); redirect('/login');
} }
const user = await db.user.findUnique({ const [user, billing, creatableWorkspaces] = await Promise.all([
where: { id: session.user.id }, db.user.findUnique({
select: { onboardingCompletedAt: true, name: true, email: true }, where: { id: session.user.id },
}); select: { onboardingCompletedAt: true, name: true, email: true },
}),
getBillingOverview(session.user.id),
db.workspace.findMany({
where: {
owner: buildBillingAccessWhereInput(),
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
],
},
select: {
id: true,
name: true,
ownerId: true,
},
orderBy: { name: 'asc' },
}),
]);
if (user?.onboardingCompletedAt) { if (user?.onboardingCompletedAt) {
redirect('/dashboard'); redirect('/dashboard');
@@ -20,5 +39,15 @@ export default async function OnboardingPage() {
const userName = user?.name || user?.email?.split('@')[0] || 'there'; const userName = user?.name || user?.email?.split('@')[0] || 'there';
return <OnboardingWizard userName={userName} />; return (
<OnboardingWizard
userName={userName}
canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace}
availableWorkspaces={creatableWorkspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.ownerId === session.user.id,
}))}
/>
);
} }
+3
View File
@@ -27,6 +27,7 @@
"react-window": "^2.2.7", "react-window": "^2.2.7",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"stripe": "^20.4.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tus-js-client": "^4.3.1", "tus-js-client": "^4.3.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -1732,6 +1733,8 @@
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"stripe": ["[email protected]", "", { "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w=="],
"strnum": ["[email protected]", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], "strnum": ["[email protected]", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
+21 -12
View File
@@ -59,9 +59,10 @@ interface HeaderProps {
image?: string | null; image?: string | null;
isAdmin?: boolean; isAdmin?: boolean;
} | null; } | null;
showAppNavigation?: boolean;
} }
export function Header({ user }: HeaderProps) { export function Header({ user, showAppNavigation = false }: HeaderProps) {
const pathname = usePathname(); const pathname = usePathname();
const [shortcutsOpen, setShortcutsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
@@ -70,13 +71,17 @@ export function Header({ user }: HeaderProps) {
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
if (!user || !showAppNavigation) {
return;
}
e.preventDefault(); e.preventDefault();
if (user) setSearchOpen((v) => !v); setSearchOpen((v) => !v);
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [user]); }, [showAppNavigation, user]);
// Hide header on video player pages — they use full viewport with their own back button // Hide header on video player pages — they use full viewport with their own back button
const isVideoPage = /\/videos\/[^/]+($|\/compare)/.test(pathname) || pathname.startsWith('/watch/'); const isVideoPage = /\/videos\/[^/]+($|\/compare)/.test(pathname) || pathname.startsWith('/watch/');
@@ -97,7 +102,7 @@ export function Header({ user }: HeaderProps) {
<SheetTitle className="sr-only">Navigation Menu</SheetTitle> <SheetTitle className="sr-only">Navigation Menu</SheetTitle>
<SheetDescription className="sr-only">Access your projects and workspaces</SheetDescription> <SheetDescription className="sr-only">Access your projects and workspaces</SheetDescription>
<nav className="flex flex-col gap-2 mt-10"> <nav className="flex flex-col gap-2 mt-10">
{navItems.map((item) => ( {showAppNavigation && navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
@@ -138,7 +143,7 @@ export function Header({ user }: HeaderProps) {
{/* Desktop nav */} {/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1"> <nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => ( {showAppNavigation && navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
@@ -171,7 +176,7 @@ export function Header({ user }: HeaderProps) {
{/* Right side */} {/* Right side */}
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
{user && ( {user && showAppNavigation && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -193,7 +198,7 @@ export function Header({ user }: HeaderProps) {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
{user && ( {user && showAppNavigation && (
<Button asChild variant="outline" size="sm" className="hidden sm:inline-flex"> <Button asChild variant="outline" size="sm" className="hidden sm:inline-flex">
<Link href="/feedback"> <Link href="/feedback">
<MessageSquareQuote className="h-4 w-4 mr-1.5" /> <MessageSquareQuote className="h-4 w-4 mr-1.5" />
@@ -201,7 +206,7 @@ export function Header({ user }: HeaderProps) {
</Link> </Link>
</Button> </Button>
)} )}
{user && ( {user && showAppNavigation && (
<Button asChild variant="ghost" size="icon" className="sm:hidden" aria-label="Feedback and reviews"> <Button asChild variant="ghost" size="icon" className="sm:hidden" aria-label="Feedback and reviews">
<Link href="/feedback"> <Link href="/feedback">
<MessageSquareQuote className="h-4 w-4" /> <MessageSquareQuote className="h-4 w-4" />
@@ -223,12 +228,16 @@ export function Header({ user }: HeaderProps) {
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent
<div className="flex items-center justify-start gap-2 p-2"> align="end"
sideOffset={10}
className="w-64 min-w-64 rounded-md border bg-popover/98 p-1 shadow-2xl backdrop-blur-md"
>
<div className="rounded-sm px-3 py-2.5">
<div className="flex flex-col space-y-1 leading-none"> <div className="flex flex-col space-y-1 leading-none">
{user.name && <p className="font-medium">{user.name}</p>} {user.name && <p className="font-medium">{user.name}</p>}
{user.email && ( {user.email && (
<p className="w-[200px] truncate text-sm text-muted-foreground"> <p className="truncate text-sm text-muted-foreground">
{user.email} {user.email}
</p> </p>
)} )}
@@ -273,7 +282,7 @@ export function Header({ user }: HeaderProps) {
</div> </div>
</div> </div>
<KeyboardShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} /> <KeyboardShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
{user && <SearchModal open={searchOpen} onOpenChange={setSearchOpen} />} {user && showAppNavigation && <SearchModal open={searchOpen} onOpenChange={setSearchOpen} />}
</header> </header>
); );
} }
+51 -7
View File
@@ -3,6 +3,7 @@ import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { hasBillingAccess } from '@/lib/billing';
// Dummy hash for timing-safe comparison when user doesn't exist // Dummy hash for timing-safe comparison when user doesn't exist
// This prevents user enumeration via timing attacks // This prevents user enumeration via timing attacks
@@ -114,6 +115,7 @@ export async function checkProjectAccess(
// Check workspace membership/role // Check workspace membership/role
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null; let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
let workspaceOwnerBillingAccess = false;
if (shouldLoadWorkspaceRole && userId) { if (shouldLoadWorkspaceRole && userId) {
const [wsMember, wsOwner] = await Promise.all([ const [wsMember, wsOwner] = await Promise.all([
db.workspaceMember.findUnique({ db.workspaceMember.findUnique({
@@ -121,7 +123,17 @@ export async function checkProjectAccess(
}), }),
db.workspace.findUnique({ db.workspace.findUnique({
where: { id: project.workspaceId }, where: { id: project.workspaceId },
select: { ownerId: true }, select: {
ownerId: true,
owner: {
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
},
},
}), }),
]); ]);
@@ -130,13 +142,32 @@ export async function checkProjectAccess(
} else if (wsMember) { } else if (wsMember) {
workspaceRole = wsMember.role; workspaceRole = wsMember.role;
} }
if (wsOwner?.owner) {
workspaceOwnerBillingAccess = hasBillingAccess(wsOwner.owner);
}
} else {
const wsOwner = await db.workspace.findUnique({
where: { id: project.workspaceId },
select: {
owner: {
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
},
},
});
workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false;
} }
const isWorkspaceMember = !!workspaceRole; const isWorkspaceMember = !!workspaceRole;
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER'; const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
const hasAccess = isOwner || isProjectMember || isPublic || isWorkspaceMember; const hasAccess = workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
const canEdit = isOwner || isProjectAdmin || isWorkspaceAdmin; const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
const canDelete = isOwner || workspaceRole === 'OWNER'; const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
return { return {
isOwner, isOwner,
@@ -147,6 +178,7 @@ export async function checkProjectAccess(
hasAccess, hasAccess,
canEdit, canEdit,
canDelete, canDelete,
ownerBillingActive: workspaceOwnerBillingAccess,
}; };
} }
@@ -166,9 +198,20 @@ export async function checkWorkspaceAccess(
const isMember = !!workspaceMember; const isMember = !!workspaceMember;
const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN; const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN;
const hasAccess = isOwner || isMember; const owner = await db.user.findUnique({
const canEdit = isOwner || isAdmin; where: { id: workspace.ownerId },
const canDelete = isOwner; select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
const ownerBillingActive = owner ? hasBillingAccess(owner) : false;
const hasAccess = ownerBillingActive && (isOwner || isMember);
const canEdit = ownerBillingActive && (isOwner || isAdmin);
const canDelete = ownerBillingActive && isOwner;
return { return {
isOwner, isOwner,
@@ -177,5 +220,6 @@ export async function checkWorkspaceAccess(
hasAccess, hasAccess,
canEdit, canEdit,
canDelete, canDelete,
ownerBillingActive,
}; };
} }
+418
View File
@@ -0,0 +1,418 @@
import type { Prisma } from '@prisma/client';
import type Stripe from 'stripe';
import { BillingSubscriptionStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.ACTIVE,
BillingSubscriptionStatus.TRIALING,
]);
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
const STORAGE_CLEANUP_GRACE_DAYS = 15;
type BillingAccessSubject = {
subscriptionStatus: BillingSubscriptionStatus;
trialEndsAt: Date | null;
stripeCurrentPeriodEnd: Date | null;
stripeCancelAtPeriodEnd?: boolean | null;
stripeCancelAt?: Date | null;
billingAccessEndedAt: Date | null;
};
export function getDefaultTrialEndsAt(from: Date = new Date()) {
return new Date(from.getTime() + DEFAULT_TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000);
}
export function hasActiveTrial(trialEndsAt: Date | null | undefined, now: Date = new Date()) {
return Boolean(trialEndsAt && trialEndsAt.getTime() > now.getTime());
}
export function hasActiveSubscription(status: BillingSubscriptionStatus | null | undefined) {
if (!status) return false;
return ACTIVE_SUBSCRIPTION_STATUSES.has(status);
}
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
if (hasActiveSubscription(subject.subscriptionStatus)) {
return true;
}
if (hasActiveTrial(subject.trialEndsAt, now)) {
return true;
}
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
}
export function getBillingAccessEndDate(subject: BillingAccessSubject) {
if (subject.billingAccessEndedAt) {
return subject.billingAccessEndedAt;
}
if (subject.stripeCurrentPeriodEnd) {
return subject.stripeCurrentPeriodEnd;
}
return subject.trialEndsAt;
}
export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
const accessEndDate = getBillingAccessEndDate(subject);
if (!accessEndDate) return null;
return new Date(accessEndDate.getTime() + STORAGE_CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000);
}
export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
return {
OR: [
{ subscriptionStatus: { in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING] } },
{ trialEndsAt: { gt: now } },
{ stripeCurrentPeriodEnd: { gt: now } },
],
};
}
export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
const cleanupCutoff = new Date(now.getTime() - STORAGE_CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000);
return {
AND: [
{
NOT: buildBillingAccessWhereInput(now),
},
{
OR: [
{ billingAccessEndedAt: { lte: cleanupCutoff } },
{
AND: [
{ billingAccessEndedAt: null },
{ trialEndsAt: { lte: cleanupCutoff } },
],
},
],
},
],
};
}
export function mapStripeSubscriptionStatus(
status: Stripe.Subscription.Status | null | undefined
): BillingSubscriptionStatus {
switch (status) {
case 'trialing':
return BillingSubscriptionStatus.TRIALING;
case 'active':
return BillingSubscriptionStatus.ACTIVE;
case 'past_due':
return BillingSubscriptionStatus.PAST_DUE;
case 'canceled':
return BillingSubscriptionStatus.CANCELED;
case 'unpaid':
return BillingSubscriptionStatus.UNPAID;
case 'incomplete':
return BillingSubscriptionStatus.INCOMPLETE;
case 'incomplete_expired':
return BillingSubscriptionStatus.INCOMPLETE_EXPIRED;
default:
return BillingSubscriptionStatus.FREE;
}
}
export function getBillingStatusLabel(status: BillingSubscriptionStatus) {
switch (status) {
case BillingSubscriptionStatus.TRIALING:
return 'Trialing';
case BillingSubscriptionStatus.ACTIVE:
return 'Active';
case BillingSubscriptionStatus.PAST_DUE:
return 'Past due';
case BillingSubscriptionStatus.CANCELED:
return 'Canceled';
case BillingSubscriptionStatus.UNPAID:
return 'Unpaid';
case BillingSubscriptionStatus.INCOMPLETE:
return 'Incomplete';
case BillingSubscriptionStatus.INCOMPLETE_EXPIRED:
return 'Expired';
case BillingSubscriptionStatus.FREE:
default:
return 'Free';
}
}
export async function getStripeCheckoutState(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
billingTrialConsumedAt: true,
},
});
if (!user) {
throw new Error(`User ${userId} not found`);
}
return {
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
isTrialEligible: !user.billingTrialConsumedAt,
};
}
export async function getWorkspaceCreationEligibility(userId: string) {
const [user, ownedWorkspaceCount, invitedWorkspaceCount, projectOnlyCollaborationCount] = await Promise.all([
db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCustomerId: true,
stripeSubscriptionId: true,
stripePriceId: true,
stripeCurrentPeriodEnd: true,
stripeCancelAtPeriodEnd: true,
stripeCancelAt: true,
billingAccessEndedAt: true,
},
}),
db.workspace.count({
where: { ownerId: userId },
}),
db.workspaceMember.count({
where: {
userId,
workspace: {
ownerId: {
not: userId,
},
},
},
}),
db.projectMember.count({
where: {
userId,
project: {
ownerId: {
not: userId,
},
workspace: {
ownerId: {
not: userId,
},
},
},
},
}),
]);
if (!user) {
throw new Error(`User ${userId} not found`);
}
const billingAccess = hasBillingAccess(user);
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
const canCreateWorkspace =
billingAccess || (ownedWorkspaceCount === 0 && collaborationCount === 0);
let reason: string | null = null;
if (!canCreateWorkspace) {
if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
reason =
'You are currently collaborating in someone elses workspace or project. Start a subscription to create a workspace of your own.';
} else {
reason =
'Your trial has ended. Start a subscription to create and keep owning workspaces.';
}
}
return {
canCreateWorkspace,
reason,
ownedWorkspaceCount,
invitedWorkspaceCount,
projectOnlyCollaborationCount,
subscription: {
status: user.subscriptionStatus,
label: getBillingStatusLabel(user.subscriptionStatus),
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
hasActiveTrial: hasActiveTrial(user.trialEndsAt),
hasBillingAccess: billingAccess,
stripeCustomerId: user.stripeCustomerId,
stripeSubscriptionId: user.stripeSubscriptionId,
stripePriceId: user.stripePriceId,
currentPeriodEnd: user.stripeCurrentPeriodEnd,
cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
cancelAt: user.stripeCancelAt,
trialEndsAt: user.trialEndsAt,
billingAccessEndedAt: user.billingAccessEndedAt,
storageCleanupEligibleAt: getStorageCleanupEligibleAt(user),
},
};
}
export async function getBillingOverview(userId: string) {
const billing = await getWorkspaceCreationEligibility(userId);
return {
workspaceCreation: {
canCreateWorkspace: billing.canCreateWorkspace,
reason: billing.reason,
ownedWorkspaceCount: billing.ownedWorkspaceCount,
invitedWorkspaceCount: billing.invitedWorkspaceCount,
},
subscription: billing.subscription,
};
}
export async function getOrCreateStripeCustomerId(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
stripeCustomerId: true,
},
});
if (!user) {
throw new Error(`User ${userId} not found`);
}
if (user.stripeCustomerId) {
return user.stripeCustomerId;
}
const stripe = getStripe();
const customer = await stripe.customers.create({
email: user.email ?? undefined,
name: user.name ?? undefined,
metadata: { userId: user.id },
});
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customer.id },
});
return customer.id;
}
function getStripeTimestamp(value: unknown): number | null {
return typeof value === 'number' ? value : null;
}
function getInactiveBillingAccessEndedAt(subscription: Stripe.Subscription, currentPeriodEnd: number | null) {
const endedAt = getStripeTimestamp((subscription as Stripe.Subscription & { ended_at?: unknown }).ended_at);
const canceledAt = getStripeTimestamp((subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at);
const reference = currentPeriodEnd ?? endedAt ?? canceledAt;
return reference ? new Date(reference * 1000) : new Date();
}
function getEntitledStripePriceId(subscription: Stripe.Subscription) {
const configuredPriceId = getStripePriceId();
return subscription.items.data.find((item) => item.price.id === configuredPriceId)?.price.id ?? null;
}
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
billingTrialConsumedAt: true,
},
});
if (!user) {
return null;
}
const currentPeriodEnd =
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
? subscription.current_period_end
: null;
const cancelAt =
'cancel_at' in subscription && typeof subscription.cancel_at === 'number'
? subscription.cancel_at
: null;
const cancelAtPeriodEnd =
'cancel_at_period_end' in subscription && typeof subscription.cancel_at_period_end === 'boolean'
? subscription.cancel_at_period_end
: false;
const trialEnd =
'trial_end' in subscription && typeof subscription.trial_end === 'number'
? subscription.trial_end
: null;
const entitledPriceId = getEntitledStripePriceId(subscription);
const hasEntitledPrice = Boolean(entitledPriceId);
const mappedStatus = hasEntitledPrice
? mapStripeSubscriptionStatus(subscription.status)
: BillingSubscriptionStatus.FREE;
const effectiveCurrentPeriodEnd = hasEntitledPrice && currentPeriodEnd
? new Date(currentPeriodEnd * 1000)
: null;
const effectiveTrialEnd = hasEntitledPrice && trialEnd
? new Date(trialEnd * 1000)
: null;
const hasAccess = hasEntitledPrice
&& (hasActiveSubscription(mappedStatus) || Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
return db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: entitledPriceId ?? subscription.items.data[0]?.price.id ?? null,
stripeCurrentPeriodEnd: effectiveCurrentPeriodEnd,
stripeCancelAtPeriodEnd: cancelAtPeriodEnd,
stripeCancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
subscriptionStatus: mappedStatus,
trialEndsAt: effectiveTrialEnd,
billingTrialConsumedAt: hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
billingAccessEndedAt: hasAccess
? null
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
},
});
}
export async function markSubscriptionCanceledByCustomerId(
customerId: string,
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
) {
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId },
select: { id: true },
});
if (!user) {
return null;
}
return db.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
trialEndsAt: null,
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
stripeCancelAtPeriodEnd: false,
stripeCancelAt: null,
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
},
});
}
+98
View File
@@ -1,11 +1,13 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { auth, checkProjectAccess, checkWorkspaceAccess } from '@/lib/auth'; import { auth, checkProjectAccess, checkWorkspaceAccess } from '@/lib/auth';
import { hasBillingAccess } from '@/lib/billing';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
type AccessIntent = 'view' | 'manage'; type AccessIntent = 'view' | 'manage';
const LOGIN_REDIRECT = '/login'; const LOGIN_REDIRECT = '/login';
const FORBIDDEN_REDIRECT = '/dashboard'; const FORBIDDEN_REDIRECT = '/dashboard';
const BILLING_REDIRECT = '/settings';
function redirectForMissingAuth() { function redirectForMissingAuth() {
redirect(LOGIN_REDIRECT); redirect(LOGIN_REDIRECT);
@@ -15,6 +17,10 @@ function redirectForForbidden() {
redirect(FORBIDDEN_REDIRECT); redirect(FORBIDDEN_REDIRECT);
} }
function redirectForBilling() {
redirect(BILLING_REDIRECT);
}
function ensureGuestPolicy(options: { userId?: string; intent: AccessIntent; allowPublicView: boolean }) { function ensureGuestPolicy(options: { userId?: string; intent: AccessIntent; allowPublicView: boolean }) {
const { userId, intent, allowPublicView } = options; const { userId, intent, allowPublicView } = options;
if (userId) return; if (userId) return;
@@ -60,6 +66,92 @@ export async function requireAuthOrRedirect() {
return session; return session;
} }
export async function requireBillingAccessOrRedirect(options?: {
userId?: string;
}) {
const resolvedUserId = options?.userId ?? (await auth())?.user?.id;
if (!resolvedUserId) {
redirectForMissingAuth();
}
const user = await db.user.findUnique({
where: { id: resolvedUserId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
if (!user || !hasBillingAccess(user)) {
redirectForBilling();
}
return user;
}
export async function hasCollaboratorBillingBackedAccess(userId: string) {
const now = new Date();
const [workspaceCount, projectCount] = await Promise.all([
db.workspace.count({
where: {
owner: {
OR: [
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
{ trialEndsAt: { gt: now } },
{ stripeCurrentPeriodEnd: { gt: now } },
],
},
OR: [
{ ownerId: userId },
{ members: { some: { userId } } },
],
},
}),
db.project.count({
where: {
workspace: {
owner: {
OR: [
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
{ trialEndsAt: { gt: now } },
{ stripeCurrentPeriodEnd: { gt: now } },
],
},
},
OR: [
{ ownerId: userId },
{ members: { some: { userId } } },
{ workspace: { members: { some: { userId } } } },
],
},
}),
]);
return workspaceCount > 0 || projectCount > 0;
}
export async function hasAppNavigationAccess(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
if (user && hasBillingAccess(user)) {
return true;
}
return hasCollaboratorBillingBackedAccess(userId);
}
export async function requireWorkspaceAccessOrRedirect(options: { export async function requireWorkspaceAccessOrRedirect(options: {
workspaceId: string; workspaceId: string;
userId?: string; userId?: string;
@@ -84,10 +176,16 @@ export async function requireWorkspaceAccessOrRedirect(options: {
const access = await checkWorkspaceAccess(workspace, resolvedUserId); const access = await checkWorkspaceAccess(workspace, resolvedUserId);
if (!access.hasAccess) { if (!access.hasAccess) {
if (!access.ownerBillingActive) {
redirectForBilling();
}
redirectForForbidden(); redirectForForbidden();
} }
if (intent === 'manage' && !access.canEdit) { if (intent === 'manage' && !access.canEdit) {
if (!access.ownerBillingActive) {
redirectForBilling();
}
redirectForForbidden(); redirectForForbidden();
} }
+23
View File
@@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import type { ShareLink, SharePermission } from '@prisma/client'; import type { ShareLink, SharePermission } from '@prisma/client';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { hasBillingAccess } from '@/lib/billing';
export const MAX_SHARE_PASSWORD_LENGTH = 128; export const MAX_SHARE_PASSWORD_LENGTH = 128;
@@ -45,6 +46,24 @@ export async function validateShareLinkAccess({
}: ValidateShareLinkParams): Promise<ShareLinkAccessResult> { }: ValidateShareLinkParams): Promise<ShareLinkAccessResult> {
const link = await db.shareLink.findUnique({ const link = await db.shareLink.findUnique({
where: { token }, where: { token },
include: {
project: {
select: {
workspace: {
select: {
owner: {
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
},
},
},
},
},
},
}); });
if (!link) { if (!link) {
@@ -60,6 +79,10 @@ export async function validateShareLinkAccess({
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link }; return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link };
} }
if (!link.project?.workspace.owner || !hasBillingAccess(link.project.workspace.owner)) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link };
}
if (link.passwordHash && !passwordVerified) { if (link.passwordHash && !passwordVerified) {
if (!presentedPassword) { if (!presentedPassword) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link }; return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
+38
View File
@@ -0,0 +1,38 @@
import Stripe from 'stripe';
let stripeClient: Stripe | null = null;
export function isStripeConfigured() {
return Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID);
}
export function getStripe() {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error('STRIPE_SECRET_KEY is not configured');
}
if (!stripeClient) {
stripeClient = new Stripe(secretKey);
}
return stripeClient;
}
export function getStripePriceId() {
const priceId = process.env.STRIPE_PRICE_ID;
if (!priceId) {
throw new Error('STRIPE_PRICE_ID is not configured');
}
return priceId;
}
export function getStripeWebhookSecret() {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('STRIPE_WEBHOOK_SECRET is not configured');
}
return webhookSecret;
}
+1
View File
@@ -44,6 +44,7 @@
"react-window": "^2.2.7", "react-window": "^2.2.7",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"stripe": "^20.4.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tus-js-client": "^4.3.1", "tus-js-client": "^4.3.1",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
@@ -0,0 +1,20 @@
CREATE TYPE "BillingSubscriptionStatus" AS ENUM (
'FREE',
'TRIALING',
'ACTIVE',
'PAST_DUE',
'CANCELED',
'UNPAID',
'INCOMPLETE',
'INCOMPLETE_EXPIRED'
);
ALTER TABLE "users"
ADD COLUMN "stripeCustomerId" TEXT,
ADD COLUMN "stripeSubscriptionId" TEXT,
ADD COLUMN "stripePriceId" TEXT,
ADD COLUMN "stripeCurrentPeriodEnd" TIMESTAMP(3),
ADD COLUMN "subscriptionStatus" "BillingSubscriptionStatus" NOT NULL DEFAULT 'FREE';
CREATE UNIQUE INDEX "users_stripeCustomerId_key" ON "users"("stripeCustomerId");
CREATE UNIQUE INDEX "users_stripeSubscriptionId_key" ON "users"("stripeSubscriptionId");
@@ -0,0 +1,7 @@
ALTER TABLE "users"
ADD COLUMN "trialEndsAt" TIMESTAMP(3),
ADD COLUMN "billingAccessEndedAt" TIMESTAMP(3);
UPDATE "users"
SET "trialEndsAt" = "createdAt" + INTERVAL '7 days'
WHERE "trialEndsAt" IS NULL;
@@ -0,0 +1,3 @@
ALTER TABLE "users"
ADD COLUMN "stripeCancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "stripeCancelAt" TIMESTAMP(3);
@@ -0,0 +1,6 @@
ALTER TABLE "users"
ADD COLUMN "billingTrialConsumedAt" TIMESTAMP(3);
UPDATE "users"
SET "billingTrialConsumedAt" = "trialEndsAt"
WHERE "trialEndsAt" IS NOT NULL;
+21
View File
@@ -22,6 +22,16 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
onboardingCompletedAt DateTime? onboardingCompletedAt DateTime?
trialEndsAt DateTime?
billingTrialConsumedAt DateTime?
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
stripeCancelAtPeriodEnd Boolean @default(false)
stripeCancelAt DateTime?
billingAccessEndedAt DateTime?
subscriptionStatus BillingSubscriptionStatus @default(FREE)
// Relations // Relations
accounts Account[] accounts Account[]
@@ -44,6 +54,17 @@ model User {
@@map("users") @@map("users")
} }
enum BillingSubscriptionStatus {
FREE
TRIALING
ACTIVE
PAST_DUE
CANCELED
UNPAID
INCOMPLETE
INCOMPLETE_EXPIRED
}
enum FeedbackEntryType { enum FeedbackEntryType {
FEEDBACK FEEDBACK
REVIEW REVIEW
+5
View File
@@ -1,4 +1,5 @@
import { db, disconnectDb } from '../lib/db'; import { db, disconnectDb } from '../lib/db';
import { cleanupExpiredBillingWorkspaces } from './expired-billing-cleanup';
const BUNNY_API_BASE = 'https://video.bunnycdn.com'; const BUNNY_API_BASE = 'https://video.bunnycdn.com';
const BUNNY_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; const BUNNY_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
@@ -197,6 +198,10 @@ async function main() {
console.log(`[bunny-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`); console.log(`[bunny-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`);
console.log(`[bunny-orphan-cleanup] Grace period: ${graceHours}h`); console.log(`[bunny-orphan-cleanup] Grace period: ${graceHours}h`);
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
console.log(`[bunny-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`);
console.log(`[bunny-orphan-cleanup] Expired owner workspaces deleted: ${expiredBillingCleanup.deleted}`);
const { videos, scanned, skippedInvalid } = await listBunnyVideos(config); const { videos, scanned, skippedInvalid } = await listBunnyVideos(config);
const eligible = videos.filter((video) => video.uploadedAt.getTime() <= cutoff); const eligible = videos.filter((video) => video.uploadedAt.getTime() <= cutoff);
console.log(`[bunny-orphan-cleanup] Scanned: ${scanned}`); console.log(`[bunny-orphan-cleanup] Scanned: ${scanned}`);
+111
View File
@@ -0,0 +1,111 @@
import { db } from '../lib/db';
import { buildExpiredBillingWhereInput } from '../lib/billing';
import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '../lib/r2-cleanup';
import { cleanupBunnyStreamVideosBestEffort } from '../lib/bunny-stream-cleanup';
type ExpiredWorkspaceTarget = {
id: string;
ownerId: string;
ownerEmail: string | null;
};
async function getExpiredWorkspaceTargets(): Promise<ExpiredWorkspaceTarget[]> {
const expiredOwners = await db.user.findMany({
where: buildExpiredBillingWhereInput(),
select: { id: true },
});
if (expiredOwners.length === 0) {
return [];
}
return db.workspace.findMany({
where: {
ownerId: { in: expiredOwners.map((owner) => owner.id) },
},
select: {
id: true,
ownerId: true,
owner: {
select: {
email: true,
},
},
},
}).then((workspaces) =>
workspaces.map((workspace) => ({
id: workspace.id,
ownerId: workspace.ownerId,
ownerEmail: workspace.owner.email,
}))
);
}
export async function cleanupExpiredBillingWorkspaces(options?: { dryRun?: boolean }) {
const dryRun = options?.dryRun ?? false;
const workspaces = await getExpiredWorkspaceTargets();
if (workspaces.length === 0) {
return { scanned: 0, deleted: 0 };
}
let deleted = 0;
for (const workspace of workspaces) {
const [workspaceVersionRefs, workspaceAssetRefs, mediaUrls] = await Promise.all([
db.videoVersion.findMany({
where: {
video: {
project: {
workspaceId: workspace.id,
},
},
},
select: {
providerId: true,
videoId: true,
},
}),
db.videoAsset.findMany({
where: {
provider: 'BUNNY',
providerVideoId: { not: null },
video: {
project: {
workspaceId: workspace.id,
},
},
},
select: {
providerVideoId: true,
},
}),
collectWorkspaceMediaUrls(workspace.id),
]);
if (dryRun) {
const ownerLabel = workspace.ownerEmail ?? workspace.ownerId;
console.log(
`[expired-billing-cleanup] Would delete workspace ${workspace.id} owned by ${ownerLabel}`
);
continue;
}
const bunnyRefs = [
...workspaceVersionRefs,
...workspaceAssetRefs.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
];
await db.workspace.delete({ where: { id: workspace.id } });
await Promise.all([
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
deleteMediaFilesBestEffort(mediaUrls),
]);
deleted += 1;
}
return { scanned: workspaces.length, deleted };
}
+5
View File
@@ -1,6 +1,7 @@
import { DeleteObjectCommand, ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3'; import { DeleteObjectCommand, ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
import { db, disconnectDb } from '../lib/db'; import { db, disconnectDb } from '../lib/db';
import { r2Client, R2_BUCKET_NAME } from '../lib/r2'; import { r2Client, R2_BUCKET_NAME } from '../lib/r2';
import { cleanupExpiredBillingWorkspaces } from './expired-billing-cleanup';
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000; const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
const CHUNK_SIZE = 500; const CHUNK_SIZE = 500;
@@ -127,6 +128,10 @@ async function main() {
const dryRun = process.argv.includes('--dry-run'); const dryRun = process.argv.includes('--dry-run');
console.log(`[r2-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`); console.log(`[r2-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`);
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
console.log(`[r2-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`);
console.log(`[r2-orphan-cleanup] Expired owner workspaces deleted: ${expiredBillingCleanup.deleted}`);
const { candidates, scanned } = await listCleanupCandidates(); const { candidates, scanned } = await listCleanupCandidates();
console.log(`[r2-orphan-cleanup] Scanned: ${scanned}, eligible (old enough): ${candidates.length}`); console.log(`[r2-orphan-cleanup] Scanned: ${scanned}, eligible (old enough): ${candidates.length}`);