From 048e296fe6ca932109edca2a19ba11fc96772b13 Mon Sep 17 00:00:00 2001 From: McPizza0 Date: Tue, 18 Feb 2025 22:11:10 +0100 Subject: [PATCH 01/17] update cloud db with SH config and functions WIP --- apps/web/app/superadmin/layout.tsx | 41 ++++ apps/web/app/superadmin/page.tsx | 0 apps/web/utils/instance/api.ts | 195 +++++++++++++++++ apps/web/utils/instance/constants.ts | 4 + apps/web/utils/instance/functions.ts | 278 +++++++++++++++++++++++++ apps/web/utils/instance/helpers.ts | 79 +++++++ packages/database/auth/auth-options.ts | 3 +- packages/database/emails/config.ts | 33 ++- packages/database/index.ts | 4 +- packages/database/schema.ts | 16 ++ 10 files changed, 645 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/superadmin/layout.tsx create mode 100644 apps/web/app/superadmin/page.tsx create mode 100644 apps/web/utils/instance/api.ts create mode 100644 apps/web/utils/instance/constants.ts create mode 100644 apps/web/utils/instance/functions.ts create mode 100644 apps/web/utils/instance/helpers.ts diff --git a/apps/web/app/superadmin/layout.tsx b/apps/web/app/superadmin/layout.tsx new file mode 100644 index 000000000..ba0e7db11 --- /dev/null +++ b/apps/web/app/superadmin/layout.tsx @@ -0,0 +1,41 @@ +import DynamicSharedLayout from "@/app/dashboard/_components/DynamicSharedLayout"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { redirect } from "next/navigation"; +import { DashboardTemplate } from "@/components/templates/DashboardTemplate"; +import { db } from "@cap/database"; +import { + spaceMembers, + spaces, + spaceInvites, + users, +} from "@cap/database/schema"; +import { eq, inArray, or, and, count, sql } from "drizzle-orm"; + +export default async function SuperAdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getCurrentUser(); + + if (!user || !user.id) { + redirect("/login"); + } + + if (!user.name || user.name.length <= 1) { + redirect("/onboarding"); + } + + return ( + +
+ {children} +
+
+ ); +} diff --git a/apps/web/app/superadmin/page.tsx b/apps/web/app/superadmin/page.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/web/utils/instance/api.ts b/apps/web/utils/instance/api.ts new file mode 100644 index 000000000..ff1a0e959 --- /dev/null +++ b/apps/web/utils/instance/api.ts @@ -0,0 +1,195 @@ +import { serverConfigTable } from "@cap/database/schema"; +import { INSTANCE_SITE_URL, LICENSE_SERVER_URL } from "./constants"; + +type LicenseApiTypes = { + validate: { + params: { + usedSeats: number; + }; + response: { + refresh: string; + isCapCloudLicense: boolean; + isValid: boolean; + }; + codes: { + 200: { refresh: string; isCapCloudLicense: boolean; isValid: boolean }; + 402: { error: "License expired" }; + 403: { error: "Too many seats" }; + 404: { error: "License not found" }; + 409: { error: "License already activated on another site" }; + }; + }; + addUserCheck: { + params: { + usedSeats: number; + }; + response: void; + codes: { + 200: { success: true }; + 403: { error: "Too many seats" }; + 404: { error: "License not found" }; + }; + }; + addUserPost: { + params: { + usedSeats: number; + }; + response: void; + codes: { + 200: { success: true }; + 403: { error: "Too many seats" }; + 404: { error: "License not found" }; + }; + }; + getUser: { + params: { + email: string; + }; + response: { + exists: boolean; + }; + codes: { + 200: { exists: boolean }; + 404: { error: "License not found" }; + }; + }; + workspace: { + params: { + workspaceId: string; + name: string; + }; + response: void; + codes: { + 200: { success: true }; + 404: { error: "License not found" }; + }; + }; + workspaceAddUser: { + params: { + workspaceId: string; + email: string; + }; + response: void; + codes: { + 200: { success: true }; + 403: { error: "Too many seats" }; + 404: { error: "License or workspace not found" }; + }; + }; + workspaceCheckout: { + params: { + workspaceId: string; + successUrl: string; + cancelUrl: string; + }; + response: { + url: string; + }; + codes: { + 200: { url: string }; + 404: { error: "License or workspace not found" }; + }; + }; + workspacePortal: { + params: { + workspaceId: string; + }; + response: { + url: string; + }; + codes: { + 200: { url: string }; + 404: { error: "License or workspace not found" }; + }; + }; +}; + +export function licenseApi(config: { + serverConfig: typeof serverConfigTable.$inferSelect; +}) { + if (!config.serverConfig.licenseKey || !config.serverConfig.licenseValid) { + throw new Error("Server does not have a valid license"); + } + + const makeRequest = async ( + endpoint: string, + method: string, + body: object + ): Promise => { + const response = await fetch(`${LICENSE_SERVER_URL}/api/${endpoint}`, { + method, + headers: { + licenseKey: config.serverConfig.licenseKey!, + siteUrl: INSTANCE_SITE_URL, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`License API error: ${response.status}`); + } + + return response.json(); + }; + + return { + validate: (params: LicenseApiTypes["validate"]["params"]) => + makeRequest( + "instances/validate", + "POST", + params + ), + + addUserCheck: (params: LicenseApiTypes["addUserCheck"]["params"]) => + makeRequest( + "instances/add-user/check", + "POST", + params + ), + + addUserPost: (params: LicenseApiTypes["addUserPost"]["params"]) => + makeRequest( + "instances/add-user/post", + "POST", + params + ), + + getUser: (params: LicenseApiTypes["getUser"]["params"]) => + makeRequest( + "instances/user", + "GET", + params + ), + + workspace: (params: LicenseApiTypes["workspace"]["params"]) => + makeRequest( + "instances/workspace", + "POST", + params + ), + + workspaceAddUser: (params: LicenseApiTypes["workspaceAddUser"]["params"]) => + makeRequest( + "instances/workspace/add-user", + "POST", + params + ), + + workspaceCheckout: ( + params: LicenseApiTypes["workspaceCheckout"]["params"] + ) => + makeRequest( + "instances/workspace/checkout", + "POST", + params + ), + + workspacePortal: (params: LicenseApiTypes["workspacePortal"]["params"]) => + makeRequest( + "instances/workspace/portal", + "POST", + params + ), + }; +} diff --git a/apps/web/utils/instance/constants.ts b/apps/web/utils/instance/constants.ts new file mode 100644 index 000000000..63524da34 --- /dev/null +++ b/apps/web/utils/instance/constants.ts @@ -0,0 +1,4 @@ +import { clientEnv } from "@cap/env"; + +export const LICENSE_SERVER_URL = "https://l.cap.so"; +export const INSTANCE_SITE_URL = clientEnv.NEXT_PUBLIC_WEB_URL; diff --git a/apps/web/utils/instance/functions.ts b/apps/web/utils/instance/functions.ts new file mode 100644 index 000000000..30c7c7394 --- /dev/null +++ b/apps/web/utils/instance/functions.ts @@ -0,0 +1,278 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { serverConfigTable, spaces, users } from "@cap/database/schema"; +import { eq } from "drizzle-orm"; +import { INSTANCE_SITE_URL, LICENSE_SERVER_URL } from "./constants"; +import { + getServerUserCount, + getUserWorkspaceMembershipWorkspaceIds, +} from "./helpers"; + +export const getServerConfig = async (): Promise< + typeof serverConfigTable.$inferSelect +> => { + const serverConfig = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), + }); + + // create server config object if it doesn't exist + if (!serverConfig) { + const newServerConfig = { + id: 1, + licenseKey: null, + licenseValid: false, + isCapCloud: false, + licenseValidityCache: null, + superAdminIds: [], + signupsEnabled: false, + emailSendFromName: null, + emailSendFromEmail: null, + }; + await db.insert(serverConfigTable).values(newServerConfig); + + return newServerConfig; + } + + if ( + serverConfig.licenseKey && + (serverConfig.licenseValidityCache === null || + serverConfig.licenseValidityCache.getTime() < Date.now()) + ) { + const validationResult = await validateServerLicense({ + serverConfig, + }); + return validationResult; + } + + return serverConfig; +}; + +// check if the server is a Cap Cloud server +export const isCapCloud = async (): Promise => { + const serverConfig = await getServerConfig(); + return serverConfig.isCapCloud; +}; + +export const addServerSuperAdmin = async ({ userId }: { userId: string }) => { + const currentUser = await getCurrentUser(); + if (!currentUser) { + throw new Error("Not authorized"); + } + + const serverConfig = await getServerConfig(); + + const existingSuperAdminIds = serverConfig.superAdminIds; + + if ( + existingSuperAdminIds.length > 0 && + !existingSuperAdminIds.includes(currentUser.id) + ) { + throw new Error("Not authorized"); + } + + // update server config superAdminIds array + await db + .update(serverConfigTable) + .set({ + superAdminIds: [...existingSuperAdminIds, userId], + }) + .where(eq(serverConfigTable.id, 1)); + return; +}; + +export async function validateServerLicense({ + serverConfig, +}: { + serverConfig: typeof serverConfigTable.$inferSelect; +}): Promise { + if (!serverConfig?.licenseKey) { + return serverConfig; + } + + const licenseServerResponse = await fetch( + `${LICENSE_SERVER_URL}/api/instances/validate`, + { + method: "POST", + headers: { + licenseKey: serverConfig.licenseKey, + siteUrl: INSTANCE_SITE_URL, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + usedSeats: await getServerUserCount(), + }), + } + ); + + const licenseServerResponseCode = await licenseServerResponse.status; + const licenseServerResponseJson = await licenseServerResponse.json(); + + let newPartialServerConfig: Partial< + typeof serverConfigTable.$inferInsert + > | null = null; + + // handle 404, 403, 402, 409 : License not found, Too many seats, expired, already activated on another siteURL + if ( + licenseServerResponseCode === 404 || + licenseServerResponseCode === 403 || + licenseServerResponseCode === 402 || + licenseServerResponseCode === 409 + ) { + newPartialServerConfig = { + licenseValid: false, + licenseValidityCache: null, + isCapCloud: false, + }; + } + // handle 200: license is valid + if (licenseServerResponseCode === 200) { + newPartialServerConfig = { + licenseValid: true, + licenseValidityCache: licenseServerResponseJson.refresh, + isCapCloud: licenseServerResponseJson.isCapCloudLicense, + }; + } + newPartialServerConfig && + (await db + .update(serverConfigTable) + .set(newPartialServerConfig) + .where(eq(serverConfigTable.id, 1))); + + return { + ...serverConfig, + ...newPartialServerConfig, + }; +} + +// check if a single user is a member of a pro workspace +export async function isUserPro({ userId }: { userId: string }) { + const serverConfig = await getServerConfig(); + if (!serverConfig.licenseKey) { + return false; + } + const isCapCloud = serverConfig.isCapCloud; + + // if self hosting, all users on the server have the same pro status as the server itself + if (!isCapCloud) { + return serverConfig.licenseValid; + } + + // check user pro cache status + const userResponse = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + if (!userResponse) { + return false; + } + + // if user is pro and pro expires at is in the future, return true + if ( + userResponse.pro && + userResponse.proExpiresAt && + userResponse.proExpiresAt > new Date() + ) { + return true; + } + + // refresh user pro cache + const userWorkspaceMembershipWorkspaceIds = + await getUserWorkspaceMembershipWorkspaceIds(); + + const licenseServerResponse = (await fetch( + `${LICENSE_SERVER_URL}/api/instances/cloudPro/user`, + { + method: "POST", + headers: { + licenseKey: serverConfig.licenseKey, + siteUrl: INSTANCE_SITE_URL, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + workspaceIds: userWorkspaceMembershipWorkspaceIds, + }), + } + ).then((res) => res.json())) as { + isPro: boolean; + viaWorkspaceId: string | null; + cacheRefresh: number | null; + }; + + await db + .update(users) + .set({ + pro: licenseServerResponse.isPro, + proExpiresAt: licenseServerResponse.cacheRefresh + ? new Date(licenseServerResponse.cacheRefresh) + : null, + proWorkspaceId: licenseServerResponse.viaWorkspaceId, + }) + .where(eq(users.id, userId)); + + return licenseServerResponse.isPro; +} + +// check if a single workspace is a pro workspace +export async function isWorkspacePro({ workspaceId }: { workspaceId: string }) { + const serverConfig = await getServerConfig(); + if (!serverConfig.licenseKey) { + return false; + } + const isCapCloud = serverConfig.isCapCloud; + + // if self hosting, all workspaces on the server have the same pro status as the server itself + if (!isCapCloud) { + return serverConfig.licenseValid; + } + + // check workspace pro cache status + const workspaceResponse = await db.query.spaces.findFirst({ + where: eq(spaces.id, workspaceId), + }); + + if (!workspaceResponse) { + return false; + } + + // if workspace is pro and pro expires at is in the future, return true + if ( + workspaceResponse.pro && + workspaceResponse.proExpiresAt && + workspaceResponse.proExpiresAt > new Date() + ) { + return true; + } + + // refresh workspace pro cache + const licenseServerResponse = (await fetch( + `${LICENSE_SERVER_URL}/api/instances/cloudPro/workspace`, + { + method: "POST", + headers: { + licenseKey: serverConfig.licenseKey, + siteUrl: INSTANCE_SITE_URL, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + workspaceId: workspaceId, + }), + } + ).then((res) => res.json())) as { + isPro: boolean; + workspaceId: string; + cacheRefresh: number | null; + }; + + await db + .update(spaces) + .set({ + pro: licenseServerResponse.isPro, + proExpiresAt: licenseServerResponse.cacheRefresh + ? new Date(licenseServerResponse.cacheRefresh) + : null, + proWorkspaceId: licenseServerResponse.workspaceId, + }) + .where(eq(spaces.id, workspaceId)); + + return licenseServerResponse.isPro; +} diff --git a/apps/web/utils/instance/helpers.ts b/apps/web/utils/instance/helpers.ts new file mode 100644 index 000000000..8d68ec830 --- /dev/null +++ b/apps/web/utils/instance/helpers.ts @@ -0,0 +1,79 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { serverConfigTable, spaceMembers, users } from "@cap/database/schema"; +import { count, eq } from "drizzle-orm"; +import { getServerConfig } from "./functions"; +import { INSTANCE_SITE_URL, LICENSE_SERVER_URL } from "./constants"; + +// get the total number of users on the server +export const getServerUserCount = async () => { + const userCount = await db.select({ count: count() }).from(users); + return userCount[0]?.count || 0; +}; + +// get all workspace IDs the user is a member of +export const getUserWorkspaceMembershipWorkspaceIds = async () => { + const currentUser = await getCurrentUser(); + if (!currentUser) { + throw new Error("Not authorized"); + } + + const userWorkspaceMemberships = await db.query.spaceMembers.findMany({ + where: eq(spaceMembers.userId, currentUser.id), + columns: { + id: true, + spaceId: true, + }, + with: { + space: { + columns: { + id: true, + }, + }, + }, + }); + + const workspaceMembershipsWorkspaceIds = userWorkspaceMemberships.map( + (membership) => membership.space.id + ); + + return workspaceMembershipsWorkspaceIds; +}; + +export async function licenseApi({ + method, + endpoint, + body, + serverConfig, +}: { + method: "POST" | "GET"; + endpoint: + | "instances/validate" + | "instances/add-user" + | "instances/user" + | "instances/workspace" + | "instances/workspace/add-user" + | "instances/workspace/checkout" + | "instances/workspace/portal"; + body: object; + serverConfig: typeof serverConfigTable.$inferSelect; +}) { + if (!serverConfig.licenseKey || !serverConfig.licenseValid) { + throw new Error("Server does not have a valid license"); + } + + const licenseServerResponse = await fetch( + `${LICENSE_SERVER_URL}/api/${endpoint}`, + { + method, + headers: { + licenseKey: serverConfig.licenseKey, + siteUrl: INSTANCE_SITE_URL, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + } + ); + + return licenseServerResponse; +} diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index ed9134ef0..a80b52016 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -10,6 +10,7 @@ import { LoginLink } from "../emails/login-link"; import { nanoId } from "../helpers"; import WorkOSProvider from "next-auth/providers/workos"; import { NODE_ENV, serverEnv } from "@cap/env"; +import { PlanetScaleDatabase } from "drizzle-orm/planetscale-serverless"; export const config = { maxDuration: 120, @@ -18,7 +19,7 @@ export const config = { const secret = serverEnv.NEXTAUTH_SECRET; export const authOptions: NextAuthOptions = { - adapter: DrizzleAdapter(db), + adapter: DrizzleAdapter(db as PlanetScaleDatabase), debug: true, session: { strategy: "jwt", diff --git a/packages/database/emails/config.ts b/packages/database/emails/config.ts index 96ab4090a..bef71c7da 100644 --- a/packages/database/emails/config.ts +++ b/packages/database/emails/config.ts @@ -1,6 +1,9 @@ import { clientEnv, serverEnv } from "@cap/env"; +import { eq } from "drizzle-orm"; +import { db } from "index"; import { JSXElementConstructor, ReactElement } from "react"; import { Resend } from "resend"; +import { serverConfig } from "schema"; export const resend = serverEnv.RESEND_API_KEY ? new Resend(serverEnv.RESEND_API_KEY) @@ -24,12 +27,32 @@ export const sendEmail = async ({ return Promise.resolve(); } + const sendFrom = async () => { + if (marketing) { + return "Richie from Cap.so "; + } + + if (clientEnv.NEXT_PUBLIC_IS_CAP) { + return "Cap Auth "; + } + + const serverConfigResponse = await db.query.serverConfig.findFirst({ + where: eq(serverConfig.id, 1), + }); + + if ( + serverConfigResponse && + serverConfigResponse.emailSendFromName && + serverConfigResponse.emailSendFromEmail + ) { + return `${serverConfigResponse.emailSendFromName} <${serverConfigResponse.emailSendFromEmail}>`; + } + + return `auth@${clientEnv.NEXT_PUBLIC_WEB_URL}`; + }; + return resend.emails.send({ - from: marketing - ? "Richie from Cap.so " - : clientEnv.NEXT_PUBLIC_IS_CAP - ? "Cap Auth " - : `auth@${clientEnv.NEXT_PUBLIC_WEB_URL}`, + from: await sendFrom(), to: test ? "delivered@resend.dev" : email, subject, react, diff --git a/packages/database/index.ts b/packages/database/index.ts index f633f34f9..63ef6130e 100644 --- a/packages/database/index.ts +++ b/packages/database/index.ts @@ -1,7 +1,7 @@ import { drizzle } from "drizzle-orm/planetscale-serverless"; import { Client, Config } from "@planetscale/database"; import { NODE_ENV, serverEnv } from "@cap/env"; - +import * as schema from "./schema"; const URL = serverEnv.DATABASE_URL; let fetchHandler: Promise | undefined = undefined; @@ -19,4 +19,4 @@ export const connection = new Client({ }, }); -export const db = drizzle(connection); +export const db = drizzle(connection, { schema }); diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 5bff02c02..e0643228d 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -11,6 +11,7 @@ import { uniqueIndex, varchar, float, + serial, } from "drizzle-orm/mysql-core"; import { relations } from "drizzle-orm/relations"; import { nanoIdLength } from "./helpers"; @@ -40,6 +41,18 @@ const encryptedTextNullable = customType<{ data: string; notNull: false }>({ }, }); +export const serverConfigTable = mysqlTable("server_config", { + id: serial("id").notNull().primaryKey().unique(), + licenseKey: varchar("licenseKey", { length: 255 }), + licenseValid: boolean("licenseValid").notNull().default(false), + isCapCloud: boolean("isCapCloud").notNull().default(false), + licenseValidityCache: timestamp("licenseValidityCache"), + superAdminIds: json("superAdminIds").$type().notNull().default([]), + signupsEnabled: boolean("signupsEnabled").notNull().default(false), + emailSendFromName: varchar("emailSendFromName", { length: 255 }), + emailSendFromEmail: varchar("emailSendFromEmail", { length: 255 }), +}); + export const users = mysqlTable( "users", { @@ -68,6 +81,9 @@ export const users = mysqlTable( onboarding_completed_at: timestamp("onboarding_completed_at"), customBucket: nanoIdNullable("customBucket"), inviteQuota: int("inviteQuota").notNull().default(1), + pro: boolean("pro").notNull().default(false), + proExpiresAt: timestamp("proExpiresAt"), + proWorkspaceId: nanoIdNullable("proWorkspaceId"), }, (table) => ({ emailIndex: uniqueIndex("email_idx").on(table.email), From 65375014b71c64c0f37d5728d24f767dd91862b4 Mon Sep 17 00:00:00 2001 From: McPizza0 Date: Wed, 19 Feb 2025 21:23:27 +0100 Subject: [PATCH 02/17] switch billing to licenseServer --- apps/web/app/api/desktop/plan/route.ts | 45 +--- apps/web/app/api/desktop/subscribe/route.ts | 80 ++++--- .../web/app/api/desktop/video/create/route.ts | 3 +- apps/web/app/api/invite/accept/route.ts | 27 +-- apps/web/app/api/invite/decline/route.ts | 17 ++ .../app/api/settings/billing/manage/route.ts | 40 ++-- .../api/settings/billing/subscribe/route.ts | 76 +++---- .../app/api/settings/billing/usage/route.ts | 10 +- .../settings/workspace/invite/remove/route.ts | 6 + .../settings/workspace/invite/send/route.ts | 2 + .../app/api/video/transcribe/status/route.ts | 3 +- apps/web/app/dashboard/layout.tsx | 9 +- apps/web/app/superadmin/layout.tsx | 13 +- apps/web/utils/instance/api.ts | 195 ------------------ apps/web/utils/instance/functions.ts | 151 +++++++++++++- apps/web/utils/instance/helpers.ts | 62 ++---- packages/database/auth/drizzle-adapter.ts | 30 ++- packages/utils/src/constants/plans.ts | 17 +- 18 files changed, 326 insertions(+), 460 deletions(-) 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..a643a834c 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 } = await request.json(); console.log("Received request with priceId:", priceId); console.log("Current user:", user?.id); @@ -25,51 +24,40 @@ export async function POST(request: NextRequest) { return Response.json({ error: true, auth: false }, { status: 401 }); } - if ( - isUserOnProPlan({ - subscriptionStatus: user.stripeSubscriptionStatus as string, - }) - ) { - console.error("User already has pro plan"); - return Response.json({ error: true, subscription: true }, { status: 400 }); + const userActiveWorkspaceId = user.activeSpaceId; + + if (!userActiveWorkspaceId) { + console.error("User has no active workspace"); + return Response.json({ error: 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, - }, - }); + // get the current workspace pro status and return if it is already on pro + const workspaceProStatus = await isWorkspacePro({ + workspaceId: userActiveWorkspaceId, + }); + if (workspaceProStatus) { + console.error("Workspace already has pro plan"); + return Response.json({ error: true, subscription: true }, { status: 400 }); + } - 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: userActiveWorkspaceId, + 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/layout.tsx b/apps/web/app/dashboard/layout.tsx index ccd898d19..e531cbf8b 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -10,6 +10,7 @@ import { users, } from "@cap/database/schema"; import { eq, inArray, or, and, count, sql } from "drizzle-orm"; +import { getIsUserPro } from "@/utils/instance/functions"; export type Space = { space: typeof spaces.$inferSelect; @@ -128,10 +129,10 @@ export default async function DashboardLayout({ findActiveSpace = spaceSelect[0]; } - const isSubscribed = - (user.stripeSubscriptionId && - user.stripeSubscriptionStatus !== "cancelled") || - !!user.thirdPartyStripeSubscriptionId; + const isSubscribed = await getIsUserPro({ userId: user.id }); + // (user.stripeSubscriptionId && + // user.stripeSubscriptionStatus !== "cancelled") || + // !!user.thirdPartyStripeSubscriptionId; return ( ( - endpoint: string, - method: string, - body: object - ): Promise => { - const response = await fetch(`${LICENSE_SERVER_URL}/api/${endpoint}`, { - method, - headers: { - licenseKey: config.serverConfig.licenseKey!, - siteUrl: INSTANCE_SITE_URL, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`License API error: ${response.status}`); - } - - return response.json(); - }; - - return { - validate: (params: LicenseApiTypes["validate"]["params"]) => - makeRequest( - "instances/validate", - "POST", - params - ), - - addUserCheck: (params: LicenseApiTypes["addUserCheck"]["params"]) => - makeRequest( - "instances/add-user/check", - "POST", - params - ), - - addUserPost: (params: LicenseApiTypes["addUserPost"]["params"]) => - makeRequest( - "instances/add-user/post", - "POST", - params - ), - - getUser: (params: LicenseApiTypes["getUser"]["params"]) => - makeRequest( - "instances/user", - "GET", - params - ), - - workspace: (params: LicenseApiTypes["workspace"]["params"]) => - makeRequest( - "instances/workspace", - "POST", - params - ), - - workspaceAddUser: (params: LicenseApiTypes["workspaceAddUser"]["params"]) => - makeRequest( - "instances/workspace/add-user", - "POST", - params - ), - - workspaceCheckout: ( - params: LicenseApiTypes["workspaceCheckout"]["params"] - ) => - makeRequest( - "instances/workspace/checkout", - "POST", - params - ), - - workspacePortal: (params: LicenseApiTypes["workspacePortal"]["params"]) => - makeRequest( - "instances/workspace/portal", - "POST", - params - ), - }; -} diff --git a/apps/web/utils/instance/functions.ts b/apps/web/utils/instance/functions.ts index 30c7c7394..926f2f2cc 100644 --- a/apps/web/utils/instance/functions.ts +++ b/apps/web/utils/instance/functions.ts @@ -4,6 +4,7 @@ import { serverConfigTable, spaces, users } from "@cap/database/schema"; import { eq } from "drizzle-orm"; import { INSTANCE_SITE_URL, LICENSE_SERVER_URL } from "./constants"; import { + getCloudWorkspaceUserCount, getServerUserCount, getUserWorkspaceMembershipWorkspaceIds, } from "./helpers"; @@ -53,6 +54,7 @@ export const isCapCloud = async (): Promise => { return serverConfig.isCapCloud; }; +// Used in self hosted instances to add a user to the server superAdminIds array export const addServerSuperAdmin = async ({ userId }: { userId: string }) => { const currentUser = await getCurrentUser(); if (!currentUser) { @@ -80,6 +82,7 @@ export const addServerSuperAdmin = async ({ userId }: { userId: string }) => { return; }; +// used in all instances to validate the self hosted server license export async function validateServerLicense({ serverConfig, }: { @@ -145,15 +148,14 @@ export async function validateServerLicense({ } // check if a single user is a member of a pro workspace -export async function isUserPro({ userId }: { userId: string }) { +export async function getIsUserPro({ userId }: { userId: string }) { const serverConfig = await getServerConfig(); if (!serverConfig.licenseKey) { return false; } - const isCapCloud = serverConfig.isCapCloud; // if self hosting, all users on the server have the same pro status as the server itself - if (!isCapCloud) { + if (!serverConfig.isCapCloud) { return serverConfig.licenseValid; } @@ -218,10 +220,9 @@ export async function isWorkspacePro({ workspaceId }: { workspaceId: string }) { if (!serverConfig.licenseKey) { return false; } - const isCapCloud = serverConfig.isCapCloud; // if self hosting, all workspaces on the server have the same pro status as the server itself - if (!isCapCloud) { + if (!serverConfig.isCapCloud) { return serverConfig.licenseValid; } @@ -276,3 +277,143 @@ export async function isWorkspacePro({ workspaceId }: { workspaceId: string }) { return licenseServerResponse.isPro; } + +// Update workspace user counts - This includes both users and invites +export async function updateCloudWorkspaceUserCount({ + workspaceId, +}: { + workspaceId: string; +}) { + const serverConfig = await getServerConfig(); + if (!serverConfig.licenseKey) { + return false; + } + + // if selfhosting, seats will be updated on next server license check automatically + if (!serverConfig.isCapCloud) { + return true; + } + + const workspaceUserCount = await getCloudWorkspaceUserCount({ workspaceId }); + + try { + await fetch( + `${LICENSE_SERVER_URL}/api/instances/cloudPro/workspace/addUser`, + { + method: "POST", + headers: { + licenseKey: serverConfig.licenseKey, + siteUrl: INSTANCE_SITE_URL, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + workspaceId: workspaceId, + seatCount: workspaceUserCount, + }), + } + ); + return true; + } catch (error) { + console.error(error); + return false; + } +} + +export async function generateCloudProStripeCheckoutSession({ + cloudWorkspaceId, + cloudUserId, + email, + type, +}: { + cloudWorkspaceId: string; + cloudUserId: string; + email: string; + type: "yearly" | "monthly"; +}) { + const serverConfig = await getServerConfig(); + if (!serverConfig.licenseKey) { + return false; + } + + if (!serverConfig.isCapCloud) { + return false; + } + + const seatCount = await getCloudWorkspaceUserCount({ + workspaceId: cloudWorkspaceId, + }); + + // refresh workspace pro cache + const licenseServerResponse = (await fetch( + `${LICENSE_SERVER_URL}/api/instances/cloudPro/workspace/checkout`, + { + method: "POST", + headers: { + licenseKey: serverConfig.licenseKey, + siteUrl: INSTANCE_SITE_URL, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + cloudWorkspaceId: cloudWorkspaceId, + cloudUserId: cloudUserId, + email: email, + seatCount: seatCount, + type: type, + }), + } + ).then((res) => res.json())) as { + workspaceId: string; + newSeatCount: number; + checkoutLink: string; + }; + + return licenseServerResponse; +} + +export async function generateCloudProStripePortalLink({ + cloudWorkspaceId, +}: { + cloudWorkspaceId: string; +}) { + const serverConfig = await getServerConfig(); + if (!serverConfig.licenseKey) { + return false; + } + + if (!serverConfig.isCapCloud) { + return false; + } + + const seatCount = await getCloudWorkspaceUserCount({ + workspaceId: cloudWorkspaceId, + }); + + // refresh workspace pro cache + try { + const licenseServerResponse = (await fetch( + `${LICENSE_SERVER_URL}/api/instances/cloudPro/workspace/portal`, + { + method: "POST", + headers: { + licenseKey: serverConfig.licenseKey, + siteUrl: INSTANCE_SITE_URL, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + cloudWorkspaceId: cloudWorkspaceId, + seatCount: seatCount, + }), + } + ).then((res) => res.json())) as { + workspaceId: string; + newSeatCount: number; + portalLink: string; + }; + + return licenseServerResponse; + } catch (error) { + console.error(error); + + return null; + } +} diff --git a/apps/web/utils/instance/helpers.ts b/apps/web/utils/instance/helpers.ts index 8d68ec830..49e3cced2 100644 --- a/apps/web/utils/instance/helpers.ts +++ b/apps/web/utils/instance/helpers.ts @@ -1,14 +1,30 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { serverConfigTable, spaceMembers, users } from "@cap/database/schema"; +import { spaceInvites, spaceMembers, users } from "@cap/database/schema"; import { count, eq } from "drizzle-orm"; -import { getServerConfig } from "./functions"; -import { INSTANCE_SITE_URL, LICENSE_SERVER_URL } from "./constants"; // get the total number of users on the server export const getServerUserCount = async () => { const userCount = await db.select({ count: count() }).from(users); - return userCount[0]?.count || 0; + const inviteCount = await db.select({ count: count() }).from(spaceInvites); + return (userCount[0]?.count || 0) + (inviteCount[0]?.count || 0); +}; + +// get the number of users on a workspace (including invites) +export const getCloudWorkspaceUserCount = async ({ + workspaceId, +}: { + workspaceId: string; +}) => { + const userCount = await db + .select({ count: count() }) + .from(spaceMembers) + .where(eq(spaceMembers.spaceId, workspaceId)); + const inviteCount = await db + .select({ count: count() }) + .from(spaceInvites) + .where(eq(spaceInvites.spaceId, workspaceId)); + return (userCount[0]?.count || 0) + (inviteCount[0]?.count || 0); }; // get all workspace IDs the user is a member of @@ -39,41 +55,3 @@ export const getUserWorkspaceMembershipWorkspaceIds = async () => { return workspaceMembershipsWorkspaceIds; }; - -export async function licenseApi({ - method, - endpoint, - body, - serverConfig, -}: { - method: "POST" | "GET"; - endpoint: - | "instances/validate" - | "instances/add-user" - | "instances/user" - | "instances/workspace" - | "instances/workspace/add-user" - | "instances/workspace/checkout" - | "instances/workspace/portal"; - body: object; - serverConfig: typeof serverConfigTable.$inferSelect; -}) { - if (!serverConfig.licenseKey || !serverConfig.licenseValid) { - throw new Error("Server does not have a valid license"); - } - - const licenseServerResponse = await fetch( - `${LICENSE_SERVER_URL}/api/${endpoint}`, - { - method, - headers: { - licenseKey: serverConfig.licenseKey, - siteUrl: INSTANCE_SITE_URL, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - } - ); - - return licenseServerResponse; -} diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 3538997e1..0d7364d95 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -3,8 +3,6 @@ import { and, eq } from "drizzle-orm"; import { accounts, sessions, users, verificationTokens } from "../schema"; import type { Adapter } from "next-auth/adapters"; import type { PlanetScaleDatabase } from "drizzle-orm/planetscale-serverless"; -import { stripe, STRIPE_AVAILABLE } from "@cap/utils"; -import { serverEnv } from "@cap/env"; export function DrizzleAdapter(db: PlanetScaleDatabase): Adapter { return { @@ -25,21 +23,21 @@ export function DrizzleAdapter(db: PlanetScaleDatabase): Adapter { const row = rows[0]; if (!row) throw new Error("User not found"); - if (STRIPE_AVAILABLE) { - const customer = await stripe.customers.create({ - email: userData.email, - metadata: { - userId: nanoId(), - }, - }); + // if (STRIPE_AVAILABLE) { + // const customer = await stripe.customers.create({ + // email: userData.email, + // metadata: { + // userId: nanoId(), + // }, + // }); - await db - .update(users) - .set({ - stripeCustomerId: customer.id, - }) - .where(eq(users.id, row.id)); - } + // await db + // .update(users) + // .set({ + // stripeCustomerId: customer.id, + // }) + // .where(eq(users.id, row.id)); + // } return row; }, diff --git a/packages/utils/src/constants/plans.ts b/packages/utils/src/constants/plans.ts index c74f5724d..13d99f401 100644 --- a/packages/utils/src/constants/plans.ts +++ b/packages/utils/src/constants/plans.ts @@ -18,19 +18,12 @@ export const getProPlanId = (billingCycle: "yearly" | "monthly") => { return planIds[environment]?.[billingCycle] || ""; }; -export const isUserOnProPlan = ({ - subscriptionStatus, -}: { - subscriptionStatus: string; -}) => { +export const getProPlanBillingCycle = (priceId: string) => { if ( - subscriptionStatus === "active" || - subscriptionStatus === "trialing" || - subscriptionStatus === "complete" || - subscriptionStatus === "paid" + priceId === planIds.development.yearly || + priceId === planIds.production.yearly ) { - return true; + return "yearly"; } - - return false; + return "monthly"; }; From dcf0df2359c6004b9ef6292270df1b6dca5c6a4b Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:29:51 +0300 Subject: [PATCH 03/17] new header --- apps/web/components/Navbar.tsx | 29 ++++++++++++++++++++++- apps/web/components/pages/PricingPage.tsx | 3 +-- pnpm-lock.yaml | 4 ++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/web/components/Navbar.tsx b/apps/web/components/Navbar.tsx index 1867f0d5d..eddc651cc 100644 --- a/apps/web/components/Navbar.tsx +++ b/apps/web/components/Navbar.tsx @@ -178,8 +178,35 @@ export const Navbar = ({ auth }: { auth: boolean }) => { + +
+ + +
+
+ +
+ - {showMobileMenu && (
{

Cap is currently in public beta, and we're offering special early adopter pricing to our first users. This pricing will be locked in diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07123d8af..356f06f02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -723,7 +723,7 @@ importers: version: 14.2.9(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: ^4.24.5 - version: 4.24.8(next@14.2.9(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.8(next@14.2.9(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-email: specifier: ^1.10.1 version: 1.10.1 @@ -22099,7 +22099,7 @@ snapshots: optionalDependencies: nodemailer: 6.9.15 - next-auth@4.24.8(next@14.2.9(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.8(next@14.2.9(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.25.6 '@panva/hkdf': 1.2.1 From 2fbbfe9508aa22de8a37bf04f92b9dab2b766446 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 27 Feb 2025 05:14:22 +1100 Subject: [PATCH 04/17] fail injection (#344) * fail injectino * remove live_state * remove get_store * more correctness * fmt --- apps/desktop/src-tauri/src/lib.rs | 12 ++++++++++ apps/desktop/src-tauri/src/recording.rs | 22 +++++++++++++++++++ .../src/routes/(window-chrome)/(main).tsx | 13 +++++++++++ apps/desktop/src/routes/debug.tsx | 3 +++ 4 files changed, 50 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5a32870d3..148f7bb4b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -226,7 +226,11 @@ impl App { } } .inspect(|_| { +<<<<<<< HEAD self.recording_options.camera_label = new_options.camera_label.clone(); +======= + self.start_recording_options.camera_label = new_options.camera_label.clone(); +>>>>>>> aee39a6c (fail injection (#344)) }); // try update microphone @@ -252,11 +256,19 @@ impl App { } } .inspect(|_| { +<<<<<<< HEAD self.recording_options.audio_input_name = new_options.audio_input_name.clone(); }); if camera.is_ok() || microphone.is_ok() { self.recording_options.capture_target = new_options.capture_target; +======= + self.start_recording_options.audio_input_name = new_options.audio_input_name.clone(); + }); + + if camera.is_ok() || microphone.is_ok() { + self.start_recording_options.capture_target = new_options.capture_target; +>>>>>>> aee39a6c (fail injection (#344)) RecordingOptionsChanged.emit(&self.handle).ok(); } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 6f74fc5d5..c9e4a4232 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -15,6 +15,11 @@ use crate::{ RecordingStarted, RecordingStopped, UploadMode, }; use cap_fail::fail; +<<<<<<< HEAD +======= +use cap_flags::FLAGS; +use cap_media::sources::{CaptureScreen, CaptureWindow}; +>>>>>>> aee39a6c (fail injection (#344)) use cap_media::{feeds::CameraFeed, sources::ScreenCaptureTarget}; use cap_media::{ platform::Bounds, @@ -154,6 +159,7 @@ pub async fn start_recording( if let Ok(s3_config) = get_s3_config(&app, false, None).await { let link = web_api::make_url(format!("/s/{}", s3_config.id())); +<<<<<<< HEAD state.pre_created_video = Some(PreCreatedVideo { id: s3_config.id().to_string(), link: link.clone(), @@ -178,6 +184,22 @@ pub async fn start_recording( if let Some(window) = camera_window { let _ = window.set_content_protected(true); } +======= + if let Ok(Some(auth)) = AuthStore::get(&app) { + if auto_create_shareable_link && auth.is_upgraded() { + // Pre-create the video and get the shareable link + if let Ok(s3_config) = get_s3_config(&app, false, None).await { + let link = web_api::make_url(format!("/s/{}", s3_config.id())); + + state.pre_created_video = Some(PreCreatedVideo { + id: s3_config.id().to_string(), + link: link.clone(), + config: s3_config, + }); + + println!("Pre-created shareable link: {}", link); + }; +>>>>>>> aee39a6c (fail injection (#344)) } } diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index 655429b86..83c1dc64c 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -202,16 +202,24 @@ export default function () { /> +<<<<<<< HEAD +======= +>>>>>>> aee39a6c (fail injection (#344)) {import.meta.env.DEV && ( )}

@@ -956,11 +964,16 @@ function TargetSelect(props: { const v = props.value; if (!v) return null; +<<<<<<< HEAD const o = props.options.find((o) => o.id === v.id); if (o) return props.value; props.onChange(props.options[0]); return props.options[0]; +======= + if (props.options.some((o) => o.id === v.id)) return props.value; + else return props.options[0]; +>>>>>>> aee39a6c (fail injection (#344)) }; return ( diff --git a/apps/desktop/src/routes/debug.tsx b/apps/desktop/src/routes/debug.tsx index 55a16b59c..6670212bb 100644 --- a/apps/desktop/src/routes/debug.tsx +++ b/apps/desktop/src/routes/debug.tsx @@ -12,6 +12,7 @@ export default function Debug() { return (
+<<<<<<< HEAD

Debug Windows

+======= +>>>>>>> aee39a6c (fail injection (#344))

Fail Points

    From 4e71c81cdf3b8157240f8c98a689898e0a6e63eb Mon Sep 17 00:00:00 2001 From: McPizza0 Date: Wed, 26 Feb 2025 17:31:22 +0100 Subject: [PATCH 05/17] add server admin checks --- .../_components/AdminNavbar/AdminNavItems.tsx | 8 +- .../_components/DynamicSharedLayout.tsx | 7 +- apps/web/app/dashboard/layout.tsx | 18 +- apps/web/app/superadmin/layout.tsx | 5 +- apps/web/utils/instance/functions.ts | 230 ++++++++++++++++-- packages/database/emails/config.ts | 8 +- packages/database/schema.ts | 3 + 7 files changed, 250 insertions(+), 29 deletions(-) diff --git a/apps/web/app/dashboard/_components/AdminNavbar/AdminNavItems.tsx b/apps/web/app/dashboard/_components/AdminNavbar/AdminNavItems.tsx index 5d866f318..701f061d6 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); diff --git a/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx b/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx index 785af4d6b..bee0364a6 100644 --- a/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx +++ b/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx @@ -10,6 +10,7 @@ type SharedContext = { activeSpace: Space | null; user: typeof users.$inferSelect; isSubscribed: boolean; + isSuperAdmin: boolean; }; const Context = createContext({} as SharedContext); @@ -20,15 +21,19 @@ export default function DynamicSharedLayout({ activeSpace, user, isSubscribed, + isSuperAdmin, }: { children: React.ReactNode; spaceData: SharedContext["spaceData"]; activeSpace: SharedContext["activeSpace"]; user: SharedContext["user"]; isSubscribed: SharedContext["isSubscribed"]; + isSuperAdmin: SharedContext["isSuperAdmin"]; }) { return ( - +
    diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index e531cbf8b..f3e602802 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -10,7 +10,11 @@ import { users, } from "@cap/database/schema"; import { eq, inArray, or, and, count, sql } from "drizzle-orm"; -import { getIsUserPro } from "@/utils/instance/functions"; +import { + addServerSuperAdmin, + getIsUserPro, + getServerConfig, +} from "@/utils/instance/functions"; export type Space = { space: typeof spaces.$inferSelect; @@ -130,9 +134,14 @@ export default async function DashboardLayout({ } const isSubscribed = await getIsUserPro({ userId: user.id }); - // (user.stripeSubscriptionId && - // user.stripeSubscriptionStatus !== "cancelled") || - // !!user.thirdPartyStripeSubscriptionId; + + 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/superadmin/layout.tsx b/apps/web/app/superadmin/layout.tsx index 873231f33..02356cd54 100644 --- a/apps/web/app/superadmin/layout.tsx +++ b/apps/web/app/superadmin/layout.tsx @@ -10,7 +10,10 @@ import { users, } from "@cap/database/schema"; import { eq, inArray, or, and, count, sql } from "drizzle-orm"; -import { addServerSuperAdmin, getServerConfig } from "@/utils/instance"; +import { + addServerSuperAdmin, + getServerConfig, +} from "@/utils/instance/functions"; export default async function SuperAdminLayout({ children, diff --git a/apps/web/utils/instance/functions.ts b/apps/web/utils/instance/functions.ts index 926f2f2cc..7a35c33f7 100644 --- a/apps/web/utils/instance/functions.ts +++ b/apps/web/utils/instance/functions.ts @@ -12,12 +12,17 @@ import { export const getServerConfig = async (): Promise< typeof serverConfigTable.$inferSelect > => { + console.log("[function call: getServerConfig"); const serverConfig = await db.query.serverConfigTable.findFirst({ where: eq(serverConfigTable.id, 1), }); + console.log("[function call: getServerConfig: serverConfig", { + serverConfig, + }); // create server config object if it doesn't exist if (!serverConfig) { + console.log("[function call: getServerConfig: serverConfig not found"); const newServerConfig = { id: 1, licenseKey: null, @@ -29,8 +34,11 @@ export const getServerConfig = async (): Promise< emailSendFromName: null, emailSendFromEmail: null, }; + console.log("[function call: getServerConfig: newServerConfig", { + newServerConfig, + }); await db.insert(serverConfigTable).values(newServerConfig); - + console.log("[function call: getServerConfig returning newServerConfig"); return newServerConfig; } @@ -39,9 +47,17 @@ export const getServerConfig = async (): Promise< (serverConfig.licenseValidityCache === null || serverConfig.licenseValidityCache.getTime() < Date.now()) ) { + console.log("[function call: getServerConfig: ifStatement", { + licenseKey: serverConfig.licenseKey, + licenseValidityCache: serverConfig.licenseValidityCache, + dateNow: Date.now(), + }); const validationResult = await validateServerLicense({ serverConfig, }); + console.log("[function call: getServerConfig: validationResult", { + validationResult, + }); return validationResult; } @@ -50,25 +66,39 @@ export const getServerConfig = async (): Promise< // check if the server is a Cap Cloud server export const isCapCloud = async (): Promise => { + console.log("[function call: isCapCloud"); const serverConfig = await getServerConfig(); + console.log("[function call: isCapCloud: serverConfig", { serverConfig }); return serverConfig.isCapCloud; }; // Used in self hosted instances to add a user to the server superAdminIds array export const addServerSuperAdmin = async ({ userId }: { userId: string }) => { + console.log("[function call: addServerSuperAdmin input", { userId }); const currentUser = await getCurrentUser(); + console.log("[function call: addServerSuperAdmin: currentUser", { + currentUser, + }); if (!currentUser) { + console.log("[function call: addServerSuperAdmin: currentUser not found"); throw new Error("Not authorized"); } const serverConfig = await getServerConfig(); - + console.log("[function call: addServerSuperAdmin: serverConfig", { + serverConfig, + }); const existingSuperAdminIds = serverConfig.superAdminIds; - + console.log("[function call: addServerSuperAdmin: existingSuperAdminIds", { + existingSuperAdminIds, + }); if ( existingSuperAdminIds.length > 0 && !existingSuperAdminIds.includes(currentUser.id) ) { + console.log( + "[function call: addServerSuperAdmin if not in admin array: not authorized" + ); throw new Error("Not authorized"); } @@ -79,6 +109,7 @@ export const addServerSuperAdmin = async ({ userId }: { userId: string }) => { superAdminIds: [...existingSuperAdminIds, userId], }) .where(eq(serverConfigTable.id, 1)); + console.log("[function call: addServerSuperAdmin: updated server config"); return; }; @@ -88,10 +119,14 @@ export async function validateServerLicense({ }: { serverConfig: typeof serverConfigTable.$inferSelect; }): Promise { + console.log("[function call: validateServerLicense input", { serverConfig }); if (!serverConfig?.licenseKey) { + console.log("[function call: validateServerLicense: licenseKey not found"); return serverConfig; } - + console.log("[function call: validateServerLicense: licenseKey", { + licenseKey: serverConfig.licenseKey, + }); const licenseServerResponse = await fetch( `${LICENSE_SERVER_URL}/api/instances/validate`, { @@ -106,9 +141,18 @@ export async function validateServerLicense({ }), } ); + console.log("[function call: validateServerLicense: licenseServerResponse", { + licenseServerResponse, + }); const licenseServerResponseCode = await licenseServerResponse.status; const licenseServerResponseJson = await licenseServerResponse.json(); + console.log( + "[function call: validateServerLicense: licenseServerResponseJson", + { + licenseServerResponseJson, + } + ); let newPartialServerConfig: Partial< typeof serverConfigTable.$inferInsert @@ -121,26 +165,52 @@ export async function validateServerLicense({ licenseServerResponseCode === 402 || licenseServerResponseCode === 409 ) { + console.log( + "[function call: validateServerLicense: licenseServerResponseCode", + { + licenseServerResponseCode, + } + ); newPartialServerConfig = { licenseValid: false, licenseValidityCache: null, isCapCloud: false, }; + console.log( + "[function call: validateServerLicense: newPartialServerConfig", + { + newPartialServerConfig, + } + ); } // handle 200: license is valid if (licenseServerResponseCode === 200) { + console.log("[function call: validateServerLicense: 200: license is valid"); newPartialServerConfig = { licenseValid: true, licenseValidityCache: licenseServerResponseJson.refresh, isCapCloud: licenseServerResponseJson.isCapCloudLicense, }; + console.log( + "[function call: validateServerLicense: newPartialServerConfig", + { + newPartialServerConfig, + } + ); } + console.log("[function call: validateServerLicense: updating server config"); newPartialServerConfig && (await db .update(serverConfigTable) .set(newPartialServerConfig) .where(eq(serverConfigTable.id, 1))); - + console.log( + "[function call: validateServerLicense: returning server config", + { + ...serverConfig, + ...newPartialServerConfig, + } + ); return { ...serverConfig, ...newPartialServerConfig, @@ -149,13 +219,22 @@ export async function validateServerLicense({ // check if a single user is a member of a pro workspace export async function getIsUserPro({ userId }: { userId: string }) { + console.log("[function call: getIsUserPro input", { userId }); const serverConfig = await getServerConfig(); + console.log("[function call: getIsUserPro: serverConfig", { serverConfig }); if (!serverConfig.licenseKey) { + console.log("[function call: getIsUserPro: licenseKey not found"); return false; } - + console.log("[function call: getIsUserPro: ifStatement", { + licenseKey: serverConfig.licenseKey, + isCapCloud: serverConfig.isCapCloud, + }); // if self hosting, all users on the server have the same pro status as the server itself if (!serverConfig.isCapCloud) { + console.log( + "[function call: getIsUserPro: ifStatement: self hosting: returning licenseValid" + ); return serverConfig.licenseValid; } @@ -163,8 +242,9 @@ export async function getIsUserPro({ userId }: { userId: string }) { const userResponse = await db.query.users.findFirst({ where: eq(users.id, userId), }); - + console.log("[function call: getIsUserPro: userResponse", { userResponse }); if (!userResponse) { + console.log("[function call: getIsUserPro: userResponse not found"); return false; } @@ -174,13 +254,19 @@ export async function getIsUserPro({ userId }: { userId: string }) { userResponse.proExpiresAt && userResponse.proExpiresAt > new Date() ) { + console.log("[function call: getIsUserPro: user is pro: returning true"); return true; } // refresh user pro cache const userWorkspaceMembershipWorkspaceIds = await getUserWorkspaceMembershipWorkspaceIds(); - + console.log( + "[function call: getIsUserPro: userWorkspaceMembershipWorkspaceIds", + { + userWorkspaceMembershipWorkspaceIds, + } + ); const licenseServerResponse = (await fetch( `${LICENSE_SERVER_URL}/api/instances/cloudPro/user`, { @@ -199,6 +285,9 @@ export async function getIsUserPro({ userId }: { userId: string }) { viaWorkspaceId: string | null; cacheRefresh: number | null; }; + console.log("[function call: getIsUserPro: licenseServerResponse", { + licenseServerResponse, + }); await db .update(users) @@ -210,19 +299,31 @@ export async function getIsUserPro({ userId }: { userId: string }) { proWorkspaceId: licenseServerResponse.viaWorkspaceId, }) .where(eq(users.id, userId)); - + console.log("[function call: getIsUserPro: updated user"); + console.log( + "[function call: getIsUserPro: returning licenseServerResponse.isPro", + { + licenseServerResponse, + } + ); return licenseServerResponse.isPro; } // check if a single workspace is a pro workspace export async function isWorkspacePro({ workspaceId }: { workspaceId: string }) { + console.log("[function call: isWorkspacePro input", { workspaceId }); const serverConfig = await getServerConfig(); + console.log("[function call: isWorkspacePro: serverConfig", { serverConfig }); if (!serverConfig.licenseKey) { + console.log("[function call: isWorkspacePro: licenseKey not found"); return false; } // if self hosting, all workspaces on the server have the same pro status as the server itself if (!serverConfig.isCapCloud) { + console.log( + "[function call: isWorkspacePro: ifStatement: self hosting: returning licenseValid" + ); return serverConfig.licenseValid; } @@ -230,8 +331,11 @@ export async function isWorkspacePro({ workspaceId }: { workspaceId: string }) { const workspaceResponse = await db.query.spaces.findFirst({ where: eq(spaces.id, workspaceId), }); - + console.log("[function call: isWorkspacePro: workspaceResponse", { + workspaceResponse, + }); if (!workspaceResponse) { + console.log("[function call: isWorkspacePro: workspaceResponse not found"); return false; } @@ -241,6 +345,9 @@ export async function isWorkspacePro({ workspaceId }: { workspaceId: string }) { workspaceResponse.proExpiresAt && workspaceResponse.proExpiresAt > new Date() ) { + console.log( + "[function call: isWorkspacePro: workspace is pro: returning true" + ); return true; } @@ -263,6 +370,9 @@ export async function isWorkspacePro({ workspaceId }: { workspaceId: string }) { workspaceId: string; cacheRefresh: number | null; }; + console.log("[function call: isWorkspacePro: licenseServerResponse", { + licenseServerResponse, + }); await db .update(spaces) @@ -274,7 +384,13 @@ export async function isWorkspacePro({ workspaceId }: { workspaceId: string }) { proWorkspaceId: licenseServerResponse.workspaceId, }) .where(eq(spaces.id, workspaceId)); - + console.log("[function call: isWorkspacePro: updated workspace"); + console.log( + "[function call: isWorkspacePro: returning licenseServerResponse.isPro", + { + licenseServerResponse, + } + ); return licenseServerResponse.isPro; } @@ -284,18 +400,35 @@ export async function updateCloudWorkspaceUserCount({ }: { workspaceId: string; }) { + console.log("[function call: updateCloudWorkspaceUserCount input", { + workspaceId, + }); const serverConfig = await getServerConfig(); + console.log("[function call: updateCloudWorkspaceUserCount: serverConfig", { + serverConfig, + }); if (!serverConfig.licenseKey) { + console.log( + "[function call: updateCloudWorkspaceUserCount: licenseKey not found" + ); return false; } // if selfhosting, seats will be updated on next server license check automatically if (!serverConfig.isCapCloud) { + console.log( + "[function call: updateCloudWorkspaceUserCount: ifStatement: self hosting: returning true" + ); return true; } const workspaceUserCount = await getCloudWorkspaceUserCount({ workspaceId }); - + console.log( + "[function call: updateCloudWorkspaceUserCount: workspaceUserCount", + { + workspaceUserCount, + } + ); try { await fetch( `${LICENSE_SERVER_URL}/api/instances/cloudPro/workspace/addUser`, @@ -312,8 +445,14 @@ export async function updateCloudWorkspaceUserCount({ }), } ); + console.log( + "[function call: updateCloudWorkspaceUserCount: updated workspace" + ); return true; } catch (error) { + console.log("[function call: updateCloudWorkspaceUserCount: error", { + error, + }); console.error(error); return false; } @@ -330,19 +469,42 @@ export async function generateCloudProStripeCheckoutSession({ email: string; type: "yearly" | "monthly"; }) { + console.log("[function call: generateCloudProStripeCheckoutSession input", { + cloudWorkspaceId, + cloudUserId, + email, + type, + }); const serverConfig = await getServerConfig(); + console.log( + "[function call: generateCloudProStripeCheckoutSession: serverConfig", + { + serverConfig, + } + ); if (!serverConfig.licenseKey) { + console.log( + "[function call: generateCloudProStripeCheckoutSession: licenseKey not found" + ); return false; } if (!serverConfig.isCapCloud) { + console.log( + "[function call: generateCloudProStripeCheckoutSession: ifStatement: self hosting: returning false" + ); return false; } const seatCount = await getCloudWorkspaceUserCount({ workspaceId: cloudWorkspaceId, }); - + console.log( + "[function call: generateCloudProStripeCheckoutSession: seatCount", + { + seatCount, + } + ); // refresh workspace pro cache const licenseServerResponse = (await fetch( `${LICENSE_SERVER_URL}/api/instances/cloudPro/workspace/checkout`, @@ -366,7 +528,12 @@ export async function generateCloudProStripeCheckoutSession({ newSeatCount: number; checkoutLink: string; }; - + console.log( + "[function call: generateCloudProStripeCheckoutSession: licenseServerResponse", + { + licenseServerResponse, + } + ); return licenseServerResponse; } @@ -375,21 +542,41 @@ export async function generateCloudProStripePortalLink({ }: { cloudWorkspaceId: string; }) { + console.log("[function call: generateCloudProStripePortalLink input", { + cloudWorkspaceId, + }); const serverConfig = await getServerConfig(); + console.log( + "[function call: generateCloudProStripePortalLink: serverConfig", + { + serverConfig, + } + ); if (!serverConfig.licenseKey) { + console.log( + "[function call: generateCloudProStripePortalLink: licenseKey not found" + ); return false; } if (!serverConfig.isCapCloud) { + console.log( + "[function call: generateCloudProStripePortalLink: ifStatement: self hosting: returning false" + ); return false; } const seatCount = await getCloudWorkspaceUserCount({ workspaceId: cloudWorkspaceId, }); - + console.log("[function call: generateCloudProStripePortalLink: seatCount", { + seatCount, + }); // refresh workspace pro cache try { + console.log( + "[function call: generateCloudProStripePortalLink: trying to fetch licenseServerResponse" + ); const licenseServerResponse = (await fetch( `${LICENSE_SERVER_URL}/api/instances/cloudPro/workspace/portal`, { @@ -410,10 +597,21 @@ export async function generateCloudProStripePortalLink({ portalLink: string; }; + console.log( + "[function call: generateCloudProStripePortalLink: licenseServerResponse", + { + licenseServerResponse, + } + ); + console.log( + "[function call: generateCloudProStripePortalLink: returning licenseServerResponse" + ); return licenseServerResponse; } catch (error) { console.error(error); - + console.log("[function call: generateCloudProStripePortalLink: error", { + error, + }); return null; } } diff --git a/packages/database/emails/config.ts b/packages/database/emails/config.ts index bef71c7da..6c733cf3c 100644 --- a/packages/database/emails/config.ts +++ b/packages/database/emails/config.ts @@ -1,9 +1,9 @@ import { clientEnv, serverEnv } from "@cap/env"; import { eq } from "drizzle-orm"; -import { db } from "index"; +import { db } from "../index"; import { JSXElementConstructor, ReactElement } from "react"; import { Resend } from "resend"; -import { serverConfig } from "schema"; +import { serverConfigTable } from "../schema"; export const resend = serverEnv.RESEND_API_KEY ? new Resend(serverEnv.RESEND_API_KEY) @@ -36,8 +36,8 @@ export const sendEmail = async ({ return "Cap Auth "; } - const serverConfigResponse = await db.query.serverConfig.findFirst({ - where: eq(serverConfig.id, 1), + const serverConfigResponse = await db.query.serverConfigTable.findFirst({ + where: eq(serverConfigTable.id, 1), }); if ( diff --git a/packages/database/schema.ts b/packages/database/schema.ts index e0643228d..f9afd49db 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -155,6 +155,9 @@ export const spaces = mysqlTable( updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), workosOrganizationId: varchar("workosOrganizationId", { length: 255 }), workosConnectionId: varchar("workosConnectionId", { length: 255 }), + pro: boolean("pro").notNull().default(false), + proExpiresAt: timestamp("proExpiresAt"), + proWorkspaceId: nanoIdNullable("proWorkspaceId"), }, (table) => ({ ownerIdIndex: index("owner_id_idx").on(table.ownerId), From 898046b6602bf5180469206a1f054cf979692268 Mon Sep 17 00:00:00 2001 From: McPizza0 Date: Thu, 27 Feb 2025 16:05:45 +0100 Subject: [PATCH 06/17] conditionally use providers --- Cargo.lock | 61 +++------------- apps/web/app/layout.tsx | 34 ++++----- apps/web/app/providers.tsx | 107 +++++++++++++++++----------- apps/web/app/superadmin/layout.tsx | 53 -------------- apps/web/app/superadmin/page.tsx | 0 apps/web/components/BentoScript.tsx | 13 ++-- 6 files changed, 93 insertions(+), 175 deletions(-) delete mode 100644 apps/web/app/superadmin/layout.tsx delete mode 100644 apps/web/app/superadmin/page.tsx 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/web/app/layout.tsx b/apps/web/app/layout.tsx index 724ac87ac..52284c6e5 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -7,8 +7,7 @@ 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"; export const metadata: Metadata = { title: "Cap — Beautiful screen recordings, owned by you.", @@ -65,24 +64,19 @@ export default async function RootLayout({ - - - - -
    - - {children} -
    -
    - -
    -
    -
    + + +
    + + {children} +
    +
    +
    ); diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index dd02cbba7..7377cfec6 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,14 +1,18 @@ "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 { getServerConfig } from "@/utils/instance/functions"; +import { AuthProvider } from "./AuthProvider"; +import { BentoScript } from "@/components/BentoScript"; -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,7 +28,8 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { ); } -export function AnalyticsProvider({ +// Single exported Providers component +export function Providers({ children, userId, intercomHash, @@ -37,54 +42,72 @@ export function AnalyticsProvider({ name?: string; email?: string; }) { - 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(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const checkIsCapCloud = async () => { + try { + const serverConfig = await getServerConfig(); + setIsCapCloud(serverConfig.isCapCloud); + } catch (error) { + console.error("Failed to get server config:", error); + setIsCapCloud(false); // Default to false on error + } finally { + setIsLoading(false); + } + }; + + checkIsCapCloud(); + }, []); + // 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/app/superadmin/layout.tsx b/apps/web/app/superadmin/layout.tsx deleted file mode 100644 index 02356cd54..000000000 --- a/apps/web/app/superadmin/layout.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import DynamicSharedLayout from "@/app/dashboard/_components/DynamicSharedLayout"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { redirect } from "next/navigation"; -import { DashboardTemplate } from "@/components/templates/DashboardTemplate"; -import { db } from "@cap/database"; -import { - spaceMembers, - spaces, - spaceInvites, - users, -} from "@cap/database/schema"; -import { eq, inArray, or, and, count, sql } from "drizzle-orm"; -import { - addServerSuperAdmin, - getServerConfig, -} from "@/utils/instance/functions"; - -export default async function SuperAdminLayout({ - children, -}: { - children: React.ReactNode; -}) { - const user = await getCurrentUser(); - - if (!user || !user.id) { - redirect("/login"); - } - - const serverConfig = await getServerConfig(); - - const serverSuperAdminIds = serverConfig?.superAdminIds; - - if (serverSuperAdminIds && !serverSuperAdminIds.includes(user.id)) { - redirect("/dashboard"); - } - - if (!serverSuperAdminIds) { - await addServerSuperAdmin({ userId: user.id }); - } - - return ( - -
    - {children} -
    -
    - ); -} diff --git a/apps/web/app/superadmin/page.tsx b/apps/web/app/superadmin/page.tsx deleted file mode 100644 index e69de29bb..000000000 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 (