mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Refactor registration and dashboard features to support invite codes and Bunny uploads
- Moved registration logic to a new client component for better separation of concerns. - Integrated invite code requirement based on feature flags in the registration process. - Enhanced dashboard functionality to conditionally enable Bunny uploads based on feature flags. - Updated various components and API routes to check for Bunny uploads and Stripe billing feature flags. - Added new feature flag utilities for managing feature toggles in the application.
This commit is contained in:
@@ -14,6 +14,13 @@ DB_POOL_DEBUG="false"
|
|||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
|
NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# OPTIONAL SELF-HOSTING FEATURE FLAGS
|
||||||
|
# ============================================================================
|
||||||
|
OPENFRAME_ENABLE_STRIPE="true"
|
||||||
|
OPENFRAME_ENABLE_BUNNY_UPLOADS="true"
|
||||||
|
OPENFRAME_REQUIRE_INVITE_CODE="true"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# OAUTH PROVIDERS
|
# OAUTH PROVIDERS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -1,36 +1,38 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# OpenFrame
|
||||||
|
|
||||||
## Getting Started
|
OpenFrame is a collaborative video feedback platform built with Next.js, Bun, Prisma, and PostgreSQL.
|
||||||
|
|
||||||
First, run the development server:
|
## Development
|
||||||
|
|
||||||
|
Install dependencies and run checks with Bun:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
bun install
|
||||||
# or
|
bun run check
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## Self-hosting flags
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
OpenFrame supports env flags so self-hosted installs can disable hosted-only features without code changes:
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
```bash
|
||||||
|
OPENFRAME_ENABLE_STRIPE=true
|
||||||
|
OPENFRAME_ENABLE_BUNNY_UPLOADS=true
|
||||||
|
OPENFRAME_REQUIRE_INVITE_CODE=true
|
||||||
|
```
|
||||||
|
|
||||||
## Learn More
|
Recommended self-hosted values for a single-team deployment:
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
```bash
|
||||||
|
OPENFRAME_ENABLE_STRIPE=false
|
||||||
|
OPENFRAME_ENABLE_BUNNY_UPLOADS=false
|
||||||
|
OPENFRAME_REQUIRE_INVITE_CODE=false
|
||||||
|
```
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
Behavior when disabled:
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
- `OPENFRAME_ENABLE_STRIPE=false`: disables Stripe checkout and portal flows and removes billing-based workspace restrictions.
|
||||||
|
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false`: hides direct-upload entry points. URL-based providers such as YouTube continue to work.
|
||||||
|
- `OPENFRAME_REQUIRE_INVITE_CODE=false`: allows open registration while keeping invitation-link registration intact.
|
||||||
|
|
||||||
## Deploy on Vercel
|
Feature flags are documented in `.env.example`. Hosted defaults remain enabled.
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|||||||
@@ -1,229 +1,6 @@
|
|||||||
'use client';
|
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
||||||
|
import RegisterPageClient from './register-page-client';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { Video, Loader2, KeyRound, UserPlus } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
return <RegisterPageClient requireInviteCode={isInviteCodeRequired()} />;
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]);
|
|
||||||
const invitedEmail = useMemo(() => searchParams.get('email') || '', [searchParams]);
|
|
||||||
const isInvitationFlow = invitationToken.length > 0;
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
inviteCode: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!invitedEmail) return;
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
email: invitedEmail,
|
|
||||||
}));
|
|
||||||
}, [invitedEmail]);
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[e.target.name]: e.target.value,
|
|
||||||
}));
|
|
||||||
setError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegister = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
// Client-side validation
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
|
||||||
setError('Passwords do not match');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password.length < 8) {
|
|
||||||
setError('Password must be at least 8 characters');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: formData.name,
|
|
||||||
email: formData.email,
|
|
||||||
password: formData.password,
|
|
||||||
inviteCode: formData.inviteCode || undefined,
|
|
||||||
invitationToken: invitationToken || undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setError(data.error || 'Registration failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to login on success
|
|
||||||
router.push('/login?registered=true');
|
|
||||||
} catch {
|
|
||||||
setError('Something went wrong. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
{/* Logo */}
|
|
||||||
<Link href="/" className="flex items-center justify-center gap-2 mb-8">
|
|
||||||
<Video className="h-8 w-8 text-primary" />
|
|
||||||
<span className="font-bold text-2xl">OpenFrame</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<CardTitle className="flex items-center justify-center gap-2">
|
|
||||||
<UserPlus className="h-5 w-5" />
|
|
||||||
Create Account
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Join OpenFrame to collaborate on video projects
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleRegister} className="space-y-4">
|
|
||||||
{/* Invite Code - First and prominent */}
|
|
||||||
{isInvitationFlow ? (
|
|
||||||
<div className="p-3 rounded-md bg-primary/10 text-sm">
|
|
||||||
You are registering via an invitation link.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="inviteCode" className="flex items-center gap-2">
|
|
||||||
<KeyRound className="h-4 w-4 text-amber-500" />
|
|
||||||
Invite Code
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="inviteCode"
|
|
||||||
name="inviteCode"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter your invite code"
|
|
||||||
value={formData.inviteCode}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
className="border-amber-500/30 focus:border-amber-500"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
An invite code is required to create an account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border my-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Full Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
placeholder="John Doe"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
minLength={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
minLength={8}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
|
||||||
<Input
|
|
||||||
id="confirmPassword"
|
|
||||||
name="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={formData.confirmPassword}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
||||||
Create Account
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link href="/login" className="text-primary hover:underline">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
|
||||||
By continuing, you agree to our Terms of Service and Privacy Policy
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Video, Loader2, KeyRound, UserPlus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export default function RegisterPageClient({ requireInviteCode }: { requireInviteCode: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]);
|
||||||
|
const invitedEmail = useMemo(() => searchParams.get('email') || '', [searchParams]);
|
||||||
|
const isInvitationFlow = invitationToken.length > 0;
|
||||||
|
const shouldShowInviteCode = requireInviteCode && !isInvitationFlow;
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
inviteCode: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invitedEmail) return;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: invitedEmail,
|
||||||
|
}));
|
||||||
|
}, [invitedEmail]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
}));
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.name,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
inviteCode: shouldShowInviteCode ? formData.inviteCode || undefined : undefined,
|
||||||
|
invitationToken: invitationToken || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.error || 'Registration failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/login?registered=true');
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<Link href="/" className="flex items-center justify-center gap-2 mb-8">
|
||||||
|
<Video className="h-8 w-8 text-primary" />
|
||||||
|
<span className="font-bold text-2xl">OpenFrame</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="flex items-center justify-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
Create Account
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Join OpenFrame to collaborate on video projects
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
|
{isInvitationFlow ? (
|
||||||
|
<div className="p-3 rounded-md bg-primary/10 text-sm">
|
||||||
|
You are registering via an invitation link.
|
||||||
|
</div>
|
||||||
|
) : shouldShowInviteCode ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteCode" className="flex items-center gap-2">
|
||||||
|
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||||
|
Invite Code
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteCode"
|
||||||
|
name="inviteCode"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your invite code"
|
||||||
|
value={formData.inviteCode}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
className="border-amber-500/30 focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
An invite code is required to create an account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border my-4" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Full Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link href="/login" className="text-primary hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||||
|
By continuing, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ interface DashboardClientProps {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
canCreateProjects: boolean;
|
canCreateProjects: boolean;
|
||||||
canUploadVideos: boolean;
|
canUploadVideos: boolean;
|
||||||
|
bunnyUploadsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardClient({
|
export function DashboardClient({
|
||||||
@@ -29,10 +30,11 @@ export function DashboardClient({
|
|||||||
totalPages,
|
totalPages,
|
||||||
canCreateProjects,
|
canCreateProjects,
|
||||||
canUploadVideos,
|
canUploadVideos,
|
||||||
|
bunnyUploadsEnabled,
|
||||||
}: DashboardClientProps) {
|
}: DashboardClientProps) {
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-8 py-8 w-full">
|
<div className="px-6 lg:px-8 py-8 w-full">
|
||||||
<VideoDragDropUploader canUpload={canUploadVideos} />
|
<VideoDragDropUploader canUpload={canUploadVideos && bunnyUploadsEnabled} />
|
||||||
<ProjectFilter
|
<ProjectFilter
|
||||||
projects={serializedProjects}
|
projects={serializedProjects}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access';
|
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access';
|
||||||
import { DashboardClient } from './dashboard-client';
|
import { DashboardClient } from './dashboard-client';
|
||||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||||
|
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
export default async function DashboardPage({
|
export default async function DashboardPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -156,6 +157,7 @@ export default async function DashboardPage({
|
|||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
canCreateProjects={canCreateProjects}
|
canCreateProjects={canCreateProjects}
|
||||||
canUploadVideos={canUploadVideos}
|
canUploadVideos={canUploadVideos}
|
||||||
|
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { VideoPageContent } from '@/components/video-page-content';
|
import { VideoPageContent } from '@/components/video-page-content';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||||
import { requireVideoProjectAccessOrRedirect } from '@/lib/route-access';
|
import { requireVideoProjectAccessOrRedirect } from '@/lib/route-access';
|
||||||
|
|
||||||
interface VideoPageProps {
|
interface VideoPageProps {
|
||||||
@@ -18,5 +19,12 @@ export default async function VideoPage({ params }: VideoPageProps) {
|
|||||||
allowPublicView: true,
|
allowPublicView: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <VideoPageContent mode="dashboard" videoId={videoId} projectId={projectId} />;
|
return (
|
||||||
|
<VideoPageContent
|
||||||
|
mode="dashboard"
|
||||||
|
videoId={videoId}
|
||||||
|
projectId={projectId}
|
||||||
|
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ function isVideoFile(file: File): boolean {
|
|||||||
return !!extension && VIDEO_FILE_EXTENSIONS.includes(extension);
|
return !!extension && VIDEO_FILE_EXTENSIONS.includes(extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewVideoPageClient({ projectId }: { projectId: string }) {
|
export default function NewVideoPageClient({
|
||||||
|
projectId,
|
||||||
|
bunnyUploadsEnabled,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
bunnyUploadsEnabled: boolean;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||||
|
|
||||||
@@ -348,6 +354,10 @@ export default function NewVideoPageClient({ projectId }: { projectId: string })
|
|||||||
finalThumbnailUrl = getThumbnailUrl(videoSource, 'large');
|
finalThumbnailUrl = getThumbnailUrl(videoSource, 'large');
|
||||||
finalDuration = videoSource.metadata?.duration || null;
|
finalDuration = videoSource.metadata?.duration || null;
|
||||||
} else {
|
} else {
|
||||||
|
if (!bunnyUploadsEnabled) {
|
||||||
|
throw new Error('Direct uploads are disabled by this host');
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
setSubmitError('Please select a video file to upload');
|
setSubmitError('Please select a video file to upload');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -439,14 +449,18 @@ export default function NewVideoPageClient({ projectId }: { projectId: string })
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Add Video</CardTitle>
|
<CardTitle>Add Video</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.
|
{bunnyUploadsEnabled
|
||||||
|
? 'Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.'
|
||||||
|
: 'Paste a video link to add it to your project. Direct uploads are disabled on this host.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs value={uploadMode} onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6">
|
<Tabs value={uploadMode} onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
<TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger>
|
<TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger>
|
||||||
<TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger>
|
{bunnyUploadsEnabled ? (
|
||||||
|
<TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger>
|
||||||
|
) : null}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { requireProjectAccessOrRedirect } from '@/lib/route-access';
|
import { requireProjectAccessOrRedirect } from '@/lib/route-access';
|
||||||
|
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||||
import NewVideoPageClient from './new-video-page-client';
|
import NewVideoPageClient from './new-video-page-client';
|
||||||
|
|
||||||
interface NewVideoPageProps {
|
interface NewVideoPageProps {
|
||||||
@@ -13,5 +14,5 @@ export default async function NewVideoPage({ params }: NewVideoPageProps) {
|
|||||||
intent: 'manage',
|
intent: 'manage',
|
||||||
});
|
});
|
||||||
|
|
||||||
return <NewVideoPageClient projectId={projectId} />;
|
return <NewVideoPageClient projectId={projectId} bunnyUploadsEnabled={isBunnyUploadsEnabled()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ interface NotificationSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BillingOverview {
|
interface BillingOverview {
|
||||||
|
isEnabled: boolean;
|
||||||
isConfigured: boolean;
|
isConfigured: boolean;
|
||||||
|
status: 'disabled' | 'ready' | 'misconfigured';
|
||||||
checkoutAvailable: boolean;
|
checkoutAvailable: boolean;
|
||||||
portalAvailable: boolean;
|
portalAvailable: boolean;
|
||||||
subscription: {
|
subscription: {
|
||||||
@@ -320,6 +322,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-4 w-full" />
|
||||||
<Skeleton className="h-10 w-44 rounded-md" />
|
<Skeleton className="h-10 w-44 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
|
) : !billing.isEnabled ? (
|
||||||
|
<div className="rounded-md border border-muted bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||||
|
Stripe billing is disabled by this host. Workspace creation is unrestricted in this environment.
|
||||||
|
</div>
|
||||||
) : !billing.isConfigured ? (
|
) : !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">
|
<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.
|
Stripe is not configured yet. Add your Stripe environment variables before using billing.
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,7 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats';
|
import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -173,7 +174,9 @@ export default async function AdminDashboardPage() {
|
|||||||
<Film className="h-4 w-4 text-muted-foreground" />
|
<Film className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{formatBytes(bunnyStorageStats.totalBytes)}</div>
|
<div className="text-2xl font-bold">
|
||||||
|
{isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Metadata } from 'next';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
getCachedBunnyStorageStats,
|
getCachedBunnyStorageStats,
|
||||||
@@ -280,7 +281,9 @@ export default async function AdminUsersPage({
|
|||||||
<Film className="h-4 w-4 text-muted-foreground" />
|
<Film className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{formatBytes(bunnyStorageStats.totalBytes)}</div>
|
<div className="text-2xl font-bold">
|
||||||
|
{isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import bcrypt from 'bcryptjs';
|
|||||||
import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations';
|
import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations';
|
||||||
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
|
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -49,7 +50,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!invitationIsValid) {
|
if (!invitationIsValid && isInviteCodeRequired()) {
|
||||||
// Validate invite code using constant-time comparison to prevent timing attacks
|
// Validate invite code using constant-time comparison to prevent timing attacks
|
||||||
const validInviteCode = process.env.INVITE_CODE;
|
const validInviteCode = process.env.INVITE_CODE;
|
||||||
if (!validInviteCode || !inviteCode) {
|
if (!validInviteCode || !inviteCode) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getStripeCheckoutState,
|
getStripeCheckoutState,
|
||||||
} from '@/lib/billing';
|
} from '@/lib/billing';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
|
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
|
||||||
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ export async function POST(request: NextRequest) {
|
|||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isStripeFeatureEnabled()) {
|
||||||
|
return apiErrors.badRequest('Stripe billing is disabled by this host');
|
||||||
|
}
|
||||||
|
|
||||||
if (!isStripeConfigured()) {
|
if (!isStripeConfigured()) {
|
||||||
return apiErrors.internalError('Stripe billing is not configured');
|
return apiErrors.internalError('Stripe billing is not configured');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { auth } from '@/lib/auth';
|
|||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { getBillingOverview } from '@/lib/billing';
|
import { getBillingOverview } from '@/lib/billing';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { getStripe, isStripeConfigured } from '@/lib/stripe';
|
import { getStripe, isStripeConfigured } from '@/lib/stripe';
|
||||||
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
||||||
|
|
||||||
@@ -31,6 +32,10 @@ export async function POST(request: NextRequest) {
|
|||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isStripeFeatureEnabled()) {
|
||||||
|
return apiErrors.badRequest('Stripe billing is disabled by this host');
|
||||||
|
}
|
||||||
|
|
||||||
if (!isStripeConfigured()) {
|
if (!isStripeConfigured()) {
|
||||||
return apiErrors.internalError('Stripe billing is not configured');
|
return apiErrors.internalError('Stripe billing is not configured');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { getBillingOverview } from '@/lib/billing';
|
import { getBillingOverview } from '@/lib/billing';
|
||||||
import { isStripeConfigured } from '@/lib/stripe';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
|
import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -11,8 +12,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const billing = await getBillingOverview(session.user.id);
|
const billing = await getBillingOverview(session.user.id);
|
||||||
|
const isEnabled = isStripeFeatureEnabled();
|
||||||
|
const isConfigured = hasStripeRuntimeConfig();
|
||||||
const response = successResponse({
|
const response = successResponse({
|
||||||
isConfigured: isStripeConfigured(),
|
isEnabled,
|
||||||
|
isConfigured,
|
||||||
|
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
|
||||||
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasActiveSubscription,
|
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasActiveSubscription,
|
||||||
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
|
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
|
||||||
subscription: {
|
subscription: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { rateLimit } from '@/lib/rate-limit';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||||
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||||
|
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||||
|
|
||||||
@@ -50,6 +51,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.badRequest('Title is required');
|
return apiErrors.badRequest('Title is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isBunnyUploadsFeatureEnabled()) {
|
||||||
|
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||||
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
enforceGuestUploadQuota,
|
enforceGuestUploadQuota,
|
||||||
verifyGuestUploadToken,
|
verifyGuestUploadToken,
|
||||||
} from '@/lib/guest-upload-token';
|
} from '@/lib/guest-upload-token';
|
||||||
|
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
|
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
|
||||||
|
|
||||||
@@ -30,6 +31,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
||||||
if (!title) return apiErrors.badRequest('Title is required');
|
if (!title) return apiErrors.badRequest('Title is required');
|
||||||
|
|
||||||
|
if (!isBunnyUploadsFeatureEnabled()) {
|
||||||
|
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
||||||
|
}
|
||||||
|
|
||||||
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
||||||
if (!context.viewerUserId) {
|
if (!context.viewerUserId) {
|
||||||
const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null);
|
const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null);
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ export function VideoDragDropUploader({
|
|||||||
}
|
}
|
||||||
}, [canUpload, needsProjectSelection, projectOptions, workspaceId]);
|
}, [canUpload, needsProjectSelection, projectOptions, workspaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canUpload) {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setDroppedFile(null);
|
||||||
|
}
|
||||||
|
}, [canUpload]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasLoadedProjectsRef.current = false;
|
hasLoadedProjectsRef.current = false;
|
||||||
if (projectOptions && projectOptions.length > 0) {
|
if (projectOptions && projectOptions.length > 0) {
|
||||||
|
|||||||
@@ -67,9 +67,15 @@ interface VideoPageContentProps {
|
|||||||
mode: VideoPageMode;
|
mode: VideoPageMode;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
bunnyUploadsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) {
|
export function VideoPageContent({
|
||||||
|
mode,
|
||||||
|
videoId,
|
||||||
|
projectId: propProjectId,
|
||||||
|
bunnyUploadsEnabled = true,
|
||||||
|
}: VideoPageContentProps) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const bunnyViewportRef = useRef<HTMLDivElement>(null);
|
const bunnyViewportRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -193,6 +199,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
} = useVersionActions({
|
} = useVersionActions({
|
||||||
projectId: propProjectId,
|
projectId: propProjectId,
|
||||||
videoId,
|
videoId,
|
||||||
|
bunnyUploadsEnabled,
|
||||||
setVideo,
|
setVideo,
|
||||||
activeVersionId,
|
activeVersionId,
|
||||||
setActiveVersionId,
|
setActiveVersionId,
|
||||||
@@ -676,6 +683,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
onDownload={headerActions.onDownload}
|
onDownload={headerActions.onDownload}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
videoId={videoId}
|
videoId={videoId}
|
||||||
|
bunnyUploadsEnabled={bunnyUploadsEnabled}
|
||||||
showVersionDialog={showVersionDialog}
|
showVersionDialog={showVersionDialog}
|
||||||
setShowVersionDialog={setShowVersionDialog}
|
setShowVersionDialog={setShowVersionDialog}
|
||||||
newVersionMode={newVersionMode}
|
newVersionMode={newVersionMode}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface UseVersionActionsParams extends VersionActionsConfig {
|
|||||||
export function useVersionActions({
|
export function useVersionActions({
|
||||||
projectId,
|
projectId,
|
||||||
videoId,
|
videoId,
|
||||||
|
bunnyUploadsEnabled = true,
|
||||||
setVideo,
|
setVideo,
|
||||||
activeVersionId,
|
activeVersionId,
|
||||||
setActiveVersionId,
|
setActiveVersionId,
|
||||||
@@ -76,6 +77,7 @@ export function useVersionActions({
|
|||||||
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
||||||
finalDuration = meta?.duration || null;
|
finalDuration = meta?.duration || null;
|
||||||
} else {
|
} else {
|
||||||
|
if (!bunnyUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
|
||||||
if (!newVersionFile) throw new Error('No file selected');
|
if (!newVersionFile) throw new Error('No file selected');
|
||||||
let title = newVersionFile.name;
|
let title = newVersionFile.name;
|
||||||
if (newVersionLabel.trim()) {
|
if (newVersionLabel.trim()) {
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export interface CommentActionsConfig {
|
|||||||
export interface VersionActionsConfig {
|
export interface VersionActionsConfig {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
|
bunnyUploadsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPageHeaderActions {
|
export interface VideoPageHeaderActions {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { VideoSource } from '@/lib/video-providers';
|
|||||||
interface VersionActionsDialogProps {
|
interface VersionActionsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
bunnyUploadsEnabled: boolean;
|
||||||
newVersionMode: 'url' | 'file';
|
newVersionMode: 'url' | 'file';
|
||||||
onNewVersionModeChange: (mode: 'url' | 'file') => void;
|
onNewVersionModeChange: (mode: 'url' | 'file') => void;
|
||||||
newVersionUrl: string;
|
newVersionUrl: string;
|
||||||
@@ -33,6 +34,7 @@ interface VersionActionsDialogProps {
|
|||||||
export const VersionActionsDialog = memo(function VersionActionsDialog({
|
export const VersionActionsDialog = memo(function VersionActionsDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
bunnyUploadsEnabled,
|
||||||
newVersionMode,
|
newVersionMode,
|
||||||
onNewVersionModeChange,
|
onNewVersionModeChange,
|
||||||
newVersionUrl,
|
newVersionUrl,
|
||||||
@@ -66,9 +68,9 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 mt-2">
|
<div className="space-y-4 mt-2">
|
||||||
<Tabs value={newVersionMode} onValueChange={(v) => onNewVersionModeChange(v as 'url' | 'file')} className="mb-2">
|
<Tabs value={newVersionMode} onValueChange={(v) => onNewVersionModeChange(v as 'url' | 'file')} className="mb-2">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
<TabsTrigger value="url">Link URL</TabsTrigger>
|
<TabsTrigger value="url">Link URL</TabsTrigger>
|
||||||
<TabsTrigger value="file">Upload File</TabsTrigger>
|
{bunnyUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface VideoPageHeaderProps {
|
|||||||
onDownload: (preference?: BunnyDownloadPreference) => void;
|
onDownload: (preference?: BunnyDownloadPreference) => void;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
|
bunnyUploadsEnabled: boolean;
|
||||||
showVersionDialog: boolean;
|
showVersionDialog: boolean;
|
||||||
setShowVersionDialog: (open: boolean) => void;
|
setShowVersionDialog: (open: boolean) => void;
|
||||||
newVersionMode: 'url' | 'file';
|
newVersionMode: 'url' | 'file';
|
||||||
@@ -90,6 +91,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
onDownload,
|
onDownload,
|
||||||
projectId,
|
projectId,
|
||||||
videoId,
|
videoId,
|
||||||
|
bunnyUploadsEnabled,
|
||||||
showVersionDialog,
|
showVersionDialog,
|
||||||
setShowVersionDialog,
|
setShowVersionDialog,
|
||||||
newVersionMode,
|
newVersionMode,
|
||||||
@@ -227,6 +229,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
<VersionActionsDialog
|
<VersionActionsDialog
|
||||||
open={showVersionDialog}
|
open={showVersionDialog}
|
||||||
onOpenChange={setShowVersionDialog}
|
onOpenChange={setShowVersionDialog}
|
||||||
|
bunnyUploadsEnabled={bunnyUploadsEnabled}
|
||||||
newVersionMode={newVersionMode}
|
newVersionMode={newVersionMode}
|
||||||
onNewVersionModeChange={setNewVersionMode}
|
onNewVersionModeChange={setNewVersionMode}
|
||||||
newVersionUrl={newVersionUrl}
|
newVersionUrl={newVersionUrl}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { unstable_cache } from 'next/cache';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||||
import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
|
import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
|
||||||
|
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
||||||
const STORAGE_CACHE_SECONDS = 600;
|
const STORAGE_CACHE_SECONDS = 600;
|
||||||
@@ -116,6 +117,10 @@ export async function refreshR2StorageSnapshot(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
|
async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
|
||||||
|
if (!isBunnyUploadsFeatureEnabled()) {
|
||||||
|
return { totalBytes: 0, byVideoId: {} };
|
||||||
|
}
|
||||||
|
|
||||||
const { apiKey, libraryId } = getBunnyConfig();
|
const { apiKey, libraryId } = getBunnyConfig();
|
||||||
const byVideoId: Record<string, number> = {};
|
const byVideoId: Record<string, number> = {};
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
|
|||||||
+11
-2
@@ -3,6 +3,7 @@ import type Stripe from 'stripe';
|
|||||||
import { BillingSubscriptionStatus } from '@prisma/client';
|
import { BillingSubscriptionStatus } from '@prisma/client';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
||||||
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
||||||
BillingSubscriptionStatus.ACTIVE,
|
BillingSubscriptionStatus.ACTIVE,
|
||||||
@@ -35,6 +36,10 @@ export function hasActiveSubscription(status: BillingSubscriptionStatus | null |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
|
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
|
||||||
|
if (!isStripeFeatureEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasActiveSubscription(subject.subscriptionStatus)) {
|
if (hasActiveSubscription(subject.subscriptionStatus)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -68,6 +73,10 @@ export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
|
export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
|
||||||
|
if (!isStripeFeatureEnabled()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
OR: [
|
OR: [
|
||||||
{ subscriptionStatus: { in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING] } },
|
{ subscriptionStatus: { in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING] } },
|
||||||
@@ -217,10 +226,10 @@ export async function getWorkspaceCreationEligibility(userId: string) {
|
|||||||
const billingAccess = hasBillingAccess(user);
|
const billingAccess = hasBillingAccess(user);
|
||||||
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
|
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
|
||||||
const canCreateWorkspace =
|
const canCreateWorkspace =
|
||||||
billingAccess || (ownedWorkspaceCount === 0 && collaborationCount === 0);
|
!isStripeFeatureEnabled() || billingAccess || (ownedWorkspaceCount === 0 && collaborationCount === 0);
|
||||||
|
|
||||||
let reason: string | null = null;
|
let reason: string | null = null;
|
||||||
if (!canCreateWorkspace) {
|
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
|
||||||
if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
|
if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
|
||||||
reason =
|
reason =
|
||||||
'You are currently collaborating in someone else’s workspace or project. Start a subscription to create a workspace of your own.';
|
'You are currently collaborating in someone else’s workspace or project. Start a subscription to create a workspace of your own.';
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
function readBooleanEnv(name: string, defaultValue: boolean): boolean {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === 'true') return true;
|
||||||
|
if (normalized === 'false') return false;
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStripeFeatureEnabled() {
|
||||||
|
return readBooleanEnv('OPENFRAME_ENABLE_STRIPE', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasStripeConfig() {
|
||||||
|
return Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStripeBillingEnabled() {
|
||||||
|
return isStripeFeatureEnabled() && hasStripeConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBunnyUploadsFeatureEnabled() {
|
||||||
|
return readBooleanEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBunnyUploadsConfig() {
|
||||||
|
return Boolean(
|
||||||
|
process.env.BUNNY_STREAM_API_KEY &&
|
||||||
|
(process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBunnyUploadsEnabled() {
|
||||||
|
return isBunnyUploadsFeatureEnabled() && hasBunnyUploadsConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInviteCodeRequired() {
|
||||||
|
return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true);
|
||||||
|
}
|
||||||
+3
-15
@@ -1,6 +1,6 @@
|
|||||||
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 { buildBillingAccessWhereInput, hasBillingAccess } from '@/lib/billing';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
type AccessIntent = 'view' | 'manage';
|
type AccessIntent = 'view' | 'manage';
|
||||||
@@ -98,13 +98,7 @@ export async function hasCollaboratorBillingBackedAccess(userId: string) {
|
|||||||
const [workspaceCount, projectCount] = await Promise.all([
|
const [workspaceCount, projectCount] = await Promise.all([
|
||||||
db.workspace.count({
|
db.workspace.count({
|
||||||
where: {
|
where: {
|
||||||
owner: {
|
owner: buildBillingAccessWhereInput(now),
|
||||||
OR: [
|
|
||||||
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
|
|
||||||
{ trialEndsAt: { gt: now } },
|
|
||||||
{ stripeCurrentPeriodEnd: { gt: now } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: userId },
|
{ ownerId: userId },
|
||||||
{ members: { some: { userId } } },
|
{ members: { some: { userId } } },
|
||||||
@@ -114,13 +108,7 @@ export async function hasCollaboratorBillingBackedAccess(userId: string) {
|
|||||||
db.project.count({
|
db.project.count({
|
||||||
where: {
|
where: {
|
||||||
workspace: {
|
workspace: {
|
||||||
owner: {
|
owner: buildBillingAccessWhereInput(now),
|
||||||
OR: [
|
|
||||||
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
|
|
||||||
{ trialEndsAt: { gt: now } },
|
|
||||||
{ stripeCurrentPeriodEnd: { gt: now } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: userId },
|
{ ownerId: userId },
|
||||||
|
|||||||
+6
-1
@@ -1,9 +1,14 @@
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
import { hasStripeConfig, isStripeBillingEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
let stripeClient: Stripe | null = null;
|
let stripeClient: Stripe | null = null;
|
||||||
|
|
||||||
export function isStripeConfigured() {
|
export function isStripeConfigured() {
|
||||||
return Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID);
|
return isStripeBillingEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasStripeRuntimeConfig() {
|
||||||
|
return hasStripeConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStripe() {
|
export function getStripe() {
|
||||||
|
|||||||
Reference in New Issue
Block a user