OR
diff --git a/apps/web/app/(org)/signup/form.tsx b/apps/web/app/(org)/signup/form.tsx
new file mode 100644
index 0000000000..d5817ac073
--- /dev/null
+++ b/apps/web/app/(org)/signup/form.tsx
@@ -0,0 +1,494 @@
+"use client";
+
+import { Button, Input, LogoBadge } from "@cap/ui";
+import {
+ faArrowLeft,
+ faEnvelope,
+ faExclamationCircle,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { AnimatePresence, motion } from "framer-motion";
+import Cookies from "js-cookie";
+import { LucideArrowUpRight } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { signIn } from "next-auth/react";
+import { Suspense, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { getOrganizationSSOData } from "@/actions/organization/get-organization-sso-data";
+import { trackEvent } from "@/app/utils/analytics";
+
+const MotionInput = motion(Input);
+const MotionLogoBadge = motion(LogoBadge);
+const MotionLink = motion(Link);
+const MotionButton = motion(Button);
+
+export function SignupForm() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const next = searchParams?.get("next");
+ const [email, setEmail] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [emailSent, setEmailSent] = useState(false);
+ const [oauthError, setOauthError] = useState(false);
+ const [showOrgInput, setShowOrgInput] = useState(false);
+ const [organizationId, setOrganizationId] = useState("");
+ const [organizationName, setOrganizationName] = useState
(null);
+ const [lastEmailSentTime, setLastEmailSentTime] = useState(
+ null,
+ );
+ const theme = Cookies.get("theme") || "light";
+
+ useEffect(() => {
+ theme === "dark"
+ ? (document.body.className = "dark")
+ : (document.body.className = "light");
+ //remove the dark mode when we leave the dashboard
+ return () => {
+ document.body.className = "light";
+ };
+ }, [theme]);
+
+ useEffect(() => {
+ const error = searchParams?.get("error");
+ const errorDesc = searchParams?.get("error_description");
+
+ const handleErrors = () => {
+ if (error === "OAuthAccountNotLinked" && !errorDesc) {
+ setOauthError(true);
+ return toast.error(
+ "This email is already associated with a different sign-in method",
+ );
+ } else if (
+ error === "profile_not_allowed_outside_organization" &&
+ !errorDesc
+ ) {
+ return toast.error(
+ "Your email domain is not authorized for SSO access. Please use your work email or contact your administrator.",
+ );
+ } else if (error && errorDesc) {
+ return toast.error(errorDesc);
+ }
+ };
+ handleErrors();
+ }, [searchParams]);
+
+ useEffect(() => {
+ const pendingPriceId = localStorage.getItem("pendingPriceId");
+ const pendingQuantity = localStorage.getItem("pendingQuantity") ?? "1";
+ if (emailSent && pendingPriceId) {
+ localStorage.removeItem("pendingPriceId");
+ localStorage.removeItem("pendingQuantity");
+
+ // Wait a bit to ensure the user is created
+ setTimeout(async () => {
+ const response = await fetch(`/api/settings/billing/subscribe`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ priceId: pendingPriceId,
+ quantity: parseInt(pendingQuantity),
+ }),
+ });
+ const data = await response.json();
+
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ }, 2000);
+ }
+ }, [emailSent]);
+
+ const handleGoogleSignIn = () => {
+ trackEvent("auth_started", { method: "google", is_signup: true });
+ signIn("google", {
+ ...(next && next.length > 0 ? { callbackUrl: next } : {}),
+ });
+ };
+
+ const handleOrganizationLookup = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!organizationId) {
+ toast.error("Please enter an organization ID");
+ return;
+ }
+
+ try {
+ const data = await getOrganizationSSOData(organizationId);
+ setOrganizationName(data.name);
+
+ signIn("workos", undefined, {
+ organization: data.organizationId,
+ connection: data.connectionId,
+ });
+ } catch (error) {
+ console.error("Lookup Error:", error);
+ toast.error("Organization not found or SSO not configured");
+ }
+ };
+
+ return (
+
+ setShowOrgInput(false)}
+ className="absolute overflow-hidden top-5 rounded-full left-5 z-20 hover:bg-gray-1 gap-2 items-center py-1.5 px-3 text-gray-12 bg-transparent border border-gray-4 transition-colors duration-300 cursor-pointer"
+ >
+
+
+ Back
+
+
+
+
+
+
+
+ Sign up to Cap
+
+
+ Beautiful screen recordings, owned by you.
+
+
+
+
+
+
+
+ >
+ }
+ >
+
+
+
+ {showOrgInput ? (
+
+
+
+ ) : (
+ {
+ e.preventDefault();
+ if (!email) return;
+
+ // Check if we're rate limited on the client side
+ if (lastEmailSentTime) {
+ const timeSinceLastRequest =
+ Date.now() - lastEmailSentTime;
+ const waitTime = 30000; // 30 seconds
+ if (timeSinceLastRequest < waitTime) {
+ const remainingSeconds = Math.ceil(
+ (waitTime - timeSinceLastRequest) / 1000,
+ );
+ toast.error(
+ `Please wait ${remainingSeconds} seconds before requesting a new code`,
+ );
+ return;
+ }
+ }
+
+ setLoading(true);
+ trackEvent("auth_started", {
+ method: "email",
+ is_signup: true,
+ });
+ signIn("email", {
+ email,
+ redirect: false,
+ ...(next && next.length > 0
+ ? { callbackUrl: next }
+ : {}),
+ })
+ .then((res) => {
+ setLoading(false);
+
+ if (res?.ok && !res?.error) {
+ setEmailSent(true);
+ setLastEmailSentTime(Date.now());
+ trackEvent("auth_email_sent", {
+ email_domain: email.split("@")[1],
+ });
+ const params = new URLSearchParams({
+ email,
+ ...(next && { next }),
+ lastSent: Date.now().toString(),
+ });
+ router.push(`/verify-otp?${params.toString()}`);
+ } else {
+ // NextAuth always returns "EmailSignin" for all email provider errors
+ // Since we already check rate limiting on the client side before sending,
+ // if we get an error here, it's likely rate limiting from the server
+ toast.error(
+ "Please wait 30 seconds before requesting a new code",
+ );
+ }
+ })
+ .catch((error) => {
+ setEmailSent(false);
+ setLoading(false);
+ // Catch block is rarely triggered with NextAuth
+ toast.error("Error sending email - try again?");
+ });
+ }}
+ className="flex flex-col space-y-3"
+ >
+
+
+ )}
+
+
+
+ Already have an account?{" "}
+
+ Log in here
+
+
+
+ By typing your email and clicking continue, you acknowledge that
+ you have both read and agree to Cap's{" "}
+
+ Terms of Service
+ {" "}
+ and{" "}
+
+ Privacy Policy
+
+ .
+
+
+
+
+
+ );
+}
+
+const SignupWithSSO = ({
+ handleOrganizationLookup,
+ organizationId,
+ setOrganizationId,
+ organizationName,
+}: {
+ handleOrganizationLookup: (e: React.FormEvent) => void;
+ organizationId: string;
+ setOrganizationId: (organizationId: string) => void;
+ organizationName: string | null;
+}) => {
+ return (
+
+ setOrganizationId(e.target.value)}
+ className="w-full max-w-full"
+ />
+ {organizationName && (
+
+ Signing up with: {organizationName}
+
+ )}
+
+
+
+
+ );
+};
+
+const NormalSignup = ({
+ setShowOrgInput,
+ email,
+ emailSent,
+ setEmail,
+ loading,
+ oauthError,
+ handleGoogleSignIn,
+}: {
+ setShowOrgInput: (show: boolean) => void;
+ email: string;
+ emailSent: boolean;
+ setEmail: (email: string) => void;
+ loading: boolean;
+ oauthError: boolean;
+ handleGoogleSignIn: () => void;
+}) => {
+ return (
+
+
+ {
+ setEmail(e.target.value);
+ }}
+ />
+ }
+ >
+ Sign up with email
+
+
+
+
+ {!oauthError && (
+ <>
+
+
+ Sign up with Google
+
+ >
+ )}
+
+ {oauthError && (
+
+
+
+ It looks like you've previously used this email to sign up via
+ email. Please enter your email below to receive a sign up link.
+
+
+ )}
+ setShowOrgInput(true)}
+ disabled={loading}
+ >
+
+ Sign up with SAML SSO
+
+
+
+ );
+};
diff --git a/apps/web/app/(org)/signup/page.tsx b/apps/web/app/(org)/signup/page.tsx
new file mode 100644
index 0000000000..de68b63d08
--- /dev/null
+++ b/apps/web/app/(org)/signup/page.tsx
@@ -0,0 +1,29 @@
+import { getCurrentUser } from "@cap/database/auth/session";
+import { faArrowLeft } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { SignupForm } from "./form";
+
+export const dynamic = "force-dynamic";
+
+export default async function SignupPage() {
+ const session = await getCurrentUser();
+ if (session) {
+ redirect("/dashboard");
+ }
+ return (
+
+ );
+}
diff --git a/apps/web/app/(site)/Navbar.tsx b/apps/web/app/(site)/Navbar.tsx
index 34ea845f17..f16dd135c2 100644
--- a/apps/web/app/(site)/Navbar.tsx
+++ b/apps/web/app/(site)/Navbar.tsx
@@ -13,8 +13,6 @@ import {
navigationMenuTriggerStyle,
} from "@cap/ui";
import { classNames } from "@cap/utils";
-import { faGithub } from "@fortawesome/free-brands-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -95,6 +93,13 @@ const Links = [
},
];
+const AuthLinks = [
+ {
+ label: "Log In",
+ href: "/login",
+ },
+];
+
export const Navbar = () => {
const pathname = usePathname();
const [showMobileMenu, setShowMobileMenu] = useState(false);
@@ -154,20 +159,29 @@ export const Navbar = () => {
)}
))}
+ {!auth &&
+ AuthLinks.map((link) => (
+
+
+
+ {link.label}
+
+
+
+ ))}