diff --git a/Cargo.lock b/Cargo.lock index 289427699..da4d8eff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -918,14 +918,13 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.29" +version = "0.3.23" dependencies = [ "anyhow", "axum", "base64 0.22.1", "cap-editor", "cap-export", - "cap-fail", "cap-flags", "cap-media", "cap-project", @@ -986,7 +985,6 @@ dependencies = [ "tauri-specta", "tempfile", "tokio", - "tokio-stream", "tracing", "tracing-appender", "tracing-subscriber", @@ -1033,13 +1031,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "cap-fail" -version = "0.1.0" -dependencies = [ - "inventory", -] - [[package]] name = "cap-flags" version = "0.1.0" @@ -1060,7 +1051,6 @@ name = "cap-media" version = "0.1.0" dependencies = [ "axum", - "cap-fail", "cap-flags", "cap-gpu-converters", "cap-project", @@ -1117,11 +1107,9 @@ dependencies = [ "device_query", "either", "flume 0.11.0", - "futures", "image 0.25.5", "objc", "relative-path", - "screencapturekit", "serde", "serde_json", "specta", @@ -1142,6 +1130,7 @@ dependencies = [ "bytemuck", "cap-flags", "cap-project", + "cap-recording", "cidre", "ffmpeg-hw-device", "ffmpeg-next", @@ -1175,14 +1164,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "cap-video-decode" -version = "0.1.0" -dependencies = [ - "ffmpeg-next", - "ffmpeg-sys-next", -] - [[package]] name = "cargo-platform" version = "0.1.8" @@ -1313,8 +1294,8 @@ dependencies = [ [[package]] name = "cidre" -version = "0.6.0" -source = "git+https://github.com/yury/cidre?rev=ef04aaabe14ffbbce4a330973a74b6d797d073ff#ef04aaabe14ffbbce4a330973a74b6d797d073ff" +version = "0.5.0" +source = "git+https://github.com/yury/cidre?rev=3479c18b9ac81dbb15d0acb3a75edd6b42d044b1#3479c18b9ac81dbb15d0acb3a75edd6b42d044b1" dependencies = [ "cidre-macros", "parking_lot", @@ -1324,7 +1305,7 @@ dependencies = [ [[package]] name = "cidre-macros" version = "0.1.0" -source = "git+https://github.com/yury/cidre?rev=ef04aaabe14ffbbce4a330973a74b6d797d073ff#ef04aaabe14ffbbce4a330973a74b6d797d073ff" +source = "git+https://github.com/yury/cidre?rev=3479c18b9ac81dbb15d0acb3a75edd6b42d044b1#3479c18b9ac81dbb15d0acb3a75edd6b42d044b1" [[package]] name = "clang-sys" @@ -3626,15 +3607,6 @@ dependencies = [ "syn 2.0.90", ] -[[package]] -name = "inventory" -version = "0.3.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b12ebb6799019b044deaf431eadfe23245b259bba5a2c0796acec3943a3cdb" -dependencies = [ - "rustversion", -] - [[package]] name = "ipnet" version = "2.9.0" @@ -4420,7 +4392,7 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "nokhwa" version = "0.10.3" -source = "git+https://github.com/CapSoftware/nokhwa?rev=e309b938ccd6#e309b938ccd6d17afac4017b11e7bda9f6cd9f35" +source = "git+https://github.com/CapSoftware/nokhwa?rev=d37141b1883d#d37141b1883d7250f7d2ccc9bc199ecaee48d51b" dependencies = [ "flume 0.10.14", "image 0.24.9", @@ -4436,7 +4408,7 @@ dependencies = [ [[package]] name = "nokhwa-bindings-linux" version = "0.1.0" -source = "git+https://github.com/CapSoftware/nokhwa?rev=e309b938ccd6#e309b938ccd6d17afac4017b11e7bda9f6cd9f35" +source = "git+https://github.com/CapSoftware/nokhwa?rev=d37141b1883d#d37141b1883d7250f7d2ccc9bc199ecaee48d51b" dependencies = [ "nokhwa-core", "v4l", @@ -4446,7 +4418,7 @@ dependencies = [ [[package]] name = "nokhwa-bindings-macos" version = "0.2.0" -source = "git+https://github.com/CapSoftware/nokhwa?rev=e309b938ccd6#e309b938ccd6d17afac4017b11e7bda9f6cd9f35" +source = "git+https://github.com/CapSoftware/nokhwa?rev=d37141b1883d#d37141b1883d7250f7d2ccc9bc199ecaee48d51b" dependencies = [ "block", "cocoa-foundation 0.1.2", @@ -4461,7 +4433,7 @@ dependencies = [ [[package]] name = "nokhwa-bindings-windows" version = "0.4.0" -source = "git+https://github.com/CapSoftware/nokhwa?rev=e309b938ccd6#e309b938ccd6d17afac4017b11e7bda9f6cd9f35" +source = "git+https://github.com/CapSoftware/nokhwa?rev=d37141b1883d#d37141b1883d7250f7d2ccc9bc199ecaee48d51b" dependencies = [ "nokhwa-core", "once_cell", @@ -4471,7 +4443,7 @@ dependencies = [ [[package]] name = "nokhwa-core" version = "0.1.0" -source = "git+https://github.com/CapSoftware/nokhwa?rev=e309b938ccd6#e309b938ccd6d17afac4017b11e7bda9f6cd9f35" +source = "git+https://github.com/CapSoftware/nokhwa?rev=d37141b1883d#d37141b1883d7250f7d2ccc9bc199ecaee48d51b" dependencies = [ "bytes", "image 0.24.9", @@ -6824,7 +6796,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ccbb212565d2dc177bc15ecb7b039d66c4490da892436a4eee5b394d620c9bc" dependencies = [ "paste", - "serde_json", "specta-macros", "thiserror 1.0.63", "uuid", @@ -7953,18 +7924,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - [[package]] name = "tokio-tungstenite" version = "0.24.0" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5a32870d3..b7e80d1a4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2497,4 +2497,4 @@ impl, T, E> TransposeAsync for Result { } } } -} +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 6f74fc5d5..928acbe48 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -547,4 +547,4 @@ fn project_config_from_recording( }), ..default_config.unwrap_or_default() } -} +} \ No newline at end of file diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index 655429b86..8b24cdf13 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -1156,4 +1156,4 @@ function ChangelogButton() { ); -} +} \ No newline at end of file diff --git a/apps/desktop/src/routes/debug.tsx b/apps/desktop/src/routes/debug.tsx index 55a16b59c..d4457c112 100644 --- a/apps/desktop/src/routes/debug.tsx +++ b/apps/desktop/src/routes/debug.tsx @@ -51,4 +51,4 @@ export default function Debug() { ); -} +} \ No newline at end of file diff --git a/apps/web/app/actions.ts b/apps/web/app/actions.ts new file mode 100644 index 000000000..cdca4d867 --- /dev/null +++ b/apps/web/app/actions.ts @@ -0,0 +1,32 @@ +"use server"; + +import { + getServerConfig as getServerConfigInternal, + canInstanceAddUser, + isWorkspacePro, +} from "@/utils/instance/functions"; +import { serverEnv } from "@cap/env"; + +export async function getServerConfigAction() { + const serverConfig = await getServerConfigInternal(); + + const googleSigninEnabled = serverEnv.GOOGLE_CLIENT_ID !== undefined; + const workosSigninEnabled = serverEnv.WORKOS_CLIENT_ID !== undefined; + return { + isCapCloud: serverConfig.isCapCloud, + signupsEnabled: serverConfig.signupsEnabled, + auth: { + google: googleSigninEnabled, + workos: workosSigninEnabled, + }, + }; +} + +export async function canInstanceAddUserAction() { + return await canInstanceAddUser(); +} + +export async function isWorkspaceProAction(workspaceId: string) { + "use server"; + return isWorkspacePro({ workspaceId }); +} diff --git a/apps/web/app/api/admin/server-config/route.ts b/apps/web/app/api/admin/server-config/route.ts new file mode 100644 index 000000000..e27c95731 --- /dev/null +++ b/apps/web/app/api/admin/server-config/route.ts @@ -0,0 +1,93 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { serverConfigTable } from "@cap/database/schema"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +// Helper function to check if user is a super admin +async function isSuperAdmin() { + const currentUser = await getCurrentUser(); + if (!currentUser) return false; + + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + if (!serverConfig) return false; + + return ( + serverConfig.superAdminIds.includes(currentUser.id) || + currentUser.email.endsWith("@cap.so") + ); +} + +// GET handler to retrieve server configuration +export async function GET() { + // Check if user is authorized + if (!(await isSuperAdmin())) { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + + try { + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + return NextResponse.json(serverConfig); + } catch (error) { + console.error("Error fetching server config:", error); + return NextResponse.json( + { error: "Failed to fetch server configuration" }, + { status: 500 } + ); + } +} + +// PUT handler to update server configuration +export async function PUT(request: NextRequest) { + // Check if user is authorized + if (!(await isSuperAdmin())) { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + + try { + const body = await request.json(); + + // Validate the request body + const validFields = [ + "licenseKey", + "signupsEnabled", + "emailSendFromName", + "emailSendFromEmail", + "superAdminIds", + ]; + + const updateData: Record = {}; + + // Only include valid fields in the update + for (const field of validFields) { + if (body[field] !== undefined) { + updateData[field] = body[field]; + } + } + + // Update the server config + await db + .update(serverConfigTable) + .set(updateData) + .where(eq(serverConfigTable.id, 1)); + + // Get the updated config + const updatedConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + return NextResponse.json(updatedConfig); + } catch (error) { + console.error("Error updating server config:", error); + return NextResponse.json( + { error: "Failed to update server configuration" }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts index 565ccb6cb..406eb3447 100644 --- a/apps/web/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,60 @@ import NextAuth from "next-auth"; import { authOptions } from "@cap/database/auth/auth-options"; +import type { NextAuthOptions } from "next-auth"; +import type { User, Account, Profile } from "next-auth"; +import { getServerConfig } from "@/utils/instance/functions"; +import { db } from "@cap/database"; +import { eq } from "drizzle-orm"; +import { spaceInvites, users } from "@cap/database/schema"; -const handler = NextAuth(authOptions); +// Create a modified version of authOptions with additional signIn callback to disable signups if the server is not CapCloud or signups are disabled +const extendedAuthOptions: NextAuthOptions = { + ...authOptions, + callbacks: { + ...authOptions.callbacks, + async signIn(params: { + user: User; + account: Account | null; + profile?: Profile; + email?: { verificationRequest?: boolean }; + credentials?: Record; + }) { + const serverConfig = await getServerConfig(); + + // If CapCloud or signups are enabled, allow all sign-ins and registrations + if (serverConfig.isCapCloud || serverConfig.signupsEnabled) { + return true; + } + + // If no email, reject sign-in + if (!params.user.email) { + return false; + } + + const existingUser = await db.query.users.findFirst({ + where: eq(users.email, params.user.email), + }); + + // If user exists, allow sign-in + if (existingUser) { + return true; + } + + // If user is invited, allow sign-in (workaround for signups being disabled) + const matchedInvitedUser = await db.query.spaceInvites.findFirst({ + where: eq(spaceInvites.invitedEmail, params.user.email), + }); + + if (matchedInvitedUser) { + return true; + } + + // If user doesn't exist, isnt invited and signups are disabled, redirect to login page with error + return `/login?error=signupDisabled`; + }, + }, +}; + +const handler = NextAuth(extendedAuthOptions); export { handler as GET, handler as POST }; diff --git a/apps/web/app/api/desktop/plan/route.ts b/apps/web/app/api/desktop/plan/route.ts index 942eb14c7..96f3ab130 100644 --- a/apps/web/app/api/desktop/plan/route.ts +++ b/apps/web/app/api/desktop/plan/route.ts @@ -1,13 +1,9 @@ import { type NextRequest } from "next/server"; -import { db } from "@cap/database"; -import { users } from "@cap/database/schema"; import { getCurrentUser } from "@cap/database/auth/session"; import { cookies } from "next/headers"; -import { isUserOnProPlan } from "@cap/utils"; -import { stripe } from "@cap/utils"; -import { eq } from "drizzle-orm"; import { clientEnv } from "@cap/env"; import crypto from "crypto"; +import { getIsUserPro } from "@/utils/instance/functions"; const allowedOrigins = [ clientEnv.NEXT_PUBLIC_WEB_URL, @@ -59,37 +55,7 @@ export async function GET(req: NextRequest) { return Response.json({ error: true }, { status: 401 }); } - let isSubscribed = isUserOnProPlan({ - subscriptionStatus: user.stripeSubscriptionStatus as string, - }); - - // Check for third-party Stripe subscription - if (user.thirdPartyStripeSubscriptionId) { - isSubscribed = true; - } - - if (!isSubscribed && !user.stripeSubscriptionId && user.stripeCustomerId) { - try { - const subscriptions = await stripe.subscriptions.list({ - customer: user.stripeCustomerId, - }); - const activeSubscription = subscriptions.data.find( - (sub) => sub.status === "active" - ); - if (activeSubscription) { - isSubscribed = true; - await db - .update(users) - .set({ - stripeSubscriptionStatus: activeSubscription.status, - stripeSubscriptionId: activeSubscription.id, - }) - .where(eq(users.id, user.id)); - } - } catch (error) { - console.error("[GET] Error fetching subscription from Stripe:", error); - } - } + const isPro = await getIsUserPro({ userId: user.id }); let intercomHash = ""; if (process.env.INTERCOM_SECRET) { @@ -101,9 +67,10 @@ export async function GET(req: NextRequest) { return new Response( JSON.stringify({ - upgraded: isSubscribed, - stripeSubscriptionStatus: user.stripeSubscriptionStatus, - intercomHash: intercomHash + upgraded: isPro, + stripeSubscriptionStatus: "active", + // TODO: Legacy: stripeSubscriptionStatus: user.stripeSubscriptionStatus, + intercomHash: intercomHash, }), { status: 200, diff --git a/apps/web/app/api/desktop/subscribe/route.ts b/apps/web/app/api/desktop/subscribe/route.ts index d96fe2ac5..1095972d1 100644 --- a/apps/web/app/api/desktop/subscribe/route.ts +++ b/apps/web/app/api/desktop/subscribe/route.ts @@ -1,11 +1,15 @@ import { type NextRequest } from "next/server"; import { db } from "@cap/database"; -import { users } from "@cap/database/schema"; +import { spaces, users } from "@cap/database/schema"; import { getCurrentUser } from "@cap/database/auth/session"; import { cookies } from "next/headers"; -import { isUserOnProPlan, stripe } from "@cap/utils"; -import { eq } from "drizzle-orm"; +import { getProPlanBillingCycle, stripe } from "@cap/utils"; +import { asc, desc, eq } from "drizzle-orm"; import { clientEnv } from "@cap/env"; +import { + generateCloudProStripeCheckoutSession, + getIsUserPro, +} from "@/utils/instance/functions"; const allowedOrigins = [ clientEnv.NEXT_PUBLIC_WEB_URL, @@ -56,7 +60,6 @@ export async function POST(request: NextRequest) { } const user = await getCurrentUser(); - let customerId = user?.stripeCustomerId; const { priceId } = await request.json(); const params = request.nextUrl.searchParams; const origin = params.get("origin") || null; @@ -103,12 +106,23 @@ export async function POST(request: NextRequest) { ); } - if ( - isUserOnProPlan({ - subscriptionStatus: user.stripeSubscriptionStatus as string, - }) - ) { - console.log("[POST] Error: User already on Pro plan"); + // get workspaces owned by the user, and assume that the oldest one is the personal workspace + const personalWorkspace = await db.query.spaces.findFirst({ + where: eq(spaces.ownerId, user.id), + orderBy: [asc(spaces.createdAt)], + columns: { + id: true, + pro: true, + }, + }); + + if (!personalWorkspace) { + console.log("[POST] Error: User has no personal workspace"); + return Response.json({ error: true }, { status: 400 }); + } + + if (personalWorkspace.pro) { + console.log("[POST] Error: Workspace already on Pro plan"); return Response.json( { error: true, subscription: true }, { @@ -126,45 +140,25 @@ export async function POST(request: NextRequest) { ); } - if (!user.stripeCustomerId) { - console.log("[POST] Creating new Stripe customer"); - const customer = await stripe.customers.create({ - email: user.email, - metadata: { - userId: user.id, - }, - }); + // get the price type based on the priceId + const priceType = getProPlanBillingCycle(priceId); - await db - .update(users) - .set({ - stripeCustomerId: customer.id, - }) - .where(eq(users.id, user.id)); + const checkoutSession = await generateCloudProStripeCheckoutSession({ + cloudWorkspaceId: personalWorkspace.id, + cloudUserId: user.id, + email: user.email, + type: priceType, + }); - customerId = customer.id; - console.log("[POST] Created Stripe customer:", customerId); + if (!checkoutSession) { + console.log("[POST] Error: Failed to create checkout session"); + return Response.json({ error: true }, { status: 400 }); } - console.log("[POST] Creating checkout session"); - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId as string, - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - mode: "subscription", - success_url: `${clientEnv.NEXT_PUBLIC_WEB_URL}/dashboard/caps?upgrade=true`, - cancel_url: `${clientEnv.NEXT_PUBLIC_WEB_URL}/pricing`, - allow_promotion_codes: true, - }); - - if (checkoutSession.url) { + if (checkoutSession.checkoutLink) { console.log("[POST] Checkout session created successfully"); return Response.json( - { url: checkoutSession.url }, + { url: checkoutSession.checkoutLink }, { status: 200, headers: { diff --git a/apps/web/app/api/desktop/video/create/route.ts b/apps/web/app/api/desktop/video/create/route.ts index 3c7ce3f04..3305967b0 100644 --- a/apps/web/app/api/desktop/video/create/route.ts +++ b/apps/web/app/api/desktop/video/create/route.ts @@ -8,6 +8,7 @@ import { dub } from "@/utils/dub"; import { eq } from "drizzle-orm"; import { getS3Bucket, getS3Config } from "@/utils/s3"; import { clientEnv, NODE_ENV } from "@cap/env"; +import { getIsUserPro } from "@/utils/instance/functions"; const allowedOrigins = [ clientEnv.NEXT_PUBLIC_WEB_URL, @@ -81,7 +82,7 @@ export async function GET(req: NextRequest) { } // Check if user is on free plan and video is over 5 minutes - const isUpgraded = user.stripeSubscriptionStatus === "active"; + const isUpgraded = await getIsUserPro({ userId: user.id }); if (!isUpgraded && duration && duration > 300) { return new Response(JSON.stringify({ error: "upgrade_required" }), { diff --git a/apps/web/app/api/invite/accept/route.ts b/apps/web/app/api/invite/accept/route.ts index 404f32dce..e15913e6b 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -4,6 +4,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { spaceInvites, spaceMembers, users } from "@cap/database/schema"; import { eq } from "drizzle-orm"; import { nanoId } from "@cap/database/helpers"; +import { getIsUserPro } from "@/utils/instance/functions"; export async function POST(request: NextRequest) { const user = await getCurrentUser(); @@ -29,21 +30,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Email mismatch" }, { status: 403 }); } - // Get the space owner's subscription ID - const [spaceOwner] = await db - .select({ - stripeSubscriptionId: users.stripeSubscriptionId, - }) - .from(users) - .where(eq(users.id, invite.invitedByUserId)); - - if (!spaceOwner || !spaceOwner.stripeSubscriptionId) { - return NextResponse.json( - { error: "Space owner not found or has no subscription" }, - { status: 404 } - ); - } - // Create a new space member await db.insert(spaceMembers).values({ id: nanoId(), @@ -52,17 +38,12 @@ export async function POST(request: NextRequest) { role: invite.role, }); - // Update the user's thirdPartyStripeSubscriptionId - await db - .update(users) - .set({ - thirdPartyStripeSubscriptionId: spaceOwner.stripeSubscriptionId, - }) - .where(eq(users.id, user.id)); - // Delete the invite await db.delete(spaceInvites).where(eq(spaceInvites.id, inviteId)); + // Refresh user pro status + await getIsUserPro({ userId: user.id }); + return NextResponse.json({ success: true }); } catch (error) { console.error("Error accepting invite:", error); diff --git a/apps/web/app/api/invite/decline/route.ts b/apps/web/app/api/invite/decline/route.ts index b0b995b64..d3b2f0678 100644 --- a/apps/web/app/api/invite/decline/route.ts +++ b/apps/web/app/api/invite/decline/route.ts @@ -2,14 +2,31 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@cap/database"; import { spaceInvites } from "@cap/database/schema"; import { eq } from "drizzle-orm"; +import { updateCloudWorkspaceUserCount } from "@/utils/instance/functions"; export async function POST(request: NextRequest) { const { inviteId } = await request.json(); try { + const inviteData = await db.query.spaceInvites.findFirst({ + where: eq(spaceInvites.id, inviteId), + columns: { + spaceId: true, + }, + }); + + if (!inviteData) { + return NextResponse.json({ error: "Invite not found" }, { status: 404 }); + } + // Delete the invite await db.delete(spaceInvites).where(eq(spaceInvites.id, inviteId)); + // Update workspace user count + await updateCloudWorkspaceUserCount({ + workspaceId: inviteData.spaceId, + }); + return NextResponse.json({ success: true }); } catch (error) { console.error("Error declining invite:", error); diff --git a/apps/web/app/api/settings/billing/manage/route.ts b/apps/web/app/api/settings/billing/manage/route.ts index fdee85d6c..dcd5d5cfa 100644 --- a/apps/web/app/api/settings/billing/manage/route.ts +++ b/apps/web/app/api/settings/billing/manage/route.ts @@ -1,42 +1,30 @@ -import { stripe } from "@cap/utils"; import { getCurrentUser } from "@cap/database/auth/session"; import { NextRequest, NextResponse } from "next/server"; -import { eq } from "drizzle-orm"; -import { db } from "@cap/database"; -import { users } from "@cap/database/schema"; -import { clientEnv } from "@cap/env"; +import { generateCloudProStripePortalLink } from "@/utils/instance/functions"; export async function POST(request: NextRequest) { const user = await getCurrentUser(); - let customerId = user?.stripeCustomerId; if (!user) { console.error("User not found"); - return Response.json({ error: true }, { status: 401 }); } - if (!user.stripeCustomerId) { - const customer = await stripe.customers.create({ - email: user.email, - metadata: { - userId: user.id, - }, - }); - - await db - .update(users) - .set({ - stripeCustomerId: customer.id, - }) - .where(eq(users.id, user.id)); + const userActiveWorkspaceId = user.activeSpaceId; - customerId = customer.id; + if (!userActiveWorkspaceId) { + console.error("User has no active workspace"); + return Response.json({ error: true }, { status: 400 }); } - const { url } = await stripe.billingPortal.sessions.create({ - customer: customerId as string, - return_url: `${clientEnv.NEXT_PUBLIC_WEB_URL}/dashboard/settings/workspace`, + const portalLink = await generateCloudProStripePortalLink({ + cloudWorkspaceId: userActiveWorkspaceId, }); - return NextResponse.json(url); + + if (!portalLink) { + console.error("Failed to generate checkout link"); + return Response.json({ error: true }, { status: 400 }); + } + + return NextResponse.json(portalLink.portalLink); } diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts index eb48f93e9..dc3dd02aa 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -1,16 +1,15 @@ -import { isUserOnProPlan, stripe } from "@cap/utils"; +import { getProPlanBillingCycle } from "@cap/utils"; import { getCurrentUser } from "@cap/database/auth/session"; import { NextRequest } from "next/server"; -import { eq } from "drizzle-orm"; -import { db } from "@cap/database"; -import { users } from "@cap/database/schema"; -import { clientEnv } from "@cap/env"; +import { + generateCloudProStripeCheckoutSession, + isWorkspacePro, +} from "@/utils/instance/functions"; export async function POST(request: NextRequest) { console.log("Starting subscription process"); const user = await getCurrentUser(); - let customerId = user?.stripeCustomerId; - const { priceId, quantity } = await request.json(); + const { priceId, spaceId } = await request.json(); console.log("Received request with priceId:", priceId); console.log("Current user:", user?.id); @@ -20,56 +19,43 @@ export async function POST(request: NextRequest) { return Response.json({ error: true }, { status: 400 }); } + if (!spaceId) { + console.error("Space ID not found"); + return Response.json({ error: true }, { status: 400 }); + } + if (!user) { console.error("User not found"); return Response.json({ error: true, auth: false }, { status: 401 }); } - if ( - isUserOnProPlan({ - subscriptionStatus: user.stripeSubscriptionStatus as string, - }) - ) { - console.error("User already has pro plan"); + // get the current workspace pro status and return if it is already on pro + const workspaceProStatus = await isWorkspacePro({ + workspaceId: spaceId, + }); + if (workspaceProStatus) { + console.error("Workspace already has pro plan"); return Response.json({ error: true, subscription: true }, { status: 400 }); } - try { - if (!user.stripeCustomerId) { - console.log("Creating new Stripe customer for user:", user.id); - const customer = await stripe.customers.create({ - email: user.email, - metadata: { - userId: user.id, - }, - }); - - console.log("Created Stripe customer:", customer.id); + const priceType = getProPlanBillingCycle(priceId); - await db - .update(users) - .set({ - stripeCustomerId: customer.id, - }) - .where(eq(users.id, user.id)); + try { + const checkoutLink = await generateCloudProStripeCheckoutSession({ + cloudWorkspaceId: spaceId, + cloudUserId: user.id, + email: user.email, + type: priceType, + }); - console.log("Updated user with Stripe customer ID"); - customerId = customer.id; + if (!checkoutLink) { + console.error("Failed to create checkout session"); + return Response.json({ error: true }, { status: 400 }); } - console.log("Creating checkout session for customer:", customerId); - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId as string, - line_items: [{ price: priceId, quantity: quantity }], - mode: "subscription", - success_url: `${clientEnv.NEXT_PUBLIC_WEB_URL}/dashboard/caps?upgrade=true`, - cancel_url: `${clientEnv.NEXT_PUBLIC_WEB_URL}/pricing`, - allow_promotion_codes: true, - }); - - if (checkoutSession.url) { + if (checkoutLink.checkoutLink) { console.log("Successfully created checkout session"); - return Response.json({ url: checkoutSession.url }, { status: 200 }); + return Response.json({ url: checkoutLink.checkoutLink }, { status: 200 }); } console.error("Checkout session created but no URL returned"); diff --git a/apps/web/app/api/settings/billing/usage/route.ts b/apps/web/app/api/settings/billing/usage/route.ts index 2a0bb87e8..e6d885f71 100644 --- a/apps/web/app/api/settings/billing/usage/route.ts +++ b/apps/web/app/api/settings/billing/usage/route.ts @@ -1,9 +1,9 @@ -import { isUserOnProPlan } from "@cap/utils"; import { getCurrentUser } from "@cap/database/auth/session"; import { NextRequest } from "next/server"; import { count, eq } from "drizzle-orm"; import { db } from "@cap/database"; import { videos } from "@cap/database/schema"; +import { getIsUserPro } from "@/utils/instance/functions"; export async function GET(request: NextRequest) { const user = await getCurrentUser(); @@ -24,11 +24,9 @@ export async function GET(request: NextRequest) { ); } - if ( - isUserOnProPlan({ - subscriptionStatus: user.stripeSubscriptionStatus as string, - }) - ) { + const isPro = await getIsUserPro({ userId: user.id }); + + if (isPro) { return Response.json( { subscription: true, diff --git a/apps/web/app/api/settings/workspace/invite/remove/route.ts b/apps/web/app/api/settings/workspace/invite/remove/route.ts index f1cb355e4..136fb025e 100644 --- a/apps/web/app/api/settings/workspace/invite/remove/route.ts +++ b/apps/web/app/api/settings/workspace/invite/remove/route.ts @@ -3,6 +3,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { spaces, spaceInvites } from "@cap/database/schema"; import { db } from "@cap/database"; import { eq, and } from "drizzle-orm"; +import { updateCloudWorkspaceUserCount } from "@/utils/instance/functions"; export async function POST(request: NextRequest) { console.log("POST request received for removing workspace invite"); @@ -45,6 +46,11 @@ export async function POST(request: NextRequest) { return Response.json({ error: true }, { status: 404 }); } + // Update workspace user count + await updateCloudWorkspaceUserCount({ + workspaceId: spaceId, + }); + console.log("Workspace invite removed successfully"); return Response.json({ success: true }, { status: 200 }); } diff --git a/apps/web/app/api/settings/workspace/invite/send/route.ts b/apps/web/app/api/settings/workspace/invite/send/route.ts index ad41a97a4..ce857a9e3 100644 --- a/apps/web/app/api/settings/workspace/invite/send/route.ts +++ b/apps/web/app/api/settings/workspace/invite/send/route.ts @@ -7,6 +7,7 @@ import { nanoId } from "@cap/database/helpers"; import { sendEmail } from "@cap/database/emails/config"; import { WorkspaceInvite } from "@cap/database/emails/workspace-invite"; import { clientEnv } from "@cap/env"; +import { updateCloudWorkspaceUserCount } from "@/utils/instance/functions"; export async function POST(request: NextRequest) { console.log("POST request received for workspace invite"); @@ -61,6 +62,7 @@ export async function POST(request: NextRequest) { }), }); } + await updateCloudWorkspaceUserCount({ workspaceId: spaceId }); console.log("Workspace invites created and emails sent successfully"); return Response.json(true, { status: 200 }); diff --git a/apps/web/app/api/video/transcribe/status/route.ts b/apps/web/app/api/video/transcribe/status/route.ts index 8cb2877a6..6591941d4 100644 --- a/apps/web/app/api/video/transcribe/status/route.ts +++ b/apps/web/app/api/video/transcribe/status/route.ts @@ -1,7 +1,6 @@ -import { isUserOnProPlan } from "@cap/utils"; import { getCurrentUser } from "@cap/database/auth/session"; import { NextRequest } from "next/server"; -import { count, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db } from "@cap/database"; import { videos } from "@cap/database/schema"; diff --git a/apps/web/app/dashboard/_components/AdminNavbar/AdminNavItems.tsx b/apps/web/app/dashboard/_components/AdminNavbar/AdminNavItems.tsx index 5d866f318..3ede9a1a0 100644 --- a/apps/web/app/dashboard/_components/AdminNavbar/AdminNavItems.tsx +++ b/apps/web/app/dashboard/_components/AdminNavbar/AdminNavItems.tsx @@ -11,6 +11,7 @@ import { Share2, Check, Building, + Star, } from "lucide-react"; import Link from "next/link"; import { classNames } from "@cap/utils"; @@ -76,7 +77,8 @@ const Download = ({ className }: { className: string }) => ( export const AdminNavItems = () => { const pathname = usePathname(); const [open, setOpen] = useState(false); - const { spaceData, activeSpace, user, isSubscribed } = useSharedContext(); + const { spaceData, activeSpace, user, isSubscribed, isSuperAdmin } = + useSharedContext(); const [menuOpen, setMenuOpen] = useState(false); const router = useRouter(); @@ -105,10 +107,10 @@ export const AdminNavItems = () => { icon: Building, subNav: [], }, - user.email.endsWith("@cap.so") && { + isSuperAdmin && { name: "Admin", href: "/dashboard/admin", - icon: () => {}, + icon: Star, subNav: [], }, ].filter(Boolean); @@ -210,7 +212,7 @@ export const AdminNavItems = () => {
- +
diff --git a/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx b/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx index 785af4d6b..87f772d00 100644 --- a/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx +++ b/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx @@ -10,6 +10,8 @@ type SharedContext = { activeSpace: Space | null; user: typeof users.$inferSelect; isSubscribed: boolean; + isSuperAdmin: boolean; + isCapCloud: boolean; }; const Context = createContext({} as SharedContext); @@ -20,15 +22,28 @@ export default function DynamicSharedLayout({ activeSpace, user, isSubscribed, + isSuperAdmin, + isCapCloud, }: { children: React.ReactNode; spaceData: SharedContext["spaceData"]; activeSpace: SharedContext["activeSpace"]; user: SharedContext["user"]; isSubscribed: SharedContext["isSubscribed"]; + isSuperAdmin: SharedContext["isSuperAdmin"]; + isCapCloud: SharedContext["isCapCloud"]; }) { return ( - +
diff --git a/apps/web/app/dashboard/admin/actions.ts b/apps/web/app/dashboard/admin/actions.ts index 677d4da51..9802f5d34 100644 --- a/apps/web/app/dashboard/admin/actions.ts +++ b/apps/web/app/dashboard/admin/actions.ts @@ -2,8 +2,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { users } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; +import { users, serverConfigTable } from "@cap/database/schema"; +import { eq, like, inArray } from "drizzle-orm"; +import { ServerConfigFormValues, SuperAdminUser } from "./server-config/schema"; +import { validateServerLicense } from "@/utils/instance/functions"; export async function lookupUserById(data: FormData) { const currentUser = await getCurrentUser(); @@ -16,3 +18,200 @@ export async function lookupUserById(data: FormData) { return user; } + +// Helper function to check if user is a super admin +async function isSuperAdmin() { + const currentUser = await getCurrentUser(); + if (!currentUser) return false; + + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + if (!serverConfig) return false; + + return ( + serverConfig.superAdminIds.includes(currentUser.id) || + currentUser.email.endsWith("@cap.so") + ); +} + +// Get current user ID +export async function getCurrentUserId() { + const currentUser = await getCurrentUser(); + return currentUser?.id || null; +} + +// Get server configuration +export async function getServerConfiguration() { + if (!(await isSuperAdmin())) { + throw new Error("Not authorized"); + } + + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + return serverConfig; +} + +// Update server configuration with typed values +export async function updateServerConfiguration( + values: ServerConfigFormValues +) { + if (!(await isSuperAdmin())) { + throw new Error("Not authorized"); + } + + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + if (!serverConfig) { + throw new Error("Server configuration not found"); + } + + const updatedServerConfig = { + ...serverConfig, + licenseKey: values.licenseKey, + signupsEnabled: values.signupsEnabled, + emailSendFromName: values.emailSendFromName, + emailSendFromEmail: values.emailSendFromEmail, + }; + + await db + .update(serverConfigTable) + .set(updatedServerConfig) + .where(eq(serverConfigTable.id, 1)); + + const licenseValidationResponse = await validateServerLicense({ + serverConfig: updatedServerConfig, + }); + + return licenseValidationResponse; +} + +// Search for users by email +export async function searchUsersByEmail( + email: string +): Promise { + if (!(await isSuperAdmin())) { + throw new Error("Not authorized"); + } + + if (!email || email.trim().length < 3) { + return []; + } + + const searchResults = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + image: users.image, + }) + .from(users) + .where(like(users.email, `%${email}%`)) + .limit(5); + + return searchResults; +} + +// Add user to super admin list +export async function addSuperAdmin(userId: string) { + if (!(await isSuperAdmin())) { + throw new Error("Not authorized"); + } + + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + if (!serverConfig) { + throw new Error("Server configuration not found"); + } + + // Check if user is already a super admin + if (serverConfig.superAdminIds.includes(userId)) { + return serverConfig; + } + + // Add user to super admin list + const updatedSuperAdminIds = [...serverConfig.superAdminIds, userId]; + + await db + .update(serverConfigTable) + .set({ superAdminIds: updatedSuperAdminIds }) + .where(eq(serverConfigTable.id, 1)); + + return await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); +} + +// Remove user from super admin list +export async function removeSuperAdmin(userId: string) { + if (!(await isSuperAdmin())) { + throw new Error("Not authorized"); + } + + // Prevent removing yourself + const currentUser = await getCurrentUser(); + if (!currentUser) { + throw new Error("User not authenticated"); + } + + if (userId === currentUser.id) { + throw new Error("You cannot remove yourself from super admins"); + } + + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + if (!serverConfig) { + throw new Error("Server configuration not found"); + } + + // Remove user from super admin list + const updatedSuperAdminIds = serverConfig.superAdminIds.filter( + (id) => id !== userId + ); + + await db + .update(serverConfigTable) + .set({ superAdminIds: updatedSuperAdminIds }) + .where(eq(serverConfigTable.id, 1)); + + return await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); +} + +// Get super admin users with details +export async function getSuperAdminUsers(): Promise { + if (!(await isSuperAdmin())) { + throw new Error("Not authorized"); + } + + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + if (!serverConfig || !serverConfig.superAdminIds.length) { + return []; + } + + // Get user details for all super admins + const superAdminUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + image: users.image, + }) + .from(users) + .where(inArray(users.id, serverConfig.superAdminIds)); + + return superAdminUsers; +} diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx index 6e1f6a656..fa3bec1b8 100644 --- a/apps/web/app/dashboard/admin/page.tsx +++ b/apps/web/app/dashboard/admin/page.tsx @@ -2,24 +2,62 @@ import { useState } from "react"; import { lookupUserById } from "./actions"; +import Link from "next/link"; +import { Button } from "@cap/ui"; -export default function () { +export default function AdminPage() { const [data, setData] = useState(null); return ( -
- -
{ - e.preventDefault(); - lookupUserById(new FormData(e.currentTarget)).then(setData); - }} - > - - -
+
+

Admin Dashboard

- {data &&
{JSON.stringify(data, null, 4)}
} +
+
+

Server Configuration

+

+ Manage server settings including license key, signup settings, and + email configuration. +

+ + + +
+ +
+

User Lookup

+

Look up user information by ID.

+
{ + e.preventDefault(); + lookupUserById(new FormData(e.currentTarget)).then(setData); + }} + className="space-y-4" + > +
+ + +
+ +
+
+
+ + {data && ( +
+

User Information

+
+            {JSON.stringify(data, null, 4)}
+          
+
+ )}
); } diff --git a/apps/web/app/dashboard/admin/server-config/page.tsx b/apps/web/app/dashboard/admin/server-config/page.tsx new file mode 100644 index 000000000..85442fb21 --- /dev/null +++ b/apps/web/app/dashboard/admin/server-config/page.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + getServerConfiguration, + updateServerConfiguration, + searchUsersByEmail, + addSuperAdmin, + removeSuperAdmin, + getSuperAdminUsers, + getCurrentUserId, +} from "../actions"; +import { Button } from "@cap/ui"; +import { Input } from "@cap/ui"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + ServerConfigFormValues, + SuperAdminUser, + serverConfigSchema, +} from "./schema"; + +export default function ServerConfigPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // React Hook Form setup + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(serverConfigSchema), + defaultValues: { + licenseKey: null, + signupsEnabled: false, + emailSendFromName: null, + emailSendFromEmail: null, + }, + }); + + // Super admin management + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const [superAdmins, setSuperAdmins] = useState([]); + const [superAdminError, setSuperAdminError] = useState(null); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + const [currentUserId, setCurrentUserId] = useState(null); + + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery]); + + // Perform search when debounced query changes + useEffect(() => { + if (debouncedSearchQuery.trim().length >= 3) { + performSearch(debouncedSearchQuery); + } else { + setSearchResults([]); + } + }, [debouncedSearchQuery]); + + useEffect(() => { + const fetchData = async () => { + try { + const config = await getServerConfiguration(); + if (config) { + setData(config); + setError(null); + + // Set form default values + reset({ + licenseKey: config.licenseKey || null, + signupsEnabled: config.signupsEnabled || false, + emailSendFromName: config.emailSendFromName || null, + emailSendFromEmail: config.emailSendFromEmail || null, + }); + } + + // Fetch super admin users + const adminUsers = await getSuperAdminUsers(); + setSuperAdmins(adminUsers); + + // Get current user ID + const userId = await getCurrentUserId(); + setCurrentUserId(userId); + } catch (err) { + setError("Failed to load server configuration"); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [reset]); + + const onSubmit = async (values: ServerConfigFormValues) => { + setError(null); + setSuccess(false); + + try { + const updatedConfig = await updateServerConfiguration(values); + setData(updatedConfig); + setSuccess(true); + + // Reset success message after 3 seconds + setTimeout(() => { + setSuccess(false); + }, 3000); + } catch (err) { + setError("Failed to update server configuration"); + console.error(err); + } + }; + + // Perform search with debounce + const performSearch = async (query: string) => { + if (query.trim().length < 3) { + setSearchResults([]); + return; + } + + setSearching(true); + setSuperAdminError(null); + + try { + const results = await searchUsersByEmail(query); + setSearchResults(results); + } catch (err) { + setSuperAdminError("Failed to search for users"); + console.error(err); + } finally { + setSearching(false); + } + }; + + // Handle user search button click + const handleSearchButtonClick = () => { + performSearch(searchQuery); + }; + + // Add user as super admin + const handleAddSuperAdmin = async (userId: string) => { + setSuperAdminError(null); + + try { + const updatedConfig = await addSuperAdmin(userId); + setData(updatedConfig); + + // Refresh super admin list + const adminUsers = await getSuperAdminUsers(); + setSuperAdmins(adminUsers); + + // Clear search + setSearchQuery(""); + setSearchResults([]); + } catch (err) { + setSuperAdminError("Failed to add super admin"); + console.error(err); + } + }; + + // Remove user from super admin list + const handleRemoveSuperAdmin = async (userId: string) => { + setSuperAdminError(null); + + try { + const updatedConfig = await removeSuperAdmin(userId); + setData(updatedConfig); + + // Refresh super admin list + const adminUsers = await getSuperAdminUsers(); + setSuperAdmins(adminUsers); + } catch (err) { + setSuperAdminError( + err instanceof Error ? err.message : "Failed to remove super admin" + ); + console.error(err); + } + }; + + // Check if user is the current user + const isCurrentUser = (userId: string) => { + return userId === currentUserId; + }; + + if (loading && !data) { + return
Loading...
; + } + + if (error && !data) { + return
{error}
; + } + + return ( +
+

Server Configuration

+ + {error && ( +
{error}
+ )} + {success && ( +
+ Configuration updated successfully! +
+ )} + +
+
+
+ + + {errors.licenseKey && ( +

+ {errors.licenseKey.message} +

+ )} +
+

Your Cap license key

+ {data?.licenseValid !== undefined && ( +
+ {data.licenseValid ? "Valid" : "Invalid"} +
+ )} +
+
+ +
+ +
+ + Allow new user signups +
+ {errors.signupsEnabled && ( +

+ {errors.signupsEnabled.message} +

+ )} +
+ +
+ + + {errors.emailSendFromName && ( +

+ {errors.emailSendFromName.message} +

+ )} +

+ Name to use in the "From" field for emails +

+
+ +
+ + + {errors.emailSendFromEmail && ( +

+ {errors.emailSendFromEmail.message} +

+ )} +

+ Email address to use in the "From" field +

+
+
+ +
+ +
+
+ + {/* Super Admin Management */} +
+

Super Admin Management

+

+ Manage who can access this page and change server configuration +

+ + {superAdminError && ( +
+ {superAdminError} +
+ )} + +
+ +
+ setSearchQuery(e.target.value)} + placeholder="Enter email address (min 3 characters)" + className="w-full max-w-full" + /> + +
+ + {searching && ( +
Searching...
+ )} + + {/* Search Results */} + {searchResults.length > 0 && !searching && ( +
+ {searchResults.map((user) => ( +
+
+ {user.image && ( + {user.name + )} +
+ {user.name && ( +
{user.name}
+ )} +
{user.email}
+
+
+ +
+ ))} +
+ )} + + {searchQuery.trim().length >= 3 && + searchResults.length === 0 && + !searching && ( +
+ No users found matching "{searchQuery}" +
+ )} +
+ + {/* Current Super Admins */} +
+

Current Super Admins

+ + {superAdmins.length === 0 ? ( +
+ No super admins configured +
+ ) : ( +
+ {superAdmins.map((admin) => ( +
+
+ {admin.image && ( + {admin.name + )} +
+ {admin.name && ( +
+ {admin.name} + {isCurrentUser(admin.id) && ( + + (You) + + )} +
+ )} +
{admin.email}
+
+
+
+ + {isCurrentUser(admin.id) && ( +
+ You cannot remove yourself from super admins +
+ )} +
+
+ ))} +
+ )} +
+
+ + {/* Current Configuration */} +
+

Current Configuration

+
+          {JSON.stringify(data, null, 2)}
+        
+
+
+ ); +} diff --git a/apps/web/app/dashboard/admin/server-config/schema.ts b/apps/web/app/dashboard/admin/server-config/schema.ts new file mode 100644 index 000000000..4de2bd933 --- /dev/null +++ b/apps/web/app/dashboard/admin/server-config/schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +// Schema for server configuration form +export const serverConfigSchema = z.object({ + licenseKey: z.string().nullable(), + signupsEnabled: z.boolean().default(false), + emailSendFromName: z.string().nullable(), + emailSendFromEmail: z.string().email().nullable(), +}); + +// Type for server configuration form +export type ServerConfigFormValues = z.infer; + +// Schema for super admin management +export const superAdminSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + email: z.string().email(), + image: z.string().nullable(), +}); + +export type SuperAdminUser = z.infer; diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index ccd898d19..c62a887be 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -10,6 +10,11 @@ import { users, } from "@cap/database/schema"; import { eq, inArray, or, and, count, sql } from "drizzle-orm"; +import { + addServerSuperAdmin, + getIsUserPro, + getServerConfig, +} from "@/utils/instance/functions"; export type Space = { space: typeof spaces.$inferSelect; @@ -128,10 +133,15 @@ export default async function DashboardLayout({ findActiveSpace = spaceSelect[0]; } - const isSubscribed = - (user.stripeSubscriptionId && - user.stripeSubscriptionStatus !== "cancelled") || - !!user.thirdPartyStripeSubscriptionId; + const isSubscribed = await getIsUserPro({ userId: user.id }); + + const serverConfig = await getServerConfig(); + let serverSuperAdminIds = serverConfig?.superAdminIds; + if (!serverSuperAdminIds || serverSuperAdminIds.length === 0) { + await addServerSuperAdmin({ userId: user.id }); + serverSuperAdminIds = [user.id]; + } + const isSuperAdmin = serverSuperAdminIds.includes(user.id); return (
{children} diff --git a/apps/web/app/dashboard/settings/workspace/Workspace.tsx b/apps/web/app/dashboard/settings/workspace/Workspace.tsx index bddbfce61..99fab256d 100644 --- a/apps/web/app/dashboard/settings/workspace/Workspace.tsx +++ b/apps/web/app/dashboard/settings/workspace/Workspace.tsx @@ -24,15 +24,18 @@ import { DialogFooter, } from "@cap/ui"; import { useRouter } from "next/navigation"; -import { useState, useRef } from "react"; +import { useState, useRef, useEffect, Suspense } from "react"; import toast from "react-hot-toast"; import { useSharedContext } from "@/app/dashboard/_components/DynamicSharedLayout"; import { format } from "date-fns"; import { Tooltip } from "react-tooltip"; import { CustomDomain } from "./components/CustomDomain"; +import { canInstanceAddUserAction, isWorkspaceProAction } from "@/app/actions"; +import { CloudProUpgrade } from "./components/CloudProUpgrade"; +import Loading from "../loading"; export const Workspace = () => { - const { spaceData, activeSpace, user } = useSharedContext(); + const { activeSpace, user, isCapCloud } = useSharedContext(); const workspaceName = activeSpace?.space.name; const router = useRouter(); const [loading, setLoading] = useState(false); @@ -41,6 +44,28 @@ export const Workspace = () => { const [inviteEmails, setInviteEmails] = useState([]); const [emailInput, setEmailInput] = useState(""); const ownerToastShown = useRef(false); + const [isServerConfigLoading, setIsServerConfigLoading] = useState(true); + const [canAddUser, setCanAddUser] = useState(true); + const [isWorkspacePro, setIsWorkspacePro] = useState(false); + + useEffect(() => { + const fetchServerConfig = async () => { + setIsServerConfigLoading(true); + try { + const canAdd = await canInstanceAddUserAction(); + setCanAddUser(canAdd); + if (activeSpace?.space.id) { + const proStatus = await isWorkspaceProAction(activeSpace.space.id); + setIsWorkspacePro(proStatus); + } + setIsServerConfigLoading(false); + } catch (error) { + console.error("Failed to get server config:", error); + } + }; + + fetchServerConfig(); + }, [activeSpace?.space.id]); const showOwnerToast = () => { if (!ownerToastShown.current) { @@ -165,156 +190,130 @@ export const Workspace = () => { }; return ( -
- {isOwner === false && ( + <> + + {isOwner === false && ( + + *Only the owner can make changes + + Only the owner can make changes to this workspace. + + + )} - *Only the owner can make changes + Workspace Details - Only the owner can make changes to this workspace. + Changing the name and image will update how your workspace appears + to others members. - )} - - Workspace Details - - Changing the name and image will update how your workspace appears to - others members. - - - -
-
- - { - if (!isOwner) showOwnerToast(); - }} - /> -
-
- - { - if (!isOwner) showOwnerToast(); - }} - /> -

- Only users with email addresses from this domain will be able to - access videos shared in this workspace. Leave empty to allow all - users. -

+ +
+
+ + { + if (!isOwner) showOwnerToast(); + }} + /> +
+
+ + { + if (!isOwner) showOwnerToast(); + }} + /> +

+ Only users with email addresses from this domain will be able to + access videos shared in this workspace. Leave empty to allow all + users. +

+
-
- - - - - <> + + + + + Custom Domain Configure a custom domain for your workspace's shared caps.
- +
- +
Workspace Members - Manage your workspace members. - - Current seats capacity:{" "} - {`${activeSpace?.inviteQuota} paid ${ - activeSpace && activeSpace?.inviteQuota > 1 - ? "subscriptions" - : "subscription" - } across all of your workspaces`} - - - Seats remaining:{" "} - {activeSpace?.inviteQuota ?? 1 - (activeSpace?.totalInvites ?? 1)} - + {isCapCloud && ( + Manage your workspace members. + )}
- - - + {isCapCloud ? ( + + ) : ( + + )}
@@ -377,43 +376,61 @@ export const Workspace = () => { - - View and manage your billing details - - View and edit your billing details, as well as manage your - subscription. - - - - - - - + {isServerConfigLoading ? ( + + ) : ( + isCapCloud && ( + <> + + View and manage your billing details + + View and edit your billing details, as well as manage your + subscription. + + + + + {isWorkspacePro ? ( + <> +

You are currently on the Cap Pro plan.

+
+ +
+ + ) : ( + + )} +
+
+ + ) + )} @@ -480,6 +497,6 @@ export const Workspace = () => { - + ); }; diff --git a/apps/web/app/dashboard/settings/workspace/components/CloudProUpgrade.tsx b/apps/web/app/dashboard/settings/workspace/components/CloudProUpgrade.tsx new file mode 100644 index 000000000..57792a368 --- /dev/null +++ b/apps/web/app/dashboard/settings/workspace/components/CloudProUpgrade.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { + Button, + LogoBadge, + Card, + CardDescription, + CardTitle, + CardHeader, + CardContent, + CardFooter, + Switch, +} from "@cap/ui"; +import { Check, Construction } from "lucide-react"; +import { useState, useEffect } from "react"; +import { getProPlanId } from "@cap/utils"; +import toast from "react-hot-toast"; +import { useRouter, useSearchParams } from "next/navigation"; +import { parseAsBoolean, parseAsInteger, useQueryState } from "nuqs"; +import { pricingPerUser, proPlanFeatureList } from "@/components/pages/consts"; +import { useSharedContext } from "@/app/dashboard/_components/DynamicSharedLayout"; + +export const CloudProUpgrade = ({ + workspaceUserCount, +}: { + workspaceUserCount: number; +}) => { + const [proLoading, setProLoading] = useState(false); + const [isAnnual, setIsAnnual] = useQueryState( + "proAnnual", + parseAsBoolean.withDefault(true) + ); + const [initialRender, setInitialRender] = useState(true); + + const { activeSpace, isCapCloud } = useSharedContext(); + + const spaceName = activeSpace?.space.name; + + useEffect(() => { + const init = async () => { + setInitialRender(false); + }; + + init(); + }, []); + + const planCheckout = async (planId?: string) => { + setProLoading(true); + + if (!planId) { + planId = getProPlanId(isAnnual ? "yearly" : "monthly"); + } + + const response = await fetch(`/api/settings/billing/subscribe`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + priceId: planId, + spaceId: activeSpace?.space.id, + }), + }); + const data = await response.json(); + + if (data.subscription === true) { + toast.success("You are already on the Cap Pro plan"); + } + + if (data.url) { + window.location.href = data.url; + } + + setProLoading(false); + }; + + if (!isCapCloud) { + return null; + } + + return ( +
+

+ Cap is currently in public beta, and we're offering special early + adopter pricing to our first users. This pricing will be locked in for + the lifetime of your subscription. +

+ + +
+ + + App + Commercial License +{" "} + Cap Pro + + + For professional use + cloud features like shareable links, + transcriptions, comments, & more. Perfect for teams or sharing + with clients. + + + +
+
+

+ {isAnnual + ? `$${ + pricingPerUser.cloud.annually * workspaceUserCount + }/mo` + : `$${ + pricingPerUser.cloud.monthly * workspaceUserCount + }/mo`} +

+ +
+ Annual + setIsAnnual(!isAnnual)} + /> + Monthly +
+
+
+

+ {isAnnual + ? `$${pricingPerUser.cloud.annually}/mo per user${ + workspaceUserCount === 1 ? "" : "s" + }, billed annually at $${ + pricingPerUser.cloud.annually * workspaceUserCount * 12 + }/year.` + : `$${pricingPerUser.cloud.monthly}/mo per user${ + workspaceUserCount === 1 ? "" : "s" + }, billed monthly at $${ + pricingPerUser.cloud.monthly * workspaceUserCount + }/mo.`} +

+
+ + + +
    + {proPlanFeatureList.map((item, index) => ( +
  • +
    + +
    + + {item.text} + +
  • + ))} +
+
+
+
+
+
+ ); +}; diff --git a/apps/web/app/dashboard/settings/workspace/components/CustomDomain.tsx b/apps/web/app/dashboard/settings/workspace/components/CustomDomain.tsx index 9dbb976b6..f97435bab 100644 --- a/apps/web/app/dashboard/settings/workspace/components/CustomDomain.tsx +++ b/apps/web/app/dashboard/settings/workspace/components/CustomDomain.tsx @@ -37,9 +37,9 @@ type VerificationResponse = { }; }; -export function CustomDomain() { +export function CustomDomain({ isWorkspacePro }: { isWorkspacePro: boolean }) { const router = useRouter(); - const { activeSpace, isSubscribed } = useSharedContext(); + const { activeSpace, isSubscribed, user } = useSharedContext(); const [domain, setDomain] = useState(activeSpace?.space.customDomain || ""); const [loading, setLoading] = useState(false); const [verifying, setVerifying] = useState(false); @@ -52,6 +52,8 @@ export function CustomDomain() { const pollInterval = useRef(); const POLL_INTERVAL = 5000; // 5 seconds + const isOwner = user?.id === activeSpace?.space.ownerId; + const cleanDomain = (input: string) => { if (!input) return ""; @@ -168,7 +170,7 @@ export function CustomDomain() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!isSubscribed) { + if (!isWorkspacePro) { toast.error( (t) => ( @@ -315,10 +317,14 @@ export function CustomDomain() { placeholder="your-domain.com" value={domain} onChange={(e) => setDomain(e.target.value)} - disabled={loading} + disabled={loading || !isOwner} className="flex-1" /> - {activeSpace?.space.customDomain && ( @@ -342,7 +348,7 @@ export function CustomDomain() { type="button" variant="destructive" onClick={handleRemoveDomain} - disabled={loading} + disabled={loading || !isOwner} > Remove diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 724ac87ac..257015fe8 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -7,8 +7,8 @@ import crypto from "crypto"; import { GeistSans } from "geist/font/sans"; import type { Metadata } from "next"; import { Toaster } from "react-hot-toast"; -import { AuthProvider } from "./AuthProvider"; -import { PostHogProvider, Providers } from "./providers"; +import { Providers } from "./providers"; +import { getServerConfig } from "@/utils/instance/functions"; export const metadata: Metadata = { title: "Cap — Beautiful screen recordings, owned by you.", @@ -30,6 +30,8 @@ export default async function RootLayout({ children: React.ReactNode; }) { const user = await getCurrentUser(); + const serverConfig = await getServerConfig(); + let intercomHash = ""; if (process.env.INTERCOM_SECRET) { intercomHash = crypto @@ -65,24 +67,20 @@ export default async function RootLayout({ - - - - -
- - {children} -
-
- -
-
-
+ + +
+ + {children} +
+
+
); diff --git a/apps/web/app/login/form.tsx b/apps/web/app/login/form.tsx index 30b4eddeb..fcae8cda9 100644 --- a/apps/web/app/login/form.tsx +++ b/apps/web/app/login/form.tsx @@ -2,13 +2,287 @@ import { Button } from "@cap/ui"; import { signIn } from "next-auth/react"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; import { Input, Label } from "@cap/ui"; import { NODE_ENV } from "@cap/env"; -export function LoginForm() { +// Google Login Component +interface GoogleLoginProps { + next: string | null; + loading: boolean; + oauthError: boolean; + showOrgInput: boolean; +} + +function GoogleLoginComponent({ + next, + loading, + oauthError, + showOrgInput, +}: GoogleLoginProps) { + const handleGoogleSignIn = () => { + signIn("google", { + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }); + }; + + if (oauthError) { + return ( +
+

+ It looks like you've previously used this email to sign up via email + login. Please enter your email below to receive a sign in link. +

+
+ ); + } + + if (showOrgInput) { + return null; + } + + return ( + <> + + +
+
+
+
+
+ + Or + +
+
+ + ); +} + +// WorkOS (SSO) Login Component +interface WorkOSLoginProps { + loading: boolean; + showOrgInput: boolean; + setShowOrgInput: (show: boolean) => void; +} + +function WorkOSLoginComponent({ + loading, + showOrgInput, + setShowOrgInput, +}: WorkOSLoginProps) { + const [spaceId, setSpaceId] = useState(""); + const [spaceName, setSpaceName] = useState(null); + + const handleSpaceLookup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!spaceId) { + toast.error("Please enter a space ID"); + return; + } + + try { + const response = await fetch( + `/api/settings/workspace/lookup?spaceId=${encodeURIComponent(spaceId)}` + ); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to look up workspace"); + } + + signIn("workos", undefined, { + organization: data.organizationId, + connection: data.connectionId, + }); + } catch (error) { + console.error("Lookup Error:", error); + toast.error("Space not found or SSO not configured"); + } + }; + + if (!showOrgInput) { + return ( + + ); + } + + return ( +
+
+ + setSpaceId(e.target.value)} + className="w-full max-w-full" + /> +
+ {spaceName && ( +

Signing in to: {spaceName}

+ )} +
+ +
+
+ ); +} + +// Email Login Component +interface EmailLoginProps { + next: string | null; + loading: boolean; + setLoading: (loading: boolean) => void; + email: string; + setEmail: (email: string) => void; + emailSent: boolean; + setEmailSent: (sent: boolean) => void; + showOrgInput: boolean; +} + +function EmailLoginComponent({ + next, + loading, + setLoading, + email, + setEmail, + emailSent, + setEmailSent, + showOrgInput, +}: EmailLoginProps) { + const router = useRouter(); + + if (showOrgInput) { + return null; + } + + const handleEmailSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) return; + + setLoading(true); + signIn("email", { + email, + redirect: false, + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }) + .then((res) => { + console.log("🔥", { res }); + setLoading(false); + if (res?.ok && !res?.error) { + setEmail(""); + setEmailSent(true); + toast.success("Email sent - check your inbox!"); + } else { + console.log("🔥 res", res?.error); + toast.error("Error sending email - try again?"); + } + }) + .catch((err) => { + setEmailSent(false); + setLoading(false); + // This is a workaround for a bug in next-auth where it throws an error when the the callback returns a URL but redirect is false + if (err.message === "Failed to construct 'URL': Invalid URL") { + router.push("/login?error=signupDisabled"); + } else { + toast.error("Error sending email - try again?"); + } + }); + }; + + return ( +
+
+ { + setEmail(e.target.value); + }} + className="block w-full appearance-none rounded-full border border-gray-300 px-3 h-12 placeholder-gray-400 shadow-sm focus:border-black focus:outline-none focus:ring-black text-lg" + /> + {NODE_ENV === "development" && ( +
+

+ Development mode:{" "} + Auth URL will be logged to your dev console. +

+
+ )} +
+ +
+ ); +} + +// Main Login Form Component +interface LoginFormProps { + serverConfig: { + auth: { + google?: boolean; + workos?: boolean; + }; + }; +} + +export function LoginForm({ serverConfig }: LoginFormProps) { const searchParams = useSearchParams(); const next = searchParams?.get("next"); const [email, setEmail] = useState(""); @@ -16,9 +290,6 @@ export function LoginForm() { const [emailSent, setEmailSent] = useState(false); const [oauthError, setOauthError] = useState(false); const [showOrgInput, setShowOrgInput] = useState(false); - const [organizationId, setOrganizationId] = useState(""); - const [spaceId, setSpaceId] = useState(""); - const [spaceName, setSpaceName] = useState(null); useEffect(() => { const error = searchParams?.get("error"); @@ -68,212 +339,40 @@ export function LoginForm() { } }, [emailSent]); - const handleGoogleSignIn = () => { - signIn("google", { - ...(next && next.length > 0 ? { callbackUrl: next } : {}), - }); - }; - - const handleSpaceLookup = async (e: React.FormEvent) => { - e.preventDefault(); - if (!spaceId) { - toast.error("Please enter a space ID"); - return; - } - - try { - const response = await fetch( - `/api/settings/workspace/lookup?spaceId=${encodeURIComponent(spaceId)}` - ); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || "Failed to look up workspace"); - } - - signIn("workos", undefined, { - organization: data.organizationId, - connection: data.connectionId, - }); - } catch (error) { - console.error("Lookup Error:", error); - toast.error("Space not found or SSO not configured"); - } - }; - return ( <>
- {!oauthError && ( - <> - {showOrgInput === false && ( - - )} - - {showOrgInput === false && ( -
-
-
-
-
- - Or - -
-
- )} - + {/* Google Login */} + {serverConfig.auth.google && ( + )} - {oauthError && ( -
-

- It looks like you've previously used this email to sign up via - email login. Please enter your email below to receive a sign in - link. -

-
- )} + {/* Email Login */} + -
{ - e.preventDefault(); - if (!email) return; - - setLoading(true); - signIn("email", { - email, - redirect: false, - ...(next && next.length > 0 ? { callbackUrl: next } : {}), - }) - .then((res) => { - setLoading(false); - if (res?.ok && !res?.error) { - setEmail(""); - setEmailSent(true); - toast.success("Email sent - check your inbox!"); - } else { - toast.error("Error sending email - try again?"); - } - }) - .catch((err) => { - setEmailSent(false); - setLoading(false); - toast.error("Error sending email - try again?"); - }); - }} - className="flex flex-col space-y-3" - > - {showOrgInput === false && ( - <> -
- { - setEmail(e.target.value); - }} - className="block w-full appearance-none rounded-full border border-gray-300 px-3 h-12 placeholder-gray-400 shadow-sm focus:border-black focus:outline-none focus:ring-black text-lg" - /> - {NODE_ENV === "development" && ( -
-

- - Development mode: - {" "} - Auth URL will be logged to your dev console. -

-
- )} -
- - - )} - - {showOrgInput === false && ( - - )} -
- {showOrgInput && ( -
-
- - setSpaceId(e.target.value)} - className="w-full max-w-full" - /> -
- {spaceName && ( -

- Signing in to: {spaceName} -

- )} -
- -
-
+ {/* WorkOS Login */} + {serverConfig.auth.workos && ( + )} +

By typing your email and clicking continue, you acknowledge that you have both read and agree to Cap's{" "} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 646dfda3e..9ef21e55a 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -3,14 +3,23 @@ import { Suspense } from "react"; import { LoginForm } from "./form"; import { getCurrentUser } from "@cap/database/auth/session"; import { redirect } from "next/navigation"; +import Loading from "../dashboard/loading"; +import { getServerConfigAction } from "../actions"; -export default async function LoginPage() { +export default async function LoginPage({ + searchParams, +}: { + searchParams: { error?: string }; +}) { const session = await getCurrentUser(); + const serverConfig = await getServerConfigAction(); if (session) { redirect("/dashboard"); } + const showSignupDisabledError = searchParams.error === "signupDisabled"; + return (

@@ -25,18 +34,23 @@ export default async function LoginPage() { Beautiful screen recordings, owned by you.

+ {showSignupDisabledError && ( +
+

Sign-ups are disabled

+

+ Only existing users can sign in at this time. +

+
+ )}
-
diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index dd02cbba7..6e5aa4beb 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,14 +1,19 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { identifyUser, initAnonymousUser } from "./utils/analytics"; import posthog from "posthog-js"; import { PostHogProvider as PHProvider } from "posthog-js/react"; import { clientEnv } from "@cap/env"; import PostHogPageView from "./PosthogPageView"; import Intercom from "@intercom/messenger-js-sdk"; +import { AuthProvider } from "./AuthProvider"; +import { BentoScript } from "@/components/BentoScript"; +import { getServerConfigAction } from "./actions"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; -export function PostHogProvider({ children }: { children: React.ReactNode }) { +// Internal PostHog provider component +function PostHogWrapper({ children }: { children: React.ReactNode }) { useEffect(() => { posthog.init(clientEnv.NEXT_PUBLIC_POSTHOG_KEY as string, { api_host: clientEnv.NEXT_PUBLIC_POSTHOG_HOST as string, @@ -24,67 +29,96 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { ); } -export function AnalyticsProvider({ +// Single exported Providers component +export function Providers({ children, userId, intercomHash, name, email, + initialIsCapCloud, }: { children: React.ReactNode; userId?: string; intercomHash?: string; name?: string; email?: string; + initialIsCapCloud?: boolean; }) { - if (intercomHash === "") { - Intercom({ - app_id: "efxq71cv", - utm_source: "web", - }); - } else { - Intercom({ - app_id: "efxq71cv", - user_id: userId ?? "", - user_hash: intercomHash ?? "", - name: name, - email: email, - utm_source: "web", - }); - } + const [isCapCloud, setIsCapCloud] = useState( + initialIsCapCloud ?? null + ); + const [isLoading, setIsLoading] = useState( + initialIsCapCloud !== undefined ? false : true + ); useEffect(() => { + const checkIsCapCloud = async () => { + if (initialIsCapCloud !== undefined) { + return; // Skip fetching if we already have the value + } + + try { + const serverConfig = await getServerConfigAction(); + setIsCapCloud(serverConfig.isCapCloud); + } catch (error) { + console.error("Failed to get server config:", error); + setIsCapCloud(false); // Default to false on error + } finally { + setIsLoading(false); + } + }; + + checkIsCapCloud(); + }, [initialIsCapCloud]); + + // Initialize Intercom and analytics if isCapCloud is true + useEffect(() => { + if (!isCapCloud) return; + + // Initialize Intercom + if (intercomHash === "") { + Intercom({ + app_id: "efxq71cv", + utm_source: "web", + }); + } else { + Intercom({ + app_id: "efxq71cv", + user_id: userId ?? "", + user_hash: intercomHash ?? "", + name: name, + email: email, + utm_source: "web", + }); + } + + // Initialize analytics identification if (!userId) { initAnonymousUser(); } else { identifyUser(userId); } - }, [userId]); + }, [userId, intercomHash, name, email, isCapCloud]); - return <>{children}; -} + // Show nothing during initial load to prevent flash of content + if (isLoading) { + return null; + } -export function Providers({ - children, - userId, - intercomHash, - name, - email, -}: { - children: React.ReactNode; - userId?: string; - intercomHash?: string; - name?: string; - email?: string; -}) { + // Always wrap with AuthProvider, but conditionally wrap with PostHog if isCapCloud return ( - - {children} - + + + {isCapCloud ? ( + <> + {children} + {email && } + + ) : ( + children + )} + + ); } diff --git a/apps/web/components/BentoScript.tsx b/apps/web/components/BentoScript.tsx index a044c641e..a62cebb14 100644 --- a/apps/web/components/BentoScript.tsx +++ b/apps/web/components/BentoScript.tsx @@ -2,7 +2,6 @@ import { useEffect } from "react"; import Script from "next/script"; -import { users } from "@cap/database/schema"; import { usePathname, useSearchParams } from "next/navigation"; declare global { @@ -11,22 +10,18 @@ declare global { } } -export function BentoScript({ - user, -}: { - user?: typeof users.$inferSelect | null; -}) { +export function BentoScript({ userEmail }: { userEmail: string }) { const pathname = usePathname(); const searchParams = useSearchParams(); useEffect(() => { if (window.bento !== undefined) { - if (user) { - window.bento.identify(user.email); + if (userEmail) { + window.bento.identify(userEmail); } window.bento.view(); } - }, [pathname, searchParams]); + }, [pathname, searchParams, userEmail]); return (