mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Three places still carried the old promise. The register form, which is where the CTA lands and where the trial is granted, said nothing about it at all, so the landing page's claim had to be taken on faith across a page load. The comparison profile summary still described the trial without mentioning the card. And the CtaLink comment quoted a button label that no longer exists. The hero and closing CTAs now read 'Open dashboard' for a signed-in visitor. That button already pointed at /dashboard for them, and offering a free trial to someone who is mid-subscription reads as a bug. The pricing card keeps the short label because it sits directly under 'No credit card' and the card narrows to about 230px at the md breakpoint, where the long label would wrap out of a fixed-height button.
402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
'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 { signIn } from 'next-auth/react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { getSafeCallbackUrl } from '@/lib/safe-redirect';
|
|
|
|
export interface RegisterInvitation {
|
|
email: string;
|
|
inviterName: string;
|
|
roleLabel: string;
|
|
scopeLabel: string;
|
|
targetName: string | null;
|
|
}
|
|
|
|
interface RegisterPageClientProps {
|
|
requireInviteCode: boolean;
|
|
/**
|
|
* Billing is on, so this account starts a free trial. False on a self-hosted
|
|
* instance, where there is nothing to trial and the promise would be a lie.
|
|
*/
|
|
trialOnSignup?: boolean;
|
|
googleEnabled: boolean;
|
|
githubEnabled: boolean;
|
|
invitation?: RegisterInvitation | null;
|
|
/** Preview lookup was rate-limited, so `invitation` says nothing about its validity. */
|
|
invitationLookupThrottled?: boolean;
|
|
}
|
|
|
|
export default function RegisterPageClient({
|
|
requireInviteCode,
|
|
trialOnSignup = false,
|
|
googleEnabled,
|
|
githubEnabled,
|
|
invitation = null,
|
|
invitationLookupThrottled = false,
|
|
}: RegisterPageClientProps) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]);
|
|
const invitedEmail = useMemo(
|
|
() => invitation?.email || searchParams.get('email') || '',
|
|
[invitation, searchParams]
|
|
);
|
|
// Where to send the user once they are signed in — for invitations this points back
|
|
// at /invitations/accept so they land on the workspace/project they were invited to
|
|
// instead of the onboarding wizard.
|
|
const callbackUrl = useMemo(
|
|
() => getSafeCallbackUrl(searchParams.get('callbackUrl')),
|
|
[searchParams]
|
|
);
|
|
const isInvitationFlow = invitationToken.length > 0;
|
|
const shouldShowInviteCode = requireInviteCode && !isInvitationFlow;
|
|
const loginHref =
|
|
callbackUrl === '/dashboard'
|
|
? '/login'
|
|
: `/login?callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
|
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;
|
|
}
|
|
|
|
const callbackParam = `&callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
|
if (data.data?.emailVerificationRequired) {
|
|
router.push(`/verify-email?email=${encodeURIComponent(formData.email)}${callbackParam}`);
|
|
} else {
|
|
router.push(`/login?registered=true${callbackParam}`);
|
|
}
|
|
} catch {
|
|
setError('Something went wrong. Please try again.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOAuthSignUp = async (provider: string) => {
|
|
setOauthLoading(provider);
|
|
setError('');
|
|
await signIn(provider, { callbackUrl });
|
|
};
|
|
|
|
const hasOAuth = googleEnabled || githubEnabled;
|
|
const anyLoading = isLoading || oauthLoading !== null;
|
|
|
|
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>
|
|
{/* The landing page CTA promises a trial and no card. Say it again here,
|
|
where the promise is actually kept, rather than making people take the
|
|
previous page's word for it. Invitees are joining someone else's
|
|
workspace, so the trial is not what brought them. */}
|
|
{trialOnSignup && !isInvitationFlow && (
|
|
<CardDescription>
|
|
Your 7-day free trial starts as soon as you create the account. No credit card.
|
|
</CardDescription>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{/* OAuth Buttons */}
|
|
{hasOAuth && (
|
|
<div className="space-y-2 mb-4">
|
|
{googleEnabled && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={anyLoading}
|
|
onClick={() => handleOAuthSignUp('google')}
|
|
>
|
|
{oauthLoading === 'google' ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
fill="#4285F4"
|
|
/>
|
|
<path
|
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
fill="#34A853"
|
|
/>
|
|
<path
|
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
fill="#FBBC05"
|
|
/>
|
|
<path
|
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
fill="#EA4335"
|
|
/>
|
|
</svg>
|
|
)}
|
|
Continue with Google
|
|
</Button>
|
|
)}
|
|
{githubEnabled && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={anyLoading}
|
|
onClick={() => handleOAuthSignUp('github')}
|
|
>
|
|
{oauthLoading === 'github' ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<svg
|
|
className="h-4 w-4 mr-2"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
|
</svg>
|
|
)}
|
|
Continue with GitHub
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Divider */}
|
|
{hasOAuth && (
|
|
<div className="relative mb-4">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<span className="w-full border-t" />
|
|
</div>
|
|
<div className="relative flex justify-center text-xs uppercase">
|
|
<span className="bg-card px-2 text-muted-foreground">or continue with email</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleRegister} className="space-y-4">
|
|
{isInvitationFlow && invitation ? (
|
|
<div className="p-3 rounded-md bg-primary/10 text-sm space-y-1">
|
|
<p>
|
|
{invitation.inviterName} invited you to{' '}
|
|
<strong>
|
|
{invitation.targetName
|
|
? `${invitation.targetName} (${invitation.scopeLabel})`
|
|
: `a ${invitation.scopeLabel}`}
|
|
</strong>{' '}
|
|
as {invitation.roleLabel}.
|
|
</p>
|
|
<p className="text-muted-foreground">
|
|
Create your account below — you'll be taken straight to it.
|
|
</p>
|
|
</div>
|
|
) : isInvitationFlow && invitationLookupThrottled ? (
|
|
<div className="p-3 rounded-md bg-amber-500/10 text-sm">
|
|
We couldn't check this invitation right now. Please wait a few minutes and
|
|
open the link again.
|
|
</div>
|
|
) : isInvitationFlow ? (
|
|
<div className="p-3 rounded-md bg-amber-500/10 text-sm">
|
|
This invitation link is no longer valid. Ask whoever invited you for a new one.
|
|
</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}
|
|
readOnly={Boolean(invitation)}
|
|
className={invitation ? 'bg-muted text-muted-foreground' : undefined}
|
|
/>
|
|
{invitation && (
|
|
<p className="text-xs text-muted-foreground">
|
|
The invitation is tied to this address.
|
|
</p>
|
|
)}
|
|
</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={anyLoading}>
|
|
{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={loginHref} 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{' '}
|
|
<Link href="/terms" className="underline hover:text-foreground">
|
|
Terms of Service
|
|
</Link>{' '}
|
|
and{' '}
|
|
<Link href="/privacy" className="underline hover:text-foreground">
|
|
Privacy Policy
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|